FastAPI を使ってWEBアプリを作ってみる その4

Python,TIPSDocker,FastAPI,PostgreSQL,pytest

前回の投稿ではリポジトリパターンの導入と依存性注入を行い、APIエンドポイントをPostgreSQLと接続させました。
今回の投稿では前回までに作成した構成でユニットテストを行うための土台作りをし、いくつかのテストケースを実装します。
方針として、テストで使用するPostgreSQLはテスト開始時に使い捨ての専用のコンテナを新規で立ち上げることにします。

過去の投稿はこちらから辿ることができます。

FastAPI を使ってWEBアプリを作ってみる その1FastAPIとDockerでHelloWorld
FastAPI を使ってWEBアプリを作ってみる その2AlembicとPostgreSQLでDB Migrate
FastAPI を使ってWEBアプリを作ってみる その3APIエンドポイントをPostgreSQLに接続
FastAPI を使ってWEBアプリを作ってみる その4今ここ

テストについて

エンジニア達はテストについて十人十色、様々な意見を持っていると思います。

  • カバレッジが100%になっていなければ意味がない
  • テストは運用するのにコストが掛かるくせに全然バグを炙り出してくれない
  • 時間がないのでテストはしてない、でも最終的にはやるつもり
  • Dockerでやればいい

以上は私が実際に過去関わった人たちの意見です。
どれが正しい意見なのか理解するつもりはありませんが、何年か前に比べクラウドにおけるCICD周りのエコサイクルも発達し、敷居もコストも大幅に下がってきています。

テストケースを書いておくと潜在的なバグを踏みつけてしまった時のメンタルダメージに対するお薬になり得るので、今回のようなベースとなるアプリ実行基盤が用意できた段階でテスト構築を行うことを強くお勧めします。

Credits

この投稿で作成するテスト関連のコードは以下のGitHubリポジトリを参考にしています。

pytest の導入

実際にテストを作成し実行するのに pytest を利用していきます。
pytest は成熟したPython用のテストツールです。

最初に依存関係の追加を行いましょう。

fastapi==0.63.0
uvicorn==0.13.3
pydantic==1.7.3

databases[postgresql]==0.4.1
SQLAlchemy==1.3.22
alembic==1.5.2
psycopg2==2.8.6

pytest==6.2.1 # 追加
pytest-asyncio==0.14.0 # 追加
httpx==0.16.1 # 追加
asgi-lifespan==1.0.1 # 追加
docker==4.4.1 # 追加
  • pytest テストフレームワーク
  • pytest-asyncio 非同期コードをテストするためのユーティリティを提供
  • httpx エンドポイントをテストするための非同期リクエストクライアントを提供
  • asgi-lifespan ASGIサーバーを起動せずに非同期アプリケーションをテスト可能にする
  • docker Python用のDocker SDK

今回はこの5種のパッケージを組み合わせてテストシステムを構築します。
続いて、testsディレクトリの配下にconfigファイルとテスト用ファイルを配置しましょう。

$ touch backend/tests/__init__.py 
$ touch backend/tests/conftest.py
$ touch backend/tests/test_hedgehogs.py backend/tests/utility.py
import time
from functools import wraps
from typing import Any, Callable, Type

import psycopg2


def do_with_retry(
    catching_exc: Type[Exception], reraised_exc: Type[Exception], error_msg: str
) -> Callable:  # pragma: no cover
    def outer_wrapper(call: Callable) -> Callable:
        @wraps(call)
        def inner_wrapper(*args: Any, **kwargs: Any) -> Any:
            delay = 0.001
            for i in range(15):
                try:
                    return call(*args, **kwargs)
                except catching_exc:
                    time.sleep(delay)
                    delay *= 2
            else:  # pragma: no cover
                raise reraised_exc(error_msg)

        return inner_wrapper

    return outer_wrapper


