Pythonプロジェクトにおけるユニットテスト～カバレッジ計測

## インポートパス設定

プロジェクトルートに pyproject.toml を設置して以下の内容を記載

```
[tool.pytest.ini_options]
pythonpath = "src"
testpaths = ["tests",]
```

これにより src 以下の相対パスで import 文が書けるようになる

## pytest導入

以下を実行

 - `pipenv install -d pytest`
 - `pipenv install -d pytest-mock`
 - `pipenv install -d pytest-cov`

    標準の unittest モジュールよりも高機能なのでこちらの使用を推奨

## ディレクトリ構成

 - `src` ディレクトリをトレースする形で `tests` ディレクトリを構成する
 - 各ディレクトリには `conftest.py` を配置し、必要が無ければ内容を空にしておく

```
  + [project-root]
     +-- src
          +-- foo
               +-- sample1.py
               +-- sample2.py
          +-- bar
               +-- sample3.py
               +-- sample4.py
     +-- tests
          +-- foo
               +-- conftest.py
               +-- sample1_test.py
               +-- sample2_test.py
          +-- bar
               +-- conftest.py
               +-- sample3_test.py
               +-- sample4_test.py
          +-- conftest.py
     +-- Pipfile
     +-- pyproject.toml
```

## テストコード実装

 - xUnit系にあるようなテストクラスは設けない
 - テストメソッドごとに以下の内容を記載していく
     - テスト対象クラスのインスタンス化
     - テスト対象メソッドの実行
     - テスト対象メソッド実行結果の検証
 - テストメソッド間に結果の依存が無いようにする (後述)

```foo/sample1_test.py
import pytest
from foo.sample1 import Sample


def test_execute():
    sample = Sample()
    result = sample.execute('test_test')
    assert result == 123

def test_execute_with_invalid_param():
    sample = Sample()
    with pytest.raises(FooBarException):
        sample.execute('invalid')
```

## fixture実装… の前に

以下の例では一方のテストメソッドを実行した結果 `path/to/test_file` にゴミファイルが残る
そのためもう一方のテストメソッドが期待通りの振る舞いをしてくれない

```python
import os
import pathlib
from bar.sample3 import Sample


def test_write_foo():
    sample = Sample()
    test_file_path = 'path/to/test_file'
    pathlib.Path(test_file_path).touch()
    sample.write_foo(test_file_path)
    assert Path(test_file_path).read_text() == 'foo'

def test_write_bar():
    sample = Sample()
    test_file_path = 'path/to/test_file'
    pathlib.Path(test_file_path).touch()
    sample.write_bar(test_file_path)
    assert Path(test_file_path).read_text() == 'bar'
    # expect: 'bar', actual: 'foobar'
```

この例ではテストメソッドごとに異なるファイルパスを指定するという回避策もある
しかし本来は**テストメソッドによる実行環境汚染自体を避けるべき**である

よって、以下のようにファイル削除の後処理を行うのが正しい

```python
import os
import pathlib
from bar.sample3 import Sample


def test_write_foo():
    sample = Sample()
    test_file_path = 'path/to/test_file'
    pathlib.Path(test_file_path).touch()
    try:
        sample.write_foo(test_file_path)
        assert Path(test_file_path).read_text() == 'foo'
    finally:
        if os.path.exists(test_file_path):
            os.remove(test_file_path)

def test_write_bar():
    sample = Sample()
    test_file_path = 'path/to/test_file'
    pathlib.Path(test_file_path).touch()
    try:
        sample.write_bar(test_file_path)
        assert Path(test_file_path).read_text() == 'bar'
    finally:
        if os.path.exists(test_file_path):
            os.remove(test_file_path)
```

### 補足

より最悪なのが他のテストメソッドの実行結果を前処理代わりにしている例

```python
import os
import pathlib
from foo.sample2 import Sample

def test_create_file():
    sample = Sample()
    sample.create_file('path/to/file')

def test_delete_file():
    sample = Sample()
    sample.delete_file('path/to/file')
    # test_create_file() の実行によりファイルが存在する前提のテスト
```

## fixture実装

上記の前処理/後処理を全テストメソッドに記載するのは冗長である

fixture の仕組みを使うことで以下のように共通処理を書き直せる
  - `yield` の前が前処理、後ろが後処理
  - 後処理が存在しなければ `yield` 自体不要

```python
import os
import pathlib
from bar.sample3 import Sample

@pytest.fixture(scope='function', autouse=True)
def setup_file():
    test_file_path = 'path/to/test_file'
    pathlib.Path(test_file_path).touch()
    yield
    if os.path.exists(test_file_path):
        os.remove(test_file_path)

def test_create_foo_file():
    sample = Sample()
    test_file_path = 'path/to/test_file'
    sample.write_foo(test_file_path)
    assert Path(test_file_path).read_text() == 'foo'

def test_create_bar_file():
    sample = Sample()
    test_file_path = 'path/to/test_file'
    sample.write_bar(test_file_path)
    assert Path(test_file_path).read_text() == 'bar'
```

## conftest.py

複数のテストモジュールに共通するfixtureを実装する場合に使用する
例として以下の `foo/conftest.py` にfixtureを実装した場合、内容は `foo` ディレクトリ以下のテストに適用される

```
     +-- tests
          +-- foo
               +-- conftest.py
               +-- sample1_test.py
               +-- sample2_test.py
```

例えばテストに使用するデータベースの構築など

