0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Djangoの組み込みテストツールを使ってテストを書いてみた

Posted at

はじめに

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
にアクセスできれば一旦成功です。

Screenshot 2023-12-09 at 15.22.02.png

テストケース

それでは、テストケースを作成しましょう。
作成するテストは、

  • モデル
  • フォーム
  • ビュー

のそれぞれ作成します。

全てのテストを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の組み込みテストツールの使い方を、サンプルアプリケーションを通じてまとめました。
個人的には非常に簡単なステップで、自動テストが作成できるなといった印象です。
また、自動テストは通った時が気持ちいいので、作るのは結構好きです笑

業務にも活かしていきたいと思います!💪

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?