@do_with_retry(psycopg2.Error, RuntimeError, "Cannot start postgres server")
def ping_postgres(dsn: str) -> None:  # pragma: no cover
    conn = psycopg2.connect(dsn)
    cur = conn.cursor()
    cur.execute('select pid, state from pg_stat_activity;')
    cur.close()
    conn.close()

このファイルではPostgreSQLとの接続が確率されるまで待機する目的の関数を作成しており、接続の確率に失敗するたびインターバルを少しずつ伸ばし最大で30秒と少しの間試行し続けます。
接続に成功した時点で処理は終了し、最後まで確率できなかった際は例外をスローします。

続いて conftest.pyファイルを作成します。

import os
import subprocess
import uuid
import warnings

import alembic
import docker as pydocker
import pytest
from alembic.config import Config
from app.db.repositories.hedgehogs import HedgehogsRepository
from app.models.hedgehog import HedgehogCreate, HedgehogInDB
from asgi_lifespan import LifespanManager
from databases import Database
from fastapi import FastAPI
from httpx import AsyncClient


from tests.utility import ping_postgres

config = Config("alembic.ini")


@pytest.fixture(scope="session")
def docker() -> pydocker.APIClient:
    # base url is the unix socket we use to communicate with docker
    return pydocker.APIClient(base_url="unix://var/run/docker.sock", version="auto")


@pytest.fixture(scope="session", autouse=True)
def postgres_container(docker: pydocker.APIClient) -> None:
    """
    Use docker to spin up a postgres container for the duration of the testing session.
    Kill it as soon as all tests are run.
    DB actions persist across the entirety of the testing session.
    """
    warnings.filterwarnings("ignore", category=DeprecationWarning)

    image = "postgres:12.1-alpine"
    docker.pull(image)

    # create the new container using
    # the same image used by our database
    command = """head -1 /proc/self/cgroup|cut -d/ -f3"""
    bin_own_container_id = subprocess.check_output(['sh', '-c', command])
    own_container_id = bin_own_container_id.decode().replace('\n', '')
    inspection = docker.inspect_container(own_container_id)

    network = list(inspection["NetworkSettings"]["Networks"].keys())[0]
    networking_config = docker.create_networking_config({
        network: docker.create_endpoint_config()
    })

    container_name = f"test-postgres-{uuid.uuid4()}"
    container = docker.create_container(
        image=image,
        name=container_name,
        detach=True,
        networking_config=networking_config
    )
    docker.start(container=container["Id"])

    inspection = docker.inspect_container(container["Id"])
    ip_address = inspection['NetworkSettings']['Networks'][network]['IPAddress']
    dsn = f"postgresql://postgres:postgres@{ip_address}/postgres"

    try:
        ping_postgres(dsn)
        os.environ['CONTAINER_DSN'] = dsn
        alembic.command.upgrade(config, "head")
        yield container
    finally:
        docker.kill(container["Id"])
        docker.remove_container(container["Id"])


@pytest.fixture
def app() -> FastAPI:
    from app.api.server import get_application
    return get_application()


@pytest.fixture
def db(app: FastAPI) -> Database:
    return app.state._db


@pytest.fixture
async def client(app: FastAPI) -> AsyncClient:
    async with LifespanManager(app):
        async with AsyncClient(
            app=app,
            base_url="http://testserver",
            headers={"Content-Type": "application/json"}
        ) as client:
            yield client

少し長くなってしまいました、何を行っているのか説明していきます。

postgres_container の部分はテストで使用するPostgreSQLコンテナを立ち上げるフィクスチャで、autouse=True をデコレータで指定することでテストに対して前後処理として追加することができます。
前処理では、FastAPIコンテナが稼働しているネットワーク上にDocker SDKを使用して新しいPostgreSQLコンテナを立ち上げ、上述した接続確認を行ってからDSNを CONTAINER_DSN という環境変数にセットしています。

後ほど、この CONTAINER_DSN は接続先DBの分岐に使用します。

app db のフィクスチャは標準的な実装で、それぞれFastAPIインスタンスを作成し必要に応じてデータベース接続の参照を取得します。

