単体テストデータ整備の最適解

テストデータ定義には色々なパターンや考えが示されてきた。

メジャーなところはTestDataBuilderObjectMother など。

一般的に提唱されているテストデータ定義の方法論の実践ではなく、自分なりに考えた今一番最適だと考えているテストデータ定義を示す。

先の2つのメジャーな表現手法を個人的には「複合テストデータの整備手法」と呼んでいる。

ここで紹介する内容は"複合"ではなく、"単体"テストデータの整備手法。

単体テストデータ定義は複合テストデータ定義表現の一部で使われる関係性がある

「単体」という言葉表現は「単体テスト」との関係を意図するものではない。

自分の考えは次のような表現で単体のテストデータを定義していくのが今の所良いという考え。


/// 商品クラス
class Product {
  final String id;
  final String categoryId;
  final String name;

  Product({
    required this.id,
    required this.categoryId,
    required this.name,
  });
}

/// 商品点数の値オブジェクト
class Quantity {
  /// 点数
  final int value;

  /// コンストラクタ
  Quantity(this.value) {
    if (0 > value) {
      throw ArgumentError.value(value, 'value', '数量は0未満の値を許容していません');
    }
  }

  /// 左辺が右辺よりも大きい場合にtrueが返る
  bool operator >(Quantity _quantity) {
    return value > _quantity.value;
  }

  /// 減算する
  Quantity operator -(Quantity quantity) {
    final diff = value - quantity.value;
    return Quantity(diff);
  }

  /// [value]が0である場合にtrueが返る
  bool get isZero => value == 0;
}

/// ---- ここから ---- 

/// 商品在庫
class Stock {
  final String id;
  final String categoryId;
  final String shopId;
  final String name;
  final int price;
  final Quantity quantity;
  final DateTime createdAt;

  /// プライベートコンストラクタ
  ///
  /// 普遍条件を定義する
  Stock._({
    required this.id,
    required this.categoryId,
    required this.shopId,
    required this.name,
    required this.price,
    required this.quantity,
    required this.createdAt,
  }) {
    if (id.isEmpty) {
      throw ArgumentError.value(id, 'id', 'idの空文字は許容されていません');
    }
    // ... 他にも各フィールドごとに不変条件を守れるような制約を記述する
  }

  /// 引数に渡した数量分を減算する
  ///
  /// 不変性(副作用なし)を獲得するため、プライベートコンストラクタを使って再生成する
  Stock decreaseQuantity(Quantity _quantity) {
    //
    // 今回は例として挙げていないが、事前条件があれば、ここに記述する
    //
    return Stock._(
      id: id,
      categoryId: categoryId,
      shopId: shopId,
      name: name,
      price: price,
      quantity: quantity - _quantity,
      createdAt: createdAt,
    );
  }

  /// デフォルトコンストラクタ
  ///
  /// 作成にあたって前提に参照する値(Product)を限定化したい場合や、
  /// 初期の作成(notコンストラクタ)でのみ守りたい制約(createdAtの設定など)を記述するために、
  /// デフォルトコンストラクタをfactoryで宣言する。
  factory Stock(
    Product product,
    String shopId,
    String name,
    int price,
    Quantity quantity,
  ) {
    return Stock._(
      id: '',
      categoryId: product.categoryId,
      shopId: shopId,
      name: product.name,
      price: price,
      quantity: quantity,
      createdAt: DateTime.now(),
    );
  }
}


/// @visibleForTestingがついているので、テストファイル以外から参照するとエディターでメッセージが出る
/// extensionで定義し、"ForTest"を接尾辞につけることで、テスト用コードであることを明示する

extension StockForTest on Stock {
  /// 一部の値を意図的に変えたいケースがテストコードを書く時に頻繁に遭遇する。
  /// カジュアルに対応できる様に、基本全て引数指定が可能な状態にしておく。
  /// 誤った値を定義しても、プライベートコンストラクタ内の判定でエラーになる
  
