The Sapzil Ditto Kim's Dev Log

RESTful API 서버 테스트하기

요즘 소프트웨어 개발자라면 자동화 된 테스트가 필요하다는 것에 대부분 동의할 것입니다. 그러나 테스트를 짜본 적이 없으면 처음엔 어떻게 해야 할지 막막하기만 합니다. 저도 그랬고, 공부하려 해도 라이브러리 사용법이나 뜬구름 잡는 소리(TDD? BDD?)는 많은데 구체적인 방법은 찾아보기 힘든 것 같습니다. 그래서 이 글에서는 RESTful API 서버로 영역을 한정하여 하나의 테스트 작성 방식을 제시해보려 합니다.

애플리케이션 아키텍쳐에 대한 가정

특정 프레임워크에 한정되는 내용은 아니므로 일반적인 MVC 아키텍쳐를 가정하고 설명할 것입니다.

  • 모델: 데이터베이스에 접근하는 모듈. 서비스/DAO처럼 추상화 되어 있는 경우도 모델로 통칭하기로 합니다.
  • 컨트롤러: HTTP 요청을 받아 다듬은 다음 모델 계층을 호출해서 요청을 수행하고 결과를 돌려주는 모듈. 뷰, 요청 핸들러, 리소스라고 부르기도 합니다.
  • UI 테스트는 다른 접근 방식이 필요하기 때문에 뷰/템플릿은 고려하지 않고 JSON만을 돌려준다고 가정합니다.
  • 단일(monolithic) 애플리케이션을 가정합니다. 여러 작은 애플리케이션끼리 통신하는 아키텍쳐(SOA/마이크로서비스)에서는 다른 전략이 필요할 것입니다.

외부 인터페이스를 통해 통합 테스트

테스트에 대한 자료를 검색해보면 단위 테스트에 대한 것이 대부분인 것 같습니다. 단위 테스트는 하나의 모듈(클래스, 함수)을 격리시켜서 테스트하는 방법입니다. 한편 통합 테스트는 여러 모듈을 결합하여 시스템이 전체적으로 잘 동작하는지 테스트하는 방법이라고 할 수 있습니다.

비교적 외부에 노출되는 인터페이스가 명확한 RESTful API는 모듈을 각각 테스트하지 않아도 가장 상위 모듈을 테스트하면 하위 모듈도 대부분 커버됩니다. 구체적으로는 컨트롤러를 테스트하면 모델 계층도 거의 테스트됩니다. 따라서 통합 테스트만 작성하는 것을 기본으로 하고, 단위 테스트는 단위 테스트 방식으로 접근하면 노력이 덜 드는 경우에만 작성하는 것이 효율적입니다.

컨트롤러를 사용해서 테스트하는 방법은 컨트롤러 객체/함수를 직접 호출해서 테스트하는 방법과 HTTP 클라이언트를 사용하는 방법이 있습니다. 사용자는 HTTP를 통해 API에 접근할 것이고, 또한 URL 라우팅이나 미들웨어까지 포함해 테스트하기 위해서는 HTTP 클라이언트를 사용하는 것이 좋습니다. (물론 꼭 서버를 띄워서 소켓 통신을 해야 한다는 뜻은 아닙니다. 많은 프레임워크가 HTTP 요청을 시뮬레이션하는 방법을 제공하고 있으므로 그런 기능을 사용하면 됩니다.)

실제 데이터베이스를 사용

테스트 시 데이터베이스를 끼워넣는 전략이 여러가지 있습니다. 먼저 아예 데이터베이스를 사용하지 않는 방법입니다. 데이터베이스에 접근하는 인터페이스를 추상화하고 가짜 객체를 만듭니다. 이렇게 하면 데이터베이스의 동작을 매번 코드로 작성해줘야 하는 불편함이 있고 실제 환경과 거리가 멀어지므로 통합 테스트에는 적합하지 않습니다. 또한 SQL 기반의 RDBMS를 사용한다면 DB에도 로직을 어느 정도 맡기게 되므로 DB까지 포함해서 테스트하는 것이 좋다고 생각합니다.