```conftest.py
@pytest.fixture(scope='function', autouse=True)
def setup_database():
    initialize_database()
    yield
    cleanup_database()

def initialize_database():
    ...

def cleanup_database():
    ...
```

また、fixtureのもう一つの用途としてテストに使用するインスタンスの生成がある

これまでに出てきた `Sample()` など単純に扱えるクラスでは不要だが、
以下のように前処理/後処理を伴う場合は fixture で共通化するべき

```python
def test_read_table():
    database = DatabaseAccessor()
    try:
        database.initialize()
        database.read_table()
    finally:
        database.close()

def test_drop_table():
    database = DatabaseAccessor()
    try:
        database.initialize()
        database.drop_table()
    finally:
        database.close()
```

インスタンス生成用のfixtureは `autouse=False` として、テストメソッドの引数にメソッド名を指定するのが良い
これによって `yield` で指定した戻り値を得られる
  - 後処理が不要な場合は `yield` ではなく `return`

```python
@pytest.fixture(scope='function', autouse=False)
def database():
    database = DatabaseAccessor()
    database.initialize()
    yield database
    database.close()

def test_read_table(database):
    database.read_table()

def test_drop_table(database):
    database.drop_table()
```

fixtureは応用の幅が広すぎるため、今回はこのあたりで解説を切り上げ

## Mockの活用

あるクラスのテスト結果が、別のクラスの処理に大きく依存してしまう実装は避けるべきである

以下は悪いテストコードの例

```sample.py
class Controller:
    def read(self):
        ...
    def write(self):
        ...
    def execute(self):
        ...

class Sample:
    def foo(self):
        controller = Controller()
        controller.read()
        controller.write()
        controller.execute()
```

```sample_test.py
def test_foo():
    sample = Sample()
    sample.foo()
    # テスト結果が Controller クラスの処理に依存する
```


正しくは以下のように `Controller` のコンストラクタの呼び出しをMock化する
`Controller` や `Sample` の実装に手を加える必要は無い
  - `pytest` の場合 `mocker` をテストメソッドの引数に加えるだけで諸々利用可能


```sample_test.py
def test_foo(mocker):
    mock_controller = mocker.patch('sample.Controller', autospec=True)
    sample = Sample()
    sample.foo()
    mock_controller.return_value.read.assert_called_once()
    mock_controller.return_value.write.assert_called_once()
    mock_controller.return_value.execute.assert_called_once()
```

Mockもfixtureと同じく応用幅が広すぎるのでここまでで解説を終わる

## ユニットテスト＆カバレッジ計測

以下を実行

  - `pipenv run pytest --cov=src --cov-report=html`

     カレントディレクトリに生成される `htmlcov/index.html` を開いて結果を見る

## Docker環境を使ったユニットテスト

本番環境用のDockerfileを使い、以下のシェルスクリプトによりコンテナ内でユニットテストを実行する

  - 本番環境用のDockerfile には `dev-packages` をインストールする処理が無いはずなので併せて実行

```sh
#!/bin/sh

IMAGE_NAME="my-unittest"

cd `dirname $0`

case "$1" in
  --build | -b)
    docker build . -t $IMAGE_NAME --no-cache
    ;;
esac

docker run --rm -it -v $(pwd):/opt/backend $IMAGE_NAME pipenv install -d && pipenv run pytest --cov=src --cov-report=html
```

このようにコンテナ仮想化はサービス（永続的処理）の稼働元とするだけではなく、
バッチ処理（非永続的処理）の実行環境とする用法も強力

## DockerComposeを使用

特にデータベースを用いる場合などに使用する

本番環境用の `docker-compose.yml` にはDBの定義等が無いケースの方が多いので、
`docker-compose.local.yml` を用意してそちらに定義する想定

  - frontendコンテナなどは不要なので、ビルド＆起動するコンテナを指定する
  - `down` の際に `volumes` も削除することでDBの汚染を排除する

```sh
#!/bin/sh

BACKEND_CONTAINER_NAME="my-container"

cd `dirname $0`

case "$1" in
  --build | -b)
    docker compose -f ../docker-compose.local.yml build backend appdb --no-cache
    ;;
esac
docker compose -f ../docker-compose.local.yml up backend appdb -d

docker exec $BACKEND_CONTAINER_NAME sh -c "pipenv install -d && pipenv run pytest --cov=src --cov-report=html"
docker compose -f ../docker-compose.local.yml down --volumes
```

```docker-compose.local.yml
services:
  backend:
    build:
      context: backend
      dockerfile: Dockerfile
    image: my-backend
    container_name: my-backend
    ports:
      - 8000:8000
    volumes:
      - ./backend:/opt/backend
    networks:
      - my-network
    environment:
      APP_DB_HOST: appdb
      APP_DB_PORT: 5432
    tty: true
    restart: on-failure
    command: sh start.sh

  frontend:
    # 省略

  appdb:
    build:
      context: local/appdb
      dockerfile: Dockerfile
    image: my-appdb
    container_name: my-appdb
    ports:
      - 5432:5432
    volumes:
      - my-appdb-volume:/var/lib/postgresql/data
    networks:
      - my-network
    environment:
      POSTGRES_USER: myusername
      POSTGRES_PASSWORD: mypassword
      POSTGRES_DB: mydatabase
    tty: true
    restart: on-failure

volumes:
  my-appdb-volume:

networks:
  my-network:

```