読みやすいコードとテストコードは評価軸が違う 〜 読みやすいテストコードのために心がけること とその改良記事である 読みやすいテストコードのために心がけること ver 2024. を書きました。個人的には今見ても納得感のあるエッセンスが含まれた記事だとは思います。単体テストの考え方/使い方 という素晴らしい本が私の記事のほぼ上位互換としてありますが。
それから2年(と8か月と17日)。プログラムはAIに支配され、テストコードどころか実装コードすら読まずにLGTMする時代がやってきました。そんな時代でも、テストを書くということの重要さは変わらないと思います。AIによって高速に増えていくコードを、AIによって高速に書き換え、AIによって高速に消える時代でも、本当に変わらないエッセンスはどこにあるのか。
私自体の開発スタイルも大きく変わりました。1年前どころか半年前とも変わっていますし、半年後も1年後も変わっていくでしょう。高速に移り変わる時代のスナップショットを取るつもりで、ここに自分の考えを整理しておきたいと思います。
最後にこの記事をまとめたAI Agent向けのスキルも記載しておきます。業務でも似たようなスキルを入れて開発していますが、概ね満足しています。ぜひご活用してください。2024年の記事とこの記事両方参考にさせつつ自分好みのところを強化してskill-creatorとかで作らせても良いです。
2024年の記事との差分
本記事のタイトルは「効果的な」となっています。以前は「読みやすい」としていましたが、正直人間が読まなくなってしまったので、コードのリーダビリティは中心的な評価軸ではなくなってしまいました。だからといってこんなにも「効果的な」という抽象的な評価軸もどうかと思いますが。
そのため本記事の中で最も重要な変化としては人の読みやすさよりもAIが書いた際に重要なテストコードの書き方を重要視している点です。とはいえど、考えてみると意外に以前の読みやすさのテクニックはAIが書くコードに対しても有効なケースが多いのではと考えています。詳細は各章で触れていきます。
少なくとも本記事ではなぜこの考え方を持つことでAIが書いたとしてもテストコードがより効果的になるのか、という点に焦点を当てて解説していきます。AIだけに有効ということはなく、当然人が書いたり読んだりするときにも効果的かと思います。
追加したり改変した部分は具体的に読んでもらえれば良いですが、追加や改変の意図と削除した部分に関しては先んじてここで記載します。
- 読みやすさの話はほぼ取り去り、テストケースの中でストーリーを描く の章にギュッと押し込んでいます。2022年版および2024年版ではこれがメインだったのにね…。ここも別に読みやすさを中心にしているのではなく、AIのトークン効率や追加のコストを意識した結果です。
- 「暗黙的なテストコードを炙り出せ」は削除しました。なぜならAIは暗黙的なテストコードをほぼ書かないので。
- 「諸刃の剣のRandomly」も削除しました。これはAIではなくて僕がFakerあんまり使わなくなったからですね。AIもあんま使わないんだけど。理由としては、AIに頼むと(なんなら頼まずとも)Fuzzing Testをしてくれるため、ランダム性をCIで動かすテストに求めるのは必要ではないと考えているためです。まだまだ一般的ではないような気はする(し、僕も使ってはいない)のですが、プロパティベーステストの方がより効果的だと思います。
- テストファーストやTDDの話は、AIに書かせる上では否定的な話にしています。その前段階をきっちりやろうぜというマインドセットへの変換です。
- モックの排除を上の方に持ってきました。最近はもしかしたらそうではないですが、初期の方のClaude Codeとかなんかめっちゃモック使ったような印象あります。
- 同型のテストを
it.eachでまとめる話に1章割いています。何か、純粋に入力と出力をパターン化すればいいテストを大量のテストケースに書きがちだと思ったからです。逆にit.eachにまとめすぎて困るという問題も起きるのですが…。 - 全体を通して、AIが大量にテストを書いてくることへのブレーキとなる方向に倒しています。別にあって困ることはないだろうという話はごもっともかなと思いつつ、トークンを減らすことは多少なりともコストの低減につながるかなと思っているためです。
まだ俺は手でコードを書くぜという人は、2024年版も合わせて読んでいただくと良いかなと思います。
はじめに
本記事の前提の部分を記載します。
本記事のコード
本記事ではTypeScript及びVitestで例のコードを記載しています。
それぞれ癖があると思われる部分は利用せず、一般的な他の言語・他のテストライブラリにもあるようなコードを記載します。が、どうしても私自身がTypeScriptとVitest(Jest)ばっかり書いてきたので、そこに引っ張られた話はあると思います。
また、コードやテストコードの処理的な正しさは確認していないため、ビルドエラーや実行時エラーになる可能性があります。ニュアンスが伝われば幸いです。
「テスト」の対象
テスト、といっても様々あります。単体テスト?結合テスト?E2Eテスト?ビジュアルリグレッションテスト?負荷テスト?手動?自動?。
今回ターゲットにするのは、一般的には単体テスト・Unitテストと呼ばれるものです。ですがこの単語のニュアンスも読者次第で大きく変わるものとなっていますので言い換えると1つのファイル・クラス・コンポーネント・関数等に紐づくテストであり永続的にCI等で自動実行されるテストをメインにしています。例えば user.ts に対して user.test.ts ですし、create-user-service.ts に対して create-user-service.test.ts が該当します。ただ、一般的に上位であるE2Eや(広義の)結合テストでも使える場合はあると思います。
また、フロントエンドのテストを記載する場合も参考になるとは思いますが、基本的には強いビジネスロジック・ドメインロジックが発生するバックエンドのテストを記載する場合を中心に記載しています。
単体テスト vs 結合テスト
テストの対象として1つのファイル・クラス・コンポーネント・関数等に紐づくと記載していますが、対象が別のものに依存する場合でも単体テストです。名前的にIntegrationテスト・結合テストのように感じますが、本記事ではすべて単体テストと呼んでいます。
リファクタリングでも有名なMartin Fowler氏のOn the Diverse And Fantastical Shapes of TestingやIntegration Testを参考にして、そういったものも含めて単体テストと本記事では呼んでいます。
理由としては、基本的に単一のファイル(その中の複数の関数を対象にしているかもしれないが、いずれにせよ単一の対象)をテストしているのは変わらないため、それらを区別する理由がないと考えているからです。
別の分け方として、全く外部影響がないテスト(例えば純粋関数など)やモックやスタブをいれて外部(ここでいう外部は対象以外の関数など)の影響を一切受けないテストを単体テスト、モックやスタブを利用せずに外部の影響を受けるテストを結合テストと呼ぶ場合もあります。
これは特にKent C. Dodds氏のTesting Trophyで採用されている考え方です。
Testing Trophyについて多くは触れないですが、「どこを手厚くサポートするべきか・時間をかけるべきか」という指針であり命名には大きな意味はないと私は考えています。
ちなみに、依存先もそのまま動かすテストをSociable (Unit) Tests、依存先を差し替えて対象だけを動かすテストをSolitary (Unit) Testsと呼ぶ分け方もあります。Jay Fields氏の用語で、Martin Fowler氏がUnitTestで紹介しています。Testing Trophyの統合テストと単体テストが、だいたいこれに対応します。本記事ではこれらをまとめて、ただ「単体テスト」と呼び、これを中心に記載していきます。
テストコードの方針
テストコード、というのは何のためにあるのでしょうか。
- 振る舞いが問題ないことを確認するため?
- 人手を介さずに高速にテストを行うため?
テストというのはプログラミング黎明期から当然存在していたものです。昔の時代であればテストはもっぱら人の手で行うコストの非常にかかる工程だったと想像できます[1]。そのため、我々プログラマーが記載するテストコードの「元々の」目的は、振る舞いが問題ないことを確認するためにコストをかけずに行うため、だったはずです[2]。
一方で、自動テストが当たり前になった今の時代ではテストコードには新たな役割が増えていると考えます。
テストコードの真正性
テストコードはCIの登場により非常に重要性を増しました。テストが誤っている場合、誤ったコードの変更をテストが検知できないケースがあり、CIは通っているからちゃんとした動作確認をしないでPRを通すという可能性も大いにありえます。
すなわち、テストコードも重要性が高くなるため、テスト自体の正しさというのも(当たり前ですが)重要になってきます。割れ窓理論でもありますがCIが当たり前の用に落ちているという状況は最悪ですし、CIを通すために網羅性が低かったり、モックを多用したりするのも避けるべきです。Flaky[3]なテストも誤ったテストと考えられるため、早期に修正するべきです。
さらには現状様々な工夫はありつつも、残念ながらAIは過去永続的なコンテキストは持てません。もちろん人間もすべてのコンテキストを100%持てていたわけではないですが「このコードはこっちの機能と干渉するな」とかの勘所はAIよりは人間の方が持っているでしょう。最近のコーディングエージェントは賢いので、人間が気付かないような機能同士の干渉を見つけてくれることもあります。ただ、それは渡したコンテキストの中での話です。いずれにせよ、干渉に気付けるかどうかを勘に任せたくはありません。
AIが書いても人間が書いても、それがどれだけの影響を与えるのかを簡単に把握するためにはテストコードは便利です。影響は小さいからと変更したコードが思わぬところで不具合を引き起こすこともあります。何らか修正した結果仕様外の動作になってしまった場合「予定通り失敗する」ことを確認できれば、影響範囲を把握しやすくなります。仕様外の動作になったときに失敗するということはつまり仕様を正確にテストに反映できていればいいわけです。テストコードは常に真に正しくなければなりません。真に正しいというのは、コードを正としたテストではなく、仕様を正としたテストであるということです。
テストコードの量
現代において単体テストコードを(個人開発でもない限り)書かない選択肢を取る可能性は低いです(0ではないことは承知している)。とりわけAIに重点的にテストをかけと口酸っぱく言えば書いてくれますし、なんなら最近の気が利いているコーディングエージェントは言わずもがなテストを書いてくれます。
一方で、Cline(もはや懐かしいですね)やClaude Codeが出始めの頃にAIに書かせたコードを思い出してください。なんの制御もしなければ大量のテストコードを書いてきませんでしたか?もちろん経験やコードは人によって異なるとは思いますが、個人的な肌感覚としてなんの制御もしないAIは無駄に大量のテストコードを書いてしまう傾向がありました。
そうでなくても「とにかくテストを拡充すれば良い」「とにかくカバレッジを100%にすれば良い」という発想があると大量のテストコードが生まれがちです。まぁ個人的には分岐網羅とかを意識するのは良いと思いますが、盲目的にとにかくテストコードを書くと非常にメンテナンス性が悪くなります。
本記事の冒頭でも記載している通り、AI時代、人はコードは読みません。であれば、テストコードの量なんて多い方が良いのでは?という発想も間違ってはないと思います。ただ、テストコードは書いた後も仕様が変わるたびに直す対象です。量が増えればそのコストもそのまま増えます(次のテストコードのメンテナンス性で扱います)。仕様を表していないテストケースが混ざっていれば、テストコードの真正性も下がります。
副次的な話として**経済的な観点[4]**もあります。無駄なテストコードは、AIが読み書きするコンテキストを増やします。まぁ現状のエージェントはファイルを丸ごと読んでいるわけではなく、検索して必要な範囲だけを読むことも多いですので、どこまで効果的かはわかりませんが…。
テストは必要十分な量であるべきです。
テストコードのメンテナンス性
CIの文化が成熟するにつれ、何度も何度もテストを行うことが低コストで可能になりました。その結果として、テストコードが永遠にコードの正しさを証明することとなります。チームによっては10年間同じテストコードが休むことなくCIを照らし続けていたことでしょう。そうするとコードの正しさを永遠に証明できるため、リファクタリングがしやすくなったりデグレ[5]を防ぐようなこともテストの利点として数えられるようになりました。
つまりテストコードは1回限りのコードではなく、長期間にわたって維持されることを前提に書かれるものです。テスト対象のコードにもよりますが、1回書かれて終わりではなく仕様が追加されるたびに追加され・仕様が変わるたびに変更され・仕様が消えるたびに削除されるわけです。
テストは長期間にわたり維持されることを前提に書かれるべきです。
これはすなわちテストコードの寿命も非常に長くなるということです。
方針のまとめ
上記を踏まえ、下記を方針として、下記を達成するための考え方や方法論を記載していきます。
- テストコードは仕様を表すこと。
- テストコードの真正性でも記載していますが、コードではなく仕様を正としたテストであることが重要です。テストコードが真に仕様を表していればコードが誤る余地はありません。
- テストコードは可能な限り動作環境と同じであること。ただしコストは低いこと。
- テストコードが仕様を表していたとしても、本番と異なる挙動をしてしまう環境だと正確性は薄れてしまいます。そのため、できる限り本番に近い環境でテストできることが望ましいです。簡単に言えばモックなどにはできる限り頼らず、実際に近い環境やエミュレーターなどを用いて本番に近づけることを重要としています。
- この理論だとフロントエンドのテストは全部ブラウザで実行するのが望ましいということになります。すなわちE2Eテストですね。一方でE2Eテストは実行時間が長かったり、そもそも実行のコストが単体テストと比べて高かったり、不安定であったり。特にE2Eはパターン1つ増やすだけで実行時間が増える場合が多く、パターンを網羅していくことと相性が悪いです[6]。
- テストコードは実際の使われ方をできる限り模倣すること。
- テストコードが仕様を表すことに似ていますが、実際の使われ方を模倣することで、より現実的なテストが可能になります。事実上発生しない例外ケースや実際にはほぼ確実に発生しないだろうケースを考慮する必要はないということです。
- 一方でこれは必ず守らなければならない方針でもないです。ユーザーの入力に関するテストを考えると、通常のユーザーは入力しないだろう値でも、
'なり<h1>heading1</h1>なり、何らかのセキュリティリスクを抱えるものもあります。そういった境界における入力値をセキュリティ的な目的で検証するテストはもちろん重要です。 - また、これによりテストのコード量を削減できる可能性があります。AIがテストコードを大量に生成すると人間の感覚で思ってしまう原因は、意味のない例外的なテストケースを無駄に生成してしまうことにあると考えています。こういったものを抑制し意味のあるテストを優先すれば、シンプルに行数は減っていくでしょう。
- BDDの概念や、Kent C. Dodds氏による「テストがソフトウェアの使用方法に似ているほど、より信頼できるようになります」という言葉にも近い話です。
- テストコードはテストの意味を表現できるなかで少ないコード量で表現することを目指すこと。
- テストコードが仕様を表すことを前提としていますが、シンプルにコード量が減れば読みやすくなることが多いです。変数名をちゃんとつけるとか、そういったことはもちろん必要ですが。
- テストコードはテストケースを追加しやすいこと。
- テストの網羅性・メンテナンス性を高めるために非常に重要です。
- 例えば、テストを行うためにモックの前提条件を整理してモックの動作を記述する・何十行もある準備処理を細かく書き換える必要がある状態はテストケースを追加しづらい状態です。
コードよりも仕様
当たり前っちゃ当たり前なのですが、仕様がなければ書くコードは決まりません。
クラシックなコーディングであれば、なんとなくやりたいことを言語化せず頭の中に浮かべ、それをコードに翻訳していき、その中で「あれこのコードだとこういった問題ありそうだな」「後ろのこの処理とバッティングするな」とか考えながら書いていったと思います。もう忘れましたけど。そして、テストファーストやTDDはこれをより効率化してくれました。
ことAIにおいて、TDDは有効だ有効じゃないみたいな話は(1年前ぐらいは)あったかなと思います。まぁただ現状はテストを最初に書くような動作はコーディングエージェントでは少ないように感じます。個人的にはテストファーストの指示を1年ぐらい前に入れたまま放置していますが、最近のコーディングエージェントはそれを綺麗に無視してくれます。
個人的な感覚として、AIは与えられた指示を満たすコードとテストコードを同時に生成するようなイメージを持っています。であれば、AIに書かせる限り、テストファーストに意味がありません。コードとテストコードが同時に生成されるのであれば、どちらを先に書いたかで結果は変わらないからです。
そうなると重要なのは「事前に渡す指示」と「それを検証する手段」です。事前に渡す指示が十分練られていなければ生成されるコードも行いたいことを十分に満たしていないでしょうし、AIが試行錯誤して書いた結果、思ったよりも大量のテストコードやコードが生まれます。
設計ドキュメントを書いて渡す
開発の規模によりますが、そこそこの規模ならまぁ大抵の場合はいきなり開発を始めるのではなく、設計ドキュメントを残すでしょう。AIは指示に書かれていないことを自分の想定で埋めます。想定で埋めた部分は、コードとテストの両方に同じ形で入ります。そのため実装とテストが一致していても、仕様と一致しているとは限りません。指示の抜けは、そのままテストの抜けになるわけです。
推測で埋めないように強めに依頼すれば抜け漏れなどはAIが報告してくれるときもあります。ドキュメントを書く際は一般論的な話ですが、推測をせず一次情報を探すか人間に設計判断を任せるような方向に倒すと良いでしょう。
インターフェースを先に決める
設計ドキュメントにおいて、モジュール間のインターフェースを決めることはある程度重要です。関数設計についての私論では、関数名と引数の型、戻り値の型からやっていることが分かる状態を目指していると書きました。インターフェースが決まれば、その関数の責務が決まるからです。
インターフェースはAIにとっても強い制約になります。型として渡しておけば、AIが引数を増やしたり戻り値の形を変えたりしたときに、型チェックで落ちます。テストもこのインターフェースを呼ぶ形で書かれるので、実装の内部に入り込みにくくなります。
もちろん(明るくないですが)Effect-TSなり、より強い型や契約を定義できるような言語であれば、より強力になるでしょう。
すべての関数の定義を設計段階で書けと言うわけではないですが、少なくともモジュールという分け方をするのであれば重要な要素の1つとなります。
振る舞いを仕様として書く
インターフェースの次に、振る舞いを書きます。ここで書くのはテストケースではなく仕様です。まぁただテストファーストも似たようなもんで先に振る舞いをちゃんと自然言語にする代わりにitを書いていたわけで(まぁitの中は自然言語でしたが)。
比較的デカい機能を開発する際は、開発の前に設計を書かせるのはある程度自然に選択するプラクティスかと思います。影響度調査なりやってくれますからね。もちろん規模が小さいならいきなり実装しても良いでしょうし。
仕様そのものを検証する
ただ、振る舞いのリストには、書いた人が思いついたケースしか並びません。状態の遷移が複雑だったり、並行して動く処理があったりする場合は、形式手法で仕様そのものを検査する手があります。TLA+のような仕様記述言語は最近よく聞きますよね。
形式手法自体が銀の弾丸なわけでもないですし、毎回やるものでは無いと思っています。ただ、仕様記述言語の記述もAIに下書きさせられるようになったので、以前よりは手を出しやすくなりました。AIに実装を任せるほど、実装の前に仕様の穴を潰しておく価値は上がると考えています。
生成されたコードとテストを検証する
AIが実装とテストを書き終えたら、機械で確かめられるものから順に確かめます。
静的な検証
まずは型チェックとlintです。インターフェースを型として渡していれば、実装がそれと違う形になっていないかは型チェックで分かります。
テストコードの書き方も、lintである程度縛れます。Vitestであれば@vitest/eslint-pluginを活用すれば下記のようなルールを設定可能です。
vitest/expect-expect:アサーションのないテストを禁止するvitest/no-conditional-in-test:テストの中のifなどの条件分岐を禁止するvitest/no-hooks:beforeEachなどのフックを禁止する(allowオプションで、DBの初期化に使うフックだけを許可できます)vitest/max-expects:1つのテストの中のexpectの数を制限する
ESLintはもちろん、Oxlintでもカスタムルールは記載できます。上記になかったとしても、自分で作成できると思います。特にAIがかなり書けるので。
AIによるレビュー
実装とはまた別のコンテキストでレビューさせることも必要になるでしょう。レビュールールに関してはまぁ個々人一家言あるかもしれないし、何よりもどのプロジェクトでも通用するようなものはすでにスキルとしてClaudeなりに搭載されているでしょう。せっかくAIによるレビューと言っているので、AIにこの記事を読ませてどんなレビューが望ましいか聞いてみました。下記はその出力をそのまま載せています。
- テストケースが、設計ドキュメントの振る舞いと対応しているか。生成されたテストの
itの説明文を一覧にして、振る舞いと1つずつ照らします。対応するテストのない振る舞いや、どの振る舞いにも対応しないテストがあれば理由を確認します。後者の扱いは次の節で書きます。- 期待値が仕様から導かれているか。AIは実装を書いた直後にテストを書くので、実装の出力をそのまま期待値に写してしまうことがあります。そうしたテストは実装が誤っていても通るので、テストコードの真正性で書いた「仕様を正としたテスト」になりません。具体例は起と結を明確にするで扱います。
- 振る舞いを壊したときに、対応するテストが落ちるか。例えば実装からmediaの有無の条件を消したら、「mediaが付いていないツイートは返さない」に対応するテストが落ちるはずです。レビューするAIに、実装をわざと壊してテストを実行させます。ミューテーションテストなどを利用することも重要です。
いいから、モックは基本的に使うな
モックには「ロンドン派」と「デトロイト派」というテストのモックに対する派閥があります。On the Diverse And Fantastical Shapes of Testingでは前者をMock派(Mockist)・後者を古典派(Classic)と呼んでいるようで、「ロンドン派」はモックを多用する・「デトロイト派」はモックを可能な限り利用しないという違いがあるようです。
テスト駆動開発にはざっくりいうとモックを積極的に使う派(ロンドン学派)とあまり使わない派(デトロイト学派、古典派)がありまして、私は後者なのでほとんど使わず、このエントリに深く同意するところです / “モックは必要悪で、しないにこしたことはない - …” https://t.co/VjDvospTKu
— Takuto Wada (@t_wada) October 15, 2021
「テストコードは可能な限り動作環境と同じであること」という方針を建てているため、この記事の前提としてモックが少ないことを暗に含んで、すなわちデトロイト派としてこの話は展開しています。
また、AI時代においてモックをできる限り発生させない方が嬉しいケースがあると信じています。理由は3つあります。
1つ目は、モックが依存先の振る舞いを「テストを書いた人の想定」で固定してしまうことです。AIが実装とテストを同時に書く場合、モックの戻り値はAIの想定です。依存先の振る舞いについての想定が誤っていれば、実装もモックも同じように誤り、それでもテストは通ります。仕様ではなくAIの想定を正としたテストになってしまうわけです。
2つ目は、モックがテストを通すための道具になりやすいことです。テストが落ちたとき、AIが実装を直す代わりにモックの戻り値を書き換えてテストを通してしまう、ということが起こりえます。モックがなければ、少なくともモックを書き換えて通すという逃げ道はなくなります。
3つ目は、モックが実装の内部構造に依存することです。モックは「どの関数が、どの引数で呼ばれるか」を前提にするので、関数を分割したり名前を変えたりするだけでテストが落ちます。AIは高速にリファクタリングするので、そのたびにモックも書き換えられます。そうなると、テストの変更が仕様の変更なのか実装の都合なのか、差分から区別できなくなります。加えてモックの準備はテストの前提条件を膨らませるので、テストケースを追加するたびに読み書きするトークンも増えます。
モックの使いどころ
それでもモックを使う場面はあります。個人的には、基本として1つ、例外として1つ定めています。
基本の1つは、「テストコードは可能な限り動作環境と同じであること」から導き出されるのですが、「動作環境と同じものを用意するのが不可能な場合」です。外部のAPIや、時刻のように実行するたびに変わるものが該当します。ただし、外部サービスでもエミュレーターがある場合はそちらを優先します。例えば私はGoogle CloudのCloud Storageを利用していますが、これのエミュレーターがあるので、エミュレーターを利用してテストしています。これもモックの延長線といえばそれまでですが、少なくとも言語やライブラリのモックとは別のものです。
例えば、hasNoSocialProblemという関数がツイートの本文やメディアに問題がないかをチェックするとします。そのうちメディアのチェックは外部のAPIを呼んでいます。この環境は用意できないため、APIを呼ぶ部分だけモックします。
vi.mock("src/externals/request-media-checker");
describe("hasNoSocialProblem", () => {
it("MediaCheckerの結果がngの場合、falseを返すこと", async () => {
// Vitestでは下記のようにすればモックの挙動を制御できます
vi.mocked(requestMediaChecker).mockResolvedValue({ result: "ng", reason: "..." });
const result = await hasNoSocialProblem({
content: "こんにちは",
mediaUrls: ["https://example.com/a.png"],
});
expect(result).toBe(false);
});
});
例外は「前提条件の用意が面倒かつ、依存先の結果によって対象の振る舞いが変わること自体に意味があること」です。
例えばcreateTweetは、ツイートを保存する前にhasNoSocialProblemを呼び出すとします。hasNoSocialProblemがfalseを返すような前提条件を作るには、様々なマスタデータを用意する必要があったとしましょう。createTweetにとっては、hasNoSocialProblemの結果によって保存するかどうかが変わることさえ確認できればよいので、ここではモックを使ってもいいかなと感じます。
vi.mock("src/has-no-social-problem");
describe("createTweet", () => {
it("hasNoSocialProblemがfalseを返す場合、SocialProblemErrorになること", async () => {
vi.mocked(hasNoSocialProblem).mockResolvedValue(false);
const creating = createTweet({ content: "こんにちは", mediaUrls: [] });
await expect(creating).rejects.toThrow(SocialProblemError);
});
});
ただし、この例外を使うのは、準備のコストと呼ばれることの意味の両方がそろったときに限ります。準備が面倒という理由だけなら、前提条件を作る関数を用意すれば済みます(作り方は特にArrangeを抽象化するで書きます)。また、モックを追加するかどうかはAIに任せず、人間が判断するようにしています。先に書いた2つ目の理由のように、テストを通すためのモックが入り込みやすいからです。
こういったものはDIで代替出来そうですよね。個人的にはTypeScriptという言語においては正直そのへんが整っているとは思っていないので、活用しない前提で書いてはいます。例えばNest.jsで言えばデコレータがあるためこれを利用するのであれば、モックではなくこれを使えば良さそうです。それ以外にも、AsyncContext(AsyncLocalStorage)なんかも活用できそうですね。
ただ、いずれにせよこれをDBでの挙動を変えるのには可能な限り利用しないほうが良いと筆者は思っています。理由は先ほど記載した通り「テスト用の実装」に依存してしまうテストが書かれる可能性があるためです。
依存関係のテスト範囲
モックを使わない場合、依存先の処理もそのまま動きます。そうなると、依存先の仕様をどこまでテストするのか、という疑問が出てきます。
基本的には「大きく深掘ってテストする必要はない」と思っています。一方で「その対象の責務であればテストをした方がよい」とも思っています。これはだいぶ明確なルールがなく、勘や経験則に基づいている部分です。
先程のcreateTweetとhasNoSocialProblemの関係で考えてみます。今度はモックせずにhasNoSocialProblemを呼び出せるとしましょう。テストの書き方として4つ挙げてみます。
describe("createTweet", () => {
it("hasNoSocialProblemを呼ぶこと", async () => {
// hasNoSocialProblemを監視(spy)して、呼ばれたことだけを確認する
});
});
describe("createTweet", () => {
it("禁止ワードが含まれている場合、保存しないこと", async () => {});
it("MediaCheckerがngを返した場合、保存しないこと", async () => {});
// hasNoSocialProblemのテストケースと同じだけ続く
});
describe("createTweet", () => {
it("禁止ワードが含まれている場合、保存しないこと", async () => {
// モックを使わずに、hasNoSocialProblemがfalseになる前提条件を作る
});
});
describe("createTweet", () => {
// hasNoSocialProblemに関するテストケースはない
});
「テストコードは仕様を表すこと」を考えると4は論外です。2がよさそうに見えますが、hasNoSocialProblemに仕様が増えるたびにこちらにも増やす必要があり、「テストコードはテストケースを追加しやすいこと」が怪しくなります。テストの量も、依存先のテストケースの数だけ増えていきます。AIに任せると、依存先のテストを参考にしてこの2を書くことが多いように感じます。
個人的には3を選びます。代表的なケースを1つ確認して、結果が正しく使われていることを確認します。1も悪くないですが、結果がどう使われるかがテストから読み取れません。例えばtrueの場合に誤って保存しない実装になっていても気づけません。
「その対象の責務であればテストをした方がよい」というのは、特定の処理をSLAP原則[7]などに従って分離した場合を想定しています。
例えば、ツイートに@〜がある場合、そのIDを持つアカウントを引っ張ってきて、その部分をユーザーのホームへのリンクに置き換える処理があるとします。
export const replaceReply = async (content: string): Promise<string> => {
const { userIds, replyLocations } = extractReply(content);
const users = await UserRepository.findByIds(userIds);
return embedReplyLink({ content, replyLocations, users });
};
extractReplyとembedReplyLinkには、それぞれ詳細な単体テストがあるとします。replaceReplyのテストに、これらのテストケースも含めて仕様を網羅させるべきでしょうか。
個人的には、replaceReplyが気にするべきケースであれば記載するべきで、そうでないケース(事実上ほぼありえないようなレアケースなど)はそれぞれの単体テストでカバーできればよい、という意見です。例えば「存在しないユーザーへの@はリンクにしない」は、UserRepositoryの結果とembedReplyLinkの組み合わせで決まる振る舞いなので、replaceReplyのテストに書きます。追加しすぎると量が増え、しなさすぎると仕様を網羅できないというトレードオフです。
テストケースの中でストーリーを描く
これはあくまでJavaScriptでの一般的なテストフレームワーク、JestやVitestでの話ですが、テストはdescribeとitで構造化されます。describeはテストの大きなまとまりを表し、itは1つの具体的なテストケースを表します。この具体的なテストケースに関してです。
AIだろうと人間だろうと、テストケースを追加するときは既存のテストを参考にして書きます。その時、簡単にテストケースを増やせるのがいいですよね。
コーディングエージェントの場合、既存のテストケースを1つ読み、それをコピーして前提条件と期待値を書き換える、という動きをよくします。このとき、テストケースに必要な情報がすべて it の中に書かれていれば、エージェントはその it だけを読めば済みますし、新たな実装を追加する際、itを増やせばいいだけです。そうではなくbeforeEachなどに前提条件が書いてあると、それを変更して、最悪の場合はそれ以外のテストにも影響を与えてしまい、修正範囲が広がります。
なので、テストケースを1つのストーリーとして書きます。「こういう条件のとき」「これをやったら」「こうなる」が、it の中で上から下に読める状態です。以降の節では、そのための書き方を順に説明します。
beforeEachのような全テスト共通の処理は避ける
コードよりも仕様で書いた fetchTweetsWithMedia の振る舞いのうち、2つについてAIが書いたテストが下記だとします。TestUserFactory などは、テスト用のデータを適当な値で埋めて生成するものです。
describe("fetchTweetsWithMedia", () => {
const user = TestUserFactory.build();
const media = TestMediaFactory.build();
const tweetWithMedia = TestTweetFactory.build({ user, mediaList: [media] });
const tweetWithoutMedia = TestTweetFactory.build({ user });
// beforeAll は同じスコープ以下のテストケース全体の前に一度だけ実行される処理です
beforeAll(async () => {
await UserRepository.create(user);
await MediaRepository.create(media);
await TweetRepository.create(tweetWithMedia);
await TweetRepository.create(tweetWithoutMedia);
});
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
const tweets = await fetchTweetsWithMedia(user);
expect(tweets).toEqual([tweetWithMedia]);
});
it("mediaがついていないツイートは取得しないこと", async () => {
const tweets = await fetchTweetsWithMedia(user);
expect(tweets).not.toContainEqual(tweetWithoutMedia);
});
});
一見よさそうに見えます。しかし it の中だけを読むと、user が誰なのか、tweetWithMedia が本当に user のツイートなのかは分かりません。describe 直下と beforeAll を読んで初めて分かります。
ここで、新しく「指定した時刻以降に作成されたツイートに限定できる」という仕様が追加されたとしましょう。テストはどうなるでしょうか。
describe("fetchTweetsWithMedia", () => {
const user = TestUserFactory.build();
const media = TestMediaFactory.build();
const since = new Date("2026-01-01T00:00:00Z"); // 追加
const tweetWithMedia = TestTweetFactory.build({
user,
mediaList: [media],
createdAt: addHours(since, 1), // 追加
});
const oldTweetWithMedia = TestTweetFactory.build({
user,
mediaList: [media],
createdAt: subHours(since, 1),
}); // 追加
const tweetWithoutMedia = TestTweetFactory.build({ user });
beforeAll(async () => {
// ...
await TweetRepository.create(oldTweetWithMedia); // 追加
});
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
const tweets = await fetchTweetsWithMedia(user);
expect(tweets).toEqual([tweetWithMedia, oldTweetWithMedia]); // 変更
});
// ...
// 追加
it("時刻を指定した場合、それ以降に作成されたmedia付きのツイートのみを取得すること", async () => {
const tweets = await fetchTweetsWithMedia(user, { since });
expect(tweets).toEqual([tweetWithMedia]);
});
});
テストケースを1つ増やすために、共通の前提条件を変え、既存のテストケースの期待値まで変えています。これは「テストコードはテストケースを追加しやすいこと」に違反しています。
AIが書く場合、もう1つ問題があります。既存のテストケースの期待値が変わるということは、既存の仕様を書き換えているということです。今回の変更はテストの都合ですが、差分だけを見ると仕様変更と区別がつきません。
前提条件を it の中に書くと下記のようになります。
describe("fetchTweetsWithMedia", () => {
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
const user = TestUserFactory.build();
const media = TestMediaFactory.build();
const tweetWithMedia = TestTweetFactory.build({ user, mediaList: [media] });
const tweetWithoutMedia = TestTweetFactory.build({ user });
await UserRepository.create(user);
await MediaRepository.create(media);
await TweetRepository.create(tweetWithMedia);
await TweetRepository.create(tweetWithoutMedia);
const tweets = await fetchTweetsWithMedia(user);
expect(tweets).toEqual([tweetWithMedia]);
});
it("時刻を指定した場合、それ以降に作成されたmedia付きのツイートのみを取得すること", async () => {
// 同じように、このテストケースに必要なツイートだけをこの中で作る
});
});
行数は増えましたが、it の中だけで条件も結果も読めるようになりました。時刻の仕様を追加するときも、it を1つ足すだけで済み、既存のテストケースには触れません。
ただし、テストに直接関係しない処理(データベースの初期化や後片付けなど)は beforeEach に書いて構いません。避けたいのは、テストケースの前提条件を共通化することです。
行数が増えたことは「テストコードはテストの意味を表現できるなかで少ないコード量で表現することを目指すこと」と相反するように見えます。これは特にArrangeを抽象化するで減らしていきます。
AAA パターンを活用する
前節の最後のテストケースは、上から下に読めるようにはなりましたが、どこまでが準備でどこからが確認なのかが一目では分かりません。テストケースの中のストーリーには型があります。AAAパターンと呼ばれるもので、
- Arrange(用意する):前提条件と入力を用意する
- Act(実行する):テスト対象のコードを実行する
- Assert(表明する):期待される出力や状態を表明する
の頭文字です。BDDではGiven-When-Thenというシナリオの型を使いますが、考え方は同じです。簡単に言えば「これこれこういう条件の場合」「これをやったら」「こうなる」というのがテストの本質であり、この順番にテストを書きましょうね、ということです。
先程のテストケースをAAAの3つに分けると下記のようになります。
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
// Arrange
const user = TestUserFactory.build();
const media = TestMediaFactory.build();
const tweetWithMedia = TestTweetFactory.build({ user, mediaList: [media] });
const tweetWithoutMedia = TestTweetFactory.build({ user });
await UserRepository.create(user);
await MediaRepository.create(media);
await TweetRepository.create(tweetWithMedia);
await TweetRepository.create(tweetWithoutMedia);
// Act
const tweets = await fetchTweetsWithMedia(user);
// Assert
expect(tweets).toEqual([tweetWithMedia]);
});
// Arrange などのコメントは必須ではありません。空行で区切るだけでも十分です。大事なのは、この順番が守られていることです。何も言わないと、なんか勝手にAIが // Arrange などのコメントを書くケースありますよね。一般的なのでしょうか。
AAAが守られていると、テストケースをコピーして新しいテストケースを作るときに、どこを書き換えればいいかが決まります。条件を変えるならArrange、期待する結果を変えるならAssertです。逆に、Assertの後にもう一度Actがあるようなテストケースは、1つの it に複数のストーリーが入っています。これはテストケースの粒度は関心対象で分離しろで扱います。
また、AAAに分けるとArrangeがテストケースの大半を占めていることがよく分かります。上の例ではActとAssertが1行ずつなのに対して、Arrangeは8行あります。
特にArrangeを抽象化する
Arrangeの8行が表している条件を、日本語で書いてみます。
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
// Arrange
// - 対象のユーザーがいる
// - そのユーザーのmedia付きのツイートがDBにある
// - そのユーザーのmediaのないツイートがDBにある
// ...
});
日本語だと3行です。コードもこの3行に近づけます。そのために、モデルの生成とDBへの保存を同時に行う関数を、テストファイルの中に用意します。
describe("fetchTweetsWithMedia", () => {
const addUserIntoStore = async (): Promise<User> => {
const user = TestUserFactory.build();
await UserRepository.create(user);
return user;
};
const addMediaIntoStore = async (): Promise<Media> => {
const media = TestMediaFactory.build();
await MediaRepository.create(media);
return media;
};
// Partial<Tweet> は Tweet のすべてのプロパティを任意にした型です
const addTweetIntoStore = async (props: { user: User } & Partial<Tweet>): Promise<Tweet> => {
const tweet = TestTweetFactory.build(props);
await TweetRepository.create(tweet);
return tweet;
};
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
const user = await addUserIntoStore();
const tweetWithMedia = await addTweetIntoStore({ user, mediaList: [await addMediaIntoStore()] });
await addTweetIntoStore({ user, mediaList: [] });
const tweets = await fetchTweetsWithMedia(user);
expect(tweets).toEqual([tweetWithMedia]);
});
});
Arrangeが3行になり、日本語で書いた条件とほぼ1対1で対応するようになりました。関数を用意した分、テストケースが1つだけならコード量はほとんど変わりませんが、テストケースが増えるほど相対的にコード量は減っていきます。テストケースを追加するときにAIが読み書きするトークンも減ります。
ここで TestTweetFactory などのFactoryの役割も説明しておきます。これはRubyでいうFactoryBotのようなもので、TypeScriptではfisheryというライブラリがあります。Factoryを使わずにモデルを作ると下記のようになります。
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
const user = new User({ identifier: "testUser", name: "testUser" });
const media = new Media({ url: "https://example.com/a.png" });
const tweetWithMedia = new Tweet({ user, content: "tweet", mediaList: [media], createdAt: new Date() });
const tweetWithoutMedia = new Tweet({ user, content: "tweet", mediaList: [], createdAt: new Date() });
// ...
});
identifier や content、createdAt は、モデルを作るために必要なだけで、このテストケースの仕様とは関係ありません。それでもテストケースの中に現れると、読む人にはどれがテストにとって意味のある値なのかが分かりません。AIがこのテストケースをコピーして新しいテストケースを書くときも、関係のない値を意味があるかのように引き継いだり、書き換えたりします。Factoryに関係のない値を任せておけば、テストケースに現れるのは仕様に関係する値だけになります。
前提条件を作る関数をテストファイルごとに書いているのは、そのテストにとって意味のある名前と引数にできるからです。複数のファイルで共通化してもいいのですが、汎用的にするほど引数が増え、テストケースから条件が読み取りにくくなります。
ただし、抽象化するときに関数やFactoryの中に隠さない方がいい条件もあります。Assertで確認する値が依存している条件です。次の節で具体例を見ます。
起と結を明確にする
fetchTweetsWithMedia には、取得したツイートの閲覧回数を1増やすという仕様もあったとします。そのテストケースが下記です。
it("media付きのツイートを取得した場合、閲覧回数が増えること", async () => {
const user = await addUserIntoStore();
await addTweetIntoStore({ user, mediaList: [await addMediaIntoStore()] });
const tweets = await fetchTweetsWithMedia(user);
expect(tweets[0].viewsCount).toBe(1);
});
AAAの形にはなっていますが、ここには2つの暗黙的な前提があります。
addTweetIntoStoreで作ったツイートの閲覧回数が0であることfetchTweetsWithMediaの戻り値が、DBで更新した後の値と同じであること
ストーリーの始まり(起)と終わり(結)が、どちらもテストケースの外にある前提に依存しています。1つずつ見ていきます。
生成した値に前提する動作はやめる
このテストケースのストーリーは次のようになるはずです。
- Arrange:ツイートの閲覧回数を0とする
- Act:
fetchTweetsWithMediaを呼び出す(裏側で閲覧回数が1増える) - Assert:閲覧回数が1であることを確認する
今のテストケースには、このArrangeの「閲覧回数を0とする」が書かれていません。期待値の1は、Factoryのデフォルト値が0であることを前提にしています。
AIが書く場合、この1はFactoryのデフォルト値や実装を読んで決めた値かもしれません。AIによるレビューで書いた「実装の出力を期待値に写す」の典型です。Factoryのデフォルト値が変わればテストは落ちますし、そもそも読む人は「0から1になる」という仕様をテストケースから読み取れません。
Arrangeで初期値を明示します。
it("media付きのツイートを取得した場合、閲覧回数が増えること", async () => {
const user = await addUserIntoStore();
await addTweetIntoStore({ user, mediaList: [await addMediaIntoStore()], viewsCount: 0 });
const tweets = await fetchTweetsWithMedia(user);
expect(tweets[0].viewsCount).toBe(1);
});
これでArrangeの0とAssertの1が、テストケースの中で結びつきました。期待値は「1増える」という仕様から導かれたものになります。
確認すべき値がどこにあるかを意識する
もう1つの前提です。このテストケースは fetchTweetsWithMedia の戻り値の viewsCount を確認しています。でも本来の仕様は、DB上のツイートの閲覧回数が増えることのはずです(DBに viewsCount というカラムがあることを前提としています)。
戻り値とDBの値は、実装が正しければ同じになります。しかし、実装がDBへの保存を忘れていたり、非同期処理の await を付け忘れていたりしても、戻り値の値さえ増やしていればこのテストは通ります。AIは確認しやすい戻り値を検証することが多いように感じるので、この違いには意識的に気をつけています。
it("media付きのツイートを取得した場合、DB上の閲覧回数が増えること", async () => {
const user = await addUserIntoStore();
const tweetWithMedia = await addTweetIntoStore({
user,
mediaList: [await addMediaIntoStore()],
viewsCount: 0,
});
await fetchTweetsWithMedia(user);
const storedTweet = await TweetRepository.find(tweetWithMedia.id);
expect(storedTweet?.viewsCount).toBe(1);
});
it の説明文にも「DB上の」を付けました。テストケースの説明と確認している値が一致し、より仕様に寄り添ったテストになりました。
テストケースの粒度は関心対象で分離しろ
前章で、fetchTweetsWithMedia はツイートの閲覧回数も増やすことが分かりました。さらに、閲覧したことをログとして保存する仕様もあったとします。つまり、この関数には次の3つの仕様があります。
- 指定したユーザーのmedia付きのツイートを返す
- 返したツイートのDB上の閲覧回数を1増やす
- 返したツイートの閲覧ログを保存する
取得する関数が副作用を2つ持つのは設計として褒められたものではないですが、1つのテストケースに複数の仕様が入る例として進めます。
前提条件はどれも同じなので、1つのテストケースにまとめたくなります。
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
const user = await addUserIntoStore();
const tweetWithMedia = await addTweetIntoStore({
user,
mediaList: [await addMediaIntoStore()],
viewsCount: 0,
});
const tweets = await fetchTweetsWithMedia(user);
expect(tweets).toEqual([tweetWithMedia]);
expect((await TweetRepository.find(tweetWithMedia.id))?.viewsCount).toBe(1);
expect(await ViewLogRepository.findByTweetId(tweetWithMedia.id)).toHaveLength(1);
});
問題は、it の説明文と中身が合っていないことです。説明文は「取得できること」ですが、中身では閲覧回数とログも確認しています。これではAIによるレビューで書いた、振る舞いとテストケースを1つずつ照らす確認ができません。人間やレビューするAIがテストケースの一覧を読んでも、閲覧回数やログの仕様があることに気づけません。
失敗したときにも困ります。このテストケースが落ちたとき、3つの仕様のうちどれが壊れたのかはテスト名から分かりません。AIにテストを直させると、落ちた原因とは関係のないAssertまで書き換えてテストを通そうとすることもあります。
取るべき方法は2つあります。説明文を中身に合わせて「media付きのツイートを取得でき、閲覧回数が増え、ログが保存されること」とするか、テストケースを分割するかです。私は分割します。
it("指定したユーザーのmedia付きのツイートを取得できること", async () => {
// ...
});
it("media付きのツイートを取得した場合、DB上の閲覧回数が増えること", async () => {
// ...
});
it("media付きのツイートを取得した場合、閲覧ログが保存されること", async () => {
// ...
});
1つの it が1つの仕様に対応するので、テストケースの一覧がそのまま仕様のリストになります。新しい仕様が増えたときも it を1つ増やすだけです。
分割するとArrangeを3回繰り返しますが特にArrangeを抽象化するで用意した関数があれば、1つのテストケースあたり数行で済みます。前提条件が大きく変わったときに、すべてのテストケースを直す必要があるというデメリットはあります。ただ、同じ形の修正を複数箇所に入れるのはAIが得意な作業ですし、そもそもテストが通らなければ、AIが勝手に直してくれます[8]。
前提条件が長い、または1つの前提条件に対して確認したいことが多い場合は、describe に前提条件の名前を書いてまとめることもあります。
describe("media付きのツイートを取得した場合", () => {
it("DB上の閲覧回数が増えること", async () => {
// ...
});
it("閲覧ログが保存されること", async () => {
// ...
});
});
この場合も、describe に書くのは前提条件の名前だけです。前提条件のデータそのものは、各 it の中で作ります。
同型のテストをまとめる
関心対象で分離していくと、形がまったく同じテストケースが並ぶことがあります。例として、日付を文字列で受け取っていい感じに Date に直す convertToDate を考えます。
it("西暦の日付を変換できること", () => {
expect(convertToDate("2026-01-02")).toEqual(new Date("2026-01-02"));
});
it("和暦の日付を変換できること", () => {
expect(convertToDate("R8-01-02")).toEqual(new Date("2026-01-02"));
});
it("US式の日付を変換できること", () => {
// まぁこれだと2001年2月26日や、UK式として判断した場合の2026年2月1日との違いは分からないのですが...
expect(convertToDate("01-02-26")).toEqual(new Date("2026-01-02"));
});
it("年がない場合、今年の日付として変換できること", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-05"));
expect(convertToDate("01-02")).toEqual(new Date("2026-01-02"));
});
it("月がありえない値の場合、nullを返すこと", () => {
// まぁ2月13日の可能性はあるのですが、月から読む仕様とします
expect(convertToDate("13-02")).toBeNull();
});
it("日がありえない値の場合、nullを返すこと", () => {
expect(convertToDate("08-32")).toBeNull();
});
まぁ正直これでも良いとは思いますが、形がそろっているものを it.each でまとめると、新しい形式に対応するときは表に1行足すだけで済みます。
it.each([
{ label: "西暦", input: "2026-01-02" },
{ label: "和暦", input: "R8-01-02" },
// まぁ2001年2月26日や、UK式として判断した場合の2026年2月1日との違いは分からないのですが...
{ label: "US式", input: "01-02-26" },
])("$label形式の日付を変換できること", ({ input }) => {
expect(convertToDate(input)).toEqual(new Date("2026-01-02"));
});
it.each([
{ label: "月", input: "13-02" },
{ label: "日", input: "08-32" },
])("$labelがありえない値の場合、nullを返すこと", ({ input }) => {
expect(convertToDate(input)).toBeNull();
});
6つあったテストケースが2つの表になり、対応する形式が増えたときに書き足す量も1行になりました。個別の it に分けたほうが、説明文を自然言語で書ける分だけ意図は表しやすいですが、it.each でも行の名前とコメントでだいたい同じことはできます。このまとめる判断は、ある程度AIに任せて良いと思っています。ちなみにAIがit.eachを使うと上記の例のようにlabelというものを多用します。これはこれでitの説明文の代わりに近いので、ある程度意図は掴めるようになるので良いかなぁと思ってはいます(好みではないですが、諦めてます)。
まとめすぎたit.each
一方で、it.each にまとめすぎることもあります。AIが書くテストでよく見るのが、「正常系」と「異常系」を1つの表にまとめたものです。
it.each([
{ label: "正常系(西暦)", input: "2026-01-02", expected: new Date("2026-01-02") },
{ label: "正常系(和暦)", input: "R8-01-02", expected: new Date("2026-01-02") },
{ label: "正常系(年なし)", input: "01-02", now: new Date("2026-05-05"), expected: new Date("2026-01-02") },
{ label: "異常系(月)", input: "13-02", expected: null },
{ label: "異常系(日)", input: "08-32", expected: null },
])("$labelの日付を正しく処理できること", ({ input, now, expected }) => {
if (now) {
vi.useFakeTimers();
vi.setSystemTime(now);
}
expect(convertToDate(input)).toEqual(expected);
});
このテストには問題が3つあります。
- 「正常系」と「異常系」では、確認する範囲が違います。変換できる場合は結果の日付そのものを確認しますが、変換できない場合は
nullであることしか確認していません。それが同じ表に並んでいるので、どの行がどこまで確認しているのかが読み取りづらくなっています。個人的には正常系と異常系は明確に分けたいみたいな欲求もあるので、ここは人によるかもしれません。 - 「年がない場合」は、入力された文字列だけから日付を組み立てる他の行とは別の関心対象です。現在時刻を固定する必要があるので本体に
ifが入り、行によってArrangeも変わっています。ここも前提としてnowを全体で共通的なものにするという形にしても良いとは思います。 - テスト名が「正常系」「異常系」で、仕様を表していません。失敗しても、何の仕様が壊れたのかがテスト名から分かりにくいです。
上記の観点で改善するなら下記のようになります。
it.each([
{ label: "西暦", input: "2026-01-02" },
{ label: "和暦", input: "R8-01-02" },
{ label: "US式", input: "01-02-26" },
])("$label形式の日付を変換できること", ({ input }) => {
expect(convertToDate(input)).toEqual(new Date("2026-01-02"));
});
it.each([
{ label: "月", input: "13-02" },
{ label: "日", input: "08-32" },
])("$labelがありえない値の場合、nullを返すこと", ({ input }) => {
expect(convertToDate(input)).toBeNull();
});
it("年がない場合、今年の日付として変換できること", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-05"));
expect(convertToDate("01-02")).toEqual(new Date("2026-01-02"));
});
まとめてよいのは、ArrangeとActとAssertの形が同じで、値だけが違うテストケースです。本体に if が必要になったら、まとめすぎのサインです。個人的には先程も書いた通り正常系と異常系はまとめないようにはしています。
まとめ
改めて、AIがテストを書く時代に、テストを書く際の考えをまとめてみました。
方針は5つ。
- テストコードは仕様を表すこと。
- テストコードは可能な限り動作環境と同じであること。ただしコストは低いこと。
- テストコードは実際の使われ方をできる限り模倣すること。
- テストコードはテストの意味を表現できるなかで少ないコード量で表現することを目指すこと。
- テストコードはテストケースを追加しやすいこと。
そして、方法論としては下記のようなものが挙げられます。
- コードよりも仕様。
- AIはコードとテストを同時に書くので、書く順番ではなく事前に渡す指示で「テストを実装に依存させない」を守る。
- インターフェースと振る舞いを、テストケースではなく仕様として設計ドキュメントに書き、人間が確認してから実装に進む。必要なら形式手法で仕様そのものを検査する。
- 設計ドキュメントはテンプレートで項目をそろえ、全文を読ませてから実装させる。
- 生成されたコードとテストは、型チェックとlintで静的に検証し、別のAIに設計ドキュメントと照らしてレビューさせる。
- 実装中に増えたテストケースは仕様の追加として扱い、人間が確認する。
- モックは基本的に使わない。
- モックはAIの想定を固定し、テストを通すための道具になりやすく、実装の内部構造に依存する。
- 使うのは、動作環境と同じものを用意できない場合。例外として、準備が面倒かつ、依存先の結果で対象の振る舞いが変わること自体に意味がある場合。
- 依存先の仕様は網羅せず、対象が気にするべきケースを代表的に確認する。
- テストケースの中でストーリーを描く。
- 前提条件は
beforeEachやdescribe直下に置かず、itの中に書く。 - AAA(Arrange、Act、Assert)の順番で書く。
- Arrangeは前提条件を作る関数とFactoryで抽象化し、仕様に関係する条件だけがテストケースに残るようにする。
- Assertで確認する値の初期値はArrangeで明示し、確認する値は本来あるべき場所(DBなど)から取る。
- 前提条件は
- テストケースの粒度は関心対象で分離する。
- 1つの
itに1つの仕様。テストケースの一覧がそのまま仕様のリストになるようにする。
- 1つの
- 同型のテストをまとめる。
- 値だけが違うテストケースは
it.eachでまとめ、各行に仕様上の意味を名前として付ける。 - 本体に
ifが必要になったら、まとめすぎ。
- 値だけが違うテストケースは
2年後は…ないかな。
Appendix: SKILL
本記事の内容を、コーディングエージェント向けのスキルとしてまとめました。Claude CodeやCursorのスキル(SKILL.md)として配置することを想定しています。私の環境に合わせて書いているので、適宜書き換えてください。
---
name: effective-unit-test
description: 単体テストを設計、追加、修正、レビューするときに使う。仕様を正としたテスト、モックの最小化、テストケースの中で完結するAAA、関心対象ごとの分割、同型のテストのit.eachを扱う。テストファイルを作成・編集するとき、テストが落ちたとき、モックを追加しようとしたとき、バグ修正の回帰テストを書くとき、リファクタリングの前に既存の挙動を保護するとき、テストの拡充やカバレッジの向上を依頼されたときは必ず使う。
---
# 効果的な単体テスト
## 判断の基準
個別のルールで迷ったら、下記に照らして判断する。それでも決まらない場合はユーザーに聞く。
- **仕様を正とする**:実装の出力を写したテストは、実装が誤っていても通るので何も保証しない。
- **動作環境に近づける**:モックやテスト用の実装で置き換えた部分は、検証できていない部分になる。
- **実際の使われ方を模倣する**:事実上発生しないケースは書かない。ただし、外から任意の値が来る場所は、セキュリティ上の境界値を確認する。
- **意味を保てる範囲で短く書く**:テストケースは書いた後も読まれ、変更され、トークンを消費する。網羅率を上げること自体を目的にしない。
- **追加しやすさを保つ**:仕様が1つ増えたときに `it` を1つ足すだけで済む形にする。
- **落ちたときに原因が分かる**:テスト名と失敗したアサーションだけで、どの仕様が壊れたのかが分かる状態を保つ。
## 実装の前に
- 設計ドキュメントを渡されたら、実装を始める前に全文を読む。未決事項があれば先に質問する。
- 設計ドキュメントのインターフェース(関数名、引数の型、戻り値の型)を変えない。変える必要があればユーザーに確認する。
- テストケースは、設計ドキュメントの振る舞いから導く。どの振る舞いにも対応しないテストケースは書かない。
- テストの説明文は「〜の場合、〜すること」の形で、条件と結果を書く。「正常系」「異常系」「適切に動作すること」のような、仕様を表さない説明文は使わない。
- その仕様を単体テストで守れるかを先に考える。複数のAPIをまたぐ流れのように単体テストの範囲を超えるものは、より広いスコープのテストに回し、単体テストに押し込まない。
- 実装中に仕様の穴を見つけたら、テストケースを追加する前にユーザーに報告し、仕様として確認する。事実上発生しないケースのテストは追加しない。
## モック
- モックは、外部APIや時刻のように、動作環境と同じものを用意できない場合に限って使う。エミュレーターがあればそちらを使う。
- データベースはモックやテスト用の実装に差し替えず、実物かエミュレーターを使う。差し替えると、動作環境ではなくテスト用の実装に対するテストになる。
- テストを通すためにモックを追加したり、モックの戻り値を書き換えたりしない。テストが落ちたら実装を直す。
- 上記以外でモックが必要だと判断したら、理由を添えてユーザーに確認する。
- テストのための分岐、フラグ、メソッド、exportをプロダクションコードに追加しない。必要になったのは設計かモックの使い方に理由がある。
- モックに対するアサーション(呼ばれたこと、モックの戻り値そのもの)は書かない。確認するのは対象の振る舞いであって、モックの設定内容ではない。
- 依存先の関数の仕様は網羅しない。対象が依存先の結果をどう使うかを、代表的なケースで確認する。呼ばれたことだけを確認するテストにはしない。結果の使い方が壊れても落ちない。
## テストケースの書き方
- 前提条件は `it` の中に書く。`beforeEach`、`beforeAll`、`describe` 直下には、データベースの初期化のようなテストの仕様に関係しない処理だけを置く。
- Arrange、Act、Assertの順に書き、空行で区切る。Actは1つにする。
- Arrangeは、モデルの生成と保存を同時に行う関数をテストファイル内に用意して短くする。テスト用データはFactoryで作り、仕様に関係する値だけをテストケースに書く。
- Assertで確認する値の初期値は、Factoryのデフォルト値に頼らずArrangeで明示する。
- 確認する値は、仕様が指す場所から取る。DBに保存されることが仕様なら、戻り値ではなくDBから読み直して確認する。
- 期待値は仕様から導く。実装の出力をそのまま期待値に写さない。
- 実行するたびに変わるもの(現在時刻、ランダムな値、テストケースの実行順序、前のテストケースが残したデータ)に結果を依存させない。時刻は固定し、必要なデータはそのテストケースの中で作る。
## アサーション
- そのテストケースで守りたい振る舞いだけを確認する。構造全体の一致やsnapshotは、関係のない変更でも落ちるので慎重に使う。
- どんな結果でも通るアサーションにしない。エラーになることを確認するなら `rejects.toThrow(MediaLimitExceededError)` のように型まで、値を確認するなら仕様が決めている値まで指定する。
- 型で保証されていることは確認しない。
- テストケースを増やす前に、既にあるアサーションが弱くないかを見る。弱いアサーションを強くするほうが、テストケースを足すより回帰を検出できる。
## テストケースの粒度
- 1つの `it` で1つの仕様だけを確認する。説明文に「〜し、〜すること」と複数の結果が入るなら分割する。
- 同じ前提条件で確認したいことが多い場合は、前提条件の名前を `describe` に書く。前提条件のデータは各 `it` の中で作る。
- 既存のテストケースの期待値を変更するのは、その仕様が変わったときだけにする。テストケースを追加するために既存のテストケースを書き換えない。
- 仕様がなくなったら、対応するテストケースも削除する。
## it.each
- Arrange、Act、Assertの形が同じで、値だけが違うテストケースは `it.each` でまとめる。
- 表の値は仕様の境界から選ぶ。同じ扱いになる値を何行も並べない。
- 表の各行には仕様上の意味を名前として付け、テスト名に表示する。
- 確認する範囲が違う行を同じ表に混ぜない。結果の値そのものを確認する行と、エラーや `null` になることだけを確認する行が並ぶと、どの行がどこまで確認しているのかが読み取れなくなる。
- 本体に `if` が必要になる場合はまとめない。行によってArrangeやAssertが変わるのは、まとめすぎのサイン。
## 完了前の確認
- 型チェックとlintを実行し、すべて通ることを確認する。
- テストケースの説明文の一覧が、設計ドキュメントの振る舞いと対応しているか。対応するテストのない振る舞いと、どの振る舞いにも対応しないテストケースはユーザーに報告する。
- 主要なテストケースについて、実装の該当箇所を一時的に壊し、そのテストが落ちることを確認する。確認後、実装は元に戻す。
Waterfallを振り返った際にそのあたりの事情が垣間見えました。 ↩︎
SUnitはxUnitシリーズの一番最初のSmalltalk用のテストツールですが、上記記事のPhilosopyを読むと、少し異なることが書いています。テスターはテストを最新の情報に保ち、false negativeやfalse positive(原文だとfalse failures and false successes)に多くの時間をかけていると記載があります。これはSmalltalk自体がGUIと結びつきの強い言語であり、ロジックと実際のインターフェースとの乖離があることから発生する、言語特性に関わるコストだと考えられます。 ↩︎
実行環境・ランダムな値・実行順序などにより全く同じコード・テストコードなのに通ったり落ちたり結果が不確実なテストのことをFlakyなテストと呼びます。 ↩︎
The Economic Benefit of Refactoring という記事はリファクタリングにより入力トークンが減り、AIのコストが削減されたという記事です。この記事ではリファクタリングですが、それ以外の点でもこの「経済的な観点」、言い換えればトークン効率というのはAI時代において重要な観点になっていくと感じています。まぁさすがに記事内のコードがさすがにありえないぐらい無駄が多すぎるとは思いますが。 ↩︎
デグレ(デグレード)はとある変更をいれたことで、異なる処理に影響が発生し問題が発生することです。例えばユーザーIDの採番を連番からランダムな数字にしたら、IDのソートで生成順を担保していた処理で想定外の事象が発生するようになった、といったことです。これの発生を抑えるためのテストをリグレッションテストと呼んだりします。 ↩︎
私が関わっているプロジェクトの1つでは、単体テストとE2Eの中間のようなテストを導入しています。中間なので統合テストと呼んでいます。該当のシステムはCloud Tasksによるキューを用いて数珠つなぎのようにAPIを呼び出していきます。本記事の定義において単体テストは単一のファイルに関するものですが、複数APIを順に実行していくようなものは単体テストの対象外ではあります。一方でE2Eだと気にするべきAPIの数珠つなぎ以外も含めて全体をテストしたくなります。そのため、その間の子として特定APIの前提条件のデータだけDBに入れて、そのAPIをキックして、特定のAPIまで完了した時点での結果を確認するようなテストを行っています。これによってE2Eほどのコストをかけず大きい括りで複数パターンをテストできます。 ↩︎
Single Level of Abstraction Principleの略です。1つの関数の中では処理の抽象度を揃える、という原則です。 ↩︎
個人的には、こういった前提条件(インターフェースとか)の修正と機能追加は別のコミットに分けてほしいのですが、どんなに強く言ってもあんまり分けてくれないので困っています。 ↩︎
