DjangoPython

Djangoで簡単な自動テストを行う方法

更新日:2022-09-18 公開日:2022-09-18

今回はPython Djangoアプリケーションにおいて、簡単な自動テストを実装する方法についてまとめました。

なぜ自動テストを行うか

テストコードを書くのは面倒だと思う人も多いかと思います。
私もその一人です。
とはいえ、プログラムは一度リリースされた後も改修が行われ続けるもので、そのたびにテストを何等かの形で行わなければいけません。
手動でぽちぽち動かしてテストしてもいいのですが、人間がやるものですから、漏れやダブりが起こりがちな部分です。アプリケーションが巨大になるほど、手間も増えていきます。
テストコードを活用することで、より堅牢なアプリケーションになるでしょうし、最終的な時間の節約にもなるでしょう。

テストについての話はDjangoの公式ページが詳しい且つ面白いです。
はじめての Django アプリ作成、その 5

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への遷移と、データが追加されていないことを確認しています。

testを実行する方法

仮想環境を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に公開しました。
https://github.com/qlitre/django-autotest-try