🍣

pytest&Docker outside of Docker(DooD)を利用してDBアクセスのテストを副作用なく軽量に実現する

2022/03/06に公開

前書き

アプリケーション開発において大変重要となるのがテストです。既存のアプリケーションに様々な変更が入る度に、既存の機能に新たなバグを潜めてないか確認するために、多くのエンジニアが苦汁を舐めた経験があることでしょう・・・。(そこでバグが見つかればいいが、忘れたころに発見すると・・・)
そんな面倒なテストを自動化するために、最近はテストコード、あるいはテスト自動化が流行ってきていると思います。
ただし、オンプレミスでテストを行っていると、すぐにテスト用のDBなんて用意出来ないです。そのため、開発用で利用しているDBをそのまま使うパターンがままあると思います。

しかし、そうなると次に問題になるのがDBの状態です。様々な開発及びテストによってぐちゃぐちゃになったDB内部のデータを利用すると、その状態に応じて、結果は変わってきます。このような状態になってしまうと、本来確認したい観点を確認することが難しくなってきます。

そこで今回は、Dockerコンテナを利用することで、開発環境やDBに副作用を発生させずにテストを完了させる方法を紹介したいと思います。

DooDを利用した揮発性のDBコンテナの立ち上げ

コンテナネイティブなアプリケーション

コンテナを利用したクラウドネイティブな開発をしていると、アプリケーションとDBはそれぞれ別コンテナで、相互通信をするようなアーキテクチャ構成になっていることが多いと思います。
つまり、DBはDB用のコンテナが用意されていて、それを立ち上げることで動作しています。今回は、APコンテナにFastAPI(python)を、DBコンテナにMongodoDBを使用しています。

さて、上記が通常のアプリケーションの状態となりますが、ここで大事なのは、「DBコンテナがAPコンテナと独立している」点です。アプリケーション(コンテナ)はDBとの通信インターフェースのみを意識し、DBの状態に一切影響しません。ほとんどWebAPIと同じ状態です。
そして、コンテナの特徴といえば、軽量で瞬間的に立ち上がることですよね。これを利用して、現在立ち上がっているDBコンテナとは全く別の新しいコンテナをテストのためだけに立ち上げ、新規で立ち上がったテスト用のコンテナに繋げることで、現在動作しているDBコンテナを汚さずにテストを実現することができます。

コンテナ内部でコンテナを使いたい

ここで気になるのが、「テスト用のDBコンテナを誰が管理するか」です。(気になるよね!)
一般的に、コンテナの管理はホストOSの仕事ですが、テスト用のDBは、完全にAPコンテナの都合で立ち上がるものです。そのため、責務を明確にする意味でも、ホストOSがコンテナを立ち上げるのではなく、APコンテナ内で、テスト用のコンテナを立ち上げたいです。

しかし、Dockerには制約があります。デフォルトではDockerコンテナはデバイスファイルにアクセスできないのです。そのため、単純にDockerコンテナ内部にDockerをインストールしたとしても、Dockerエンジンを起動しようとするとエラーになります。そこで対応策となる一つが、今回紹介する「Docker outside of Docker」、略してDooDです。

DooDとは

DooDとは、起動したDockerコンテナの中で、dockerを利用する方法の1つで、コンテナ側からホストのソケットファイルをマウントする手法です。これによって、コンテナ上のDockerコマンドはホスト側のDocker環境で実行されるようになります。こうすることで、ホストOSはAP内部でどんなテストが実行され、どんなコンテナを管理しているかを意識する必要がなくなります。(厳密には、ホストOSのDockerデーモンを利用しているため、同じコンテナ管理ランタイムを使用します。あくまでDockerコマンドの実行場所の分離となります。)

この他にも、「Docker in Docker(DinD)」と呼ばれる手法もありますが、こちらはコンテナに対して過剰な権限を与えることになりますので、個人的には推奨いたしません(あくまで素人の一意見です)。

実装編

コンテナの準備

というわけで、実際に実装していこうと思います。
まずは各コンテナの準備です。ホストOSにて、以下のコマンドを実行します。