client フィクスチャでは実行中のアプリケーションにリクエストを送信できる、クリーンなテストクライアントを用意するために LifespanManager と AsyncClient を使用しています。
これは asgi-lifespan のサンプルを参考にしました。

テスト環境のセットアップはこれで完了です。
次にテスト時に接続されるデータベースを conftest.py で作成されるPostgreSQLコンテナに向くように分岐させる必要があります。

データベースの向き先変更

最初にデータベースへの接続文字列を更新します。

import logging
import os  # 追加

from app.core.config import DATABASE_URL
from databases import Database
from fastapi import FastAPI

logger = logging.getLogger(__name__)


async def connect_to_db(app: FastAPI) -> None:
    CONTAINER_DSN = os.environ.get('CONTAINER_DSN', '')  # 追加
    DB_URL = CONTAINER_DSN if CONTAINER_DSN else DATABASE_URL  # 追加
    database = Database(DB_URL, min_size=2, max_size=5)

    try:
        await database.connect()
        app.state._db = database
    except Exception as e:
        logger.warn("--- DATABASE CONNECTION ERROR ---")
        logger.warn(e)
        logger.warn("--- DATABASE CONNECTION ERROR ---")


async def close_db_connection(app: FastAPI) -> None:
    try:
        await app.state._db.disconnect()
    except Exception as e:
        logger.warn("--- DATABASEDISCONNECT ERROR ---")
        logger.warn(e)
        logger.warn("--- DATABASE DISCONNECT ERROR ---")

先ほどPostgreSQLコンテナを作成した際に格納した CONTAINER_DSN が存在しているかどうかで接続先のデータベースを切り替えます。
アプリを普通に立ち上げた際は os.environ.get の第2パラメータで指定した空文字がデフォルト値として入るため、.envファイルに記載した環境変数が適用されます。

同様の分岐をマイグレート時にも組み込む必要があります。

import logging
import os
import pathlib
import sys
from logging.config import fileConfig

import alembic
from sqlalchemy import engine_from_config, pool

sys.path.append(str(pathlib.Path(__file__).resolve().parents[3]))
from app.core.config import DATABASE_URL  # noqa

config = alembic.context.config
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")


def run_migrations_online() -> None:
    CONTAINER_DSN = os.environ.get('CONTAINER_DSN', '')
    DB_URL = CONTAINER_DSN if CONTAINER_DSN else DATABASE_URL
    logger.info('Run migrate on {0}'.format(str(DB_URL)))

    connectable = config.attributes.get("connection", None)
    config.set_main_option("sqlalchemy.url", str(DB_URL))

    if connectable is None:
        connectable = engine_from_config(
            config.get_section(config.config_ini_section),
            prefix="sqlalchemy.",
            poolclass=pool.NullPool,
        )

    with connectable.connect() as connection:
        alembic.context.configure(
            connection=connection,
            target_metadata=None
        )
        with alembic.context.begin_transaction():
            alembic.context.run_migrations()


run_migrations_online()

行っていることは先ほどの tasks.py と同じです。
CONTAINER_DSN の有無を確認し、存在していた際はテスト用コンテナのPostgreSQLに向き先を変更しています。

これでテスト実行時に新しいPostgreSQLコンテナに接続先を変更する全ての初期設定が完了しました。
続いて本題のテストケースを書いていきます。

テストケースの作成

import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY


class TestHedgehogsRoutes:
    @pytest.mark.asyncio
    async def test_routes_exist(
        self,
        app: FastAPI,
        client: AsyncClient
    ) -> None:
        res = await client.post(app.url_path_for("hedgehogs:create-hedgehog"), json={})
        assert res.status_code != HTTP_404_NOT_FOUND

    @pytest.mark.asyncio
    async def test_invalid_input_raises_error(
        self,
        app: FastAPI,
        client: AsyncClient
    ) -> None:
        res = await client.post(app.url_path_for("hedgehogs:create-hedgehog"), json={})
        assert res.status_code == HTTP_422_UNPROCESSABLE_ENTITY

