Pytestでメール送信テストを行おう！

## 概要
Django Rest FrameworkでDockerとMailCatcherを使ってメール送信を行おう！

https://qiita.com/shun198/items/35c97c95079ecbe80e9d

で作成したメール送信機能のテストコードをPytestで書いてみましょう
今回のテストでは
- 件名
- 宛先
- 送信元

が正しいかどうか確認していきたいと思います

## 前提
- フレームワークはDjango Rest Framework
- pytest-djangoをインストール済み

## ディレクトリ構成
```
tree
・
├── application
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── emails.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── templates
│   │   ├── invite_user_email.html
│   │   └── invite_user_email.txt
│   ├── tests
│   │   ├── factories
│   │   │    └── user.py
│   │   ├── conftest.py
│   │   ├── common_method.py
│   │   └── test_invite_user.py
│   ├── urls.py
│   └── views.py
└── project
    └── settings.py
```

## 実装
今回は
- pytest.ini
- models.py
- user.py
- conftest.py
- common_method.py
- test_invite_user.py

に必要な設定を行います

### pytest.ini
PytestをDjangoで使用するには`DJANGO_SETTINGS_MODULE`という環境変数に<プロジェクト名>.settingsの値を入れないと
```
raise ImproperlyConfigured(
E   django.core.exceptions.ImproperlyConfigured: 
Requested setting INSTALLED_APPS, but settings are not configured. 
You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.
```
と表示されてしまいます
今回はプロジェクト名がprojectのため、pytest.iniをプロジェクト直下に配置し、以下のように記載します
```pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE=project.settings
```
### models.py
```models.py
import uuid

from django.contrib.auth.models import AbstractUser
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.core.validators import RegexValidator
from django.db import models


class User(AbstractUser):
    """システムユーザ"""

    username_validator = UnicodeUsernameValidator()

    class Role(models.IntegerChoices):
        """システムユーザのロール

        Args:
            MANAGEMENT(0): 管理者
            GENERAL(1):    一般
        """

        MANAGEMENT = 0, "管理者"
        GENERAL = 1, "一般"

    # 不要なフィールドはNoneにすることができる
    first_name = None
    last_name = None
    date_joined = None
    groups = None
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False,
        db_comment="システムユーザID",
    )
    employee_number = models.CharField(
        unique=True,
        validators=[RegexValidator(r"^[0-9]{8}$")],
        max_length=8,
        db_comment="社員番号",
    )
    username = models.CharField(
        max_length=150,
        unique=True,
        validators=[username_validator],
        db_comment="ユーザ名",
    )
    email = models.EmailField(
        max_length=254,
        unique=True,
        db_comment="メールアドレス",
    )
    role = models.PositiveIntegerField(
        choices=Role.choices,
        default=Role.MANAGEMENT,
        db_comment="システムユーザのロール",
    )
    created_at = models.DateTimeField(
        auto_now_add=True,
        db_comment="作成日",
    )
    updated_at = models.DateTimeField(
        auto_now=True,
        db_comment="更新日",
    )
    is_verified = models.BooleanField(
        default=False,
        db_comment="有効化有無",
    )
    created_by = models.ForeignKey(
        "self",
        null=True,
        on_delete=models.SET_NULL,
        related_name="%(class)s_created_by",
        db_comment="作成者",
    )
    updated_by = models.ForeignKey(
        "self",
        null=True,
        on_delete=models.SET_NULL,
        related_name="%(class)s_updated_by",
        db_comment="更新者",
    )

    USERNAME_FIELD = "employee_number"
    REQUIRED_FIELDS = ["email", "username"]

    class Meta:
        ordering = ["employee_number"]
        db_table = "User"
        db_table_comment = "システムユーザ"

    def save(self, *args, **kwargs):
        # 既に登録されているシステム利用者情報の保存処理
        if self.id:
            if not "updated_by" in kwargs:
                self.updated_by = self
            else:
                self.updated_by = kwargs.get("updated_by")
                kwargs.pop("updated_by")
        super(User, self).save(*args, **kwargs)

    def __str__(self):
        return self.username


class UserInvitation(models.Model):
    """ユーザ招待用テーブルに対応するモデルクラス"""

    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False,
        db_comment="システムユーザID",
    )
    token = models.CharField(
        max_length=255,
        db_comment="ユーザ招待用メールURL用トークン",
    )
    expiry = models.DateTimeField(
        null=True,
        default=None,
        db_comment="有効期限",
    )
    created_at = models.DateTimeField(
        auto_now_add=True,
        db_comment="作成日時",
    )
    user = models.ForeignKey(
        User,
        on_delete=models.PROTECT,
        related_name="user_invitation",
        db_comment="社員テーブル外部キー",
    )
    is_used = models.BooleanField(
        default=False,
        db_comment="使用有無",
    )

    class Meta:
        db_table = "Invitation"
        db_table_comment = "ユーザ招待用"
```

### UserのFactoryの作成
テストで使用するUserのFactoryクラスを作成します