# 各コンテナイメージをpull
docker pull python
docker pull mongo

# コンテナ同士が相互通信するためのネットワークを用意
docker network create dood

# イメージを使用して各コンテナの立ち上げ
docker run --name=dood_ap --net=dood -it -d -v /var/run/docker.sock:/var/run/docker.sock python 
docker run --name=dood_db -d -p 27017:27017 --net=dood mongo

これで各コンテナが立ち上がりました。docker runコマンドには様々なオプションを付けていますが、ここでは触れません。詳細については公式リファレンスを参照してください。

APサーバーの設定

さて、それでは各コンテナ内部の設定をします。mongodbは特に設定をしなくても良しなに動いてくれるので、pythonコンテナの設定をしていきます。まず、pythonコンテナ内部に入ります。

docker exec -it python bash

上記コマンドを叩くとカレントディレクトリがコンテナ内部のルートディレクトリになると思います。これでコンテナ内部に入れました。

それではまず、今回必要になるライブラリ群をインストールします。

pip install pymongo
pip install fastapi
pip install uvicorn[standard]
pip install inject
pip install pytest
pip install requests

それではmongodbへアクセスするpythonコードを実装していこうと思います。

constants.py
class Constants:
    DB_CLIENT = "dood_db"
    DB_PORT = 27017
    DB_NAME = "test"
    ITEM_COLLECTION_NAME = "item"
db_access.py
import inject
from pymongo import MongoClient

from constants import Constants


class ItemMongoDBRepository:
    @inject.params(constants=Constants)
    def __init__(self, constants: Constants):
        client = MongoClient(constants.DB_CLIENT, constants.DB_PORT)
        db = client.get_database(constants.DB_NAME)
        self.item_collenction = db.get_collection(constants.ITEM_COLLECTION_NAME)

    def get(self) -> list[dict] | None:
        items = list(self.item_collenction.find(projection={"_id": False}))
        if not items:
            return None
        return items

    def put(self, item: dict):
        self.item_collenction.insert_one(item)

まずはDBアクセス周りのコードです。DBにある予め決め打ちしたコレクションに対して、全件取得をする関数と、新しいレコードを登録する関数の2つを用意しておきます。
また、後でテスト用のコンテナに接続先を変更できるように、定数ファイルをDIするようにしておきます。今回、DIコンテナにはinjectを使用していますが、FastAPIにはデフォルトでDIコンテナ機能が用意されていますので、そちらを利用しても構いません。

main.py
import inject
import uvicorn
from fastapi import FastAPI
from pydantic import BaseModel

from constants import Constants
from db_access import ItemMongoDBRepository


class Item(BaseModel):
    id: str
    name: str


app = FastAPI(title="DooDテスト用アプリケーション")
inject.configure(config=lambda binder: binder.bind(Constants, Constants()))


@app.get("/item", response_model=list[Item])
def read_item():
    item_repository = ItemMongoDBRepository()
    item_list = item_repository.get()
    if item_list:
        item_list = [
            Item(id=item.get("id", None), name=item.get("name", None))
            for item in item_list
        ]
    return item_list


@app.post("/item", response_model=Item)
def post_item(item: Item):
    item_repository = ItemMongoDBRepository()
    item_repository.put(item.dict())
    return item


if __name__ == "__main__":
    uvicorn.run(app, port=8080)

次に、FastAPIのmain関数です。ここで先ほど作成した関数に対応する2つのAPIを用意しています。FastAPIはtypehintのサポートが絶大なので、必ずtypehintを利用しましょう。

FastAPIによるテスト

今回はシンプルに、この3つのファイルだけで構成されたアプリケーションでテストしていきます。まずはこのサーバーを起動してみましょう。

python main.py

そうすると、APサーバーが立ち上がります。ブラウザから「http://localhost:
8080/docs」にアクセスしましょう。