실제 데이터베이스를 사용하더라도, ORM을 사용한다면 서비스에 쓰는 DB보다 가벼운 SQLite나 HSQLDB 같은 인메모리 DB를 사용해서 테스트할 수 있습니다. 하지만 ORM의 추상화가 항상 완벽하지는 않으므로 서비스에서 쓰는 것과 같은 종류의 DB를 띄워서 테스트하는 것이 좋습니다.

많은 프레임워크들이 테스트 시 DB를 비우는 기능을 제공하고 있기 때문에 개발 환경에 DB를 설치하는 부담을 제외하면 크게 불편함은 없는 것 같습니다. 요즘은 Docker 같은 기술도 있으니 로컬 개발 환경에 DB를 쉽게 설치할 수 있을 것입니다.

물론, 로컬 환경에 띄울 수 없는 외부 서비스는 추상 인터페이스를 만들고 가짜 객체를 사용하는 수 밖에 없겠습니다.

테스트를 작성할 예제 API

간단한 할일 목록 API를 테스트한다고 생각하겠습니다. 다음과 같은 API 엔드포인트가 있다고 합시다.

  • GET /tasks
    • 저장된 할일 목록을 최근에 추가된 것 먼저 돌려줍니다.
    • excludeCompleted=true 파라미터를 지정하면 완료되지 않은 목록을 돌려줍니다.
  • POST /tasks
    • 할일을 추가합니다.
    • 추가된 할일 객체를 돌려줍니다.

Task 객체는 다음과 같이 생겼습니다.

{
    "id": "idididid",
    "text": "Write tests",
    "completed": false
}

테스트 작성하기

그럼 본격적으로 테스트를 작성해보겠습니다. 먼저 GET /tasks를 테스트해봅니다. 하나의 테스트 함수는 크게 세 단계로 나눠집니다.

  1. 원하는 상태를 준비
  2. 테스트할 API 호출
  3. 응답 및 상태 검증

저장된 할일 목록이 제대로 돌아오는지 확인하려면 할일을 저장해야 합니다. 데이터베이스를 직접 조작해서 할일을 저장할 수도 있습니다. 어떤 프레임워크는 미리 정해진 데이터(fixture)를 DB에 로드하는 기능을 제공합니다. 간단한 테스트에서는 이렇게 해도 무방하지만, 조금만 복잡해져도 fixture 데이터를 직접 작성하기 쉽지 않습니다. 테스트 코드와 테스트 데이터가 한 곳에 모이지 않게 되므로 테스트 데이터가 그렇게 만들어진 의도를 알아내기 힘들어질 수 있고 중복이 발생합니다. 또한 애플리케이션 차원에서 구현된 DB상 데이터의 제약을 제대로 지키기 어렵습니다.

따라서 외부 인터페이스를 통해 테스트한다는 원칙에 따라 POST /tasks를 호출해서 할일을 생성하겠습니다. 아직 테스트되지 않은 API를 사용한다는 것이 어색하게 느껴질 수 있지만 괜찮습니다.

# 파이썬 코드처럼 보이지만 파이썬이 아닐지도 모릅니다...
class TasksControllerTest(TestCase):
    def setUp(self):
        # 각 메소드 실행 전에 호출되는 메소드
        self.client = Client()
        self.reset_db()  # 데이터베이스를 청소해주는 게 있다고 칩시다.

    def testGet(self):
        # 원하는 상태를 준비
        task1 = self.client.post('/tasks', {'text': 'Write tests', 'completed': False}).json()
        task2 = self.client.post('/tasks', {'text': 'Eat lunch', 'completed': True}).json()

        # 테스트할 API 호출
        response = self.client.get('/tasks')

        # 응답 검증
        assert response.statusCode == 200
        assert response.json() == [task2, task1]

        # 쓰기가 발생하는 API가 아니므로 호출 이후의 상태를 따로 검증하지는 않습니다.

