はじめに
こんにちは!モジュール開発部のyamakazu (@yamarkz) です。
10Xではテストコードの標準化を目指して、テストコード規約 (Test Coding Standards) を整備してきました。この記事では数ある規約の中でも、実際に活用して効果が大きかったものをいくつか取り上げて紹介します。
テストコードの品質に課題を持っている方や、コーディング規約の整備に関心を寄せる方の参考になれれば幸いです。
ChatGPT 要約
ChatGPTに内容の要約をお願いしました。この記事で述べられている内容は以下の通りです。
テストコードに規約を設ける目的は、「テストの資産性を高めることで、プロダクトの発展性と事業の継続可能性を守るため」である。テストコード規約は、ボトムアップで開発組織全体が守る基準を明確にし、良質なテストを書くために設ける。この規約ではGroup, Arrange, Act, Assertionという4つのセクションを定義し、各セクションごとに細かいルールを示している。例えば、「group()にはテスト対象を書き、3階層以上にはしない」「test()には事前条件と事後条件を日本語で書く」など、テストコードの表現についての細かい規定がある。
目次
Why テストコード規約
テストコードに規約を定める理由は、
「テストの資産性 (学習容易性, 網羅性, 堅牢性) をボトムアップで高めることで、プロダクトの発展性 (変更, 拡張) に貢献し、事業の継続可能性 (運用) を守り続けるため」です。
固い定義から入ると上記の定義に落ち着きますが、要するに良いテストを書いて開発の質を向上させることです。ここでの「良いテスト」の定義は「資産性が備わっているテスト」とします。
特にトップダウンではなくボトムアップであるという点が特徴的で、開発組織全体として最低限守られなければいけない基準を明確にし、それを守った成果が自然と生み出される状態を作るために、規約を設けます。
コーディング規約に従うことは一般的になりました。
言語によってはツールチェインも充実してきて、規約に従わないで書く方が珍しい時代かもしれません。対してテストのコーディング規約に限って言えば提言数も少なく、まだ一般的ではなさそうです。
10Xではテストの資産性にも強い意識(こだわり)を向けられるようになってきており、最近ではQA/SETチームを中心に「てにをは」レベルでテストコードの表現に強い意識を向けた整備が進められています。
直近取り組んだテストコードの整備過程で得た気づきをもとに、自分たちで守るべき(守りたい)基準として定めたのが、今回取り上げるテストコードの規約です。
前提
2023/03時点での環境は以下です。 時点によっては推奨される内容 (APIや表現) が変わりうるので注意してください。 また、Flutterといった固有のプラットフォームに依存しないDartとしてのルールを定義しています。
テストコード規約
Group
Groupはテストのまとまりに関する規約です。
group()にはテスト対象を書く
group()にはテスト対象のクラスとメソッド名を書きます。
スタティックメソッドの場合は .
、インスタンスメソッドの場合は #
をつけ、対象メソッドの種別を明示的にしましょう。
/// DON'T group('ポイント利用申請時のバリデーション', () { }); group('the validation method for PointApplier', () { }); /// DO group('PointApplier#validate(ポイント利用申請時のバリデーション)', () { });
group()は3階層以上書かない
group
は2階層目までにとどめましょう。3階層目以上は書いてはいけません。
1階層目にはテスト対象を書くことが必須で求められ、2階層目にはカスタマイズ定義で使用します。カスタマイズ定義に制約はありませんが、事前条件をまとめた表現が書かれることを想定しています。
/// DON'T group('PointApplier#validate(ポイント利用申請時のバリデーション)', () { group('ポイント利用数が可能', () { group('全てポイントで支払いの場合', () { // 3階層目を定義してはいけない }); }); }); /// DO group('PointApplier#validate(ポイント利用申請時のバリデーション)', () { group('ポイント利用数が可能で全てポイント支払いの場合', () { test('利用申請に成功する', () { expect('...'); }); }); });
test()には事前条件と事後条件を日本語で書く
事前条件はテスト対象が呼び出される直前の状態で、満たすべき条件です。「xxxの場合」「zzzではない場合」といった表現で書きます。
事後条件はテスト対象が呼ばれた直後の状態で、満たすべき条件です。「注文成功」などではなく、「注文が作成される」といった具体の状態を書きます。
/// DON'T test("利用するポイントがマイナス", () async { // 事前条件のみ expect(...); }); test("Orderが作成される", () async { // 事後条件のみ expect(...); }); test("注文成功", () async { // 事後条件があやふや expect(...); }); /// DO test("利用するポイントがマイナスになっている場合、不正な引数の例外になる", () async { // 事前条件を設定する // テスト対象を実行する expect(...); // 事後条件を確認する }); test("パートナーで利用するポイントがマイナスになっている場合、不明のエラーになる", () async { // 事前条件を設定する // テスト対象を実行する expect(...); // 事後条件を確認する }); test("バリデーションが通った注文の場合、注文が作成される", () async { // 事前条件を設定する // テスト対象を実行する expect(...); // 事後条件を確認する });
事前条件と事後条件はコードに依存した表現を書かない
事前条件と事後条件は非開発者でも理解できるように書きましょう。
非開発者とはプロダクトマネージャー、デザイナー、QA、BizDevといったロールを指します。
コード上の表現に依存した言葉をテストケース名に含めないことで、認識の誤りを減らせます。
/// DON'T test('User#isBuyerがtrueの場合、permissionDeniedエラーになる', () { }); /// DO test('購入済みユーザーの場合、アクセス拒否のエラーになる', () { });
事後条件にはどのような状態になるかを書く
テストの事後条件が抽象的すぎる場合、何を期待していて実際どういう状態になるのか、わからなくなります。具体的にどの状態を期待するのか書きましょう。
/// DON'T test('バリデーションが通った注文の場合、注文成功', () { // 注文成功とは? expect(...); expect(...); }); /// DO test('バリデーションが通った注文の場合、注文データが作成される', () { // 注文データが作られている expect(...); expect(... }); test('バリデーションが通った注文の場合、注文完了通知が送られる', () { // 通知データが送られている expect(...); expect(...); });
事後条件には結果に至った理由を書く
根本原因が記述されているとなぜその振る舞いが正しいのかを非開発者でも理解できるようになり、確認者の認知負荷を下げることに寄与します。
/// DON'T test('API呼び出しでエラーコードにAGB2が返ってきた場合、例外が発生する', () async { expect(...); expect(...); }); /// DO test('API呼び出しでエラーコードにAGB2が返ってきた場合、カード残高の不足で例外が発生する', () async { expect(...); expect(...); });
test内でawaitを使っていなければ、asyncを宣言しない
test内で非同期処理をしなければasync宣言は避けましょう。
同期/非同期であるかを明確に分けることは、読み手が考えることを減らします。
/// DON'T test('同期処理で振る舞いを検証するテスト', () async { // asyncであるが、await処理を呼び出していない // (テストケースとして何が正しいのか確認しなければいけない) }); /// DO test('同期処理で振る舞いを検証するテスト', () { // asyncではないため、await処理は期待されていないことがわかる });
Arrange
共通の変数はトップレベルで定義しない
共通の変数は main()
関数の内側で定義しましょう。
main()
関数の外側で定義しても動作しますが、定義位置を統一にすることで読み解く側の認知負荷と実装者側に判断の迷いを生まないようにします
/// DON'T /// main関数の外側で定義する。※ これでも動作はする MockLogger _mockLogger; DomainService _domainService; MockApiClient _mockApiClient; void main() { setUp(() { _mockLogger = MockLogger(); _domainService = DomainService(); _mockApiClient = MockApiClient(); }); } /// DO void main() { /// main関数の内側で定義する MockLogger mockLogger; DomainService domainService; MockApiClient mockApiClient; setUp(() { mockLogger = MockLogger(); domainService = DomainService(); mockApiClient = MockApiClient(); }); }
共通のスタブ定義はmain関数直下に定義する
setUp
構文でモック/スタブ定義を共有すると重複したモック定義が生まれてしまうことや、テストケースごとの結合がうまれ、変更する際に各テストに気を使わなければいけなくなります。
モック/スタブのセットアップを関数化し、必要とするテストのみで呼び出しましょう。
/// DON'T void main() { group('DeliveryService#delivery(配達する)', () { setUp(() { when(mockApiClient.post( argThat('url'), headers: anyNamed('headers'), body: anyNamed('body'), )).thenAnswer( (_) => Future.value(http.Response()), ); }); group('正常系 '配達リクエストを送った場合、成功する', () async { await expectLater( service.createDeliveryRequest(), completes, ); }); }); } /// DO void main() { void mockPostRequest() { when(mockApiClient.post( argThat('url'), headers: anyNamed('headers'), body: anyNamed('body'), )).thenAnswer( (_) => Future.value(http.Response()), ); } group('DeliveryService#delivery(配達する)', () { test('正常系 配達リクエストを送った場合、成功する', () async { mockPostRequest(); await expectLater( service.createDeliveryRequest(), completes, ); }); }); }
モッククラス定義はモックファイルに定義する
モッククラスは共通のモックファイルに定義して、生成されたコードを参照しましょう。
/// DO @GenerateNiceMocks([ MockSpec<HttpClient>(), MockSpec<Logger>(), // mock化するクラスを定義する // etc... ]) void main() {} ---- // 自動生成されたmockクラスを利用する import 'mocks/generate.mocks.dart'; void main() { late MockLogger mockLogger; setUp(() { mockLogger = MockLogger(); }); }
モッククラスのインスタンスを代入する変数名はmockから始める
モッククラスのインスタンスを持つ変数の名前には接頭辞に mock
をつけましょう。
こう記述することでMockとして振る舞うのか、そうではないのかを明示化します。
テストケースは必ずしも変数の定義位置と近いとは限りません。
位置が離れても存在が何であるのかを明確にすることで、読み手の迷いを減らします。
/// DON'T late MockLogger logger; logger = MockLogger(); /// DO late MockLogger mockLogger; mockLogger = MockLogger();
Act
テストケース内でマジックナンバーを定義しない
テストケース内で登場する数字の意味を明確にするために、数字は一時変数を用いて扱いましょう。
数字がテストケース内で重要な意味を含む場合 (数字の変化が検証対象であるなど) は特にマジックナンバーを排除を推奨します。
/// DON'T test('引数の変更カウント(alterCount)に値をセットしてAPIの呼び出しに成功する', () async { await expectLater( service.authorizeTransaction( userId: userId, orderId: orderId, cardId: cardId, amount: amount, logger: logger, alterCount: 12, ), completes, ); }); /// DO test('引数の変更カウント(alterCount)に値をセットしてAPIの呼び出しに成功する', () async { final updatedAlterCount = 12; await expectLater( service.authorizeTransaction( userId: userId, orderId: orderId, cardId: cardId, amount: amount, logger: logger, alterCount: updatedAlterCount, ), completes, ); });
テストケース内では条件分岐を避ける
テストケースでは条件分岐を使うことを避けます。
やむ追えず使うシーンあるかもしれませんが、推奨はしません。
使う場合は重複責務や過度の抽象化を行なっている可能性を疑い、分岐をなくすことを検討してください。
例では過度の抽象化を行なった悪い例です、抽象的なテストケースでモックの振る舞いを制御するために if分岐を加えています。この場合は抽象化を諦めて分岐をなくし、別のテストケースに分けて定義するのが正しいアプローチです。
/// DON'T test('ダミーのテストケース', () { final mock = when(mockHttpClient.post( argThat(uriEndsWith(entryTranSuffix)), headers: anyNamed('headers'), body: anyNamed('body'), )); if (isException) { mock.thenThrow(Exception()); } else { mock.thenAnswer( (_) => Future.value(http.Response()), ); } }); /// DO test('ダミーのテストケース', () { when(mockHttpClient.post( argThat(uriEndsWith('/entry')), headers: anyNamed('headers'), body: anyNamed('body'), )).thenAnswer( (_) => Future.value(http.Response() ), ); });
時間依存のテストではClockを使う
実行時間に依存したテストを書く場合は、実行時間をカスタマイズできるclockを使いましょう。
/// DO test('3時間以上前に作られた準備完了のリマインドタスクがあったら通知する(1個)', () async { // withClockのなかでClock.fixedを呼び、実行時間を制限する await withClock(Clock.fixed(DateTime(2022, 6, 1, 13, 00)), () { return reminder.execute(PARTNER); }); });
Parameterized Testを使う
テストケースをパラメータを基準に共通化できるのであれば、共通化を検討しましょう。
非開発者目線でも、テスト対象がどういったインプットをもとに振る舞うのかを理解しやすくなります。
/// DO void main() { group('', () { final testCases = [ _TestCase( description: '引数Aでリクエストを送り、不正な値のエラーが返ってきた場合、入力情報を確認する例外が発生する', errorMessage: '入力情報が誤っています、確認してください', resultCode: 'BC42', ), _TestCase( description: '引数Bでリクエストを送り、有効期限切れのエラーが返ってきた場合、問い合わせ確認する例外が発生する', errorMessage: '有効期限が切れています、電話で確認してください', resultCode: 'NK38', ), ]; for (final testCase in testCases) { test(testCase.description, () async { await expectLater( service.execute(argA: 'A', argB: 'B'), throwsA(isA<HogeFugaException>().having( (e) => e.toString(), 'message', equals(testCase.errorMessage), ), ); }); }); }); }
Assertion
同期呼び出しの成功を期待する場合はreturnsNormallyを使う
同期処理の正常終了検証には、 returnsNormally
Matcherを使いましょう。
/// DO test('配列に値が含まれて渡される場合、正常終了する', () async { expect(() { Validator.isNotEmptyList(<int>[1, 2]); }, returnsNormally); });
非同期呼び出しの成功を期待する場合はcompletesを使う
非同期処理の正常終了検証には、 completes
Matcherを使いましょう。
returnsNormally
を誤って選択してしまうこともありますが、非同期処理の呼び出しを正しく検証できません。
/// DON'T test('例外が発生した場合、ロールバックする', () async { await expectLater( service.rollback(order), returnsNormally, // 非同期処理の完了を待たずに呼び出し成功と判断されてしまい、正しくテストできない ); }); /// DO test('例外が発生した場合、ロールバックする', () async { await expectLater( service.rollback(order), completes, // completesで非同期呼び出しの正常終了を検証する ); });
例外やエラーのスロー確認にはビルトインのMatcherを使う
例外やエラーのスロー確認だけを行う場合には、ビルトインのMatcherを使いましょう。
ビルトインではないMatcherでも検証可能ですが、ビルトインで用意されているMatcherの方が簡潔であるのと、指定Matcherを使用することで検査対象が限定化され、読み手の確認負荷を落とすことに寄与します。
/// DON'T test('コメントの引数が不足している場合、リクエストでエラーが返る', () { // isA<Exception>というMatcherを使った表現 await expectLater( service.createContact(call, request), throwsA(isA<Exception>(), ); }); /// DO test('コメントの引数が不足している場合、リクエストでエラーが返る', () { // throwsExceptionというMatcherを使った表現 await expectLater( service.createContact(call, request), throwsException, ); });
2023/03時点でビルトインで用意されているMatcher
throwsConcurrentModificationError
throwsCyclicInitializationError
throwsException
throwsFormatException
throwsNoSuchMethodError
throwsNullThrownError
throwsRangeError
throwsStateError
throwsUnimplementedError
throwsUnsupportedError
TypeMatcherは使わない
TypeMatcherの使用は避けましょう。
公式でもdeprecateであることが決まっており、代わりに throwsA
の使用が推奨されています。
/// DON'T test('引数の値が誤っている場合、リンクの取り消しに失敗する', () { await expectLater( client.unlink(token: token), throwsA(TypeMatcher<UnlinkException>()), ); }); /// DO test('引数の値が誤っている場合、リンクの取り消しに失敗する', () { await expectLater( client.unlink(token: token), throwsA(isA<UnlinkException>()), ); });
predicateは型検証で使わない
レスポンスで返る型とデータの検証に predicate
を使うことは避けましょう。
predicateはboolean型で検証できる便利なMatcherですが、書き振りが冗長になります。
本来の使われ方は計算結果の検算などで用いるMatcherであるため、型検証には isA
や isException
といったMatcherで検証しましょう。
/// DON'T test('お知らせを削除した場合、エラーになる', () async { await expectLater( service.deleteAnnouncement(call, request), // predicateを使った表現でも検証できるが、冗長になる throwsA( predicate<Exception>((error) => error is GrpcError && error.code == GrpcError.permissionDenied().code), ), ); }); /// DO test('ユーザーのサイトタイプと異なるサイトタイプのお知らせを削除しようとするとエラーになる', () async { // predicateを使わずに、Matcherで簡潔に確認する方が良い await expectLater( service.createContact(call, request), throwsGrpcError(StatusCode.invalidArgument), ); });
boolean型のMatcherは過度に使わない
boolean型のMatcherを過度に使い回すことは避けましょう。
boolean型のMatcherを選ぶ前に、ビルトインで備わっている簡潔なMatcher表現を選択できないかを検討してください。
/// DON'T expect(hoge == null, isTrue); expect(huga.length != 0, isFalse); expect(piyo.isMap, isTrue); /// DO expect(hoge, isNull); expect(huga, isNotEmpty); expect(piyo, isMap);
try catchは使わない
テストコードではtry-catchを使うことは避けましょう。
try-catchを使って表現することもできますが、冗長になります。
/// DON'T test('コメントの引数が不足している場合、リクエストでエラーが返る', () { // try catchを使った場合の検証表現 try { await service.createContact(call, request); fail('should throw'); } on GrpcError catch (error) { expect(error.code, StatusCode.invalidArgument); } }); /// DO test('コメントの引数が不足している場合、リクエストでエラーが返る', () { // try catchを使わない表現の検証表現 await expectLater( service.createContact(call, request), throwsGrpcError(StatusCode.invalidArgument), ); });
expect()の reasonは書かなくても良い
Matcherがよしなに設定してくれるので書く必要はありません。
個別のコメントが必要だと判断するのであれば、書いても構いません。
expect(hoge, isTrue, reason: 'ホゲはtrueになることを期待する'); /// DO expect(hoge, isTrue);
全部で23の規約を取り上げてきました。
次にこれらの規約を守り続けるための仕組みとして、Lintの活用を紹介します。
Lintを活用して規約を守る
規約を整備したものの、それらを守っていかなければ意味がありません。守ることで一貫性を生み、可読性や学習容易性に寄与することで初めて規約の価値 (資産性の向上) が証明されます。
「守る」とは言うのは簡単ですが、徹底するのは難しいです。組織の規模が大きければ尚更難しく、守ることを大きな声で呼びかけたとしても、実際に守りきれているかを人が介入してチェックし続けることは持続可能な形態ではありません。
自分も数人規模で取り組んだこともありましたが見事に失敗しました。。。組織で ”守る” ことを人の力だけで解決するのは極めて困難な問題だと認識しましょう。
そんな守りを支える手段としてオススメしたいのがLintです。少し前にも紹介させてもらいましたが、Custom Lint Ruleを活用することで持続可能性が備わった仕組みで、規約が守られる状態を実現できます。
Dartの場合、Lint Ruleを作ることは難しくありません。平均で1つ80行前後でルールが定義できます。Lint Ruleを作ってしまえばPull Request時点で機械的に修正のフィードバックをかけられますし、エディターと組み合わせればリアルタイムでフィードバックを受け取れます。
規約の整備と守り方はセットで考えましょう。そしてその守り方にはCustom Lint Ruleが有効です。
最後に
10Xで整備したテストコード規約を紹介してきました。
Dartというマイナー言語でのテストコード規約の紹介でしたが、汎用性と実用性の観点で見れば他言語でも応用が効く内容になったのではないでしょうか?
これらを定める上で、自分たちも他言語(Go, Kotlin, Java, TypeScript)で発達している考えやプラクティスを参考にしながら整備してきました。
10Xでの事例が今後何らかのコード規約を定める方の参考になれれば幸いです。
規約として標準を決めることとLintで守ることには手応えを感じていますが、まだまだ改善しきれているとは言い難く、多くの課題が残っています。
例えばテストデータ周りなどは答えが見つかっていません。長い整備の道のりになりますが、プロダクト品質の向上に向けて1つずつ積み重ねて改善していきたいです。
10Xの開発に興味を持っていただけた方は、ぜひカジュアルにお話ししましょう 🤝