FastAPIのCI環境を構築してみた（FastAPI・pytest・flake8・Black・GithubActions）

# はじめに
開発を進める上で、コードの品質を保つためにTest環境の構築やlint・formatterを設定する場面があると思います。
今回はFastAPIのアプリに、**pytestを用いたテスト環境とflake8のlintチェックやBlackのformatterを実装してみた**ので、記事として残したいと思います。
GithubActionsでCIを構築するところまで解説したいと思いますので、同じような実装をしたい方の参考になれば嬉しいです。

# 作りたいもののゴール

今回は以下のとおりCI環境を構築したいと思っています。

- test・lint・formatterの実行環境を構築
- GithubActionsにtestとlintのCIを設定する。


# ソースコード
以下のソースコードのリポジトリを貼らせていただきます。
主にコードの解説になりますので、こちら適宜参考にしていただければと思います。

https://github.com/ooooose/fastapi_CI

# 使用する技術・ライブラリについて
以下の技術やライブラリを選定しています。
今回はCI構築がメインなので、ライブラリごとの比較はそこまで重要視せず選定を行いました。
- Python： `3.11`
- FastAPI： `0.104.1`
- Poetry（パッケージ管理ツール）
- pytest（テストライブラリ）
- flake8(静的解析ツール)
- black（formatterツール）

全体としては以下`pyproject.toml`で管理しています。
```toml: pyproject.toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.1"
uvicorn = {extras = ["standard"], version = "^0.24.0.post1"}
sqlalchemy = "^2.0.23"
alembic = "^1.12.1"
psycopg2 = "^2.9.9"
pydantic = "^2.5.0"
sqlalchemy-utils = "^0.41.1"
httpx = "^0.25.1"


[tool.poetry.group.dev.dependencies]
pytest = "^7.4.3"
flake8 = "^6.1.0"
black = "^23.11.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
```
# テスト環境の構築
テストツールには`pytest`を使用しています。
`pytest`とは、Python用に設計された単体テスト用のフレームワーク（テスティングフレームワーク）です。以下公式ドキュメントを添付しますので、ご参照ください。

https://docs.pytest.org/en/7.4.x/contents.html

今回Dockerを使用してFastAPIの環境構築をしており、DBもpostgresイメージのコンテナを作成しています。
そのため、テスト用DBコンテナを使用してテストを実行できる環境を構築しようと思います。具体的には、**テスト実行時のDBセッションの接続先をテストDBに切り替え、アプリDBに影響を与えないという設計で実装**します。
## テスト環境のイメージ
今回実装するテスト環境のイメージを図で表すと以下のようになります。

