軽量 DDD の設計を活用したテスト実装の効率化

こんにちは。エンジニアの濱田 (@hamakou108) です。

エンジニアの皆さん、テストコードを書いていますか?

アジャイルや DevOps の普及によって、自動テストはもはやソフトウェア開発に欠かせない存在となりました。最近では開発向けAIエージェントの進化も目覚ましく、「人間は仕様や設計の指示を出すだけで、コードはすべてAIが書く」といった未来も想像に難くありません。そんな時代においても、自動テストはソフトウェアの品質を担保し、開発者にとって強力な味方であり続けるでしょう。

ただし、自動テストを活用するには、エンジニアが使いやすい状態に保つことが不可欠です。信頼性やパフォーマンス、カバレッジの向上も重要ですが、テストのメンテナンス性を向上させることも見逃せません。この記事では、テストコードのメンテナンス性を向上させるための汎用ファクトリクラスの導入について、弊社の事例を紹介します。特に軽量 DDD を採用しているプロダクトでの活用にフォーカスしています。

環境

背景: 軽量 DDD を活用した既存コードベースの課題

私たちのプロダクトでは、軽量 DDD (Domain-Driven Design) のアプローチを取り入れ、ドメインオブジェクトを用いた堅牢な設計を行っています。ドメインオブジェクトを正確に構築するために多くの Value Object (VO) を作成し、それらを構成要素とする VO や Entity を組み立てています。

<?php

// 構成要素となる VO
readonly class Id extends BaseId {}
readonly class Name extends BaseString {}
readonly class Address extends BaseString {}
readonly class Url extends BaseUrl {}

// VO を組み合わせて構築された VO
readonly class BasicInfo {
    public function __construct(
        public Name $name,
        public Address $address,
        public Url $url
    ) {}
}

// VO を組み合わせて構築された Entity
readonly class Company {
    public function __construct(
        public Id $id,
        public BasicInfo $basicInfo
    ) {}
}

しかし、テストを作成する上で、この設計には以下のような課題がありました。

  • 多くのプロパティを持つドメインオブジェクトのインスタンス生成が面倒
    前述した Company のようなシンプルなクラスなら良いですが、プロパティが合計で数十個になるようなドメインオブジェクトの場合、関連する全ての VO のインスタンスを生成してコンストラクタに渡すコードを書くのは大変です。

  • パラメータ化テストを書くのが面倒
    テストケースごとに値を変更したい VO が1つだけであっても、それ以外の全ての VO を手動で作成しなければなりません。

これらの結果、テストコードが冗長になり、メンテナンス性が低下するという問題が発生していました。

課題解決のアプローチ: 汎用ファクトリクラスの開発

これらの課題を解決するため、どのようなドメインオブジェクトのインスタンスでも生成できる汎用ファクトリクラス (DataClassFactory) を開発しました。基本的な使い方は次の通りです(詳細な実装については後述します)。

<?php
use Tests\Support\Factory\DataClassFactory;

// 値を特に指定しない場合
$company = (new DataClassFactory(
    Company::class
))->make();
// ID の値を指定したい場合
$company = (new DataClassFactory(
    Company::class,
    defaultValues: [Id::class => new Id(999)])
)::make()
// ネストされたプロパティの値を指定したい場合
$company = (new DataClassFactory(
    Company::class,
    defaultValues: [Name::class => new Name('株式会社M&Aクラウド')])
)::make()

DataClassFactory を使うことで、テストケースごとに必要なプロパティだけを簡単に指定でき、それ以外の値は自動で補完されます。

DataClassFactory の実装

DataClassFactory は次のように実装されています 1。要点だけ知りたい方はコードを読み飛ばしても構いません。

<?php

namespace Tests\Support\Factory;

use InvalidArgumentException;
use LogicException;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;

/**
 * プロパティにダミー値が設定されたインスタンスを簡易的に作成するためのファクトリ。
 * 生成したいインスタンスのクラス名を $targetClassName に代入すると、 `make()` でそのインスタンスが作成される。
 *
 * @template T
 * @template U
 */
class DataClassFactory
{
    /**
     * @var array 型とデフォルトパラメータの組み合わせ
     */
    private readonly array $defaultValues;

    /**
     * @param class-string<T> $targetClassName インスタンス生成したいクラスのパス
     * @param bool $allowNull true のとき、 nullable なプロパティには null を設定する。そうでなければ具体的な値を設定する。
     * @param array<string|class-string, mixed> $defaultValues 型とデフォルトパラメータの組み合わせ。
     *   追加・更新したいものを指定する。 `$allowNull` の方が優先される。
     */
    public function __construct(
        private readonly string $targetClassName,
        private readonly bool $allowNull = false,
        array $defaultValues = [],
    ) {
        $this->defaultValues = array_replace([
            'int' => 1,
            'float' => 1.0,
            'string' => 'foo',
            'bool' => true,
            'array' => [],
        ], $defaultValues);
    }

