テストを再設計して開発効率と実効速度を向上しました。

こんにちは、M&Aクラウドのかずへいです。

M&Aクラウドのサービスでは、サービスが拡大するにつれて、開発当初は気にならなかったいくつかの課題が生まれました。

今回、テストの設計を見直し、これらの課題を解決する取り組みを行いましたので、ご紹介したいと思います。

テスト環境に発生していた課題

テスト環境に発生していた課題には以下のようなものがありました。

  • Migrateが遅い
  • Seedingが遅い、Seedが複数のテストで使われており、変更がしづらい
  • テストが足りていないと感じる
  • DomainServiceのテストが書きづらい
  • Test時間が長い

それぞれどういうことが説明します。

Migrateが遅い

M&Aクラウドでは、2年間サービスを運用してきて、Migrationのファイルが積み重なっていました。テーブルを追加するものから、カラムを1つ追加するだけの細かなものまで合わせて280ファイルあまりが存在しました。

このMigrateをテスト実行の一番最初に実行していましたが、1回にローカルのPCで40秒ほど掛かっていました。

Seedingが遅い、Seedが複数のテストで使われており、変更がしづらい

M&Aクラウドのアプリケーションでは、テスト用のデータもSeedingという形で、テスト前のデータベース構成時に一括でデータを流し込んでいました。

そのおかげでテスト時にDBにデータを言える手間が省け、すぐにテストが書けるという状態になっていましたが、2年間かけてSeedingの分量が積み上がっていました。

また、アプリケーションが複雑になるにつれて、Seedのデータを複数のテストが呼び出すようになってしまいました。これでは、テストのためにSeedを変更すると関係ないテストが落ちるということが起きてしまいます。テストがSeedを介して密結合になり壊れやすくなってしまいました。

テストが少ないと感じる

テストが多いかどうかについては、カバレッジのような指標もありますが、基本的には開発者がどう感じているかが重要だと考えています。 「今のコードをリリースするのが不安だ」、「テストをちゃんと書いているのに不具合が出る」と感じているとき、テストが足りていないのでは無いかと思います。

既存のアプリケーションの設計上、テストを書くのに大きなコストが掛かったりパターンを網羅するのがむずかしい状態になっていたりすると、個別の開発者の努力だけではこの不安を払拭するのは難しいと思います。

DomainServiceのテストが書きづらい

M&Aクラウドのアプリケーションでは、データを持ったDomainクラスに収まらない、複数のDomainクラスを呼び出して調整するような操作をDomainServiceクラスという名前で扱っています。

Domainクラスを返してくれるRepositoryクラスがあり、DomainServiceクラスは複数のRepositoryクラスを呼び出し、Repositoryクラスから受け取ったDomainクラスをもとにビジネス上の処理を行います。

このようなクラスは、他の複数のクラスを呼び出す構造にあるため、Unitテストを書こうとするとMockを複数箇所に使ってテストを書かなければならず、テストを書く際の大きな負担になっていました。

Test時間が長い

M&Aクラウドのアプリケーションでは、テストの数は1,500件程度とそれほど多くありませんが、その6割に当たる900件ほどのテストがUnitテストではなくFeatureテストであり、すべて実行すると10分ほど掛かるようになっていました。

解決策

このような課題が発生していましたが、中にはアプリケーションの設計そのものに関係するものもあり、個別の課題を一つづつ直せば解決するというわけではありませんでした。

ここで問題を整理し、以下のような複数の解決案を講じました。

  • 過去のMigrateをまとめてSQLのダンプにする
  • Unit、Featureの2つの区分だったテストをUnit、DbIntegration、Featureの3種類に分割する
  • テストを並列実行し、実行時間を短くする

過去のMigrateをまとめてSQLのダンプにする

過去のMigrateをMySQLのdumpにしてすべてまとめた1つのmigrateにしました。 これによって実行時間が40秒→6秒ほどになりました。

MySQLのdumpにしてしまったので、MySQL以外のDBとの互換性が無くなってしまったということはありますが、すでにMigrateの中にMySQL依存のSQLなども書かれていたことから思い切ってMySQLのみというふうに決め込むことにしました。

Unit、Featureの2つの区分だったテストをUnit、DbIntegration、Featureの3種類に分割する

テストピラミッドという考え方をご存知でしょうか?Unitテスト > 統合テスト > UIテストの順にテストの数を増やすことで、無駄なく信頼性の高いテストを保守できるという考えです。

f:id:mac-tech:20200607185951p:plain
テストピラミッド

