🚀

[自動テスト]ドメイン情報は含ませない👀

に公開

はじめに

自動テストに関することを勉強していた際に上記のアンチパターンを学んだので、今回はこれを記事にしたいと思います。

ドメイン情報を含ませないとは

この原則は自動テストを実装する際には、テスト対象コンポーネントの内部構造やデータ構造といったドメイン情報に依存したテストを避けるべきである、という原則です。

その趣旨は、テストのメンテナンス性や柔軟性を高めるために重要であるからです。

自動テストとは、アプリケーションのUIやAPIを通じて外部から見た振る舞いを検証するブラックボックステストが基本です。

上記ドメイン情報に直接アクセスするホワイトボックステストはアプリケーションの内部構造の変更にテストが影響を受けやすく、それ故にメンテナンスコストが増加する可能性があります。

テスト対象のコンポーネントの振る舞い、役割、関心事、責務をブラックボックスの観点に立ってテストを自動化させる訳なので、

そのスコープ外のことは含めるべきではありません。

それはそれを責務とするコンポーネントの自動テストにて実装するべきです。

具体例

テキストベースだとなかなか難しいので、ここで具体例なアンチパターンを示してみたいと思います。

今回はこんなユースケースで考えてみましょう。

ユースケース:

架空の「ユーザー管理」のロジックを想定し、ビジネスロジック部分の自動コンポーネントテストコードを実装する。

なお以下のようにレイヤー構造は抽象化され、自動テストコードも実装されるものとする。


① user_model.dart: ドメイン情報であるユーザーモデル

② user_data_source.dart: 外部APIとの通信をシミュレートするData層

③ user_service.dart: ビジネスロジックを持つService層

④ user_service_test.dart: Service層のコンポーネントテスト



こちらを踏まえて実際にservice層のメソッドの自動テストを実装してみましょう。

各レイヤーの実装は以下のようになっているものとします。

model層
/// user_model.dart
/// model layer

class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  // APIレスポンス(Map)からモデルを生成するファクトリ
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }
}
data層
/// user_data_source.dart
///  Data layer
/// 抽象インターフェースで定義する

abstract class UserDataSource {
  Future<User> fetchUser(int userId);
  Future<List<User>> fetchAllUsers();
}
service層
/// user_service.dart
/// service layer

import 'user_model.dart';
import 'user_data_source.dart';

class UserService {
  final UserDataSource _dataSource;

  UserService(this._dataSource);

  // ユーザーIDに基づいてユーザー情報を取得する
  Future<User> getUser(int userId) async {
    // Service層はData層のロジックを呼び出す
    return await _dataSource.fetchUser(userId);
  }

  // 全ユーザー情報を取得する
  Future<List<User>> getAllUsers() async {
    return await _dataSource.fetchAllUsers();
  }
}



この状態にある時のservice層の自動テストを考えてみましょう。

今回は比較しやすいようにアンチパターンとベストプラクティスを両方とも実装してみます。

自動テスト
/// user_service_test.dart
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';

import 'user_model.dart'; 
import 'user_data_source.dart'; 
import 'user_service.dart'; 

// 💡 mocktailを利用
class MockUserDataSource extends Mock implements UserDataSource {}

