mimesisでマスタ参照の組み合わせ制約テストデータを自動生成する
この記事は、ソフトウェアテスト Advent Calendar 2025の12日目の記事です。
(※遅刻してごめんなさい)
はじめに
この記事では、pytestとダミーデータ生成のライブラリであるmimesisを使ってAPIのバリデーションロジックをテストする実装を紹介していきます。
複雑な基幹システムをテストする際に、「この商品の数量データは5未満でなければいけない。」「この商品とこの商品は組み合わせてはいけない」などの業務ロジックに関するバリデーションを実装することがあります。
このようなロジックのテストについて、pytestでもjunitでも、言語はなんでもよいのですが、パターンが膨大になることが往々にしてありがちです。
それらの一つ一つにmockやダミーパターンを定義してテストすること自体は可能なのですが、量が多かったり、あるいは新しいパターンや変更への追従性が大きな課題になり、テストのメンテナンスコストが徐々に爆発することがありがちです。
ダミーデータの生成ライブラリとしては、fakerやfactory_boyなどが有名ですが、mimesisというライブラリが今回のケースに便利そうだったので触ってみます。
事例の説明
まず今回の題材として、ECサイトの注文作成APIを考えます。ユーザーがカートに入れた商品を注文するとき、以下のようなJSONをAPIに送信するとします。
{
"items": [
{"sku": "SKU-001", "quantity": 2},
{"sku": "SKU-002", "quantity": 1}
]
}
このAPIには、いくつかのバリデーションルールがあるものとします。
- 数量は1以上でなければならない
- SKU(商品識別子)は商品マスタに存在していなければならない
- 危険物(可燃性の化学品など)と要冷蔵品(生鮮食品など)は、配送上の理由で同時購入できない
バリデーションのテストを書いていると、単項目のチェックまでは順調に進みます。たとえば「数量は1以上でなければならない」というルールなら、テストデータは簡単に用意できます。
# 単項目チェックのテストは素直に書ける
@pytest.mark.parametrize("quantity,expected_valid", [
(1, True), # 境界値:OK
(0, False), # 境界値:NG
(-1, False), # 異常値
(100, True), # 正常値
])
def test_quantity_validation(quantity, expected_valid):
item = {"sku": "TEST-001", "quantity": quantity}
assert validate_quantity(item) == expected_valid
数量のバリデーションは、マスタデータに依存しません。テストデータを自由に決められるので、境界値分析に基づいてケースを列挙すれば十分です。
しかし、マスタ参照が入ってくると、途端にテストデータの管理が難しくなります。
# マスタ参照が入ると辛くなる
@pytest.mark.parametrize("sku,expected_valid", [
("SKU-001", True), # これはマスタに存在する?
("SKU-002", True), # 本番では販売停止になっていない?
("SKU-999", False), # 存在しないSKUのつもりだけど、いつか追加されたら?
])
def test_sku_exists_in_master(sku, expected_valid):
result = validate_sku_exists(sku)
assert result == expected_valid
テストに書いたSKUが、実際の商品マスタに存在するかどうかは、テストコードからは分かりません。
マスタが更新されてSKU-001が削除されたり、SKU-999が新規登録されたりすると、テストが意図しない形で失敗(または成功)してしまいます。
また、組み合わせ制約が入ると、さらに状況は複雑になります。
# 組み合わせ制約のテストは特に辛い
@pytest.mark.parametrize("sku_a,sku_b,expected_error", [
("SKU-HAZ-001", "SKU-COLD-001", "E_ILLEGAL_COMBO"), # 危険物 × 要冷蔵
("SKU-HAZ-002", "SKU-COLD-002", "E_ILLEGAL_COMBO"), # 別の組み合わせ
# マスタに危険物が100件、要冷蔵品が50件あったら?
# 全組み合わせ5000件を手書きする?
# マスタが更新されたらどうする?
])
def test_illegal_combination(sku_a, sku_b, expected_error):
items = [{"sku": sku_a, "quantity": 1}, {"sku": sku_b, "quantity": 1}]
errors = validate_order(items)
assert expected_error in errors
「危険物と要冷蔵品の組み合わせは禁止」というルールをテストするには、実際にマスタに存在する危険物のSKUと要冷蔵品のSKUを知っている必要があります。手書きで数パターン用意しても、マスタの更新に追従できません。
逐一テストのたびに対象データをマスタからSQLで検索してから手書きするのは結構な手間です。
この記事では、Pythonのデータ生成ライブラリであるmimesisを使って、マスタデータを参照しながら組み合わせ制約のテストケースを効率的に生成する方法を紹介します。
よくある別のアプローチ
組み合わせ制約のテストで、よくある個人的には好みでないアプローチがあります。
それは「テスト要件のみを満たす小さな仮想マスタデータを手作りする」ことです。
# テスト要件のみを満たすパターンやテストDBを管理する
TEST_PRODUCTS = [
{"sku": "TEST-HAZ-001", "hazardous": True, "cold_chain": False},
{"sku": "TEST-COLD-001", "hazardous": False, "cold_chain": True},
{"sku": "TEST-NORMAL-001", "hazardous": False, "cold_chain": False},
]
@pytest.fixture
def mock_master():
# 本番のマスタではなく、テスト用のマスタを使う
return TEST_PRODUCTS
最初は便利に見えます。テストに必要なデータだけを定義すればいいからです。
実際のデータのパターンなどを完全に把握できているのであればよいのですが、ありがちなのが要件やテストデータとしては存在し得るが、実際のデータとしてはありえないようなものなどがあったりすることです。
このような場合、仮想マスタデータがかえってノイズとなり見逃したり、修正が手間になったりします。
また、ロジックを実装した時点でありうるデータでは問題なくとも、データのカテゴリが増えたり、新たなバリデーションルールなどが実は必要だった、あるいはデータ自体が更新されたりなども発生しがちです。
このような場合、テストは通るが本番環境ではエラーになる、というような事故も発生するリスクがあります。
結局のところ、規模感にもよりますが「本番と同等のマスタを参照してテストデータを作る」方が長期的にはメンテナンスしやすいのではないかと思っています。
ここで問題になるのが、どうやってマスタから適切なテストデータを生成するかという点です。
mimesisとは
mimesisは、Pythonでテスト用のダミーデータを生成するためのライブラリです(この記事ではv18.0.0を使用しています)。
mimesisには、今回の用途に適した2つの機能があります。
1. Providerで独自の生成ロジックを定義できる
mimesisでは、BaseProviderを継承して独自のProviderクラスを作成できます。Providerとは、特定の種類のデータを生成することができるクラスです。
from mimesis.providers.base import BaseProvider
class MyProvider(BaseProvider):
class Meta:
name = "my_provider"
def __init__(self, seed: int, **kwargs) -> None:
super().__init__(seed=seed)
def generate_code(self) -> str:
# self.randomはmimesisが提供する乱数生成器
# seedを固定すれば、毎回同じ結果が得られる
prefix = self.random.choice(["A", "B", "C"])
number = self.random.randint(100, 999)
return f"{prefix}-{number}"
このように、業務ドメインに特化した生成ロジックをProviderとして切り出すことで、テストコードの可読性が向上します。
実際には、generate_codeの部分についてillegal_pair_hazardous_cold()のような関数名を付けて組み合わせのtupleを戻り値とするなどすれば、何を生成しているのかが一目で分かります。
2. SchemaでJSONデータなどをまとめて生成できる
mimesisのSchemaクラスを使うと、辞書形式の構造化データを一括で生成できます。APIのリクエストボディのような、複雑な構造を持つデータを作成するのに便利です。
from mimesis import Field, Schema
from mimesis.locales import Locale
# Fieldはデータ生成の起点となるオブジェクト
field = Field(locale=Locale.EN, seed=42)
# schemaパラメータにはlambdaを渡す
# iterationsで生成する件数を指定する
schema = Schema(
schema=lambda: {
"id": field("cryptographic.uuid"),
"name": field("person.full_name"),
"email": field("person.email"),
},
iterations=3,
)
# create()を呼ぶと、指定した件数分のデータがリストで返る
data = schema.create()
Fieldはfield("provider.method")の形式で、mimesisが提供する様々なデータ生成メソッドを呼び出せます。iterationsパラメータで生成件数を指定すれば、同じ構造のデータを簡単に量産できます。
他のライブラリとしては、FakerもBaseProviderを継承してカスタムプロバイダを作れます。factory_boyはFakerとの統合機能があり、factory.FakerでFakerのメソッドを呼び出せます。
ただし、今回のように「マスタDBから候補集合を取得し、条件に応じて禁止ペアを生成する」という用途では、mimesisのProvider設計がフィットするのではと感じました。Providerにマスタキャッシュを注入し、関数名で生成ルールを表現できるのが便利です。
実装例
ここからは仮想の実務的な実装例を見ていきます。題材として、以下のような要件を持つ注文作成APIを想定します。
- SKUがマスタに存在しない場合はエラー
- 危険物(hazardous)と要冷蔵品(cold_chain)の同時購入は禁止
- エラーコードはYAMLファイルで一元管理
エラーコードの定義
実際のプロダクトでは、大量のエラーコードをシステム的にはYAMLなどで管理していることが多いでしょう。テストでは、このYAMLに定義されているコードだけを期待値として使います。
# error_codes.yml
E_ILLEGAL_COMBO:
http_status: 400
message: "同時購入できない組み合わせが含まれています"
マスタデータのモデル
シナリオのため、商品マスタのデータ構造を定義します。dataclassのfrozen=Trueを指定することで、イミュータブル(変更不可)なオブジェクトとして扱えます。
from dataclasses import dataclass
@dataclass(frozen=True)
class Product:
sku: str # 商品識別子
category: str # カテゴリ
hazardous: bool # 危険物フラグ
cold_chain: bool # 要冷蔵フラグ
active: bool # 販売中フラグ
unit_price: int # 単価
マスタ候補のキャッシュ
テストデータを生成するたびにDBへアクセスすると、テストの実行時間が長くなってしまいます。そこで、マスタデータを一度だけ取得してメモリにキャッシュするクラスを用意します。
class MasterCache:
"""
マスタデータをキャッシュし、条件ごとの候補集合を提供するクラス
"""
def __init__(self, products: list[Product]):
self._products = products
self._hazardous: list[Product] | None = None
self._cold_chain: list[Product] | None = None
def hazardous(self) -> list[Product]:
"""販売中の危険物一覧を返す(遅延初期化)"""
if self._hazardous is None:
self._hazardous = [p for p in self._products if p.hazardous and p.active]
return self._hazardous
def cold_chain(self) -> list[Product]:
"""販売中の要冷蔵品一覧を返す(遅延初期化)"""
if self._cold_chain is None:
self._cold_chain = [p for p in self._products if p.cold_chain and p.active]
return self._cold_chain
def unit_price_of(self, sku: str) -> int:
"""SKUから単価を取得する"""
for p in self._products:
if p.sku == sku:
return p.unit_price
raise ValueError(f"Unknown SKU: {sku}")
def all_active(self) -> list[Product]:
"""販売中の全商品を返す"""
return [p for p in self._products if p.active]
このクラスのポイントは、「条件ごとの候補集合を返すメソッド」を用意している点です。hazardous()は危険物の一覧を、cold_chain()は要冷蔵品の一覧を返します。これらを組み合わせることで、禁止される組み合わせを簡単に生成できます。
カスタムProviderの実装
いよいよ本題です。組み合わせ制約のテストデータを生成するProviderを実装します。
from mimesis.providers.base import BaseProvider
class ComboProvider(BaseProvider):
"""
組み合わせ制約のテストデータを生成するProvider
このProviderは、マスタキャッシュを受け取り、
様々な組み合わせパターンを生成するメソッドを提供します。
"""
class Meta:
# Genericに登録した際のアクセス名
# generic.combo.xxx() の形式でアクセスできるようになる
name = "combo"
def __init__(self, seed: int, cache: MasterCache, **kwargs) -> None:
super().__init__(seed=seed)
self.cache = cache
def illegal_pair_hazardous_cold(self) -> tuple[str, str]:
"""
危険物と要冷蔵品のペアを生成する(禁止される組み合わせ)
Returns:
(危険物のSKU, 要冷蔵品のSKU) のタプル
Raises:
ValueError: 候補となる商品が存在しない場合
"""
hazardous_items = self.cache.hazardous()
cold_chain_items = self.cache.cold_chain()
if not hazardous_items:
raise ValueError("スナップショットに販売中の危険物が存在しません")
if not cold_chain_items:
raise ValueError("スナップショットに販売中の要冷蔵品が存在しません")
# self.randomはmimesisが提供する乱数生成器
hazardous_product = self.random.choice(hazardous_items)
cold_chain_product = self.random.choice(cold_chain_items)
return hazardous_product.sku, cold_chain_product.sku
このProviderには重要なポイントがいくつかあります。
Meta.nameを指定することで、Genericに登録した際のアクセス名を決められます。name = "combo"と指定すると、generic.combo.illegal_pair_hazardous_cold()の形式でメソッドを呼び出せます。
__init__ではseedを明示的に受け取り、super().__init__(seed=seed)で親クラスに渡しています。Generic.add_provider()がseedを自動的に注入するため、この形式で記述します。また、マスタキャッシュも受け取り、Providerがマスタデータにアクセスできるようにしています。
self.randomはmimesisが内部で管理する乱数生成器です。Python標準のrandomモジュールを別途インポートする必要はありません。seedは__init__で受け取った値が使われ、すべてのProviderで共有されます。
メソッド名のillegal_pair_hazardous_coldが、そのまま「何を生成しているか」の説明になっています。テストコードを読む人が、このメソッドが何をしているか迷うことがありません。
Genericへの登録と使用
作成したProviderをGenericに登録して使用します。
from mimesis import Generic
from mimesis.locales import Locale
# マスタデータを取得(実際はDBから取得する)
products = fetch_products_from_db()
cache = MasterCache(products)
# Genericを初期化(seedを固定して再現性を確保)
generic = Generic(locale=Locale.EN, seed=42)
# カスタムProviderを登録
# cache引数はProviderの__init__に渡される
generic.add_provider(ComboProvider, cache=cache)
# 禁止ペアを生成
pair = generic.combo.illegal_pair_hazardous_cold()
print(pair) # ('SKU-HAZ-001', 'SKU-COLD-002') のような結果
add_providerの第2引数以降は、Providerの__init__にキーワード引数として渡されます。これにより、Providerに必要な依存(この場合はマスタキャッシュ)を注入できます。
pytestでの検証
実際のテストコードでは、パラメータ化を活用して複数のルールを効率的にテストできます。
以下のコードでは、master_cacheとproduct_validateというフィクスチャを使用しています。これらの定義は「まとめ」セクションで示します。
import pytest
from mimesis import Generic
from mimesis.locales import Locale
@pytest.mark.parametrize("case_fn,expected_code", [
("illegal_pair_hazardous_cold", "E_ILLEGAL_COMBO"),
# ルールが増えたらここに追加する
# ("illegal_pair_xxx_yyy", "E_XXX_YYY"),
])
def test_illegal_combo(case_fn, expected_code, master_cache, product_validate):
"""
組み合わせ制約のバリデーションをテストする
Args:
case_fn: ComboProviderのメソッド名
expected_code: 期待されるエラーコード
master_cache: フィクスチャで提供されるマスタキャッシュ
product_validate: フィクスチャで提供されるプロダクトの検証関数
"""
generic = Generic(locale=Locale.EN, seed=42)
generic.add_provider(ComboProvider, cache=master_cache)
# 同じルールで20件のテストケースを生成して検証
for i in range(20):
pair = getattr(generic.combo, case_fn)()
items = [
{"sku": pair[0], "quantity": 1},
{"sku": pair[1], "quantity": 1},
]
errors = product_validate(items) # プロダクトのバリデーションロジックを呼び出す
# 失敗時にseedとpairを表示して再現可能にする
assert expected_code in errors, (
f"Iteration {i}, seed=42, pair={pair}: "
f"Expected {expected_code} but got {errors}"
)
seedを固定すれば、テストが失敗した場合に同じ入力を再現できます。失敗時のメッセージにseedと生成されたペアを含めておくと、原因特定が容易になります。
Schemaとの組み合わせ
より複雑なリクエストボディを生成する場合は、SchemaとカスタムProviderを組み合わせることができます。
以下のコードでは、前述のMasterCacheインスタンス(cache)を使用しています。
from mimesis import Field, Schema, Generic
from mimesis.locales import Locale
# cacheは前述のMasterCacheインスタンス
# FieldとGenericを同じseedで初期化
field = Field(locale=Locale.EN, seed=42)
generic = Generic(locale=Locale.EN, seed=42)
generic.add_provider(ComboProvider, cache=cache)
def schema_definition():
"""注文リクエストのスキーマ定義"""
pair = generic.combo.illegal_pair_hazardous_cold()
return {
"order_id": field("cryptographic.uuid"),
"customer_id": field("cryptographic.uuid"),
"items": [
{"sku": pair[0], "quantity": 1},
{"sku": pair[1], "quantity": 1},
],
"created_at": field("datetime.datetime"),
}
# 10件の注文リクエストを生成
schema = Schema(schema=schema_definition, iterations=10)
orders = schema.create()
この構成では、注文リクエスト全体の構造はSchemaで定義し、禁止される組み合わせの部分だけカスタムProviderで生成しています。役割を分離することで、コードの見通しが良くなります。
まとめ
最後に、この記事で紹介した内容を使ったテスト実装を示します。
エラーコードと生成関数のマッピング
エラーコードとProviderのメソッド名を対応付けることで、テストケースの管理を一元化できます。
# バリデーションルールの定義
# 新しいルールを追加する場合は、ここに1行追加する
VALIDATION_RULES = {
"E_ILLEGAL_COMBO": "illegal_pair_hazardous_cold",
# "E_WEIGHT_LIMIT": "illegal_pair_heavy_items",
# "E_REGION_RESTRICT": "illegal_pair_region_locked",
}
テストの実装
import pytest
from mimesis import Generic
from mimesis.locales import Locale
@pytest.fixture
def master_cache():
"""マスタデータをキャッシュするフィクスチャ"""
products = fetch_products_from_db() # 実際はDBから取得
return MasterCache(products)
@pytest.fixture
def product_validate():
"""プロダクトのバリデーション関数を返すフィクスチャ"""
from myapp.validators import validate_order
return validate_order
@pytest.fixture
def combo_generic(master_cache):
"""ComboProviderを登録したGenericを返すフィクスチャ"""
generic = Generic(locale=Locale.EN, seed=42)
generic.add_provider(ComboProvider, cache=master_cache)
return generic
@pytest.mark.parametrize("expected_code,case_fn", VALIDATION_RULES.items())
def test_combination_validation(expected_code, case_fn, combo_generic, product_validate):
"""
組み合わせ制約のバリデーションをテストする
各ルールに対して20パターンのテストデータを生成し、
期待されるエラーコードが返ることを確認する。
"""
for i in range(20):
# Providerからテストデータを生成
pair = getattr(combo_generic.combo, case_fn)()
# リクエストボディを構築
items = [
{"sku": pair[0], "quantity": 1},
{"sku": pair[1], "quantity": 1},
]
# プロダクトのバリデーションを実行
errors = product_validate(items)
# エラーコードのアサート(失敗時にseedとpairを表示)
assert expected_code in errors, (
f"Iteration {i}, seed=42, pair={pair}: "
f"Expected {expected_code} but got {errors}"
)
テストの拡張
新しい組み合わせルールについてロジック側に追加する場合のテスト側の対応は以下の通りです。
-
ComboProviderに生成メソッドを追加する
class ComboProvider(BaseProvider):
# ... 既存のメソッド ...
def illegal_pair_high_price(self) -> tuple[str, str]:
"""高額商品の同時購入制限を超える組み合わせを生成する"""
high_price_items = [p for p in self.cache.all_active() if p.unit_price > 1000]
a = self.random.choice(high_price_items)
b = self.random.choice(high_price_items)
return a.sku, b.sku
-
VALIDATION_RULESに1行追加する
VALIDATION_RULES = {
"E_ILLEGAL_COMBO": "illegal_pair_hazardous_cold",
"E_HIGH_PRICE_LIMIT": "illegal_pair_high_price", # 追加
}
これによりテストコード自体は変更不要です。パラメータ化されているため、新しいルールが自動的にテスト対象に含まれます。
マスタ参照と組み合わせ制約を含むバリデーションテストは、テストデータの管理方法を誤ると破綻しやすいです。
この記事で紹介したアプローチでは、以下の3つのポイントを押さえています。
- テスト用マスタを作らず、本番相当のマスタを参照する
- 条件ごとの候補集合をキャッシュして再利用する
- 生成パターンをProviderのメソッド名で表現する
新しい組み合わせルールが追加された場合は、Providerにメソッドを追加し、VALIDATION_RULESに1行追加するだけで対応できます。マスタデータが更新されても、キャッシュを再取得すれば自動的に追従します。
大量のバリデーションが必要であるとどうしてもテストは大変になります。そのような場合にこそ、メンテナンスしやすい本質的なテストを書いていきたいですね。
Discussion