DartのMockitoでカスタムMacherを作る


`Mockito`は、Dartでモック、スタブ、およびスパイを簡単に作成できるライブラリです。Mockitoには、テストに使用される様々なMatcherが用意されていますが、既存のMatcherだとうまく行かないことがあったので、自作する方法を調べて実際に実装してみました。

## Matcherとは何にか？

Matcherは、テスト中に値を比較するために使用されるオブジェクトです。Matcherを使用すると、テストの読みやすさと保守性が向上します。Matcherは、単純な値と複雑なオブジェクトを比較するために使用できます。

## Matcherの使用方法

説明のために`PersonRepository`クラスと`PersonRepository`クラスを定義します。

```dart
class PersonRepository {
  void save(String name, int age) {
    // DBなどの永続化する処理
  };
}

class PersonService {
  final PersonRepository personRepository;

  RegistrationViewModel(this.personRepository);

  void register(String name, int age) {
    // 名前や年齢のバリデーション

    personRepository.save(name, age);  
  }
}
```

`PersonRepository`はDBやローカルに保存する役割を持ち、`save`メソッドはDBやローカルストレージなどに名前と年齢の情報を保存する役割を持ちます。`PersonService`の`register`メソッドは何らかのボタンなどをタップされた時に使われるイメージで、正しい値が入力されたことの確認をし、保存の処理を呼び出す役割です。

ここで「指定された名前と年齢で保存処理を呼び出すこと」という検証のテストを考えます。

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'person_service_test.mocks.dart';

@GenerateMocks([PersonRepository])
void main() {
  late PersonService sut;
  late MockPersonRepository personRepository;

  setUp(() {
    costRepository = MockCostRepository();
    sut = RegistrationViewModel(costRepository);
  });

  test('registerは指定された名前と年齢で保存処理を呼び出すこと', () {
		var name = 'Taro';
		var age = 21;
		
		sut.register(name, age);

		verify(personRepository.save(argThat('Taro', 21)).called(1);
  });
}
```

上記のように`argThat`を用いて指定された名前と年齢で保存処理を呼び出すことを検証できます。

## Matcherを自作する

同姓同名で同じ年齢の人がいた時に区別できるよう、`Person`クラスを作成し、ユニークなIDを持つようにします。それに従い`PersonRepository`の引数と`PersonService`の実装を変更します。

```dart
import 'package:uuid/uuid.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'person.freezed.dart';

@freezed
class Person {
  factory Person._({
    required String id,
    required String name,
    required int age
  }) = _Person;

  factory Person.of(String name, int age) {
    return Person._(
      id: const Uuid().v4(),
      name: name,
      age: age
    );
  }
}

class PersonRepository {
  void save(Person person) {
    // DBなどの永続化する処理
  };
}

class PersonService {
  final PersonRepository personRepository;

  RegistrationViewModel(this.personRepository);
　
  void register(String name, int age) {
    personRepository.save(Person.of(name, age));  
  }
}
```

先ほどと同様に「指定された名前と年齢で保存処理を呼び出すこと」という検証のテストを考えます。IDは生成のたびに変化してしまうので以下のように書いても失敗してしまいます。

```dart
test('registerは指定された名前と年齢で保存処理を呼び出すこと', () {
  var name = 'Taro';
	var age = 21;
		
	sut.register(name, age);

  var expected = Person.of(name, age); 
	verify(personRepository.save(argThat(expected)).called(1);
 });
```

そこで、名前と年齢が等しい時にマッチするようなカスタムMatcherを作ります。

```dart

class PersonMatcher extends Matcher {
  final Person expectedPerson;

  PersonMatcher(this.expectedCost);

  @override
  Description describe(Description description) {
    return description.add('Person: ${expectedCost.toString()}');
  }

  @override
  bool matches(item, Map matchState) {
    return item is Person &&
        item.name == expectedPerson.name &&
        item.age == expectedPerson.age;
  }
}
```

カスタムMatcherを作成する際の基本的な考え方は、`Matcher`クラスを継承し、`matches`メソッドをオーバーライドすることです。このカスタムMacherを使うことで、以下のようにテストを書くことができます。

```dart
test('registerは指定された名前と年齢で保存処理を呼び出すこと', () {
  var name = 'Taro';
	var age = 21;
		
	sut.register(name, age);

  var expected = Person.of(name, age); 
	verify(personRepository.save(argThat(PersonMatcher(expected))).called(1);
});
```

## まとめ

カスタムMatcherを作成することで、Mockitoを使用してテストをより効果的に実行できます。カスタムMatcherは、テストの可読性と保守性を向上させ、より複雑なオブジェクトの比較も可能にします。

DartのMockitoは元々JavaのMockitoに影響されて作られているので、Java側でできることはある程度できそうな気がします。

## 最後に

初めて友人と二人でスマホアプリをリリースしました。Spenderというアプリです。

[Spender for Android](https://play.google.com/store/apps/details?id=com.torichu.spender&pli=1)
[Spender for iOS](https://apps.apple.com/app/spender/id6461213140)

このアプリ開発過程で今回のような学びがありました。また何か学びがあれば書いていきます。
アプリ開発の経緯などはこちらから

https://note.com/nugget_okapi/n/n7c52a591b16d