테스트가 필요한 순간
게시판이 처음 완성됐을 때는 브라우저로 직접 확인해도 충분해 보인다. 목록 페이지를 열어 보고, 글을 작성해 보고, 수정과 삭제 버튼을 눌러 본다. 하지만 기능이 늘어나면 이 방식은 금방 한계에 부딪힌다.
예를 들어 게시글에 작성자 권한을 추가했다고 하자. 이때 확인해야 할 것은 하나가 아니다.
비로그인 사용자는 글 작성 페이지에 들어갈 수 없어야 한다.
로그인 사용자는 글을 작성할 수 있어야 한다.
작성자는 자신의 글을 수정할 수 있어야 한다.
다른 사용자는 남의 글을 수정할 수 없어야 한다.
목록 페이지에는 여전히 게시글이 보여야 한다.
이 모든 것을 매번 손으로 확인하면 시간이 오래 걸리고, 언젠가는 빠뜨린다. 테스트는 이 반복 확인을 코드로 남기는 일이다. 기능을 추가하거나 코드를 정리한 뒤 python manage.py test를 실행하면, 기존 동작이 깨졌는지 빠르게 알 수 있다.
테스트 코드는 보통 세 단계로 읽으면 쉽다.
준비한다: 필요한 사용자와 게시글을 만든다.
실행한다: 모델 메서드를 호출하거나 URL에 요청을 보낸다.
확인한다: 결과가 기대한 값과 같은지 검사한다.
이 흐름은 흔히 Arrange, Act, Assert라고 부른다. 이름은 중요하지 않다. 중요한 것은 테스트가 "무엇을 준비했고, 무엇을 실행했고, 무엇을 기대하는지" 분명해야 한다는 점이다.
AI 생성 이미지
TestCase와 테스트 데이터베이스
Django 테스트는 django.test.TestCase를 중심으로 작성한다. TestCase는 데이터베이스를 사용하는 테스트에 적합하다. 테스트를 실행하면 Django는 개발용 데이터베이스를 직접 건드리지 않고 별도의 테스트 데이터베이스를 준비한다. 그래서 테스트 중에 만든 게시글이나 사용자는 실제 개발 데이터에 남지 않는다.
기본 실행 명령은 다음과 같다.
python manage.py test특정 앱만 실행할 수도 있다.
python manage.py test boards가장 단순한 테스트부터 보자. boards/tests.py에 작성한다.
from django.test import TestCase
class BasicTests(TestCase):
def test_addition(self):
self.assertEqual(1 + 1, 2)테스트 메서드 이름은 test_로 시작해야 한다. 그래야 Django가 테스트로 인식한다. assertEqual(a, b)는 a와 b가 같은지 확인한다. 다르면 테스트는 실패한다.
실제 앱에서는 테스트용 데이터가 필요하다. 매 테스트마다 같은 데이터를 준비할 때는 setUpTestData()를 자주 사용한다.
from django.test import TestCase
from django.contrib.auth import get_user_model
from .models import Category, Post
class PostModelTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = get_user_model().objects.create_user(
username="tester",
password="testpass1234",
)
cls.category = Category.objects.create(name="Django")
cls.post = Post.objects.create(
title="테스트 게시글",
content="테스트 본문입니다.",
category=cls.category,
author=cls.user,
is_published=True,
)
def test_post_title(self):
self.assertEqual(self.post.title, "테스트 게시글")이 장의 예제에서는 앞에서 만든 게시판 모델에 author, category, is_published 같은 필드가 있다고 가정한다. 만약 여러분의 코드에서 공개 여부 필드 이름을 is_public으로 만들었다면 테스트 코드에서도 그 이름을 그대로 써야 한다. 테스트는 일반적인 정답을 외워 적는 것이 아니라, 지금 내 프로젝트의 약속을 코드로 확인하는 일이기 때문이다.
get_user_model()은 현재 프로젝트에서 사용하는 사용자 모델을 가져온다. 기본 User 모델을 쓰든, 나중에 커스텀 사용자 모델로 바꾸든 테스트 코드가 덜 흔들린다.
setUpTestData()는 클래스 안의 테스트들이 함께 사용할 데이터를 한 번 준비한다. 반대로 각 테스트마다 새로 변경해야 하는 데이터라면 setUp()을 사용한다. 처음에는 이 정도만 기억해도 충분하다. "공통으로 읽기만 할 데이터는 setUpTestData(), 테스트마다 바뀌는 데이터는 setUp()"이다.
모델과 QuerySet 테스트하기
모델 테스트는 데이터 로직이 기대대로 동작하는지 확인한다. 여기서 말하는 데이터 로직은 필드 값, __str__() 표현, 모델 메서드, QuerySet 조건 등을 포함한다.
예를 들어 Post 모델의 __str__()가 제목을 반환하도록 작성했다면 다음처럼 테스트할 수 있다.
class PostModelTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = get_user_model().objects.create_user(
username="tester",
password="testpass1234",
)
cls.post = Post.objects.create(
title="테스트 게시글",
content="테스트 본문입니다.",
author=cls.user,
is_published=True,
)
def test_str_returns_title(self):
self.assertEqual(str(self.post), "테스트 게시글")만약 모델에 요약문을 만드는 메서드를 추가했다면, 그 메서드도 테스트 대상이 된다.
# models.py 예시
class Post(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
def summary(self):
return self.content[:30]def test_summary_returns_first_30_characters(self):
post = Post.objects.create(
title="긴 글",
content="가" * 50,
author=self.user,
is_published=True,
)
self.assertEqual(post.summary(), "가" * 30)QuerySet도 테스트할 수 있다. 예를 들어 공개 글만 목록에 보여 주기로 했다면, 공개 글과 비공개 글을 함께 만든 뒤 조건이 맞는지 확인한다.
def test_published_posts_queryset(self):
hidden_post = Post.objects.create(
title="비공개 글",
content="보이면 안 되는 글입니다.",
author=self.user,
is_published=False,
)
posts = Post.objects.filter(is_published=True)
self.assertIn(self.post, posts)
self.assertNotIn(hidden_post, posts)이 테스트는 단순해 보이지만 중요하다. 나중에 목록 View를 고치다가 실수로 비공개 글까지 노출하면, 관련 테스트가 실패하면서 문제를 알려 준다.
폼 테스트하기
폼은 사용자 입력을 검증한다. 따라서 폼 테스트는 "올바른 입력은 통과하고, 잘못된 입력은 막는가"를 확인한다.
PostForm이 제목과 본문을 입력받는다고 가정해 보자.
from django.test import TestCase
from .forms import PostForm
class PostFormTests(TestCase):
def test_valid_form(self):
form = PostForm(data={
"title": "새 게시글",
"content": "본문입니다.",
})
self.assertTrue(form.is_valid())
def test_title_is_required(self):
form = PostForm(data={
"title": "",
"content": "본문입니다.",
})
self.assertFalse(form.is_valid())
self.assertIn("title", form.errors)폼 테스트에서 핵심은 is_valid()다. 이 메서드를 호출해야 Django가 필수 입력, 길이 제한, 사용자 정의 검증을 실행한다. 실패 이유는 form.errors에서 확인할 수 있다.
만약 PostForm에 카테고리 필드가 포함되어 있다면 테스트 데이터에도 카테고리 값을 넣어야 한다. 공개 여부 필드가 필수인지, 카테고리를 비워 둘 수 있는지도 실제 모델과 폼 설정에 따라 달라진다. 테스트 코드가 자주 실패한다면 먼저 "기대가 틀린 것인지, 코드가 틀린 것인지"를 구분해야 한다.
작성자 필드도 주의해야 한다. 앞에서 만든 것처럼 작성자를 request.user로 채우는 구조라면, PostForm에는 보통 author를 넣지 않는다. 폼 테스트에서는 사용자가 직접 입력하는 값만 검증하고, 작성자가 제대로 저장되는지는 View 테스트에서 확인하는 편이 자연스럽다.
Client로 View 테스트하기
Django의 테스트 Client는 브라우저처럼 URL에 요청을 보내고 응답을 받는다. 실제 서버를 실행하지 않아도 View, URL, Template 연결을 확인할 수 있다.1
목록 페이지 테스트부터 보자.
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.urls import reverse
from .models import Post
class PostViewTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = get_user_model().objects.create_user(
username="tester",
password="testpass1234",
)
cls.post = Post.objects.create(
title="테스트 게시글",
content="테스트 본문입니다.",
author=cls.user,
is_published=True,
)
def test_post_list_page(self):
url = reverse("boards:post_list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "boards/post_list.html")
self.assertContains(response, "테스트 게시글")여기서 reverse("boards:post_list")는 URL 이름으로 실제 주소를 만든다. 테스트에서도 URL을 직접 문자열로 쓰기보다 reverse()를 쓰는 편이 좋다. 나중에 /posts/를 /board/로 바꿔도 URL 이름이 그대로라면 테스트를 크게 고치지 않아도 된다.
세 가지 확인도 눈여겨보자.
status_code가200인지 확인한다. 페이지가 정상 응답했다는 뜻이다.assertTemplateUsed()로 기대한 템플릿이 사용됐는지 확인한다.assertContains()로 응답 HTML 안에 특정 내용이 있는지 확인한다.
상세 페이지도 같은 흐름이다.
def test_post_detail_page(self):
url = reverse("boards:post_detail", args=[self.post.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "boards/post_detail.html")
self.assertContains(response, "테스트 본문입니다.")존재하지 않는 게시글을 요청했을 때 404가 나오는지도 확인할 수 있다.
def test_post_detail_not_found(self):
url = reverse("boards:post_detail", args=[9999])
response = self.client.get(url)
self.assertEqual(response.status_code, 404)이 테스트는 get_object_or_404()를 제대로 사용하고 있는지 확인하는 데 도움이 된다. 물론 실제 데이터가 이미 9999번까지 만들어질 가능성이 있는 테스트라면, 존재하지 않는 값을 더 안전하게 계산해서 사용해도 된다.
redirect 동작 테스트하기
글 작성, 수정, 삭제는 보통 처리 후 다른 페이지로 이동한다. 이런 응답은 200이 아니라 302 redirect인 경우가 많다.
로그인한 사용자가 글을 작성하면 게시글이 저장되고 목록이나 상세 페이지로 이동한다고 가정해 보자.
def test_logged_in_user_can_create_post(self):
self.client.force_login(self.user)
url = reverse("boards:post_create")
response = self.client.post(url, {
"title": "새 글",
"content": "새 본문입니다.",
"is_published": "on",
})
self.assertEqual(response.status_code, 302)
self.assertTrue(Post.objects.filter(title="새 글", author=self.user).exists())self.client.post()는 HTML 폼을 제출하는 것처럼 POST 요청을 보낸다. 위 예제에서 is_published는 체크박스 필드를 흉내 낸 값이다. 여러분의 PostForm에 카테고리나 다른 필수 필드가 있다면, 실제 폼 제출과 같은 데이터를 테스트에도 넣어야 한다.
force_login()은 테스트에서 특정 사용자를 로그인 상태로 만든다. 실제 비밀번호 인증 과정까지 확인하려면 self.client.login(username=..., password=...)를 사용할 수 있지만, View 접근 권한만 확인할 때는 force_login()이 간단하다.2
redirect 목적지가 분명하다면 assertRedirects()로 더 정확히 확인할 수 있다.
def test_create_post_redirects_to_detail(self):
self.client.force_login(self.user)
response = self.client.post(reverse("boards:post_create"), {
"title": "이동 확인",
"content": "본문입니다.",
"is_published": "on",
})
post = Post.objects.get(title="이동 확인")
self.assertRedirects(response, reverse("boards:post_detail", args=[post.pk]))assertRedirects()는 기본적으로 redirect 응답의 목적지를 따라가서 최종 응답도 확인한다. 그래서 작성 후 상세 페이지로 이동하는 구조라면, 테스트에서 만든 글이 상세 페이지 조건에 맞게 공개 상태인지도 중요하다. 실제 View가 목록 페이지로 이동하도록 구현되어 있다면 기대 URL도 목록 페이지로 써야 한다. 테스트는 정답을 외워 쓰는 코드가 아니라, 우리 앱의 약속을 명확히 적어 두는 코드다.
인증과 권한 테스트하기
인증 기능은 반드시 테스트하는 것이 좋다. 화면에서는 버튼을 숨겼더라도, URL을 직접 입력하면 접근할 수 있는 실수가 자주 생긴다. 권한은 Template이 아니라 View에서 막아야 한다.
먼저 비로그인 사용자가 글 작성 페이지에 접근할 수 없는지 확인한다.
from django.conf import settings
from django.shortcuts import resolve_url
def test_anonymous_user_cannot_access_create_page(self):
url = reverse("boards:post_create")
response = self.client.get(url)
login_url = resolve_url(settings.LOGIN_URL)
self.assertEqual(response.status_code, 302)
self.assertIn(login_url, response["Location"])LoginRequiredMixin이나 login_required를 사용했다면 보통 로그인 페이지로 이동한다. 이 테스트는 비로그인 사용자가 작성 화면을 볼 수 없는지 확인한다. 설정에 따라 redirect URL에는 next 파라미터가 붙을 수 있으므로, 위 예제처럼 로그인 URL이 포함되어 있는지 확인하면 흐름을 이해하기 쉽다.
다음은 로그인 사용자가 접근할 수 있는지 확인한다.
def test_logged_in_user_can_access_create_page(self):
self.client.force_login(self.user)
response = self.client.get(reverse("boards:post_create"))
self.assertEqual(response.status_code, 200)작성자 권한도 확인해야 한다. 다른 사용자가 남의 글 수정 페이지에 접근하면 막혀야 한다.
def test_only_author_can_update_post(self):
other_user = get_user_model().objects.create_user(
username="other",
password="testpass1234",
)
self.client.force_login(other_user)
url = reverse("boards:post_update", args=[self.post.pk])
response = self.client.get(url)
self.assertIn(response.status_code, [403, 404])여기서 403은 "권한 없음"이고, 404는 "찾을 수 없음"이다. UserPassesTestMixin으로 막았다면 403이 나올 수 있고, get_queryset()에서 작성자 글만 조회하도록 제한했다면 404가 나올 수 있다. 어느 쪽이든 정책을 정하고 그에 맞춰 테스트를 작성하면 된다.
작성자는 수정 페이지에 접근할 수 있어야 한다.
def test_author_can_update_post(self):
self.client.force_login(self.user)
url = reverse("boards:post_update", args=[self.post.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)이렇게 인증 테스트를 작성해 두면, 나중에 View를 함수 기반에서 클래스 기반으로 바꾸거나 URL 구조를 정리해도 핵심 권한이 유지되는지 확인할 수 있다.
좋은 테스트의 기준
처음부터 모든 경우를 테스트하려고 하면 부담스럽다. 대신 깨지면 위험한 동작부터 테스트한다.
우선순위는 다음과 같다.
사용자가 볼 수 없어야 하는 데이터를 막는 권한 테스트
글 작성, 수정, 삭제처럼 데이터가 바뀌는 기능 테스트
모델 메서드와 QuerySet처럼 여러 View에서 재사용되는 데이터 로직 테스트
폼 검증처럼 잘못된 입력을 막는 테스트
주요 페이지의 응답 코드와 템플릿 테스트
테스트 하나는 가능하면 한 가지 동작만 확인하는 편이 좋다. 테스트 이름도 구체적으로 쓴다. test_post보다 test_anonymous_user_cannot_access_create_page가 낫다. 이름이 길어도 괜찮다. 실패했을 때 무엇이 깨졌는지 바로 알 수 있기 때문이다.
다만 "하나의 테스트가 하나의 동작만 확인한다"는 말이 assert를 반드시 하나만 쓰라는 뜻은 아니다. 예를 들어 글 작성 테스트에서 redirect 여부와 데이터 저장 여부를 함께 보는 것은 자연스러울 수 있다. 중요한 것은 테스트를 읽었을 때 확인하려는 행동이 흐려지지 않는 것이다.
테스트는 코드를 느리게 만드는 짐이 아니다. 오히려 코드를 바꿀 수 있게 해 주는 안전장치다. 게시판에 댓글, 태그, 프로필, API 기능을 추가할수록 테스트의 가치는 커진다. 우리가 원하는 것은 한 번 성공한 기능이 아니라, 고쳐도 계속 유지되는 기능이다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.