まずはルートが存在することを単純に確認するテストを用意しました。
@pytest.mark.asyncio このデコレータを付与することで非同期にテストを処理することができます。
それぞれの関数には appclient をパラメータにしていますがこれは conftest.pyファイルで定義したフィクスチャが使用されます。

app.url_path_for("hedgehogs:create-hedgehog") この部分を見ればわかるようにDjangoと同じURL反転を備えているためフルパスを書かずにルートを指定することができます。

2番目のテストは最初と全く同じリクエストを送信しますが、応答に422ステータスコードが含まれていないことを期待しています。
FastAPIはPOSTのbodyに無効な値が含まれていると常に HTTP_422 のステータスを返しますが、これは以前の投稿で Pydantic を使用してモデルを作成した時に指定した型をベースに検証されます。
json={} をbody に指定したため検証が通らず HTTP_422 がスローされテストに合格されるはずです。

一度試しにテストを実行してみましょう。

$ pytest -vv
======================= test session starts =======================
platform linux -- Python 3.9.1, pytest-6.2.1, py-1.10.0, pluggy-0.13.1 -- /usr/local/bin/python3
cachedir: .pytest_cache
rootdir: /backend
plugins: asyncio-0.14.0
collected 2 items

tests/test_hedgehogs.py::TestHedgehogsRoutes::test_routes_exist PASSED [ 50%]
tests/test_hedgehogs.py::TestHedgehogsRoutes::test_invalid_input_raises_error PASSED [100%]

======================= 2 passed, in 4.37s ========================

期待どおりテストに合格しました。

次にPOSTリクエストの結果正常にレコードが作成されるかテストを書いて検証してみます。
ただその前に、毎回関数にデコレータをセットするのは少し面倒なため全ての関数に自動でデコレータを付与するように変更しましょう。

import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY

pytestmark = pytest.mark.asyncio


class TestHedgehogsRoutes:
    async def test_routes_exist(
        self,
        app: FastAPI,
        client: AsyncClient
    ) -> None:
        res = await client.post(app.url_path_for("hedgehogs:create-hedgehog"), json={})
        assert res.status_code != HTTP_404_NOT_FOUND

    async def test_invalid_input_raises_error(
        self,
        app: FastAPI,
        client: AsyncClient
    ) -> None:
        res = await client.post(app.url_path_for("hedgehogs:create-hedgehog"), json={})
        assert res.status_code == HTTP_422_UNPROCESSABLE_ENTITY

pytestmark = pytest.mark.asyncio この一文により、ファイル内の関数には自動的にデコレータが実行時に付与されます。

本題に戻りPOSTリクエストの検証をしてみましょう。

import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from starlette.status import (HTTP_201_CREATED, 
                              HTTP_404_NOT_FOUND, 
                              HTTP_422_UNPROCESSABLE_ENTITY)

pytestmark = pytest.mark.asyncio


@pytest.fixture
def new_hedgehog():
    return HedgehogCreate(
        name="test hedgehog",
        description="test description",
        age=0.0,
        color_type="SOLT & PEPPER",
    )


class TestHedgehogsRoutes:
# ...省略

class TestCreateHedgehog:
    async def test_valid_input_creates_hedgehog(
        self, 
        app: FastAPI,
        client: AsyncClient,
        new_hedgehog: HedgehogCreate
    ) -> None:
        res = await client.post(
            app.url_path_for("hedgehogs:create-hedgehog"),
            json={"new_hedgehog": new_hedgehog.dict()}
        )
        assert res.status_code == HTTP_201_CREATED
        created_hedgehog = HedgehogCreate(**res.json())
        assert created_hedgehog == new_hedgehog

POSTリクエストを送信し、データベースからの応答が入力値と同じデータであることを確認しています。
これは conftest.py で作成したテスト用のPostgreSQLに対して実際にクエリを実行しているためモックは必要ありません。