[![Image from Gyazo](https://i.gyazo.com/98dbf0a5add9f7481e6f1c6df0e1456c.png)](https://gyazo.com/98dbf0a5add9f7481e6f1c6df0e1456c)

テスト実行時にはテストDB用のSessionを作成し、アプリで作成しているSessionをオーバライドすることでアプリDBではなく、テストDBに実行処理が反映されるようにしました。
それぞれ以下の順序で実装を行いました。
1. テスト用にPostgreSQLコンテナを作成
1. pytestのインストール
1. pytest用のSessionの作成
1. テストファイルでSessionをオーバライド

それぞれ順を追って説明できればと思います。
## テスト用にPostgreSQLコンテナを作成
`docker-compose.yml`で以下のように記載してDBコンテナを作成しています。
アプリ用DBの設定とほとんど同じで、テスト用DBとして区別するために少しだけ修正しています。
```yaml: docker-compose.yml
version: '3'

services:

  ...中略...
  
  db-test:
    image: postgres:15
    container_name: postgres-db-test
    volumes:
      - postgres_data_test:/var/lib/postgresql/data/
    environment:
      - POSTGRES_USER=postgres-test
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=postgres-test
    ports:
      - 5433:5432

volumes:
  postgres_data:
  postgres_data_test:
```
アプリ用DBのポート番号とバッティングしないように`5433`と指定してます。
以下のコマンドで、Dockerイメージをビルドしてコンテナ起動しておきます。
```terminal: ターミナル
docker-compose up -d --build
```
## pytestのインストール
Dockerコンテナを使用しているため、`docker-compose exec app`を接頭に置き、`pytest`をインストールするコマンドを実行します。

```terminal: ターミナル
docker-compose exec app poetry add pytest --dev
```
## pytest用のSessionの作成
`conftest.py`にデータベース用のSessionを定義します。
以下コードの実装例で、テストDBコンテナに接続する情報を記載しています。
```python: tests/conftest.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.db import Base


def override_session_factory():
    # ①テストDBコンテナの情報をセット
    TEST_DATABASE_URL = (
        "postgresql://postgres-test:password@postgres-db-test:5432/postgres-test"
    )

    # ②①の情報を元にデータベースのEngineを作成
    engine = create_engine(
        url=TEST_DATABASE_URL, echo=False, pool_recycle=10, isolation_level="AUTOCOMMIT"
    )

    # ③sessionmakerでSessionオブジェクトを作成する
    SessionLocal = sessionmaker(
        autocommit=False, autoflush=True, expire_on_commit=False, bind=engine
    )

    # ④一括してテーブルを作成
    Base.metadata.create_all(engine)

    # ⑤SessionLocalのインスタンスを作成し、呼び出し元に適切に渡す操作を行う。
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()
```
①では、作成したテストDBのコンテナに合わせてDATABASEのURLをセットします。
その上で、②においてデータベースのEngineを作成、`sessionmaker`メソッドでSessionオブジェクトを作成しています。
④の`Base.metadata.create_all(engine)`では、アプリ内で使用するテーブルを一括して作成します。
最後に⑤で、テストで使用するSessionオブジェクトのインスタンス`session`を定義し`yield`で渡す処理を実装しています。

:::note warn
**【テストDBコンテナのportについて（補足）】**
docker-compose.ymlでは、テストDBのポートを`5433:5432`としていますが、TEST_DATABASE_URLでは`5432`としています。
当初TEST_DATABASE_URLのポートを`5433`に指定するのだと思ったのですが、以下のエラーが発生して接続できないという事象が発生しました。

```terminal: ターミナル
E     sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) could not connect to server: Connection refused
E     Is the server running on host "postgres-db-test" (172.19.0.2) and accepting
E     TCP/IP connections on port 5433?
E
E     (Background on this error at: https://sqlalche.me/e/20/e3q8)

.venv/lib/python3.11/site-packages/fastapi/concurrency.py:36: OperationalError
```
こちら、ホスト（ローカル端末）では`5433`にマッピングされていますが、Dockerのネットワーク内部では`5432`にマッピングされており、今回みたいなアプリコンテナ（fastapiのコンテナ）からテストDBコンテナにアクセスする場合のポート番号は、`5432`となるみたいです。
以下参考にした記事を添付させていただきますので、ご参照ください🙇‍♂️
- [StackOverFlow](https://stackoverflow.com/questions/75192148/fastapi-and-postgresql-in-docker-compose-file-connection-error)
- [PostgreSQL と PGAdmin の docker-compose](https://qiita.com/Akhr/items/8d5b5127ee971a640253)（ポートフォワードの記載）
- [docker-compose.ymlで定義したコンテナがホストPCやDockerのnetwork内でどう見えるか](https://for.kobayashiii.dev/articles/w0t8dtcorosq)
:::


## テストファイルでSessionをオーバーライド
色々設定があるかと思いますが、今回はテストファイルごとにアプリのSessionを`override_session_factory()`にオーバーライドするように実装してます。
そうすることで、テスト実行時はアプリDB用のSessionではなく、テストDB用のSessionを使用することが可能になります。
以下、サンプルのテスト例として1件保存処理をするテストを記載しています。
```python: tests/test_item.py
from starlette.testclient import TestClient
from src.main import app
from fastapi import status
from tests.conftest import override_session_factory
from src.db import session_factory

client = TestClient(app)

# アプリ自体のSessionを`override_session_factory`にオーバーライド
app.dependency_overrides[session_factory] = override_session_factory


def test_create_item():
    response = client.post("/items", json={"name": "foo", "description": "bar"})

    assert response.status_code == status.HTTP_200_OK
```
上記の設定が終わったら、以下コマンドを打つことでテストが走ります。
コンテナ起動中であることを確認してください。
```terminal: ターミナル
docker-compose exec app poetry run pytest
```
以下実行され、ターミナルにはテスト結果が表示されます。

```terminal: ターミナル
docker-compose exec app poetry run pytest
================ test session starts ===================================================
platform linux -- Python 3.11.4, pytest-7.4.3, pluggy-1.3.0
rootdir: /app
plugins: anyio-3.7.1
collected 1 item

tests/test_item.py .                                                              [100%]

================ 1 passed in 0.63s =====================================================

```
:::note warn
**【補足】**
テスト実行後にDBをDROPする処理など、追加で実装できる部分は色々ありますが、今回はそこまで設定ができませんでした。
以下の記事で詳しく記載されていますので、ご参照いただければと思いますm(_ _)m
- [FastAPIでテスト用のクリーンなDBを作成してpytestでAPIのUnittestを行う](https://qiita.com/bee2/items/ff9c86d8d345dbcab497)

:::

# lintの導入
pythonのlintチェックのライブラリは複数ありますが、今回は`flake8`を導入してみました。
インストールして`.flake8`という設定ファイルを作成すれば問題なくセッティングはできます。
## `flake8`について

https://github.com/PyCQA/flake8

Pythonの静的解析を行うツールの1つで、Pythonコードの品質を向上させるために使用します。
flake8は以下3つのlintツールをまとめたラッパーです。
- pycodestyle
- pyflakes
- mccabe

[PEP8](https://pep8-ja.readthedocs.io/ja/latest/)という**Pythonのコーディングスタイルに関する公式のガイドラインに準拠してコードを精査**してくれます。[公式ガイド](https://flake8.pycqa.org/en/latest/)もありますので、ご参照ください。

## `flake8`のインストール
以下コマンドによりflake8をインストールします。
```terminal: ターミナル
docker-compose exec app poetry add flake8 --dev
```

## `.flake8`の設定
`.flake8`を設定することで、flake8の動作をカスタマイズできます。
例えば、特定のルールを無視する指示（`ignore`）や、チェックをスキップするディレクトリを設定(`exclude`)することなどが可能です。
以下、今回のリポジトリで設定している`.flake8`を掲載いたします。
```python: .flake8
[flake8]
max-line-length = 88
ignore = E203
exclude = .git, .github, .venv/, .dockerenv/, 
```
実行して修正箇所の指摘がある場合は、以下のように表示されます。
今回は使用していない不要なimportを探知して指摘してくれています。
```terminal: ターミナル
docker-compose exec app poetry run flake8
./src/routers/item.py:7:1: F401 'logging' imported but unused
make: *** [lint] Error 1
```

# formatterの導入
formatterについても種類は色々あるが、今回は`Black`を使用します。
カスタマイズ性は低く、インストールすれば細かい設定は不要で、すぐformatterの実行が可能です。
## `Black`について

https://github.com/psf/black

コード整形のライブラリの一つです。Black以外のFormatterツールとして`autopep8`や `yapf`、`Pylint`、`isort`などがあります。[公式ドキュメント](https://black.readthedocs.io/en/stable/index.html#)もありますので、ご参照ください。

## Blackのインストール
コンテナを起動している状態で以下のコマンドを打つことで、Blackがコンテナ実行環境にインストールされます。
```terminal: ターミナル
docker-compose exec app poetry add black --dev
```
## 実行方法
こちらについては導入後以下のように打つことで、formatterが起動しファイルの整形をしてくれます。
今回は`src`ディレクトリと`tests`ディレクトリにあるpythonコードを整形しようと思います。
以下コンテナ起動中に実行すれば、formatterが起動します。
```terminal: ターミナル
docker-compose exec app poetry run black src tests
```
以下実行後のターミナルです。
整形されたファイルのパスが列挙され、整形完了したことが分かります。
```terminal: ターミナル
docker-compose exec app poetry run black src tests
reformatted /app/tests/test_item.py

All done! ✨ 🍰 ✨
1 file reformatted, 14 files left unchanged.
```


# GithubActionsの設定
これで、ローカルにおけるテスト環境の構築とLintチェック、Formatterの設定が完了しました。
最後にGithubActionsを用いてpushするたびにlintとtestのチェックが入るCIを構築します。
以下GithubActionsのドキュメントです。

https://docs.github.com/ja/actions

ワークフローの設定はシンプルでリポジトリに以下のようなファイルを設定
## lintに関する設定
以下のようなyamlファイルをworkflowsディレクトリ（`.github/workflows/`）に作成し、フローを記載します。
yamlファイルは`.github/workflows/`に保存しないと機能しませんので、ご注意ください。
Lintチェックの設定のためにYamlファイルでは以下の流れで作成しています。

- Poetryをインストール
- Python3.11の環境をセットアップ
- `poetry install --no-root`を実行し、依存するライブラリをインストール
- `flake8`の実行

最終的に作成したyamlファイルは以下の内容になります。
```yaml: .github/workflows/lint.yml
name: Flake8 Lint

on: [push]

jobs:
  flake8-lint:
    runs-on: ubuntu-latest
    name: Lint

    steps:
      - name: Check out source repository
        uses: actions/checkout@v4

      - name: Install Poetry
        run: pipx install poetry 

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"
          cache: "poetry"

      - name: install dependencies
        run: poetry install --no-root

      - name: Lint with flake8
        run: poetry run flake8 src tests
```

## Testに関する設定
pytestの実行も同様に`.github/workflows/`に`test.yml`というファイルを作成してフローを定義しています。
ローカル環境でも、テスト実行時のDBは別途コンテナを立てているので、今回定義するワークフローでもPostgreSQLのコンテナを作成して実行環境を用意しています。

https://docs.github.com/ja/actions/using-containerized-services/about-service-containers


上記を踏まえてテストのワークフローは、以下の流れで作成しています。

- PosgreSQLのサービスコンテナを立ち上げる
- Poetryをインストール
- Python3.11の環境をセットアップ
- `poetry install --no-root`を実行し、依存ライブラリをインストール
- テスト用の`conftest_ci.py`を`conftest.py`に上書きをしてpytest実行

これらを踏まえて、ワークフローを定義したyamlファイルは以下の通りになります。

```yaml: .github/workflows/test.yml
name: Pytest

on: [push]

env:
  POSTGRES_USER: postgres-test
  POSTGRES_PASSWORD: password
  POSTGRES_DB: postgres-test

jobs:
  test:
    runs-on: ubuntu-latest
    name: Test

    services:
      db-test:
        image: postgres:15.4
        env:
          POSTGRES_USER: ${{ env.POSTGRES_USER }}
          POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
          POSTGRES_DB: ${{ env.POSTGRES_DB }}
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Check out source repository
        uses: actions/checkout@v4

      - name: Install poetry
        run: pipx install poetry

      - name: Use cache dependencies
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"
          cache: 'poetry'

      - name: install dependencies
        run: poetry install --no-root

      - name: pytest run
        run: | 
          cp tests/conftest_ci.py tests/conftest.py
          poetry run pytest
```

最後の手順（`cp tests/conftest_ci.py tests/conftest.py`）ですが、元々作成した`conftest.py`では、GithubActions上でDBコンテナにアクセスできないエラーが発生しました。
調べたところ、[先ほど添付したドキュメント](https://docs.github.com/ja/actions/using-containerized-services/about-service-containers)で以下の文言があり、GithubActions上のサービスコンテナのホストが`localhost`となっていたことが原因でした。

> ランナーマシン上でジョブを直接実行する場合、`localhost:<port>`か`127.0.0.1:<port>`を使ってサービスコンテナにアクセスできます。

つまり現状の`conftest.py`で設定しているTEST_DATABASE_URLではホスト名が異なっていたため、GithubActionsではDBに接続できないという状況になっていました。
他に良い設定方法はあるはずなんですが、今回以下のようにTEST_DATABASE_URLのみ変更した`conftest_ci.py`というファイルを別途作り、GithubActions上でのみ`conftest.py`に上書きする処理でエラーから逃れました。
以下`conftest_ci.py`のファイルですが、DATABASE_URLのホスト名のみ`localhost`に変更しています。

```python: tests/conftest_ci.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.db import Base


def override_session_factory():
    # host名のみlocalhostに変更 
    TEST_DATABASE_URL = (
        "postgresql://postgres-test:password@localhost:5432/postgres-test"
    )
    engine = create_engine(
        url=TEST_DATABASE_URL, echo=False, pool_recycle=10, isolation_level="AUTOCOMMIT"
    )
    SessionLocal = sessionmaker(
        autocommit=False, autoflush=True, expire_on_commit=False, bind=engine
    )

    Base.metadata.create_all(engine)

    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()
```

これらの設定を行った上で、githubにpushするとCIが走ります。
LintやTestに違反している場合はCIが通らず、違反していなければCIチェックも通ります。

[![Image from Gyazo](https://i.gyazo.com/ddf1c77f0f3f6f5020b6c9009b4edcca.png)](https://gyazo.com/ddf1c77f0f3f6f5020b6c9009b4edcca)

# 最後に
他にもいい設定はあるはずですが、今回FastAPIのCI構築の一例として記事に残しました。
FastAPIのCI構築をしたい方のヒントになれば幸いです。
ここまで読んでいただいてありがとうございました🙇‍♂️

# 参考文献

https://qiita.com/bee2/items/ff9c86d8d345dbcab497

https://zenn.dev/raku/articles/f278acb46651fc

https://docs.github.com/ja/actions/using-containerized-services/about-service-containers

https://qiita.com/Akhr/items/8d5b5127ee971a640253

https://for.kobayashiii.dev/articles/w0t8dtcorosq