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

# はじめに

Djangoの組み込みテストツールの使い方について勉強する機会がありましたので、自分のためにもまとめておこうと思います。

Django公式HP

https://docs.djangoproject.com/ja/4.2/topics/testing/

# サンプルアプリケーションの紹介

簡単なブログアプリケーションを用意してテスト方法を作成しました。
コードはGitHubで公開しています。

https://github.com/ryo-keima/django-test-demo

このアプリケーションは、以下の機能を持っています。

- ログイン・ログアウト機能
- ブログ記事の作成、編集、削除、一覧表示
- ブログの作成、編集、削除を行う場合はログインが必要です。

※ 提供しているサンプルコードは、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](https://github.com/ryo-keima/django-test-demo?tab=readme-ov-file#django-test-demo)を参考に構築してみてください。

`python manage.py runserver`で開発サーバーを立ち上げて、ブラウザで
[http://127.0.0.1:8000](http://127.0.0.1:8000)
にアクセスできれば一旦成功です。


![Screenshot 2023-12-09 at 15.22.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951524/564247fb-b687-3b54-a4ae-52e1dc8336a4.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モデルがあります。
```python
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
```

このモデルのテストを作成していきます。
モデルの各フィールドに対しても、正常系・異常系の試験を作成します。

```python
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の試験です。

```python
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "content", "category"]
```

このフォームの試験を作成します。

```python
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つあります。

```python
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の試験のみを記載します。

```python
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/")
```

# テストの実行と結果の解析

テストを実行するには、コマンドラインで以下のコマンドを実行します。


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

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