ここでテストを実行すると無事にパスしたログが表示されるはずですが、その前に無効なデータが投入された時のテストも追加しておきましょう。

TestCreateHedgehog クラスに関数を追加します。

# ...省略
class TestCreateHedgehog:
    async def test_valid_input_creates_hedgehog(
        self,
        app: FastAPI,
        client: AsyncClient, 
        new_hedgehog: HedgehogCreate
    ) -> None:
        res = await client.post(
            app.url_path_for("hedgehogs:create-hedgehog"),
            json={"new_hedgehog": new_hedgehog.dict()}
        )
        assert res.status_code == HTTP_201_CREATED
        created_hedgehog = HedgehogCreate(**res.json())
        assert created_hedgehog == new_hedgehog

    @pytest.mark.parametrize(
        "invalid_payload, status_code",
        (
            (None, 422),
            ({}, 422),
            ({"name": "test_name"}, 422),
            ({"age": 2}, 422),
            ({"name": "test_name", "description": "test"}, 422),
        ),
    )
    async def test_invalid_input_raises_error(
        self,
        app: FastAPI,
        client: AsyncClient,
        invalid_payload: dict,
        status_code: int
    ) -> None:
        res = await client.post(
            app.url_path_for("hedgehogs:create-hedgehog"),
            json={"new_hedgehog": invalid_payload}
        )
        assert res.status_code == status_code

追加した関数では pytest 組み込みデコレータである @pytest.mark.parametrize を使用しています。
これはとても便利な機能で、テストに使用する引数と結果をセットにしパラメータ化することで複数パターンのテストを一度に実行することができます。

今回は5パターンのテストパラメータを用意したため、pytestを実行すると全部で8つのテストにパスするはずです。

パスパラメータを伴うエンドポイントの作成

先ほどまでの作業で実際にPOSTリクエストを送信しレコードを作成できることが検証されました。
次はパスパラメータで id を指定し、単一のレコードを取得するエンドポイントを作ってみましょう。

テスト駆動開発チックに最初にテストコードを用意してみましょう。

# ...省略
from app.models.hedgehog import HedgehogCreate, HedgehogInDB
from starlette.status import (HTTP_200_OK, HTTP_201_CREATED,
                              HTTP_404_NOT_FOUND,
                              HTTP_422_UNPROCESSABLE_ENTITY)
# ...省略

class TestGetHedgehog:
    async def test_get_hedgehog_by_id(
        self,
        app: FastAPI,
        client: AsyncClient
    ) -> None:
        res = await client.get(app.url_path_for(
            "hedgehogs:get-hedgehog-by-id",
            id=1
        ))
        assert res.status_code == HTTP_200_OK
        hedgehog = HedgehogInDB(**res.json())
        assert hedgehog.id == 1

引数の id は app.url_path_for 関数に対して渡し、client.get 関数には渡さないことに注意してください。

この状態でテストを実行するとこれまでに作成した8つのテストにパスし、今回作成した1つのテストが starlette.routing.NoMatchFound エラーで失敗します。
まだ対応する hedgehogs:get-hedgehog-by-id を作成していないので当然ですね。

それではこのルートを早速作成します。

from fastapi import APIRouter, Body, Depends, HTTPException
from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND
# ...省略

@router.get("/{id}/", response_model=HedgehogPublic,
            name="hedgehogs:get-hedgehog-by-id")
async def get_hedgehog_by_id(
    id: int, hedgehogs_repo: HedgehogsRepository = Depends(
        get_repository(HedgehogsRepository
                       ))
) -> HedgehogPublic:
    hedgehog = await hedgehogs_repo.get_hedgehog_by_id(id=id)
    if not hedgehog:
        raise HTTPException(
            status_code=HTTP_404_NOT_FOUND,
            detail="指定されたidのハリネズミは見つかりませんでした"
        )
    return hedgehog

@router.get ルートを作成し、パスパラメータとして {id} を追加しました。
これにより関数内では id を変数として扱うことができます。

