[Django]Modelのテストを作成してみる

## はじめに
自分用の個人開発のメモ/備忘録として記録していきます。

## 環境
django5.0
↓ dockerでの開発環境作成はこちら

https://qiita.com/mkthrkw/private/588226d30690c707d2b8

↓ 認証ユーザーモデルはこちらをそのまま利用

https://qiita.com/mkthrkw/private/b2ea8fb8f33a68faab87


## やりたいこと
Modelのテストを一通り書いてみる


## テストについて下調べ

### Modelテストの種類
下記のようなテストが一般的な模様。

| No | カテゴリ | 内容 |
|:---:|:---:|:---|
| 1 | フィールド検証テスト| 各フィールドが正しい属性（型、デフォルト値、バリデーションルールなど）を持っているかを検証 |
| 2 | モデルメソッドのテスト | モデルにカスタムメソッドが含まれる場合、期待通りに動作することを確認 |
| 3 | モデルの保存と取得のテスト | モデルインスタンスがデータベースに正しく保存され、正しく取得できることを確認 |
| 4 | モデルの削除テスト | モデルインスタンスがデータベースから削除できることを確認 |
| 5 | モデルの関連テスト | ForeignKeyやManyToManyFieldなど、モデル間の関係が正しく機能していることを確認 |
| 6 | モデルのバリデーションテスト | モデルのバリデーションが期待通りに機能していることを検証 |


### よく使われるメソッド
・以下のようなメソッドを使ってテストを作っていく。

#### 1. assertEqual(a, b)
a と b が等しいことを確認。等しくない場合はテストが失敗。
``` python
self.assertEqual(1, 1)  # Pass
self.assertEqual('hello', 'hello')  # Pass
self.assertEqual(1, 2)  # Fail
```

#### 2. assertNotEqual(a, b)
a と b が等しくないことを確認。等しい場合はテストが失敗。
``` python
self.assertNotEqual(1, 2)  # Pass
self.assertNotEqual('hello', 'world')  # Pass
self.assertNotEqual(1, 1)  # Fail
```

#### 3. assertTrue(x)
x が True であることを確認。True でない場合はテストが失敗。
``` python
self.assertTrue(True)  # Pass
self.assertTrue(1 == 1)  # Pass
self.assertTrue(False)  # Fail
```

#### 4. assertFalse(x)
x が False であることを確認。False でない場合はテストが失敗。
``` python
self.assertFalse(False)  # Pass
self.assertFalse(1 == 2)  # Pass
self.assertFalse(True)  # Fail
```

#### 5. assertRaises(Exception, func, *args, **kwargs)
func の実行時に指定した Exception が発生することを確認。期待する例外が発生しない場合はテストが失敗。
``` python
with self.assertRaises(ValueError):
    int('abc')
```

#### 6. assertIn(a, b)
a が b に含まれていることを確認。含まれていない場合はテストが失敗。
``` python
self.assertIn(1, [1, 2, 3])  # Pass
self.assertIn('h', 'hello')  # Pass
self.assertIn(5, [1, 2, 3])  # Fail
```

#### 7. assertNotIn(a, b)
a が b に含まれていないことを確認。含まれている場合はテストが失敗。
``` python
self.assertNotIn(5, [1, 2, 3])  # Pass
self.assertNotIn('z', 'hello')  # Pass
self.assertNotIn(1, [1, 2, 3])  # Fail
```

#### 8. assertIsNone(x)
x が None であることを確認。None でない場合はテストが失敗。
``` python
self.assertIsNone(None)  # Pass
self.assertIsNone(not None)  # Fail
```

#### 9. assertIsNotNone(x)
x が None でないことを確認。None である場合はテストが失敗。
``` python
self.assertIsNotNone(1)  # Pass
self.assertIsNotNone(None)  # Fail
```

#### 10. assertIsInstance(obj, cls, msg=None)
objが clsに指定したデータ型のオブジェクトかサブクラスのオブジェクトか確認。データ型が一致しない場合はテストが失敗。
``` python
self.assertIsInstance(123, int)  # Pass
self.assertIsInstance("Hello", str)  # Pass
# テストが失敗した場合にカスタムメッセージを表示
self.assertIsInstance(123, str, "123 is not an instance of str")  # Fail
```


## 準備

### とりあえずモデルを準備
・user　に紐づいた project モデルを作成
・userはカスタムユーザーを利用

https://qiita.com/mkthrkw/items/b2ea8fb8f33a68faab87

``` python:models.py
from django.db import models
from django.conf import settings
import ulid


class Project(models.Model):
    id          = models.CharField(default=ulid.new, max_length=26, primary_key=True, editable=False)
    name        = models.CharField(max_length=30)
    description = models.CharField(max_length=1000, null=True, blank=True)
    is_active   = models.BooleanField(default=True)
    created_at  = models.DateTimeField(auto_now_add=True)
    updated_at  = models.DateTimeField(auto_now=True)
    user        = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='projects', on_delete=models.CASCADE)
    def __str__(self): return self.name
```


