【FastAPI】テストケース毎に独立したDBデータを使用する（GitHub Actions付き）

ORMと連携した機能のテストを行う際、テストケース(= テスト関数)毎にクリーンなデータベースが欲しい（**テストケース間の依存関係が生まれてほしくない**）。

この点についていい感じの方法が実現できたためご紹介する。

- 参考（一部重複内容あり）
  - [FastAPIでテスト用のクリーンなDBを作成してpytestでAPIのUnittestを行う](https://qiita.com/bee2/items/ff9c86d8d345dbcab497)

本記事のソースコード：[skokado/fastapi-tutorial](https://github.com/skokado/fastapi-tutorial)

# 環境

- Python: 3.8
  - fastapi==0.68.2
  - SQLAlchemy==1.4.25
  - SQLAlchemy-Utils==0.37.8
  - pytest==6.2.5
  - factory-boy==3.2.0

# アプリケーション準備

ユーザ認証とブログ管理を行う簡単なアプリケーションを用意する。
※アプリケーション本体のコードは割愛するためリポジトリを参照

<details><summary>※ディレクトリ構成</summary><div>

```shell
.
├── Pipfile
├── Pipfile.lock
├── README.md
├── alembic.ini
├── app
│   ├── __init__.py
│   ├── core
│   │   ├── __init__.py
│   │   ├── celery_app.py
│   │   ├── config.py
│   │   └── security.py
│   ├── crud
│   ├── database.py
│   ├── dependencies.py
│   ├── main.py
│   ├── middlewares.py
│   ├── models
│   ├── routers
│   │   ├── __init__.py
│   │   ├── auth.py
│   │   ├── blogs.py
│   │   └── users.py
│   ├── schemas.py
│   ├── tests
│   │   ├── ...
│   └── utils
│       ├── oauth2.py
│       └── token.py
└── migration
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions
│       ├── ...
```

</div></details>

# 本題

## データベース準備

DockerコンテナでPostgreSQLを起動しておく

```shell
$ docker run -d --rm \
   --name test_db \
  -p 5433:5432 \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=app \
  postgres:13.4
```

## フィクスチャ準備

テスト用データベースを使用する設定を`conftest.py`に仕込んでおき、データベースをfixtureとして使用できるようにしておく。

※こちらのGitHub Issueも参照されたし
[Separating database for tests and dev · Issue #125 · tiangolo/full-stack-fastapi-postgresql](https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/125#issuecomment-623641124)

<details><summary>`app/tests/conftest.py`</summary><div>

```python:app/tests/conftest.py
from typing import Generator
import pytest
from sqlalchemy.orm import Session
from sqlalchemy_utils import database_exists, create_database

from app.core.config import settings
from app.database import Base
from tests.utils.overrides import TestingSessionLocal, engine


@pytest.fixture(scope="function")
def db() -> Generator[Session, None, None]:
    if not database_exists(settings.SQLALCHEMY_DATABASE_URI):
        create_database(settings.SQLALCHEMY_DATABASE_URI)

    Base.metadata.create_all(bind=engine)
    session = TestingSessionLocal()
    yield session
    session.close()
    Base.metadata.drop_all(bind=engine)
```

</div></details>

ポイントは、`db`フィクスチャのスコープを`"function"`としている点。
こうすればテスト関数毎にセッションをクローズ&データベース初期化が行われ、データがクリーンアップされる。

```python:app/tests/conftest.py
...
    session = TestingSessionLocal()
    yield session
    session.close()
    Base.metadata.drop_all(bind=engine)
```


## テスト用データ作成

factory-boyを使いFactoryクラスを作成しておく。
テストケースの中で簡単にインスタンス化できるようにする。

<details><summary>`app/tests/factories.py`</summary><div>

```python:app/tests/factories.py
from factory.alchemy import SQLAlchemyModelFactory

import app.models.user as models
from app.tests.utils.overrides import TestingSessionLocal


class UserFactory(SQLAlchemyModelFactory):
    name = 'skokado'
    email = 'skokado@example.com'
    password = 'MyPassw0rd!'

    class Meta:
        model = models.User
        sqlalchemy_session = TestingSessionLocal()
```

</div></details>

## テストケース

テストケースの中で`db`フィクスチャを使用してデータ作成をするのみで良い。

<details><summary>`app/tests/routers/test_auth.py`</summary><div>

```python:app/tests/routers/test_auth.py
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session

from app.main import app
import app.crud.users as users_crud
from app.tests import factories

client = TestClient(app)


class TestAuthRouter:

    def test_ログイン成功(self, db: Session):
        user_in = factories.UserFactory()
        users_crud.create(user_in, db)
        response = client.post(
            '/api/login',
            data={
                'grant_type': 'password',
                'username': user_in.name,
                'password': user_in.password
            }
        )
        assert response.status_code == 200
```

</div></details>

<details><summary>`app/tests/routers/test_blogs.py`</summary><div>

```python:app/tests/routers/test_blogs.py
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session

from app.main import app
import app.crud.users as users_crud
from app.core.security import create_access_token
from app.tests import factories

client = TestClient(app)
client.headers.update({
    'Authorization': 'Bearer {}'.format(create_access_token)
})


class TestBlogRouter:
    def test_list_empty_blogs(self, db: Session):
        user_in = factories.UserFactory()
        users_crud.create(user_in, db)
        # ログインする
        response = client.post(
            '/api/login',
            data={
                'grant_type': 'password',
                'username': user_in.name,
                'password': user_in.password
            }
        )
        jwt = response.json()
        headers = {
            'Authorization': f'{jwt["token_type"].capitalize()} {jwt["access_token"]}'
        }

        # アクセストークンを使用してAPIリクエスト
        response = client.get('/api/blog/', headers=headers)
        assert response.status_code == 200
        assert response.json() == []
```

</div></details>