void main() {
  
  late MockUserDataSource mockDataSource;
  late UserService userService;

  setUp(() {
    mockDataSource = MockUserDataSource();
    userService = UserService(mockDataSource);
  });
  
  // テストデータ
  final userA = User(id: 1, name: 'Taro Yamada', email: 'taro@example.com');
  final userB = User(id: 2, name: 'Hanako Suzuki', email: 'hanako@example.com');


  // --- ❌ アンチパターン: ドメイン情報に依存したテスト ---
  group('UserService (Anti-Pattern - ドメイン情報依存)', () {

    test('fetchAllUsers - ❌ アンチパターン: Data層の実装詳細に依存', () async {
      // Data層のロジックが返すAPIレスポンスのMap形式を直接定義している
      final List<Map<String, dynamic>> rawDataList = [
        {'id': 1, 'name': 'Taro Yamada', 'email': 'taro@example.com'},
        {'id': 2, 'name': 'Hanako Suzuki', 'email': 'hanako@example.com'},
      ];

      // 💡 mocktail: when(mock.method()).thenAnswer((_) async => value);
      // Data層が MapのList から User に変換している詳細を知っている
      when(() => mockDataSource.fetchAllUsers()).thenAnswer(
        (_) async => rawDataList.map((json) => User.fromJson(json)).toList()
      );

      // 実行
      final result = await userService.getAllUsers();

      // 検証
      expect(result.length, 2); 
      // ❌ Data層の内部構造に依存した検証
      expect(result.first.name, 'Taro Yamada');

      // 💡 mocktail: verify(() => mock.method()).called(1);
      verify(() => mockDataSource.fetchAllUsers()).called(1);
    });
  });

  print('\n----------------------------------------\n');

  // --- ✅ ベストプラクティス: ブラックボックス視点でのテスト ---
  group('UserService (Principle-Compliant - ドメイン情報分離)', () {
    
    test('getUser - ✅ ベストプラクティス: Service層の振る舞いを検証', () async {
      
      // 💡 mocktail: thenReturnではなく thenAnswer を使用
      // Data層が**Userモデル**を直接返すことを想定
      // Service層の責務範囲内のオブジェクト(User)のみを扱う
      when(() => mockDataSource.fetchUser(1)).thenAnswer((_) async => userA);

      // 実行
      final result = await userService.getUser(1);

      // 検証: Service層がData層から取得したUserモデルをそのまま返していること
      expect(result, isA<User>());
      expect(result.id, 1);
      expect(result.name, 'Taro Yamada');
      
      // Data層のメソッドが正確に1度呼ばれたことを検証
      verify(() => mockDataSource.fetchUser(1)).called(1);
    });

    test('getAllUsers - ✅ ベストプラクティス: Userモデルのリスト取得を検証', () async {
      // Data層が**Userモデルのリスト**を直接返すことを想定
      when(() => mockDataSource.fetchAllUsers()).thenAnswer((_) async => [userA, userB]);

      // 実行
      final result = await userService.getAllUsers();

      // 検証:
      expect(result, isA<List<User>>());
      expect(result.length, 2);
      expect(result.last.id, userB.id); 

      // Data層のメソッドが正確に1度呼ばれたことを検証
      verify(() => mockDataSource.fetchAllUsers()).called(1);
    });
  });
}

上記コードに則ってコードの比較をしてみます。

アンチパターン

上記コードのうち、

// --- ❌ アンチパターン: ドメイン情報に依存したテスト ---
  group('UserService (Anti-Pattern - ドメイン情報依存)', () {

    test('fetchAllUsers - ❌ アンチパターン: Data層の実装詳細に依存', () async {
      // Data層のロジックが返すAPIレスポンスのMap形式を直接定義している
      final List<Map<String, dynamic>> rawDataList = [
        {'id': 1, 'name': 'Taro Yamada', 'email': 'taro@example.com'},
        {'id': 2, 'name': 'Hanako Suzuki', 'email': 'hanako@example.com'},
      ];

      // 💡 mocktail: when(mock.method()).thenAnswer((_) async => value);
      // Data層が MapのList から User に変換している詳細を知っている
      when(() => mockDataSource.fetchAllUsers()).thenAnswer(
        (_) async => rawDataList.map((json) => User.fromJson(json)).toList()
      );

      // 実行
      final result = await userService.getAllUsers();

      // 検証
      expect(result.length, 2); 
      // ❌ Data層の内部構造に依存した検証
      expect(result.first.name, 'Taro Yamada');

      // 💡 mocktail: verify(() => mock.method()).called(1);
      verify(() => mockDataSource.fetchAllUsers()).called(1);
    });
  });

ここに該当します。

ベストプラクティス

上記コードのうち、