## テスト作成

### ディレクトリ、ファイル準備
・デフォルトのtests.pyは削除
・testsディレクトリを準備し、その中に各種テストファイルを作成
　ファイル名を'test'から始まるものになっていればdjangoが自動的に認識してくれる
``` shell
...
└─ tests
  |- __init__.py
  │- test_models.py
  |- test_urls.py
  |- test_views.py
|- admin.py
|- apps.py
|- models.py
```



### test_models.pyを書いていく


#### TestCaseを継承して作成していく
``` python: test_models.py
from django.test import TestCase

class ProjectModelTest(TestCase):
    # 関数で書いていく
```


#### フィールド検証テスト
・各フィールドで設定した内容が、設定通りに動くかのテスト
・nullの許容確認や、デフォルト値の確認も行う
``` python: test_models.py
 def test_field_attributes(self):
    project = Project()
    # id フィールドの検証
    self.assertEqual(project._meta.get_field('id').primary_key, True)
    self.assertEqual(project._meta.get_field('id').editable, False)
    self.assertEqual(project._meta.get_field('id').default, ulid.new)
    self.assertEqual(project._meta.get_field('id').max_length, 26)
    # name フィールドの検証
    self.assertEqual(project._meta.get_field('name').max_length, 30)
    self.assertEqual(project._meta.get_field('name').blank, False)
    self.assertEqual(project._meta.get_field('name').null, False)
    # description フィールドの検証
    self.assertEqual(project._meta.get_field('description').max_length, 1000)
    self.assertEqual(project._meta.get_field('description').blank, True)
    self.assertEqual(project._meta.get_field('description').null, True)
    # is_active フィールドの検証
    self.assertEqual(project._meta.get_field('is_active').default, True)
```


#### モデルの保存と取得のテスト
・ユーザーを作成し、そのユーザーにてプロジェクトを作成
・レコードを取得し、作成したデータと一致するか確認

:::note info
カスタムユーザーを使っているため、
```get_user_model()```にて```settings.AUTH_USER_MODEL```に
紐づいたユーザーモデルを取得
:::

``` python: test_models.py
from django.contrib.auth import get_user_model
```
``` python: test_models.py
def test_saving_and_retrieving(self):
    User = get_user_model()
    user = User.objects.create(email='testuser@test.com', password='testpass')
    project = Project.objects.create(name='Test Project', user=user)
    project.save()
    self.assertEqual(Project.objects.first(), project)
```

#### リレーションのテスト
・プロジェクトを作成し、紐づいたユーザーが作成する際のユーザーと一致するかチェック

``` python: test_models.py
def test_model_relations(self):
    User = get_user_model()
    user = User.objects.create(email='testuser@test.com', password='testpass')
    project = Project.objects.create(name='Test Project', user=user)
    project.save()
    self.assertEqual(project.user, user)
```


#### タイムスタンプの検証
・created_at と updated_at が ```datetime.datetime``` 型のインスタンスであることを確認
・auto_now_add と auto_now がそれぞれ正しく動作して日時を自動的に設定しているかを確認

``` python: test_models.py
def test_auto_timestamp_fields(self):
    User = get_user_model()
    user = User.objects.create(email='testuser@test.com', password='testpass')
    project = Project.objects.create(name='Test Project', user=user)
    project.save()
    self.assertIsInstance(project.created_at, datetime.datetime)
    self.assertIsInstance(project.updated_at, datetime.datetime)
```


#### モデルの削除テスト
・インスタンス作成し、削除
・削除後に作成したオブジェクトを取得しようとして```DoesNotExist```となるか確認
``` python: test_models.py
def test_model_deletion(self):
    User = get_user_model()
    user = User.objects.create(email='testuser@test.com', password='testpass')
    project = Project.objects.create(name='Test Project', user=user)
    project.save()
    project_id = project.id
    project.delete()
    with self.assertRaises(Project.DoesNotExist):
        Project.objects.get(id=project_id)
```


#### モデルメソッドのテスト
・__str__メソッドが正しくnameをリターンされるかチェック

``` python: test_models.py
def test_string_representation(self):
    project = Project(name='Test Project')
    self.assertEqual(str(project), 'Test Project')
```




## test実行
テスト実行
``` shell
python manage.py test
```
全てのテストが成功すればこのような表示となる
``` shell
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.003s

OK
Destroying test database for alias 'default'...
```







## おわりに
・仕様通りのモデルになっているか、テストを作成するのは非常に重要
・読みやすいテストについてもう少し調べる必要がありそう

## 参考

https://docs.djangoproject.com/ja/5.0/intro/tutorial05/