TeamSpirit デベロッパーブログでは初登場、バックエンドエンジニアの尾上です。
以前、アドベントカレンダーでは心理的安全性について個人的な気付きをnoteに書きました。
今回は実際の開発現場で考えたことや気付きを書いておこうと思います。
テーマはユニットテストと少しだけ TDD(テスト駆動開発)です。
突然ですが、
TeamSpirit のバックエンドコードは Salesforce 上で動作するプログラミング言語 Apex で実装しています。
また、TeamSpirit のように Salesforce AppExchange へリリースするためには、
リリース対象の Apex コードについてテストを書く必要があります。
Apex コードの少なくとも 75% が単体テストでカバーされており、かつすべてのテストが成功している。
- Apex 開発者ガイド
ということで
チームスピリットへ入社するまでユニットテスト経験も浅く、
TDD について深い理解も無い自分が、不慣れながらもユニットテスト書いていく上で
やってしまいがちなテストの書き方や改善の気づきについてまとめておきます。
今後ユニットテストを書くことが必須となったがどう書けばよいかわからない、
という方の参考になれば幸いです。
読むのが辛いテストコードを書いていないか
読むのが辛いテストにはいくつかの原因が考えられます。
- テストメソッド名からテストの内容がわからない
- ステップが整理されていない
- テスト用のデータを準備するコードが長く、どこが肝心なテストなのかがわかりづらい
後者の2つについてはこのあとに記載しますが、 テストメソッド名については、長くなってしまっても正確なテスト目的が記載されているほうがよいと思います。
// テストの内容がわからない static void 休暇申請のテスト3() {} // テストの内容がわかる static void 有効期間外の休暇で休暇申請を行うと例外が発生するテスト() {}
上記はサンプルコードです。Apex では日本語のメソッド名が使えないので
自分が実装する場合はメソッドコメントでテストの目的を記載するなどの工夫をします。
これ以外にも読みづらくなる原因はありそうですが、
少なくともコメントでなんのために行っている処理かを補足しておくと、コードを読む際の負担が軽減されます
(これはテストに限りませんが……!)
ステップが整理されていないテストを書いていないか
ユニットテストには4ステップあると言われます
- 準備(set up)
- 実行(exercise)
- 検証(verify)
- 後片付け(tear down)
テストコードがそれぞれのステップに分けて書かれていれば
とりあえず大枠の流れを把握した状態でテストコードを読み始めることができます。
// 準備 createTestData(); // 実行 ActualEntity actualEntity = new SampleClass.SampleApi().execute(); // 検証 assertEquals("正常終了", actualEntity.status);
ちなみに Apex ではテストメソッドで作成したデータが永続化されることはなく、
テスト終了後に破棄されるため後片付けの必要がありません。
テストデータを準備するコードがやたらと長くないか
テスト実施あたって、前提となるデータの作成を行うコードが長すぎる/多すぎる
場合があります。
いくつかのテストケースで同じようなデータを作成するのであれば、
ユニットテスト内にテスト用データを作成してくれるメソッドを作成したり、
共通的に必要なテスト用データであれば、テスト用データを作成するテストデータクラスを作成するなどの対応をします。
共通的なテストデータクラスを作成する上で、
どこまで便利に使えるようにするか、については気をつけないと
テストデータクラスがどんどん肥大化してしまうので注意が必要です。
// ============================== // 複数のテストで同じ準備をしている // ============================== @isTest static void priceが設定されているsampleのステータスを正常終了に更新するテスト() { // 準備 SampleEntity sampleEnt = new SampleEntity(); sampleEnt.name = "テスト用01"; sampleEnt.price = 120; new SampleRepository().saveEntity(sampleEnt); // 実行 & 検証 assertEquals("正常終了", new SampleClass.SampleApi().execute().status); } @isTest static void priceが設定されていないsampleのステータスを異常終了に更新するテスト() { // 準備 SampleEntity sampleEnt = new SampleEntity(); sampleEnt.name = "テスト用02"; sampleEnt.price = null; new SampleRepository().saveEntity(sampleEnt); // 実行 & 検証 assertEquals("正常終了", new SampleClass.SampleApi().execute().status); } // ============================== // 共通的な準備は共通化してしまう // ============================== @isTest static void priceが設定されているsampleのステータスを正常終了に更新するテスト() { // 準備 createTestData("テスト用01", 120); // 実行 & 検証 assertEquals("正常終了", new SampleClass.SampleApi().execute().status); } @isTest static void priceが設定されていないsampleのステータスを異常終了に更新するテスト() { // 準備 createTestData("テスト用02", null); // 実行 & 検証 assertEquals("正常終了", new SampleClass.SampleApi().execute().status); }
不要なテストを書いていないか
前述の通り、Salesforce AppExchange へのリリースはユニットテストによるコードカバー率が 75% を超えている必要があります。
極論、テストの内容がなんであれカバー率が 75% を超えることを目的とすると、
必ず成功するテストをカバレッジ目的で書くこともできてしまいます。
が、本来テストの目的はカバレッジを上げるためではなく、アプリケーションの品質を担保するためのものなので
コードが壊れていても成功してしまうテストでは意味がありません
(自分が業務で関わった範囲でそのようなテストを見かけることはありませんでした!よかった!)
また、意図的でなくても
稀にテスト対象が正しく検証できておらず、失敗することのないテストが生まれていることがあるので
ユニットテストもしっかりメンバーにレビューしてもらう必要があります。
// アサーションが抜けており、常に成功するテストになってしまっている @isTest static void XXXXを更新するテスト() { // 準備 createTestData(); // 実行 ActualEntity actualEntity = new SampleClass.SampleApi().execute(); }
実行タイミングで結果が変わるテストを書いていないか
テストを書いている時は成功していたけど、翌月に実行してみたら失敗している
といった時限爆弾を仕込んでしまうことがあります。
主に日付や期間などを扱う機能のテストでは気をつけないといけません。
Date.today()
といった実行タイミングによって値の変わるものをテストケース内で用いるのも危険ですが、
テスト対象の API の中で today()
を利用している場合も注意です。
Date クラスを拡張するクラスを作成し、テスト用の today をセットするといった方法で回避するといった工夫が必要です。
// 実行タイミングによって結果が変わってしまう @isTest static void 指定日が操作日より未来であることを判定するテスト() { // 準備 Date targetDate = Date.newInstance(2021, 3, 1); // 実行 & 検証 assertEquals(True, new SampleClass.SampleApi().isFuture(targetDate)); // → 2021年3月1日以降、このテストは失敗してしまう、、、 } // Date を拡張した ExDate を利用し、実行タイミングによる結果の変動を防ぐ @isTest static void 指定日が操作日より未来であることを判定するテスト() { // 準備 ExDate.customToday = ExDate.newInstance(2021, 2, 28); // → today() で返す値をテスト用にセットする ExDate targetDate = ExDate.newInstance(2021, 3, 1); // 実行 & 検証 assertEquals(True, new SampleClass.SampleApi().isFuture(targetDate)); }
上記は Apex での書き方です。
また、上記サンプルコードのような today()
で返る値をカスタマイズする ExDate.customToday
についても実装に注意が必要で
通常機能からの呼び出しを制限しておかないと、操作日=現在日 の前提が崩れてしまいます。
Apex ではテスト実行時のみ private プロパティを参照できるアノテーションや、
テスト実行時のみ True を返すメソッドがあるため、それらを利用してテスト用の操作日をセットすることができます。
テストが書きづらい時は
API の機能が豊富すぎて「シンプルで使いやすい」ことが守られていない可能性があります。
もしこの問題にぶつかった場合は API の設計を見直したり
API 内の責務を分割し、メソッドごとにテストを行うなど、
テスト対象がテストしやすい形になっているか、を疑う場合もあります。
// API の機能が複雑すぎてテストが作りにくい @isTest static void XXXがYYYもしくはZZZの場合に、AAAのBBBがCCCであればDDDをEEEに更新するテスト() { }
また、レイヤードアーキテクチャのような構造になっていて、
(Application層 → Domain層 → Infrastructure層)
Application層のテストはどこまで実施すべきか?
と悩んだ時は、各レイヤーの責務を考えると、テストが書きやすいと思います。
例)
・Application層:入力に対する出力を検証
・Domain層:ドメイン知識による処理を検証
・Infrastructure層:永続化や読み書きを検証
TDD(テスト駆動開発)での改善
まだ自分自身も使いこなせているわけではありませんが、
TDD の考え方に触れることでユニットテストに対する見方が少し変わったので触れておきます。
TDD ってなんだっけ、という方は以下の YouTube リンクをご覧いただくと
丁寧な解説と、サクサクなライブコーディングで楽しく理解が進むと思います!
TDD Boot Camp 2020 Online #1 基調講演/ライブコーディング
TDD は Red → Green → Refactoring のサイクルを回すことで開発する手法です。
- まずは必要なテストを書き、そのテストが失敗する状態(Red)から
- テストが成功する状態(Green)を目指して実装を行い
- テストが成功した状態のまま、実装をきれいにしていく(Refactoring)
ということで、実装よりもまずテストから書いていくため、カバレッジ目的の不要なテストを最初から生むことはありません。
また、上記のライブコーディングでも実演されているように
テストしやすい単位で設計と実装を進めていくため、API に合わせてテストを書くこともなく、テストの抜け漏れも防げそうです。
ただし、TDD はテスト設計のコストをしっかり払う必要がありますし、
機能要件からテスト設計を起こすには慣れも必要で、決して銀の弾丸ではありません。
私自身も普段 TDD を実践できているわけではありませんが
考え方を知っているだけでもユニットテストへの理解が深まったと感じたので
TDD についても触れつつ、ユニットテストについて書いてみました。
これまでユニットテストをしっかり書いたことがないと、意外と気づきにくいことですが
「今後も繰り返し利用されていく」という点を意識するとひどいユニットテストにはなりにくい気がします。
プロダクトコードと同じように新しい機能には新しいテストが追加され、
長い期間、手の加わっていない機能については引き続き利用可能であることをユニットテストが担保しています。
繰り返し利用する、という点ではプロダクトコードとテストコードに差異はなく、
プロダクトコードと同じようにテストコードにも再利用性や保守性の高さが求められます。
今後もより良いテストが書けるようにスキルアップしたいところです。