上記のような画面が開かれたと思います。FastAPIはデフォルトでコードからOpen API準拠のSwagger UIを作成してくれます。それぞれのAPIを開けばわかりますが、先ほどのmain.pyで定義したtypehintに従ってI/Oの型が定義されています。
Getメソッドを開き、Try it out → execute をクリックしてみましょう。定義したURLに従ったCurlコマンドが内部で叩かれます。

現在はDBに何も登録されていないので、nullが返ってきています。同じようにPostリクエストを実行して、データを登録した上で、再度Getリクエストを叩いてみましょう。

今度は登録したデータが返ってきていると思います。しかし、この様に毎回テストをするのはやや面倒ですよね。ということで、テストコードを実装していこうと思います。

テストコードの実装

test_main.py
import inject
import pytest
from fastapi.testclient import TestClient

from constants import Constants
from main import app
from test_constants import TestConstants

client = TestClient(app)

test_item_list = [
    {"id": "1", "name": "テストアイテム"},
    {"id": "2", "name": "テストアイテム2"},
    {"id": "999", "name": "テストアイテム999"},
]


def test_read_none_item():
    response = client.get("/item")
    assert response.status_code == 200
    assert response.json() == None


@pytest.mark.parametrize("item", test_item_list)
def test_post_item(item: dict):
    response = client.post("/item", json=item)
    assert response.status_code == 200
    assert response.json() == item


def test_read_item():
    response = client.get("/item")
    assert response.status_code == 200
    assert response.json() == test_item_list

FastAPIでは、先ほどのmain.pyにて定義したappを使用して、APIをコードベースで叩くことができます。また、叩いた結果のデータ(response)にて、response.json()とすればAPIのレスポンスデータをjsonとして取得できます。あとはそれと想定データの比較をすればよいです。今回はテストコードにpytestを使用しています。

さて、pytestを実行すると、上図のようなエラーが発生すると思います(想定通りです)。エラーの中身を見てください。getメソッドでのassertですよね。なぜなら、先ほどSwagger UIからデータを投入したため、初期データが空のDBではないためです。

この様な事象が発生すると困りますよね?というわけで、テストのためにDBコンテナを立ち上げるために、DooDを実現しようと思います。

DooDによるテストスクリプトの作成

とはいえ、コンテナを立ち上げた段階でソケットファイルのマウントは設定しているので、後はこのコンテナ内にDockerクライアントをインストールするだけです。以下のコマンドを実行します。

apt-get update -y
apt-get install -y --no-install-recommends docker.io

上記コマンドを実行すれば、コンテナ内部でもdockerコマンドが実行できるようになっています。
では、DooDをしてテスト用のコンテナを立ち上げ、テストを実行し、完了したらコンテナを削除するシェルスクリプトを記述します。

test.sh
#!/bin/bash

# 開発用に使用しているDBがポートを占有しているため、別のポートで開く
docker run --name=dood_db_test -d --net=dood mongo --port 27018
docker start dood_db_test

# テスト実行
pytest

# テストが完了したらコンテナを破棄する
docker stop dood_db_test
docker rm dood_db_test

上記のシェルスクリプトを実行すれば、開発用のDBに一切影響を与えずにテストを実現することが可能です。ただし、このままではテストコードは動きません。接続先が変わっていないからです。下記のコードを追加して、接続先定数クラスをDIします。

test_constants.py
class TestConstants:
    DB_CLIENT = "dood_db_test"
    DB_PORT = 27018
    DB_NAME = "test"
    ITEM_COLLECTION_NAME = "item"
test_main.py
@@ +11,13 @@
+ inject.clear_and_configure(
+    config=lambda binder: binder.bind(Constants, TestConstants())
+ )

これにて設定完了です!シェルスクリプトを実行してみます。

問題なくテストが完了しました!当然ながら、先ほどのSwagger UIからGetメソッドを実行すると、DBのデータは変わっていないことが分かります。これでDooDを利用して、テスト時だけそれ専用のコンテナを立ち上げてテストをすることが実現できました。
コンテナは瞬間的に立ち上がってくれるので、簡単に新しい環境を構築で来て良いですね。

Discussion