  static Stock example({
    String id = '12345678',
    String categoryId = '100',
    String shopId = 'nihonbashi',
    String name = 'りんご',
    int price = 200,
    Quantity? quantity,
    DateTime? createdAt,
  }) {
    /// プライベートコンストラクタを経由してインスタンスが作られるので、
    /// 不変条件が守られたインスタンスが作られる。
    return Stock._(
      id: id,
      categoryId: categoryId,
      shopId: shopId,
      name: name,
      price: price,
      quantity: quantity ?? Quantity(1), // インスタンスを渡す場合は、nullableをうまく使う
      createdAt: createdAt ?? DateTime.now(),
    );
  }
}

この表現に行き着く際に考えた得たいことは次のようなものがあった。

  • ドメインモデルで示している事前条件, 不変条件, 事後条件を守りたい。
  • 不変条件が守られることが保証された中で、テスト用に柔軟に値を書き換えたい
  • テスト用であることを明示化し、テスト用途以外では悪用できないようにしたい。
  • テストコードを書く際に記述の想起を助けるために表現に一貫性を持たせたい。
  • ドメインモデルの仕様を確認したい時にすぐに確認できる状態を作りたい。
  • テストコードがアプリケーションコードに入り込むことで可読性が損なわれることを防ぎたい。

先に挙げた例では、上記の得たいことを全て満たした表現になっている。

  • ドメインモデルのコンストラクタを介してインスタンスを作るため、不変条件が守られている
  • @visibleForTestingのアノテーションにより、テストファイル以外で参照されたらLintでエラーにできる
  • ForTest を接尾辞につけることでテストデータを揃えたい対象の命名思い出せば、すぐに補完で参照できる
  • 仕様を確認したい場合はexampleからコードジャンプした先がモデルクラスなので、上部の定義を読めば良い
  • extensionで隔離し、ファイルの最下部に置くことでアプリケーションコードを汚さない
  • デフォルトの引数を用意し、フィールド宣言されている値は全て書き換え可能

このインターフェース定義のもと、テストコードを書くとどうなるのかを次に示す。


void main() {
  group('何かのテストグループ', () {
    test('何かのテストケース', () {
      /// Arrange
      final product = ProductForTest.example();
      /// 他のexmapleから作った値を参照して別の参照を作ることや
      /// デフォルト値を残しつつ、書き換えたい箇所だけ別の値にすることなどもできる。
      final stock = StockForTest.example(
        category: product.category,
        name: product.name,
        price: 300,
        quantity: Quantity(5),
      );
      final shoppingCart = ShoppingCart(
        cartItem: [
          CartItemForTest.exampleFromStock(stock),
        ]
      );
      final request = Request(shoppingCart: shoppingCart);

      /// Act & Assertion
      await expectLater(
        orderService.createOrder(),
        completes,
      );
    });
  });
}

テストコードの方では、XXXForTest.example といったインターフェースで呼び出し、テストケースに合わせてテストデータにセットしたい値を揃えてテストを実施する。

例として挙げた表現でテストケースを数百ケースほど書いてきたが、可読性や開発容易性があり、認知負荷も低く良い手応えを感じている。 今のところ課題感などは感じられておらず、この表現で書き続けていこうと考えている。

単体テストデータ定義は基本はここでの方針に従っていくとして、テストケースによってはテストデータを事前に複数用意しなければいけないケースもある。例えば事前条件が複雑なケース。

その場合は先のインターフェース定義方針に則りつつ、やはり鉄板と言われている TestDataBuilderObjectMother などを使って"複合的"なテストデータ整備の問題を解決するのが良い。

世間の意見を見ると単体テストデータの作成で TestDataBuilderObjectMother を使うことを推奨していたりするが、表現選択としては過剰な印象を持つ。もちろん使っても問題ないし、共通認識を取る上で教科書的な回答を参考にするのは良い。

しかし、本当にそれが自分たちの身の丈にあった最適解なのかは考えた方が良い。ドメインモデルごとにテストデータを作成するビルダーファイルを作っていて、さらにそれはモデル定義とは離れた位置にあるのは認識コストが大きいと、個人的には思う。

「単体テストデータはモデルに近い位置で定義し、複合テストデータは専用のクラスで整備する」が、最適解の最終結論。

参考資料

SHARES