今回はPython Djangoアプリケーションにおいて、簡単な自動テストを実装する方法についてまとめました。
テストコードを書くのは面倒だと思う人も多いかと思います。
私もその一人です。
とはいえ、プログラムは一度リリースされた後も改修が行われ続けるもので、そのたびにテストを何等かの形で行わなければいけません。
手動でぽちぽち動かしてテストしてもいいのですが、人間がやるものですから、漏れやダブりが起こりがちな部分です。アプリケーションが巨大になるほど、手間も増えていきます。
テストコードを活用することで、より堅牢なアプリケーションになるでしょうし、最終的な時間の節約にもなるでしょう。
テストについての話はDjangoの公式ページが詳しい且つ面白いです。
Django を開発した Jacob Kaplan-Moss は次の言葉を残しています。「テストのないコードは、デザインとして壊れている。」
簡単なブログアプリケーションを例にテストコードを記述していきます。
以下のようにPostモデルのみのシンプルな構成です。
# blog/models.py
from django.db import models
from django.utils import timezone
class Post(models.Model):
title = models.CharField('タイトル', max_length=32)
text = models.TextField('本文')
created_at = models.DateTimeField('作成日', default=timezone.now)
updated_at = models.DateTimeField('更新日', auto_now=True)
def __str__(self):
return self.title
views.pyは以下のような構成です。
一覧ページと詳細ページがあり、データのCRUD機能を持たせています。
作成、編集、削除に関してはDjangoのLoginRequiredMixin
を使ってログインを必須とするのを要件とします。
今回について、折角テストをするので、わざとバグを潜ませています。
# blog/views.py
from django.views import generic
from .models import Post
from django.urls import reverse_lazy
from .forms import LoginForm, PostCreateForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView, LogoutView
class Login(LoginView):
"""ログインページ"""
form_class = LoginForm
template_name = 'blog/login.html'
success_url = reverse_lazy('blog:post_list')
class Logout(LogoutView):
"""ログアウトページ"""
template_name = 'blog/logout.html'
class PostList(generic.ListView):
"""記事一覧ページ"""
template_name = 'blog/post_list.html'
model = Post
ordering = '-updated_at'
class PostDetail(generic.DetailView):
"""記事詳細ページ"""
template_name = 'blog/post_detail.html'
model = Post
class PostCreate(LoginRequiredMixin, generic.CreateView):
"""記事作成ページ"""
template_name = 'blog/post_create.html'
model = Post
success_url = reverse_lazy('blog:post_list')
form_class = PostCreateForm
class PostUpdate(LoginRequiredMixin, generic.UpdateView):
"""記事編集ページ"""
template_name = 'blog/post_create.html'
model = Post
form_class = PostCreateForm
def get_success_url(self):
return reverse_lazy('blog:post_detail', kwargs={'pk': self.kwargs.get('pk')})
class PostDelete(generic.DeleteView):
"""記事削除ページ"""
template_name = 'blog/post_delete.html'
model = Post
success_url = reverse_lazy('blog:post_list')
次にテストモジュールを作成していきます。
デフォルトで作成されるtests.py
に書いてもいいのですが、テストが複数になる場合を想定してテストモジュールを作成するのが一般的です。
元々あったtests.py
は削除して、新たにtests
ディレクトリをblogアプリケーション内に作成しました。
今回はviews.pyのテストを行うので、test_views.py
を作成し、その中にテストコードを記述していきます。
それではviews.pyの内容に沿って、作成、編集、削除をテストするコードを記述していきます。
テスト対象機能はいずれもログインが必須要件としていました。
そのため、テスト実行前にログインを行うLoggedInTestCase
を記述し、各テストクラスに継承をさせます。
まずは作成のテストです。
# blog/tests/test_views.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse_lazy
from ..models import Post
class LoggedInTestCase(TestCase):
"""各テストクラスで共通の事前準備処理をオーバーライド"""
def setUp(self) -> None:
"""テストメソッド実行前の事前設定"""
# テスト用アカウントの作成
self.password = 'password123'
self.test_user = get_user_model().objects.create_user(
username='test_user',
email='testuser@email.com',
password=self.password
)
# テスト用アカウントをログインさせる
self.client.login(username=self.test_user.username, email=self.test_user.email, password=self.password)
class TestPostCreate(LoggedInTestCase):
"""PostCreateのテスト"""
def test_create_post_success(self):
"""記事作成の成功をテスト"""
# 記事データを作成
params = {'title': 'テストタイトル',
'text': '本文'}
response = self.client.post(reverse_lazy('blog:post_create'), params)
# 一覧ページへのリダイレクトを検証
self.assertRedirects(response, reverse_lazy('blog:post_list'))
# データベースへ登録されたことを検証
self.assertEqual(Post.objects.filter(title='テストタイトル').count(), 1)
def test_create_post_failure(self):
"""記事作成の失敗をテスト"""
# データを空で作成した場合の失敗を検証
response = self.client.post(reverse_lazy('blog:post_create'))
self.assertFormError(response, form='form', field='title', errors='このフィールドは必須です。')
# タイトルのみ入れた場合の失敗を検証
params = {'title': 'タイトル'}
response = self.client.post(reverse_lazy('blog:post_create'), params)
self.assertFormError(response, form='form', field='text', errors='このフィールドは必須です。')
# 本文のみ入れて作成した場合の失敗を検証
params = {'text': '本文'}
response = self.client.post(reverse_lazy('blog:post_create'), params)
self.assertFormError(response, form='form', field='title', errors='このフィールドは必須です。')
# ログアウトしている場合
self.client.logout()
params = {'title': 'ログアウトユーザー',
'text': '本文\n本文'}
response = self.client.post(reverse_lazy('blog:post_create'), params)
# ログインページへのリダイレクトを検証
self.assertRedirects(response, '/login/?next=/post_create/')
# データが追加されていないことを検証
self.assertEqual(Post.objects.filter(title='ログアウトユーザー').count(), 0)
記事の作成の成功と失敗することを確認しています。
記事の作成に関してLoginRequiredMixin
を用いてログインを必須としています。
なので、失敗のテストではテストユーザーを一回ログアウトさせて、データがログインURLへの遷移と、データが追加されていないことを確認しています。
仮想環境をactivateし、manage.pyをコマンドラインから呼び出して行います。
Djangoプロジェクト内の全てのテストを実行する場合は以下のようにします。
$ python manage.py test
引数でテスト対象を絞り込むことが可能です。
blogアプリのみをテストする場合。
$ python manage.py test blog
blogアプリのtest_viewsのみテストする場合。
$ python manage.py test blog.tests.test_views
test_viewsのTestPostCreateのみテストする場合。
$ python manage.py test blog.tests.test_views.TestPostCreate
テストを実行すると、以下のようになります。
$ python manage.py test blog
>>>
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.444s
OK
Destroying test database for alias 'default'...
無事にテストが通りました。
次に編集と削除のテストコードも追加していきます。
# blog/tests/test_views.py
# ...省略
class TestPostUpdate(LoggedInTestCase):
"""PostUpdateのテスト"""
def test_update_post_success(self):
"""記事編集の成功をテスト"""
# テスト用データの作成
post = Post.objects.create(title='編集前', text='本文')
# 編集処理を実行
params = {'title': '編集後', 'text': '編集後の本文'}
response = self.client.post(reverse_lazy('blog:post_update', kwargs={'pk': post.pk}), params)
# 詳細ページへのリダイレクトを検証
self.assertRedirects(response, reverse_lazy('blog:post_detail', kwargs={'pk': post.pk}))
# データが編集されたことを検証
post_updated = Post.objects.get(pk=post.pk)
self.assertEqual(post_updated.title, "編集後")
self.assertEqual(post_updated.text, "編集後の本文")
def test_update_post_failure(self):
"""編集処理の失敗をテスト"""
# 存在しないデータの編集が失敗することを検証
response = self.client.post(reverse_lazy('blog:post_update', kwargs={'pk': 9999}))
self.assertEqual(response.status_code, 404)
# ログアウトしている場合
post = Post.objects.create(title='編集前', text='本文')
self.client.logout()
params = {'title': '編集後', 'text': '編集後の本文'}
response = self.client.post(reverse_lazy('blog:post_update', kwargs={'pk': post.pk}), params)
# ログインページへのリダイレクトを検証
self.assertRedirects(response, f'/login/?next=/post_update/{post.pk}/')
# データが編集されていないことの検証
self.assertEqual(Post.objects.get(pk=post.pk).title, '編集前')
class TestPostDelete(LoggedInTestCase):
"""PostDeleteのテスト"""
def test_delete_post_success(self):
"""削除処理の成功をテスト"""
# テスト用データの作成
post = Post.objects.create(title='タイトル', text='本文')
# 削除処理を実行
response = self.client.post(reverse_lazy('blog:post_delete', kwargs={'pk': post.pk}))
# 一覧ページへのリダイレクトを検証
self.assertRedirects(response, reverse_lazy('blog:post_list'))
# 削除されたことを検証
self.assertEqual(Post.objects.filter(pk=post.pk).count(), 0)
def test_delete_post_failure(self):
"""削除処理の失敗をテスト"""
# 存在しないデータの削除が失敗することを検証
response = self.client.post(reverse_lazy('blog:post_delete', kwargs={'pk': 9999}))
self.assertEqual(response.status_code, 404)
# ログアウトしている場合
post = Post.objects.create(title='タイトル', text='本文')
self.client.logout()
response = self.client.post(reverse_lazy('blog:post_delete', kwargs={'pk': post.pk}))
# ログインページへのリダイレクトを検証
self.assertRedirects(response, f'/login/?next=/post_delete/{post.pk}/')
# データが削除されていないことを検証
self.assertEqual(Post.objects.filter(pk=post.pk).count(), 1)
テストを行ってみましょう。
$ python manage.py test blog
>>>
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..F...
======================================================================
FAIL: test_delete_post_failure (blog.tests.test_views.TestPostDelete)
削除処理の失敗をテスト
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\...\blog\tests\test_views.py", line 129, in test_delete_post_failure
self.assertRedirects(response, f'/login/?next=/post_delete/{post.pk}/')
File "C:\...\myvenv\lib\site-packages\django\test\testcases.py", line 397, in assertRedirects
self.assertURLEqual(
File "C:\...\myvenv\lib\site-packages\django\test\testcases.py", line 417, in assertURLEqual
self.assertEqual(
AssertionError: '/' != '/login/?next=%2Fpost_delete%2F1%2F'
- /
+ /login/?next=%2Fpost_delete%2F1%2F
: Response redirected to '/', expected '/login/?next=/post_delete/1/'Expected '/' to equal '/login/?next=/post_delete/1/'.
----------------------------------------------------------------------
Ran 6 tests in 1.153s
FAILED (failures=1)
Destroying test database for alias 'default'...
わざと潜ませていたバグを検知してくれたようです。
エラー内容をみると、ログアウトユーザーが削除処理を実行した場合のリダイレクトURLの検証がうまくいっていないことが分かります。
views.pyのPostDeleteを確認します。
LoginRequiredMixinクラスの継承が漏れていることが分かりました。
class PostDelete(generic.DeleteView):
"""記事削除ページ"""
template_name = 'blog/post_delete.html'
model = Post
success_url = reverse_lazy('blog:post_list')
修正をして再度テストを行いましょう。
class PostDelete(LoginRequiredMixin, generic.DeleteView):
"""記事削除ページ"""
template_name = 'blog/post_delete.html'
model = Post
success_url = reverse_lazy('blog:post_list')
$ python manage.py test blog
>>>
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 1.159s
OK
Destroying test database for alias 'default'...
無事にテストが成功しました。
以上、簡単なDjangoの自動テストを用いてバグを発見、修正するということを行いました。
最後にはじめての Django アプリ作成、その 5より引用をします。
私たちのテストは、手がつけられないほど成長してしまっているように見えるかもしれません。この割合で行けば、テストコードがアプリケーションのコードよりもすぐに大きくなってしまうでしょう。そして繰り返しは、残りの私たちのコードのエレガントな簡潔さに比べて、美しくありません。
構いません。 テストコードが大きくなるのに任せましょう。たいていの場合、あなたはテストを一回書いたら、そのことを忘れて大丈夫です。プログラムを開発し続ける限りずっと、そのテストは便利に機能し続けます。
githubに公開しました。