이렇게 기본적인 동작을 테스트했습니다. 여기다가 할 일이 하나도 없는 엣지 케이스의 테스트 케이스를 하나 더 추가해볼 수 있을 겁니다.

    def testGet__empty(self):
        response = self.client.get('/tasks')
        assert response.statusCode == 200
        assert response.json() == []

테스트 헬퍼 만들기

이제 GET /tasks API의 완료되지 않은 할일만 돌려주는 동작을 테스트해야 할텐데, 할일을 저장하는 코드의 중복이 생깁니다. 그리고 매번 text를 적어주기도 귀찮으니 적당히 자동으로 만들어주면 좋을 것 같습니다. 그러므로 할일을 만들어주는 함수를 따로 빼내겠습니다. 테스트 코드라고 해서 막 짜도 되는 것이 아니라, 실제 코드와 마찬가지로 읽고 관리하기 쉽도록 신경쓰는 것이 좋습니다.

    # 모든 필드를 입력하지 않아도 되도록 적당한 기본값을 넣어줍니다.
    def _newTask(self, text=some_random_string(), completed=False):
        return self.client.post('/tasks', {'text': text, 'completed': completed}).json()

물론 테스트 케이스 클래스에 메소드로 넣지 않고 일반 함수로 만들거나 Client를 상속받아서 그쪽에 추가하는 방법도 있습니다. 여튼 여기서 중요한 것은 테스트 환경을 준비해주는 공통 헬퍼를 만들었다는 점입니다.

이제 excludeCompleted=true인 경우의 테스트 케이스를 다음과 같이 테스트 헬퍼를 이용하여 작성할 수 있습니다. 기존 메소드도 리팩토링할 수 있겠죠.

    def testGet__excludeCompleted(self):
        completedTask = self._newTask(completed=True)
        doingTask = self._newTask(completed=False)
        response = self.client.get('/tasks', {'excludeCompleted': True})
        assert response.statusCode == 200
        assert response.json() == [doingTask]

쓰기 API의 테스트

이제 POST /tasks API를 테스트해봅니다. 한 가지 주의할 점은, 이 API를 호출할 때 앞서 작성한 테스트 유틸리티를 사용하지 않는 것입니다. 테스트 대상이 되는 API에는 헬퍼 함수를 사용하지 않아야 헬퍼를 건드려서 의도치 않게 테스트의 동작이 바뀌는 일을 방지할 수 있습니다.

앞서 작성한 테스트와 다른 점은 API의 응답 외에도 API를 호출하고 난 뒤의 상태를 검증해야 한다는 것입니다. 이때도 데이터베이스를 직접 확인할 수 있지만 외부 인터페이스만을 사용한다는 원칙에 따라 GET /tasks를 호출해서 확인합니다.

    def testPost(self):
        request = {'text': 'Drink coffee', 'completed': False}
        response = self.client.post('/tasks', request)

        # 응답 검증
        assert response.statusCode == 200
        created = response.json()
        assert created['id']
        assert created['text'] == request['text']
        assert created['completed'] == request['completed']

        # 상태 검증
        assert self._getTasks() == [created]

    def _getTasks(self):
        # 이것도 테스트 헬퍼를 만들었습니다!
        return self.client.get('/tasks').json()

결론

이상으로 RESTful API 서버의 테스트를 작성하는 방법을 간단히 살펴보았습니다. (너무 간단했나요?)

저는 외부 인터페이스만 사용해서 테스트라는 원칙을 알게 된 이후로 테스트 작성하는 것이 한결 수월해졌기 때문에 그 아이디어를 끝까지 밀고 가 보았습니다. 실제 API의 사용자가 어떤 순서로 API를 호출하게 될 것인지 상상하면서 테스트 코드를 만들 수 있어서인 것 같습니다. 하지만 정말 어쩔 수 없을 때는 DB에 직접 접근하는게 훨씬 쉬울 수 있으니 실용성을 따져가면서 코딩하시면 됩니다.