M&AクラウドのアプリケーションはLaravelを使っており、PHPUnitでテストを実行しています。Laravelのアプリケーションには、インストールしたときからUnitとFeatureという2つのテスト区分が存在しており、M&Aクラウドではそれをそのまま使ってテストを書いていました。

Featureテストはリクエストからレスポンスまでの一連の処理がすべて行われて初めてGreenになるため、Featureテストが動いていれば機能がちゃんと動いているという安心感?からか、どうしてもFeatureテストを書きがちになってしまいます。

しかし、Featureテストは統合テストに近い概念なので、これではテストピラミッドに違反してしまいます。

どうすればFeatureテストを減らし、Unitテストを増やすことができるでしょうか?

Featureテストで担保していたロジックをUnitテストに移す

Featureテストでは、リクエストからレスポンスまでの処理のすべてがテストできるため、ここの部分をテストしているぞ!という意識が希薄になりがちで、実際にUnitテストできるはずのものもFeatureテストに含まれていました。

f:id:mac-tech:20200607191457p:plain

今回それらを抽出し、アプリケーションの設定とともに見直すことで、Unitテストを書けるようにしました。

f:id:mac-tech:20200607191513p:plain

以下が抽出されたクラスです。

  • Requestクラス
  • Responseクラス
  • Repositoryクラス

次からどのようにクラスが抽出されテストされるのかを説明します。

Controllerの処理をRequestとResponseに分解しテスト可能にする

Laravelはとても便利なので、ControllerでLaravelが提供してくれるリクエストとレスポンスのクラスを活用するだけで簡単に処理が書けてしまいます。

例えば

return view('view_name', $params);

と書くだけで簡単にレスポンスを返す事ができます。

しかし、これではリクエストとレスポンスをテストするためにControllerを呼び出さねばならずFeatureテストになるのは必至です。

そこでRequestをFormRequestを継承したクラスとして、ResponseをResponsableインターフェースを実装したクラスとして丁寧に実装してやります。

これによりRequestとResponseのクラスを個別にテストすることができます。

以下はResponsableなクラスのテストの例です。

qiita.com

UnitテストとFeatureテストの間として、DbIntegrationテストを定義する

データベースからデータを取得する箇所のテストは、Mockに差し替えてしまうと本質的にはテストできません。よってどうしてもFeatureテスト側によってしまいがちです。そこで、DbIntegrationテストとしてUnitテストとFeatureテストの間に「データベースにアクセスしちゃうけど、Featureテストではないテスト」を新しく定義することにしました。

また、課題になっていたDomainServiceクラスのテストもこのDbIntegrationテストに含め、データベースアクセスを許すことにしました。 Mockをたくさん書かないといけないテストはMockの処理自体がコードと密結合してしまうため、テストが壊れやすくなってしまうという問題があります。また、Mock::aが呼ばれて、Mock::bが呼ばれて…とMockが正しく呼ばれることをテストに書き、Mock経由で得られた値が返却されることをテストできても、それはテストとして正しく動いているのか?というと疑問があります。

この問題は「モックの泥沼」という名前で「初めての自動テスト」という本に紹介されていますので、是非読んでみていただければと思います。

初めての自動テスト ―Webシステムのための自動テスト基礎

初めての自動テスト ―Webシステムのための自動テスト基礎

  • 作者:Jonathan Rasmusson
  • 発売日: 2017/09/21
  • メディア: 単行本(ソフトカバー)

よってDomainServiceのテストは、テスト中にデータベース接続することを許し、Mockを使いすぎないようにするという方針にしました。

DbIntegrationテストは以下のような方法で、テストに接続はするが遅くはならないように実装しています。

qiita.com

テストを並列実行し、実行時間を短くする

新しくテストをUnitテスト、DbIntegrationテスト、Featureテストに分割したことにより、それぞれのテスト郡に属するテストが減り、並列に実行するとテスト時間が抑えられます。

弊社ではCircleCIを利用していますので、テストを並列実行し、全てが完了したら開発環境にデプロイするといった仕組みを組んでいます。

f:id:mac-tech:20200607193754p:plain

現状ではまだFeatureテストが多いですが、徐々にUnitとDbIntegrationに寄せていくつもりです。

まとめ

アプリケーションの成長とともにテストの設計が開発効率を下げるようになってしまったので、アプリケーションの設計とともにテストの設計を見直した事例を紹介させていただきました。

テストの設計もアプリケーションと同様に、最初の設計をただ守り続けるだけではなくて、状況に合わせてアップデートしていく必要があるなと改めて思いました。