挙動としては get_hedgehog_by_id 関数に id を渡して該当するハリネズミさんを受け取り、存在しなかった際は HTTP_404 を返却します。

続いて get_hedgehog_by_id を作成しましょう。

from app.db.repositories.base import BaseRepository
from app.models.hedgehog import HedgehogCreate, HedgehogInDB

CREATE_HEDGEHOG_QUERY = """
    INSERT INTO hedgehogs (name, description, age, color_type)
    VALUES (:name, :description, :age, :color_type)
    RETURNING id, name, description, age, color_type;
"""
GET_HEDGEHOG_BY_ID_QUERY = """
    SELECT id, name, description, age, color_type
    FROM hedgehogs
    WHERE id = :id;
"""


class HedgehogsRepository(BaseRepository):
    async def create_hedgehog(self, *, new_hedgehog: HedgehogCreate) -> HedgehogInDB:
        query_values = new_hedgehog.dict()
        hedgehog = await self.db.fetch_one(
            query=CREATE_HEDGEHOG_QUERY,
            values=query_values
        )
        return HedgehogInDB(**hedgehog)

    async def get_hedgehog_by_id(self, *, id: int) -> HedgehogInDB:
        hedgehog = await self.db.fetch_one(
            query=GET_HEDGEHOG_BY_ID_QUERY,
            values={"id": id}
        )
        if not hedgehog:
            return None
        return HedgehogInDB(**hedgehog)

HedgehogsRepository を GET_HEDGEHOG_BY_ID_QUERY を実行する関数で更新しました。
見つかった場合はレコードを返し、それ以外の場合は None を返すことでルートは HTTP_404 を戻します。

これでテストは全てパスするはずです。
conftest.pyで用意したPostgreSQLコンテナは、scope="session" を指定したためテストセッションの間保持されます。
そのため直前のテストケースでPOSTリクエストを送信し、レコードを作成したため None が帰らずテストにパスしているのです。

つまりテストケースを変更したり順序を入れ替えた場合、このテストは失敗になる可能性があります。
よって今のまま id をハードコーディングしておくのは得策でないためもう少し信頼性の高いアプローチに変更しましょう。

# ...省略
from app.db.repositories.hedgehogs import HedgehogsRepository
from app.models.hedgehog import HedgehogCreate, HedgehogInDB
# ...省略

@pytest.fixture
async def test_hedgehog(db: Database) -> HedgehogInDB:
    hedgehog_repo = HedgehogsRepository(db)
    new_hedgehog = HedgehogCreate(
        name="fake hedgehog name",
        description="fake description",
        age=2.2,
        color_type="SOLT & PEPPER",
    )
    return await hedgehog_repo.create_hedgehog(new_hedgehog=new_hedgehog)

pytest で一番最初に実行されるフィクスチャの中で予めレコードを一つ作成してしまうアプローチを取りました。
これで確実に id: 1 のレコードが存在した状態のデータベースでテストが実行されます。

これに併せてテストコードからマジックナンバーを撤去しましょう。

# ...省略
from app.models.hedgehog import HedgehogCreate, HedgehogInDB
# ...省略

class TestGetHedgehog:
    async def test_get_hedgehog_by_id(
        self,
        app: FastAPI,
        client: AsyncClient,
        test_hedgehog: HedgehogInDB
    ) -> None:
        res = await client.get(app.url_path_for(
            "hedgehogs:get-hedgehog-by-id",
            id=test_hedgehog.id
        ))
        assert res.status_code == HTTP_200_OK
        hedgehog = HedgehogInDB(**res.json())
        assert hedgehog == test_hedgehog

ここまで更新できた状態でテストを実行すると滞りなくテストに通過するはずです。
つまり先ほど実施したフィクスチャが正常に機能していることが確認できたため、マジックナンバーを埋め込む必要がなくなりました。

OSSやチームで開発している場合、誰がいつ、どこを編集するかわかりません。
原則として些細なことでも「このくらい、見れば誰でもわかるだろう」とタカを括らずにハードコーディングすることは避けたいですね(過去の自分への戒め)