// --- ✅ ベストプラクティス: ブラックボックス視点でのテスト ---
  group('UserService (Principle-Compliant - ドメイン情報分離)', () {
    
    test('getUser - ✅ ベストプラクティス: Service層の振る舞いを検証', () async {
      
      // 💡 mocktail: thenReturnではなく thenAnswer を使用
      // Data層が**Userモデル**を直接返すことを想定
      // Service層の責務範囲内のオブジェクト(User)のみを扱う
      when(() => mockDataSource.fetchUser(1)).thenAnswer((_) async => userA);

      // 実行
      final result = await userService.getUser(1);

      // 検証: Service層がData層から取得したUserモデルをそのまま返していること
      expect(result, isA<User>());
      expect(result.id, 1);
      expect(result.name, 'Taro Yamada');
      
      // Data層のメソッドが正確に1度呼ばれたことを検証
      verify(() => mockDataSource.fetchUser(1)).called(1);
    });

    test('getAllUsers - ✅ ベストプラクティス: Userモデルのリスト取得を検証', () async {
      // Data層が**Userモデルのリスト**を直接返すことを想定
      when(() => mockDataSource.fetchAllUsers()).thenAnswer((_) async => [userA, userB]);

      // 実行
      final result = await userService.getAllUsers();

      // 検証:
      expect(result, isA<List<User>>());
      expect(result.length, 2);
      expect(result.last.id, userB.id); 

      // Data層のメソッドが正確に1度呼ばれたことを検証
      verify(() => mockDataSource.fetchAllUsers()).called(1);
    });
  });

ここに該当します。

両者を比較してみると...

それぞれ一部抜粋して比較してみましょう。

// アンチパターン

// Data層のロジックが返すAPIレスポンスのMap形式を直接定義している
      final List<Map<String, dynamic>> rawDataList = [
        {'id': 1, 'name': 'Taro Yamada', 'email': 'taro@example.com'},
        {'id': 2, 'name': 'Hanako Suzuki', 'email': 'hanako@example.com'},
      ];

      // 💡 mocktail: when(mock.method()).thenAnswer((_) async => value);
      // Data層が MapのList から User に変換している詳細を知っている
      when(() => mockDataSource.fetchAllUsers()).thenAnswer(
        (_) async => rawDataList.map((json) => User.fromJson(json)).toList()
      );

      ---

      // ❌ Data層の内部構造に依存した検証
      expect(result.first.name, 'Taro Yamada');


// ベストプラクティス

// 💡 mocktail: thenReturnではなく thenAnswer を使用
      // Data層が**Userモデル**を直接返すことを想定
      // Service層の責務範囲内のオブジェクト(User)のみを扱う
      when(() => mockDataSource.fetchUser(1)).thenAnswer((_) async => userA);

      ---

      // 検証: Service層がData層から取得したUserモデルをそのまま返していること
      expect(result, isA<User>());
      expect(result.id, 1);
      expect(result.name, 'Taro Yamada');
      
      // Data層のメソッドが正確に1度呼ばれたことを検証
      verify(() => mockDataSource.fetchUser(1)).called(1);

      // Data層が**Userモデルのリスト**を直接返すことを想定
      when(() => mockDataSource.fetchAllUsers()).thenAnswer((_) async => [userA, userB]);

      ---

      // 検証:
      expect(result, isA<List<User>>());
      expect(result.length, 2);
      expect(result.last.id, userB.id); 

      // Data層のメソッドが正確に1度呼ばれたことを検証
      verify(() => mockDataSource.fetchAllUsers()).called(1);


アンチパターン

アンチパターンのコードはテスト対象であるservice層のgetAllUsersメソッドではなく

data層を介してcallされるmodel層の実態に依存しており、その在り方にfeatureしています。

これはその実態が何らかの理由により修正された場合、テスト対象も影響を受けるということです。

プロダクトコードが長大化した場合に自動テストはそのガードレールとして機能することでプロダクトの品質を保証していきますが、

上記アンチパターンのような自動テストですと偽陽性や偽陰性の問題を孕みかねなく、

脆弱な作りになってしまいます。。。


ベストプラクティス

ベストプラクティスの方を見てみましょう。

こちらはテスト対象であるservice層のgetAllUsersメソッドの振る舞いそのものにfeatureしており、

data層やmodel層の実態に関してはmockによる仮想化を施しカプセル化が効いています。

こうすることで内部構造の変更に影響を受けず、テストがこけた際の問題の切り分けも容易になります。



自動テストの実装の際はブラックボックステストの観点と抽象化レイヤーを意識したカプセル化の観点を意識して、テストそのものを頑健なものにすることでプロダクトコードそのものをクリーンにしていけるので、

実装の際は内部構造に依存しないように実装した方が良いでしょう。

参考

https://book.mynavi.jp/ec/products/detail/id=134252

https://zenn.dev/yudai64/articles/c1f7fba3c93536

https://bufferings.hatenablog.com/entry/2024/08/02/010813

https://future-architect.github.io/articles/20230220a/

Discussion