단위 테스트#

Django Ninja 입문 가이드의 29번째 글이에요.
"혹시 프로젝트에 단위 테스트가 있나요?"
면접에서 이런 질문을 던진다면 면접관의 얼굴빛이 어두워질지도 몰라요.
테스트의 중요성은 대부분의 개발자가 마음속으로 잘 알고 있어요. 단지 진지하게 임하는 사람이 많지 않을 뿐이죠.
하지만 진심으로 코드의 품질을 높이고 버그를 줄이며 프로젝트 유지보수를 더 쉽게 만들고 싶다면, 단위 테스트는 여전히 없어서는 안 될 도구예요.
훌륭한 테스트는 우리가 문제를 조기에 발견하도록 도울 뿐만 아니라, 프로젝트를 리팩터링하거나 새로운 기능을 추가할 때 기존 기능이 망가지지 않도록 보장해 줘요.
물론 테스트를 작성하면 초기 개발 시간이 늘어나고 유지보수에도 공을 들여야 하죠. 결코 쉬운 일이 아니에요. 하지만 장기적으로 봤을 때 프로젝트에 지속적인 건강성과 안정성을 가져다줘요.
그러니까, 우리 모두 테스트를 꼼꼼히 잘 작성해 봅시다!
GitHub 예제 프로젝트#
이번 글의 목차#
이번 글은 전체 시리즈 중에서 유일하게 전체 목차가 있는 튜토리얼이에요.
그 이유는 이번 글에서 다뤄야 할 항목이 꽤 많기 때문이에요. 단위 테스트라는 이토록 방대한 주제를 어떻게 2500자짜리 글 하나로 다 설명할 수 있겠어요. 지면 관계상 하나하나 자세히 이야기할 수는 없지만, 그렇다고 아예 생략할 수도 없는 노릇이니까요.
그래서 독자들이 전체적인 윤곽을 조감하고 내용을 더 쉽게 이해하고 흡수할 수 있도록 목차를 제공하기로 했어요. 목차는 다음과 같아요:
- 단위 테스트의 이상과 현실.
- Django API 테스트 중요 개념 설명.
- Test Client의 의미와 용도.
- pytest와 pytest-django 소개.
- pytest fixtures와 테스트 함수.
- 테스트 코드 구현 및 해설.
- 맺음말.
간단히 말해 이번 글에서는 모든 코드 변경 사항을 설명하지는 않고, 필요할 때만 언급할 거예요. 나머지 부분은 제가 직접 구현하여 예제 프로젝트에 수록해 두었으니 독자 여러분께서 자유롭게 참고해 주세요.
제한된 분량 속에서는 세부 사항에 집중하기보다 전체적인 개념을 이해하는 것이 훨씬 더 중요하니까요. 기본 개념을 파악하고 나면 코드를 볼 때 훨씬 수월할 거예요.
단위 테스트에 관한 더 많은 이야기는 다음 소감을 참고해 주세요: <왜 단위 테스트를 작성해야 하는가 - "파이썬 장인(Python Craftsman)"> 탄탄한 논리를 갖춘 좋은 책이라 분명 얻어가는 게 있으실 거예요.
이번 글의 모든 코드 변경 사항은 이 PR을 참고해 주세요.
1. 단위 테스트의 이상과 현실#
단위 테스트에 대해 논하려면 먼저 현실을 직시해야 한다고 생각해요.
소프트웨어 테스트 분야에는 테스트에 대한 온갖 광신과 교조주의가 넘쳐나서 오히려 사람들을 물러서게 만들 때가 있어요.
단위 테스트의 이상#
이론적으로 단위 테스트 작성은 모든 개발자가 해야 하는 일이에요 (저도 그렇게 생각해요).
게다가 "테스트 주도 개발(TDD)"이라는 철학도 있죠. 기능 코드를 작성하기 전에 테스트부터 먼저 작성하도록 요구하는, 테스트 중심의 개발 방법론 말이에요.
심지어 극소수 사람들은 테스트 커버리지가 무조건 100%여야 한다고 생각하기도 해요. 왜냐하면 만약 100%가 아니라 70%라면, "왜 다른 숫자가 아니라 70%인가?"라고 되물을 수 있으니까요.
현실: 대부분의 사람들은 이상에 연연하지 않아요#
하지만 현실에서 우리는 이상적인 테스트를 거의 보지 못해요. 심지어 테스트가 아예 없는 경우도 흔하죠.
현실의 프로젝트들은 시간이나 자원 등 여러 제약 때문에 테스트 작성에 노력을 기울이기를 꺼리는 경우가 많아요.
오래된 프로젝트 중에는 초기에 테스트 기반이 없어서 나중에 테스트를 추가하기가 더 어려워진 경우도 있어요 (코드가 이미 엉망진창💩이 되어버렸으니까요). 이게 바로 우리가 흔히 말하는 "기술 부채(Technical Debt)"죠.
또한 과도한 "테스트 이상주의"는 때로 초보자들을 망설이게 만들어요. 많은 초보자들이 테스트를 접할 때 100% 커버리지를 달성하지 못할까 봐 걱정하고 그로 인해 테스트에 대한 거부감이나 의구심을 갖게 되거든요.
이런 완벽주의는 보통 이로울 게 없으므로 우리는 이상과 현실 사이에서 타협점을 찾아야 해요.
타협: 실용적인 테스트 전략#
실제 개발에서 우리는 실용적이고 실현 가능한 테스트 전략을 고수해야 해요. API 호출과 200 응답 확인 같은 프로젝트의 가장 핵심적인 기능에 초점을 맞추는 거죠.
대부분의 경우 60-70% 기능만 커버해도 프로젝트 코드의 품질을 눈에 띄게 향상시킬 수 있으며, 향후 개발을 위한 상당한 수준의 안정감을 제공해요 - 이 안정감은 정말 중요하답니다.
완벽한 테스트 커버리지를 추구할 필요는 없어요. 행동에 나설 의지만 있다면 테스트는 응당 가져야 할 가치를 발휘하게 될 거예요.
2. Django API 테스트 중요 개념 설명#
이제 프로젝트 이야기로 돌아올게요.
이번 글에서 API 단위 테스트의 구체적인 디테일을 너무 많이 언급할 수는 없지만 중요한 개념을 빼놓을 수는 없죠. 아래에서 하나씩 설명해 드릴게요.
Test Client의 의미와 용도#
API 테스트에 있어서 Test client는 필수적이에요. 실제 HTTP 요청을 시뮬레이션할 수 있거든요 - 주의하세요, 어디까지나 시뮬레이션이에요.
API 테스트는 일반적인 코드 테스트와 약간 달라요. 일반 테스트는 관련 테스트 함수나 로직을 작성하고 실행하기만 하면 되지만, API 테스트에서는 요청 발송을 시뮬레이션할 "가짜 클라이언트"가 필요해요.
수동으로 API를 테스트할 때는 보통 Postman 같은 API 클라이언트를 사용해요. 자동화된 단위 테스트에서는 이 "가짜 클라이언트"를 테스트 코드 안에 직접 작성해야 하는데, 이것이 바로 test client예요.
이건 "프로젝트 내부의 API 클라이언트"와 같으며 자동으로 실행될 수 있죠.
Django Ninja도 자체적인 test client를 제공하긴 하지만 아직 충분히 안정적이지 않으므로 당분간은 사용하지 않는 것을 추천해요.
이 예제 프로젝트에서는 역사와 전통을 자랑하며 안정적이고 신뢰할 수 있는 Django에 내장된 test client를 사용했어요.
pytest 소개#
pytest(네, p는 소문자예요. pyenv처럼요)는 아주 인기 있는 Python 테스트 프레임워크로 수많은 실용적인 플러그인을 포함한 고유의 생태계를 갖추고 있어요.
Python에 내장된 unittest 모듈과 비교하면 pytest의 구문이 더 직관적이고 사용 시의 유연성도 뛰어나요. 특히 fixtures, 매개변수화된 테스트(parameterized testing) 등의 기능은 테스트 작성을 더욱 간단하고 효율적으로 만들어 주죠.
pytest-django#
pytest-django는 Django를 위해 특별히 설계된 pytest 통합 패키지예요. 다양한 내장 fixtures와 실용적인 데코레이터를 포함하여 풍부한 Django 통합 기능을 제공해요.
그중에서도 @pytest.mark.django_db 데코레이터가 가장 널리 쓰이는데, 테스트 과정 중의 데이터베이스 상태를 자동으로 관리해 주는 역할을 해요.
이 데코레이터를 사용하면 pytest가 매 테스트 실행 전에 새로운 데이터베이스를 자동으로 생성하고 테스트가 끝나면 삭제해 줘요. 덕분에 매번 테스트 환경이 일관되게 유지되고, 잔류 데이터로 인해 테스트 결과가 부정확해지는 것을 막아준답니다.
pytest Fixtures와 테스트 함수#
Fixtures는 테스트에 필요한 초기 환경을 설정하기 위해 pytest가 제공하는 메커니즘이에요. 본질적으로는 함수이지만 일반 함수처럼 사용하지 않아요. 미리 정의해 두면 테스트 함수 안에서 매개변수로 참조할 수 있죠.
Fixtures를 각 Django app의 tests.py에 정의할 수도 있지만, 보통은 프로젝트 전체가 공유할 수 있도록 conftest.py 모듈에 모아둬요.
API를 테스트할 때는 사용자, 제품 등 초기 데이터가 자주 필요해요. 이러한 데이터들을 매번 수동으로 재현할 필요 없이 fixtures를 통해 자동으로 생성할 수 있답니다.
이렇게 하면 테스트 작성 효율이 올라가고 상태 설정을 중복으로 하는 수고를 덜 수 있어요.
3. 테스트 코드 구현 및 해설#
이번 글에서는 3개의 fixture와 3개의 테스트 함수를 구현했는데 모두 user와 관련된 내용이에요. 그중 디테일한 부분의 요점만 골라 설명해 드릴게요.
강력하고 유연한 pytest Fixtures#
이것이 프로젝트의 conftest.py에 정의된 3개의 fixture예요: (분량을 줄이기 위해 docstring은 생략했어요)
import pytest
from django.test import Client
...
@pytest.fixture(scope='session')
def client() -> Client:
return Client()
@pytest.fixture
def user() -> User:
return User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpassword123'
)
@pytest.fixture
def authenticated_client(client: Client, user: User) -> Client:
response = client.post(
'/users/login/',
{'username': 'testuser', 'password': 'testpassword123'},
content_type='application/json',
)
assert response.status_code == 200
# 로그인 후의 쿠키 설정
client.cookies.update(response.cookies)
return client
이 코드에서:
client: 요청 전송을 시뮬레이션할 수 있는 Django test client를 제공해요. (미인증)user: 테스트용 사용자를 자동으로 생성하여, 테스트 함수나 심지어 다른 fixture가 참조할 수 있게 해요.authenticated_client: 위 두 개의 fixture를 인용하고 결합하여 로그인된 클라이언트를 시뮬레이션해요. 그래야 "인증으로 보호된" API들을 테스트할 수 있거든요.
Fixtures의 정의, 조합, 그리고 사용은 pytest의 큰 특징이에요.
테스트 환경 설정을 단순화할 뿐만 아니라 테스트 코드의 가독성도 높여주죠. 테스트 상태와 테스트 로직을 분리한다는 점에서 이것 또한 일종의 "관심사의 분리"라고 할 수 있어요.
실제 테스트 함수에서는 필요한 fixtures를 매개변수로 전달하기만 하면, pytest가 이들의 초기화와 정리 작업을 자동으로 처리해 줘요.
이러한 설계는 중복 코드를 크게 줄이고, 테스트가 환경 설정보다는 API 로직 검증에 더 집중할 수 있게 해준답니다.
테스트 함수#
마지막으로 테스트 함수예요. 그중 두 가지만 살펴볼게요: (fixtures 자체에 집중할 수 있도록 매개변수의 타입 힌트는 생략했어요)
def test_get_users(authenticated_client) -> None:
"""
모든 사용자 조회 테스트
"""
response = authenticated_client.get('/users/')
assert response.status_code == 200
def test_login_user(client, user) -> None:
"""
사용자 로그인 테스트
"""
response = client.post(
'/users/login/',
data={'username': 'testuser',
'password': 'testpassword123'},
content_type='application/json',
)
assert response.status_code == 200
이 두 함수를 고른 데에는 교육적인 목적이 있어요:
test_login_user함수는 "사용자 로그인" API를 테스트해요. 이 API는 "비로그인" 사용자가 접근하는 것이므로 일반 client(미인증)를 인용하면 돼요.- 로그인 성공의 전제조건은 해당 사용자가 "존재해야" 한다는 것이므로 이 함수는 user fixture도 인용하고 있어요.
- 그리고 user fixture의 역할이 바로 테스트가 시작되기 전에 해당 사용자를 미리 생성해 두는 것이에요.
test_get_users가 테스트하는 건 "인증 보호가 있는" API예요. 접근하려면 로그인이 필요하므로authenticated_client를 인용했어요.- 이 테스트 함수는 authenticated_client "하나만" 인용했지만, 실제 테스트 결과는 리스트 안에 한 명의 사용자가 존재하는 것으로 나와요. (코드에는 이 부분이 포함되어 있지 않아요)
- authenticated_client가 이미 user와 client라는 두 fixture를 참조하고 있기 때문에, authenticated_client를 참조하는 것만으로도 위의 두 가지를 참조한 것과 같기 때문이죠.
어떤 fixture를 "참조"할지는 각 함수가 어떤 테스트 상태와 조건을 필요로 하는지에 따라 결정하면 돼요.
Fixtures 자체를 재사용할 수 있으므로 이러한 설계는 테스트 자체를 매우 "모듈화"시켜줘요 - 이것이 pytest가 그토록 인기를 끄는 이유 중 하나예요.
단위 테스트 실행하기#
마지막으로 테스트를 한 번 돌려볼까요!
프로젝트 루트 디렉터리에서 pytest 명령어를 직접 사용하거나 VS Code의 Testing UI를 통해 단위 테스트를 실행할 수 있어요:

아름답네요!
4. 맺음말#
이상과 현실 사이에는 언제나 차이가 존재하기 마련이죠. 완벽함을 지나치게 추구하지 않더라도 실용적인 테스트 전략을 통해 프로젝트에 충분한 품질 보증을 제공할 수 있어요.
Test client와 pytest 같은 도구들은 API 테스트를 간단하고 체계적으로 만들어줘요. 테스트 커버리지가 꼭 100퍼센트일 필요는 없으며 어느 정도 수준에만 도달해도 개발 과정에 거대한 조력을 제공할 수 있어요.
이번 시리즈의 튜토리얼도 거의 막바지에 다다랐네요. 우리는 라우팅 설계부터 단위 테스트까지 Django Ninja의 핵심 기능과 고급 기능들을 탐구했어요. 정말 힘들지만 보람찬 과정이었죠 - 저와 여러분 모두에게요.
다음 편이 마지막 편이네요. 전체 시리즈를 간단히 회고하고 제가 이번 철인 대회를 진행하며 느꼈던 창작 및 완주 소감을 나눌 예정이에요.