はじめに
こんにちは!スタイル・エッジの ike です。 過去何回かに渡って記事を連載していましたが、モダン開発#2までを執筆していた SHISO さんからバトンパスを受けて、今回は実装者としてプロジェクトに参画した視点から執筆します!
弊社ではある業務システムの開発プロジェクトにて、テスト駆動開発(TDD)に挑戦しました。しかし、結果的にはチームメンバーの経験の少なさ故の難しさがあり、途中でユニットテストの導入を最優先とした方針に転換することになりました。
そこで、TDDに挑戦した経緯や所感、途中で方針転換することになってしまった理由、その中で得られた恩恵・気付きを振り返っていきます!
TDD の挑戦に至った経緯
弊社で保守開発を行なっているあるプロダクトでは、改修時に生じたバグの解消に時間を費やしてしまい、顧客満足度の向上に直接つながるような改善に時間を割けないという問題がありました。
このプロダクトは業務ロジックが非常に複雑なのにも関わらずユニットテストがなかったり、コード設計自体が綺麗とは言い難いという状況になっており、無自覚でバグを埋め込みやすい状態になってしまっていました。また、仕様を詳細に知っているのが一握りのメンバーに限られてしまい、改修後の動作検証で考慮漏れによる手戻りなどもありました。
この経験から、新規プロダクトでは以下のようなTDDのメリットを享受しようと考えました。
- 黄金の回転*1に含まれるリファクタリングにより、必然的にコード品質が担保される
- 自然と高凝集・低結合で多くの部品で構成された設計を行うようになる
- 小さな単位でテストと実装を繰り返すことで、実装時の認知負荷を軽減できる
- 達成すべきことが明確になった状態で実装に取り組むことができる
- 複雑な仕様がテストコードという形で確実に残る
いざ挑戦してみて感じたギャップと方針転換
上記のようなメリットを享受するためにTDDに挑戦はしてみたものの、チーム内にテスト実装自体の経験者が少なかったことや、実装工数とコードレビュー工数が想定以上に重かったことなどから、徐々にプロジェクトとしての余裕がなくなっていきました。これにより次第にテストファーストで書くことが難しくなり、TDDの黄金の回転もうまく行えなくなっていきました。
プロダクトのリリース期限との兼ね合いから、開発手法自体は従来と大きく変えずに「主要部分にユニットテストを導入すること」に照準を合わせることになりました。
ユニットテスト導入による恩恵
コードの可読性・機能拡張性が高まった
ユニットテストの実装を行うためには、テスタブルなコードにする必要があるため、自ずとクラスやモジュールの粒度のばらつきがなくなり、高凝集・低結合なコードになっていきました。また、各メソッドに単一の責務のみを持たせるようになることで、コードの複雑さを取り除くことができました。これらにより変更時の影響範囲が把握しやすく、後から機能を追加することも容易になりました。
重要な業務ロジックの変更に怯えることがなくなった
時間の兼ね合いから、テスト対象は限定する必要がありましたが、依存関係の中心になるドメインオブジェクトやユースケース、広範囲で再利用されるユーティリティに関しては必須でテストを行うようにしました。これにより中核部分のコード品質が高くなり、重要な業務ロジック部分の実装に対しての不安が無くなりました。
テスト実装を必須とした箇所
ドメインオブジェクト
対象:Entity, ValueObject, DomainService, CollectionObject, 振る舞いを持つEnum
理由:ドメインの概念やルールを表し、業務ロジックを表現するための重要な部分であるためユースケース
対象:Actions
理由:ドメインオブジェクトの操作の組み合わせでユースケースやサービスを組み立てる層として重要であり、経年的に煩雑化しやすい部分であるため広範囲で再利用されるユーティリティ
対象:Helpers, Packages, Traits
理由:多くのクラスで呼び出される共通部品で、影響範囲が広いため
テストコードの存在により新規参入者の仕様理解が早くなった
プロジェクトを進める中でメンバーの増員がありましたが、テストコードの存在により、新規参入者が実装に入るための仕様の認識合わせは、補足程度で済むようになりました。
テストコードを読みやすく、意図を把握しやすくするために実施した取り組みの中から4つを紹介します。
テストのメソッド名とテストパターン名は日本語を使用する
メソッド名・テストパターン名を、慣れ親しんでいる日本語で表現することで、曖昧さを排除しつつテスト内容を一目で判断できるようにしました。
<?php /** * @test * @dataProvider dp_条件に違反する値を設定してcreateすると、例外が発生する * @param string $attributeName * @param string $invalidValue * @return void */ public function 条件に違反する値を設定してcreateすると、例外が発生する(string $attributeName, string $invalidValue): void { $this->createParameters[$attributeName] = $invalidValue; $this->expectException(DomainValidationException::class); EventDate::create(...$this->createParameters); } /** * @return array */ public function dp_条件に違反する値を設定してcreateすると、例外が発生する(): array { return [ '日時がYYYY/MM/DD hh:mm:ss形式以外' => ['date', '2000/01/01'], ]; }
テストのメソッド内ではGiven-When-Then構文を使用する
Given、When、Then の3つのブロックに分けて書くことで、どのような条件か、何を実行するのか、期待される結果は何かを明確にしました。
<?php /** * @test * @return void */ public function createに値を渡すと、渡した値でインスタンスが生成される(): void { // Given(前提条件) $id = 'ID'; $name = '氏名'; // When(操作) $inquiry = Inquiry::create( id: $id, name: $name, ); // Then(期待する結果) $this->assertSame($id, $inquiry->id); $this->assertSame($name, $inquiry->name); }
同種のパターン系のテストはData Providersを使用する
同じアサーションで複数パターンの値を検証したい場合にData Providersを使用することで、テストメソッドの記述量を減らし、想定されるパターンが一目で分かるようにしました。
<?php /** * @test * @dataProvider dp_条件に違反する値を設定してcreateすると、例外が発生する * @param string $attributeName * @param string $invalidValue * @return void */ public function 条件に違反する値を設定してcreateすると、例外が発生する(string $attributeName, string $invalidValue): void { // Given(検証値のみ上書き) $this->parameters[$attributeName] = $invalidValue; // When $this->expectException(DomainValidationException::class); // Then Inquiry::create(...$this->parameters); } /** * @return array */ public function dp_条件に違反する値を設定してcreateすると、例外が発生する(): array { return [ '氏名が21文字以上' => ['name', 'テスト氏名テスト氏名テスト氏名テスト氏名テ'], '電話番号が12文字以上' => ['phoneNumber', '123456789012'], '電話番号の形式でない' => ['phoneNumber', 'phone111111'], '対応日時が日時の形式でない' => ['createdAt', '2022-02-03'], ]; }
モックに対しスタブメソッドを定義する場合は、expectsまたはallowsを目的別に使い分ける
スタブメソッドの作成時は以下の基準を設けました。expectsとallowsの使用基準を明示的に分けることで、メソッド内で最もテストしたい事柄を明示的に示すようにしました。
メソッド | 基準 |
---|---|
expects | - 定義するメソッドが必ず呼び出されていることを確認したい場合 - テストを行う上で重要な条件となるメソッドとして明示したい場合 |
allows | - テストを通すためだけに戻り値を定義したい場合 - 必ず呼び出されるかを問わない場合 |
今後の展望
TDDに挑戦しようとして惜しくも断念しましたが、結果としてユニットテストの実装によりコード品質が向上したことで、今後の保守運用も容易になると確信しています。
その一方で、テスト実装とコードレビューにかなりの工数が掛かってしまったことは課題に感じています。今回得た知見を土台にしつつ、AIも活用することで、実装者とレビュワーの負担を減らしていきたいと考えています。
よりテストを強化していくにあたって、機能単位でのFeatureテスト、UI層におけるE2Eテストなどを行なった後、最終的にTDDに再挑戦することが目標です。これらの取り組みを実施して開発負荷を軽減することで、顧客満足度の向上に、より多くのリソースを割けるようにしていこうと思います!
スタイル・エッジでは、モダンな開発にも積極的にチャレンジしています!
ご興味ある方はぜひ採用サイト ↓をぜひご覧ください ♪
recruit.styleedge.co.jp
*1:以下のようなサイクルを回すことで「動作するきれいなコード」を目指すTDDの進め方。
1.次の目標を考える
2.その目標を示すテストを書く(テストファースト)
3.そのテストを実行して失敗させる(レッド)
4.目的のコードを書く
5.2で書いたテストを成功させる(グリーン)
6.テストが通る状態のままコードのリファクタリングを行う
7.1~6を繰り返す