それでは、最後に無効な id が指定された場合のテストを作成します。

# ...省略

class TestGetHedgehog:
    async def test_get_hedgehog_by_id(
        self,
        app: FastAPI,
        client: AsyncClient,
        test_hedgehog: HedgehogInDB
    ) -> None:
        res = await client.get(app.url_path_for(
            "hedgehogs:get-hedgehog-by-id",
            id=test_hedgehog.id
        ))
        assert res.status_code == HTTP_200_OK
        hedgehog = HedgehogInDB(**res.json())
        assert hedgehog == test_hedgehog

    @pytest.mark.parametrize(
        "id, status_code",
        (
            (500, 404),
            (-1, 404),
            (None, 422),
        ),
    )
    async def test_wrong_id_returns_error(
        self, app: FastAPI, client: AsyncClient, id: int, status_code: int
    ) -> None:
        res = await client.get(app.url_path_for("hedgehogs:get-hedgehog-by-id", id=id))
        assert res.status_code == status_code

@pytest.mark.parametrize についてはもう大丈夫ですね。
今回は3種類の無効な id と期待するステータスコードをセットしました。

これでテストを実行すると、全部で12個のテストが緑色に染まるはずです。

$ pytest -vv
====================== test session starts =======================
platform linux -- Python 3.9.1, pytest-6.2.1, py-1.10.0, pluggy-0.13.1 -- /usr/local/bin/python3
cachedir: .pytest_cache
rootdir: /backend
plugins: asyncio-0.14.0
collected 12 items                                               

tests/test_hedgehogs.py::TestHedgehogsRoutes::test_routes_exist PASSED [  8%]
tests/test_hedgehogs.py::TestHedgehogsRoutes::test_invalid_input_raises_error PASSED [ 16%]
tests/test_hedgehogs.py::TestCreateHedgehog::test_valid_input_creates_hedgehog PASSED [ 25%]
tests/test_hedgehogs.py::TestCreateHedgehog::test_invalid_input_raises_error[None-422] PASSED [ 33%]
tests/test_hedgehogs.py::TestCreateHedgehog::test_invalid_input_raises_error[invalid_payload1-422] PASSED [ 41%]
tests/test_hedgehogs.py::TestCreateHedgehog::test_invalid_input_raises_error[invalid_payload2-422] PASSED [ 50%]
tests/test_hedgehogs.py::TestCreateHedgehog::test_invalid_input_raises_error[invalid_payload3-422] PASSED [ 58%]
tests/test_hedgehogs.py::TestCreateHedgehog::test_invalid_input_raises_error[invalid_payload4-422] PASSED [ 66%]
tests/test_hedgehogs.py::TestGetHedgehog::test_get_hedgehog_by_id PASSED [ 75%]
tests/test_hedgehogs.py::TestGetHedgehog::test_wrong_id_returns_error[500-404] PASSED [ 83%]
tests/test_hedgehogs.py::TestGetHedgehog::test_wrong_id_returns_error[-1-404] PASSED [ 91%]
tests/test_hedgehogs.py::TestGetHedgehog::test_wrong_id_returns_error[None-422] PASSED [100%]

======================= 12 passed in 4.49s =======================

まとめ

今回の投稿は少し長くなってしまいました。
その分、皆さんの開発で参考にできる部分も多かったと思います。

この投稿で作成したことを改めてまとめると、

  • pytest を導入し conftest.py を構成
  • pytest はテストセッションごとに新しいPostgreSQLコンテナを新規作成しテストに使用する
  • テスト用のPostgreSQLはそのセッションの間は持続される

これで殆どの基礎セットアップが完了しました。
以降の投稿では機能の開発に注力することができます!

作成したコードはGitHubにアップしています。
part4ブランチが対応しています。

次回の更新は次の週末を予定していますが、体力に余裕上がったら週半ばくらいで一度更新するかもしれません。