```application/tests/factories/user.py
from datetime import datetime, timedelta

from factory import Faker, PostGenerationMethodCall, Sequence
from factory.django import DjangoModelFactory

from application.models import User


class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    username = Sequence(lambda n: "テスト利用者{}".format(n))
    employee_number = Sequence(lambda n: "{0:08}".format(n + 100))
    password = PostGenerationMethodCall("set_password", "test")
    email = Faker("email")
    role = Faker(
        "random_int",
        min=0,
        max=2,
    )
    created_at = Faker(
        "date_between_dates",
        date_start=(datetime.now() - timedelta(days=20)).date(),
        date_end=datetime.now(),
    )
    updated_at = Faker(
        "date_between_dates",
        date_start=(datetime.now() - timedelta(days=20)).date(),
        date_end=datetime.now(),
    )
    is_verified = True

```

### conftest.py
DjangoでDBを使ってPytestを実行する際はテスト用のデータベースを利用するので
- テストDBのセットアップ
- fixtureファイルでDBにデータを入れる設定
- ログイン用テスト用ユーザとパスワード
-  送信元として使うテスト用メールアドレス

のfixtureをconftest.pyに記載します
conftest.pyはアプリケーション内にtestsフォルダを作成し、その直下に配置するのが一般的です
```application/tests/conftest.py
import pytest
from django.core.management import call_command
from rest_framework.test import APIClient

from application.models.user import User
from application.tests.factories.user import UserFactory


@pytest.fixture(scope="session")
def django_db_setup(django_db_setup, django_db_blocker):
    with django_db_blocker.unblock():
        call_command("loaddata", "fixture.json")


@pytest.fixture
def client(scope="session"):
    return APIClient()


@pytest.fixture
def management_user(password):
    return UserFactory(
        password=password,
        role=User.Role.MANAGEMENT,
    )

```

### メール受信内容の確認用メソッド
```application/tests/common_method.py
def mail_confirm(mail_outbox, sender: str, message: str):
    """メールが正常に送信されたかどうかを確認するテスト
    Args:
        mail_outbox (List[EmailMessage]): Djangoテスト用のメールの受信ボックス
        sender (str): メールの送り主
        message (str): メールの件名
    """
    # メールを1通受信したことを確認
    assert len(mail_outbox) == 1
    # メールの件名が正しいことを確認
    assert mail_outbox[0].subject == message
    # メールの送信元が正しいことを確認
    # 今回は指定していないのでwebmaster@localhostを設定
    assert mail_outbox[0].from_email == "webmaster@localhost"
    # メールの送信先が正しいことを確認
    assert mail_outbox[0].to[0] == sender
```

### test_invite_user.py
django.coreのmail.outboxを使用することで受け取ったメールの
- 件名
- 宛先
- 送信元

などを調べることができます
```application/tests/test_invite_user.py
import pytest
from django.core import mail
from django.db import DatabaseError
from rest_framework import status

from application.models import User, UserInvitation
from application.tests.common_method import mail_confirm


@pytest.fixture
def get_invite_user_url():
    """社員招待用のurl"""
    return "/api/users/invite_user"


@pytest.fixture
def post_invite_user_data():
    """社員招待用のインプットデータ"""
    return {
        "employee_number": "99999990",
        "username": "テストユーザ01",
        "role": User.Role.MANAGEMENT.value,
        "email": "test_user@test.com",
    }


@pytest.mark.django_db
def test_invite_user_management(
    client,
    management_user,
    password,
    get_invite_user_url,
    post_invite_user_data,
):
    """管理者がシステムユーザを招待できることを確認"""

    client.login(
        employee_number=management_user.employee_number, password=password
    )
    response = client.post(
        get_invite_user_url,
        post_invite_user_data,
        format="json",
    )
    assert response.status_code == status.HTTP_200_OK
    assert response.json() == {"msg": "招待メールを送信しました"}
    mail_confirm(
        mail.outbox,
        sender=post_invite_user_data["email"],
        message="アカウント登録のお知らせ",
    )
    user = User.objects.get(
        employee_number=post_invite_user_data["employee_number"]
    )
    assert user
    assert UserInvitation.objects.filter(user=user).exists()
```

## テストを実行してみよう！
コマンドを実行し、テストが成功したことが確認できました
```terminal
docker-compose exec app poetry run pytest
Test session starts (platform: linux, Python 3.10.9, pytest 7.2.0, pytest-sugar 0.9.6)
django: settings: study.settings (from ini)
rootdir: /code, configfile: pytest.ini
plugins: django-4.5.2, custom-exit-code-0.3.0, sugar-0.9.6, cov-4.0.0
collecting ... 
 application/tests/test_invite_user.py ✓   100% ██████████

Results (2.81s):
       1 passed
```

## 記事の紹介
以下の記事も書きましたのでよかったら読んでみてください

https://qiita.com/shun198/items/f6864ef381ed658b5aba

https://qiita.com/shun198/items/067e122bb291fed2c839

https://qiita.com/shun198/items/be6be8453723d0887ba4

https://qiita.com/shun198/items/23c6baa450ba37a5fd66

## 参考
https://djangobrothers.com/blogs/django_mail_test/

https://nwpct1.hatenablog.com/entry/django-email