はじめに
Djangoの組み込みテストツールの使い方について勉強する機会がありましたので、自分のためにもまとめておこうと思います。
Django公式HP
サンプルアプリケーションの紹介
簡単なブログアプリケーションを用意してテスト方法を作成しました。
コードはGitHubで公開しています。
このアプリケーションは、以下の機能を持っています。
- ログイン・ログアウト機能
- ブログ記事の作成、編集、削除、一覧表示
- ブログの作成、編集、削除を行う場合はログインが必要です。
※ 提供しているサンプルコードは、Djangoのテスト機能の紹介のために作成したものですので、UIは最低元のものしか用意していません🙇♂️
ディレクトリ構成
django-test-demo/
blog/
migrations/
tests/
__init__.py
test_forms.py
test_models.py
test_views.py
__init__.py
admin.py
apps.py
forms.py
models.py
urls.py
views.py
myblog/
__init__.py
settings.py
urls.py
asgi.py
wsgi.py
manage.py
requirements.txt
db.sqlite3
環境構築
まずは、サンプルアプリケーションの環境を構築。
django-test-demoのREADME.mdを参考に構築してみてください。
python manage.py runserver
で開発サーバーを立ち上げて、ブラウザで
http://127.0.0.1:8000
にアクセスできれば一旦成功です。
テストケース
それでは、テストケースを作成しましょう。
作成するテストは、
- モデル
- フォーム
- ビュー
のそれぞれ作成します。
全てのテストを1つのtests.pyにまとめると非常に大きなファイルとなってしまうため、アプリケーションのディレクトリにtests
ディレクトリを作成し、その中にそれぞれのテストファイルを作成することにします。
また、ファイルを分けると、ファイルごとにテストを実行できるなどのメリットもあります。
django-test-demo/
blog/
migrations/
tests/
__init__.py
test_forms.py <- formテスト
test_models.py <- modelテスト
test_views.py <- viewテスト
modelテスト
以下のPostモデルがあります。
class Post(models.Model):
title = models.CharField(max_length=200, null=False, blank=False)
content = models.TextField()
category = models.ForeignKey(
Category, null=True, blank=True, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
このモデルのテストを作成していきます。
モデルの各フィールドに対しても、正常系・異常系の試験を作成します。
from django.test import TestCase
from django.core.exceptions import ValidationError
from ..models import Category, Post
# テストクラスは、TestCaseを継承する
class PostModelTest(TestCase):
# クラスレベルで初期データを1回だけ作成する
@classmethod
def setUpTestData(cls):
# categoryインスタンスを作成しておく
cls.category = Category.objects.create(name="TestCategory")
# テスト関数は`def test_*(self)`の形式で定義する
def test_title_field_max_length(self):
""" titleフィールドの最大長が正しく設定されていること """
post = Post(title="a" * 200, content="Test Content",
category=self.category)
post.full_clean()
self.assertEqual(len(post.title), 200)
def test_title_field_max_length_exceeded(self):
""" フィールドの最大長を超えた場合ValidationError """
post = Post(title="a" * 201, content="Test Content",
category=self.category)
with self.assertRaises(ValidationError):
post.full_clean()
def test_title_field_null(self):
""" titleフィールドがnullの場合ValidationError """
post = Post(title=None,
content="Test Content",
category=self.category)
with self.assertRaises(ValidationError):
post.full_clean()
def test_model_instance_creation(self):
""" モデルインスタンスの作成に成功すること """
post = Post(title="Test Post",
content="Test Content",
category=self.category)
post.save()
self.assertEqual(Post.objects.count(), 1)
self.assertEqual(Post.objects.get(id=1).title, "Test Post")
def test_model_instance_string_representation(self):
""" モデルインスタンスの文字列表現テスト(__str__) """
post = Post(title="Test Post",
content="Test Content",
category=self.category)
self.assertEqual(str(post), "Test Post")
def test_post_category_relationship(self):
""" PostとCategoryのリレーションテスト """
post = Post(title="Test Post",
content="Test Content",
category=self.category)
post.save()
self.assertEqual(post.category, self.category)
def test_category_null(self):
""" categoryフィールドがnullの場合も登録できること """
post = Post(title="Test Post",
content="Test Content",
category=None)
post.save()
self.assertEqual(post.category, None)
def test_post_category_cascade_delete(self):
""" Category削除時のカスケード削除テスト """
post = Post(title="Test Post",
content="Test Content",
category=self.category)
post.save()
self.category.delete()
self.assertEqual(Post.objects.count(), 0)
formテスト
続いて、formの試験です。
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "content", "category"]
このフォームの試験を作成します。
class PostFormTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.category = Category.objects.create(name="TestCategory")
def test_form_has_fields(self):
""" フォームがPostモデルのフィールドを持っていること """
form = PostForm()
self.assertIn("title", form.fields)
self.assertIn("content", form.fields)
self.assertIn("category", form.fields)
def test_valid_data(self):
""" フォームが有効なデータで正しく動作すること """
form = PostForm(data={
"title": "Test Post",
"content": "Test Content",
"category": self.category.id
})
self.assertTrue(form.is_valid())
def test_title_blank(self):
""" フォームがタイトルを空で受け付けないこと """
form = PostForm(data={
"title": None,
"content": "Test Content",
"category": self.category.id
})
self.assertFalse(form.is_valid())
self.assertIn("title", form.errors)
def test_content_blank(self):
""" 本文は空でもOKであること """
form = PostForm(data={
"title": "Test Post",
"content": None,
"category": self.category.id
})
self.assertFalse(form.is_valid())
self.assertIn("content", form.errors)
def test_category_blank(self):
""" カテゴリは空でもOKであること """
form = PostForm(data={
"title": "Test Post",
"content": "Test Content",
"category": None
})
self.assertTrue(form.is_valid())
def test_invalid_category(self):
""" カテゴリに存在しないIDを指定した場合ValidationError """
form = PostForm(data={
"title": "Test Post",
"content": "Test Content",
"category": 999
})
self.assertFalse(form.is_valid())
self.assertIn("category", form.errors)
この他にも、「titleの文字サイズが201以上の場合」のテストなんかも用意すると良さそう。(test_models.pyでやっているけど)
viewテスト
最後にviewのテストを作成します。
viewは5つあります。
class PostListView(ListView):
model = Post
template_name = 'blog/post_list.html'
class PostDetailView(DetailView):
model = Post
template_name = 'blog/post_detail.html'
class PostCreateView(LoginRequiredMixin, CreateView):
model = Post
form_class = PostForm
template_name = 'blog/post_form.html'
success_url = reverse_lazy('blog:post_list')
class PostUpdateView(LoginRequiredMixin, UpdateView):
model = Post
form_class = PostForm
template_name = 'blog/post_form.html'
success_url = reverse_lazy('blog:post_list')
class PostDeleteView(LoginRequiredMixin, DeleteView):
model = Post
template_name = 'blog/post_confirm_delete.html'
success_url = reverse_lazy('blog:post_list')
それぞれのビューに対してテストを書いていきますが、
非常に長くなってしまうので、ここではPostCreateViewの試験のみを記載します。
class PostCreateViewTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.test_category_name = "TestCategory"
Category.objects.create(name=cls.test_category_name)
User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass")
# この関数はテストメソッド実行の度に呼び出されます。
def setUp(self):
# CreateViewは認証が必要な画面のため、
# テストメソッド実行前にsetUpTestDataで作成したユーザーでログインする
self.client.login(username="testuser", password="testpass")
def test_view_url_exists_at_desired_location(self):
response = self.client.get("/post/new/")
self.assertEqual(response.status_code, 200)
def test_view_url_accessible_by_name(self):
response = self.client.get(reverse("blog:post_create"))
self.assertEqual(response.status_code, 200)
def test_view_uses_correct_template(self):
response = self.client.get(reverse("blog:post_create"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "blog/post_form.html")
def test_form_display(self):
""" フォームが適切に表示されることをテストする """
response = self.client.get(reverse("blog:post_create"))
self.assertIsInstance(response.context["form"], PostForm)
def test_redirects_after_POST(self):
""" 正しいデータをPOSTした後に適切なページにリダイレクトすること """
category_id = Category.objects.get(name=self.test_category_name).id
response = self.client.post(
reverse("blog:post_create"), {
"title": "New Post",
"content": "New content",
"category": category_id})
self.assertRedirects(response, reverse("blog:post_list"))
def test_create_post(self):
""" POSTによって新しい投稿が作成されること """
category_id = Category.objects.get(name=self.test_category_name).id
self.client.post(
reverse("blog:post_create"), {
"title": "New Post",
"content": "New content",
"category": category_id})
new_post = Post.objects.get(title="New Post")
self.assertEqual(new_post.title, "New Post")
self.assertEqual(new_post.content, "New content")
self.assertEqual(new_post.category.id, category_id)
def test_title_empty(self):
""" タイトルが空の場合はwarningが表示されること """
category_id = Category.objects.get(name=self.test_category_name).id
response = self.client.post(
reverse("blog:post_create"), {
"title": "",
"content": "New content",
"category": category_id})
self.assertFormError(
response, "form", "title", "This field is required.")
def test_content_empty(self):
""" コンテンツが空の場合はwarningが表示されること """
category_id = Category.objects.get(name=self.test_category_name).id
response = self.client.post(
reverse("blog:post_create"), {
"title": "New Post",
"content": "",
"category": category_id})
self.assertFormError(
response, "form", "content", "This field is required.")
def test_category_empty(self):
""" カテゴリが空の場合でも登録できること """
response = self.client.post(
reverse("blog:post_create"), {
"title": "New Post",
"content": "New content",
"category": ""})
self.assertRedirects(response, reverse("blog:post_list"))
new_post = Post.objects.get(title="New Post")
self.assertEqual(new_post.title, "New Post")
self.assertEqual(new_post.content, "New content")
self.assertEqual(new_post.category, None)
def test_not_authenticated_user(self):
""" ログインしていないユーザーは投稿できず、ログインページにリダイレクトされること """
self.client.logout() # ログアウトする
response = self.client.post(reverse("blog:post_create"))
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, "/accounts/login/?next=/post/new/")
テストの実行と結果の解析
テストを実行するには、コマンドラインで以下のコマンドを実行します。
$ python manage.py test
※ 注意
テストファイルをdjango-test-demo/blog/tests/
ディレクトリ内に配置しましたが、このとき、このディレクトリに__init__.py
を作成しないとpython manage.py test
でテストが実行されません
テストが成功すると、以下のようにOK
がでます。
$ python manage.py test
Found 50 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..................................................
----------------------------------------------------------------------
Ran 50 tests in 2.967s
OK
Destroying test database for alias 'default'...
まとめ
本記事では、Djangoの組み込みテストツールの使い方を、サンプルアプリケーションを通じてまとめました。
個人的には非常に簡単なステップで、自動テストが作成できるなといった印象です。
また、自動テストは通った時が気持ちいいので、作るのは結構好きです笑
業務にも活かしていきたいと思います!💪