    /**
     * インスタンスを生成する。
     *
     * @return T
     * @throws InvalidArgumentException
     */
    public function make()
    {
        try {
            return $this->makeFromClassName($this->targetClassName);
        } catch (ReflectionException $e) {
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
        }
    }

    /**
     * クラスのパスを受け取り、プロパティにダミー値が設定されたインスタンスを生成する。
     *
     * @param class-string<U> $className 生成するクラスのパス
     * @return U 生成されたクラスのインスタンス
     * @throws InvalidArgumentException
     * @throws ReflectionException
     */
    private function makeFromClassName(string $className)
    {
        if (!class_exists($className)) {
            throw new InvalidArgumentException("クラス $className が存在しません");
        }

        return self::makeClassInstance($className);
    }

    private function makeClassInstance(string $className)
    {
        $reflectionClass = new ReflectionClass($className);

        $constructor = $reflectionClass->getConstructor();

        if ($constructor === null) {
            throw new InvalidArgumentException("クラス $className にコンストラクタが定義されていません");
        }

        $parameters = $constructor->getParameters();

        if (count($parameters) === 0) {
            throw new InvalidArgumentException("クラス $className のコンストラクタに引数が存在しません");
        }

        // 必要な依存関係を格納する配列
        $args = [];
        foreach ($parameters as $parameter) {
            $name = $parameter->getName();
            $type = $parameter->getType();

            if (is_null($type)) {
                throw new InvalidArgumentException("クラス $className のプロパティ $name の型が未定義です");
            }

            if (!$type instanceof ReflectionNamedType) {
                throw new InvalidArgumentException("クラス $className のプロパティ $name の型は1つである必要があります");
            }

            if ($type->allowsNull() && $this->allowNull === true) {
                // nullable なプロパティには null を設定
                $args[] = null;
            } elseif (array_key_exists($type->getName(), $this->defaultValues)) {
                // defaultValues でその型のデフォルト値が指定されている場合、その値を使用
                $args[] = $this->defaultValues[$type->getName()];
            } elseif ($parameter->isOptional()) {
                // オプションパラメータの場合はデフォルト値を使用
                $args[] = $parameter->getDefaultValue();
            } elseif (class_exists($type->getName())) {
                // クラス名が指定されている場合は再帰的にインスタンスを生成
                $args[] = self::makeFromClassName($type->getName());
            } else {
                throw new LogicException("クラス $className のプロパティ $name に値を設定できませんでした");
            }
        }

        // 依存関係に合わせてインスタンスを生成
        return $reflectionClass->newInstanceArgs($args);
    }
}

DataClassFactory には以下のような特徴があります。

  1. リフレクションによるクラス解析
    クラスのプロパティの型情報を解析し、型に応じた適切なダミー値を生成します。

  2. ダミー値の生成ルール
    型ごとにデフォルトのダミー値を設定します。例えば、文字列型には 'foo'、数値型には 1 を使用します。

  3. 柔軟なオプション設定
    必要なプロパティに対して、型と値の組み合わせ (defaultValues) を指定することで、特定の値を上書きできます。

この DataClassFactory は、軽量DDDを採用しているプロダクトだからこそ特に価値を発揮します。軽量DDDでは、ドメインオブジェクトのプロパティに VO を使用するため、型情報をもとに生成ルールを適用できます。その結果、オブジェクトの内部構造を意識せずに、柔軟かつ効率的なインスタンス生成が可能となっています。

例えば仮に前述の Company クラスのプロパティの型がすべてプリミティブだった場合、 $name の型は string になります。 defaultValues['string' => '株式会社M&Aクラウド'] のように指定することもできますが、同じく string 型である $address にも意図せず値が代入されてしまうことになります。プロパティの型がそれぞれ個別のクラスであれば、狙ったプロパティの値のみを defaultValues で設定できます。

汎用ファクトリクラス導入の効果

DataClassFactory を導入したことで、以下の効果を実感しました。

  • 新しいテストケースの作成が容易に
    必要な部分だけを指定すれば、残りのプロパティは自動補完されるため、テストケースの作成がスピーディーになりました。

  • 可読性の向上
    ダミー値の生成ロジックが一元化され、テストコードがシンプルで分かりやすくなりました。

  • メンテナンス性の向上
    ドメインオブジェクトのプロパティの構造が変更されても、テストコード内でインスタンス生成する箇所の修正はほぼ不要となり、テストコードへの影響が最小限で済みます。

まとめ

軽量 DDD の設計は、ビジネスロジックを明確に表現できる一方で、テストのセットアップに手間がかかるという課題もあります。本記事で紹介した DataClassFactory は、こうした課題を解決し、テスト実装の効率化に大きく寄与しました。

このツールを使うことで、オブジェクトの生成にかかるコストを削減し、開発スピードを向上させることが可能です。また、型情報を活用した設計と組み合わせることで、保守性の高いコードベースを維持できます。

同じような課題に直面している方や、軽量 DDD を採用しているプロジェクトに携わっている方にとって、この記事が参考になれば幸いです。


  1. EnumインスタンスReflectionClass::newInstanceArgs() では生成できないため、別途条件分岐を追加する必要があります。