概要
みなさん、自動テスト書いてますか〜?
自動テストに縁がない人にとって、この領域へ一歩踏み出すことは中々難しいことだなぁ、と感じる今日この頃。
この記事では、「自動テストとは何か」「なぜ必要なのか」「どうやって導入するのか」「何をテストすれば良いのか」などについて、私がエンジニアになってから今までの約4年間で経験・勉強してきたことをベースに解説していきます。
この記事を読んで、自動テスト導入へ一歩踏み出せる人が増えれば良いな、と思いながら書いています。
もしかしたら間違った解釈や反対意見もあるかもしれないので、その時はコメントでそっと教えていただけますと幸いです。
この記事のゴール
- 自動テストのことがざっくりと理解できること
- 自動テストを書くための、はじめの一歩を踏み出すきっかけになること
背景
私は新卒の時、テストエンジニアとして前職に入社したのですが、当時自動テストの存在すら知りませんでした(なぜか受かった)。
入社して特に技術研修等なく即プロジェクトに配属されまして、あの何も分からない、何もできない時のストレスたるや、思い出しただけでハゲそうです(当時はほんとにハゲた)。
なんやかんや必死に約3年間(で5年分)その会社で働いて色々なことを学んだですが、そういえば全然アウトプットしてなかったなと思い、この記事を書くことにしました。
当時の、何が分からないかも分からない状態の自分にテストについて教えてあげるとすれば、どんな内容が良いかな?を考えて書いています。
すでに自動テストについて理解のある方がこの記事を読むとちょっとくどいかもしれません。めちゃくちゃ細かく具体的に説明しています。
あまり長い文章を書く機会がなく、拙い記事になっていたらすみません。(保険)
本題
自動テストって何?
自動テストとは、その名前の通り、自動で動くテストです。
あなたが今、マウスやキーボードを使ってUIを動かしたり、APIを叩いたりして挙動やレスポンスをチェックしているその行動は、手動テストと呼ばれます。
それに対して自動テストは、手動で実行していたテストを自動化したものを指します。
今まで手動でせっせとテストしていた時間が削減され、しかもシステムが実行してくれるので確認漏れもありません。1
自動テストの正体
上記でそんな夢のようなモノがあるのかと勘違いさせてしまったのならば、すみません🙇♂️
そんな簡単な話ではありません。
自動テストを行うためには、テストコードを書く必要があります。
自動テストとは、プログラミングなのです。(ドン!!!)
(当時の自分はこれすらよく分かっていなかったのでした...)
そして、テストコードを書くためには何をテストするか(= テストケース)を考える必要があります。
確認漏れのないテストを実現するには、テストケース漏れがあってはいけないのです。
テストコードを書くには、プロダクションコードを書くのとは違った知識が必要になります。つまり、学習コストがかかります。それは避けては通れません。
また、テストは勝手に実行されてくれないので、手動で実行する、あるいは自動実行するように環境を整備する必要があります。2
せっかく自動テストを作ったのに手動実行するのでは元も子もない(実行し忘れることもある)ので、普通は自動実行する環境を整備します。(GitHub Actions, Jenkinsなどを使って環境を構築します)
自動テストは導入すべきなのか
結論、基本的に導入すべきです。
学習コストがかかるし、なんか大変そうだから導入は諦めよう、と思ったのならば、次の話を聞いた上で考えてください。
そもそも自動テストの役割とはなんなのでしょうか?
自動テストを、ただの業務効率化の手段や品質を維持する程度のものだと思っていたとしたら、それは大きな間違いです。
自動テストは、「命綱」だと覚えてください。
何の命か?
それはプロダクト、ひいては会社の命とも言えます。
たった一つのバグが、会社を潰すこともある、ということを理解しなければなりません。
致命的なバグがきっかけでプロダクトが凍結され、会社が社会的信用を失った例は実際に存在します。3
ただ、いかにテストコードとはいえ、人間が作っている以上ミスすることはあります。
しかし、人間がテストするのとは違って実行プロセスを間違えることはありません。
機械は、プログラムされた通りの動きをするからです。
もちろん、間違えたプログラムを書けばテスト結果も間違えることになります。
ですが、正しいプログラムを一度書いてしまえば、その後ミスをすることはありません。
人力では再現性を100%担保することはできないので、手動テストで確認漏れを起こしたりバグを見逃すこともあるでしょう。
ゆえに、正しいテストコードを書くことに我々エンジニアは一生を捧げなればならないのです。(過激)
テストコードを書かないという選択肢は、プロジェクトがどうなっても良いということと同義なのです。(過激)
はい。自動テスト推進派としての意見はこのようになります。
もちろん、品質より期日が優先だったり、多少ミスがあっても損害にはならないという見立てがあったり、中長期的に運用するシステムではなかったりなど、自動テストを導入しない選択をとる場合もあります。
これは、プロダクトの要件によって、バランスを考える必要があります。色々なしがらみがあるので大変ですね...
しかし、これはあくまでも例外ケースで、基本的に自動テストは導入するべきだというのが私の意見です。
中長期的に運用していくプロダクトや今後スケールしていくプロダクトであれば、必ず自動テストを書くべきだと考えています。
「自動テストを行わないということは、プロダクトや会社の寿命を削る可能性がある」ということを念頭に置いておかなければなりません。
余談
エンジニアは自分の作ったプロダクトの品質に責任を持つ仕事ですので、もしテストを使って品質を担保する必要があると考えるのならば、怖がらず強気でプロダクト責任者と交渉する必要があります。
なぜなら技術について一番詳しいのがエンジニアであり、技術的観点の提案を最も得意とするのもエンジニアであるはずだからです。
この提案ができるエンジニアとできないエンジニアでは市場価値に大きな差が出てくると言っても過言ではありません。
テストに限らずですが、エンジニア以外のステークホルダーに提案を持っていくには広い知識が必要になります。技術以外の、プロジェクトを推進するための知識も必要です。
スケジュールをどう提案するか、どのように付加価値をつけるのか、何をトレードオフするのか、どんなリスクがあり、どれを許容するのか、など検討することはたくさんあります。
POやPMのことを対等に見ることは難しく、頻繁に提案を持っていくのは大変なことですが、品質を担保し生産性を高めることにおいて、エンジニアがプライドを持って仕事をするために必要な行動だと私は学びました。(前職のスクラムチームでは完全にPOとエンジニアで上下関係ができてしまい、失敗しました😇)
*スクラム開発においては、POは投資対効果(RoI)、エンジニアは生産性(価値/時間)を高めることがミッションです。そのため、意見が対立することは健全であり、望ましいチームの状態とされています。(スクラムマスターはその意見を吟味してプロジェクトの成功率を高める判断を下す人)
自動テストの効果
自動テストはよく、コスト・時間がかかるため、導入を敬遠されることがあります。
コーディングにプラスしてテストを書く必要があるので、単純計算2倍の工数が必要になる、と考える人は多い気がします。
果たしてそれは本当でしょうか?
自動テスト導入を検討する際、実装コストのことばかり気にしてしまいがちですが、テストを導入することで、中長期的に見ると大きな効果を生むということを理解しなければいけません。
自動テストを書くことで、主に以下の作業が削減できると考えています。
- 実装時やリリース時の手動テスト
- ドキュメント(詳細設計書など)の作成
- コメントの追加
- リファクタリングや要件変更対応時の手動テスト(これ超重要)
ここまできて前提の話をしますが、自動テストはプロダクションコードを書く前に実装するか、プロダクションコードを書いた後に実装するかで効果やリスクが大きく変わってきます(後ほど説明します)。
上記で箇条書きにした項目は、プロダクションコードを書く前にテストを実装することを想定してリストアップしています。
※テストを先に書いてからプロダクションコードを実装する開発手法を、テスト駆動開発(TDD)といいます。
実装時やリリース時の手動テスト
これは言わずもがな、手動テストの代わりに自動テストを書くため、不要になります。
手動テストの時間が自動テストに置き換わっただけで、時間的には削減されていないのでは?と思うかもしれませんが、実装コストさえ払えばあとは恒久的に自動チェックできるので、長期的に見て莫大なコスト削減につながります。
手動テストの場合は、実装時に実装者が手動で動作確認し、PRのレビューでチームメンバーが動作確認し、リリース前にも動作確認するなど、何段階かテストするタイミングがあると思います。
実装時に自動テストを書いておくことで、テストの正しささえ担保できていれば理論上手動で動作確認する必要はなくなります。
さすがに全く成果物に触らずリリースすることはないので、ユースケースのチェック等で実際に成果物を触って動作確認することはありますが、その回数や時間は確実に削減されるはずです。
逆に自動テストを導入しても手動テストの回数や時間が削減されないのであれば、不必要な手動テストを行なっているか、自動テストのカバー率が悪いと思った方が良さそうです。
ドキュメント(詳細設計書など)の作成、コメントの追加
皆さんは、自分のタスクを進める際にドキュメントやコメントは書いているでしょうか?
ドキュメントやコメントの必要性については様々な意見があるかと思いますが、私個人の意見としてはどちらも不要だと考えています。
⚪︎ドキュメントについて
自動テストを書くことで詳細設計等のドキュメントが不要になるのはなぜか?
これは、自動テスト自体が詳細設計書となるからです。
なぜなら、テストケース・テストコードを見ればコードの仕様がわかるからです。
もしあなたが今詳細設計書を書いているとすれば、それは半分自動テストを書いているようなものです。
テスト駆動開発におけるテストとは「設計である」と言われることがよくあります。これから開発する対象について、最終的にどうなっていれば良いかを考えてドキュメントにまとめるのではなく、それをテストコードに落とし込んでください。それが設計書になります。
もちろんこれはエンジニア内で完結しているドキュメントに限るので、エンジニア以外が見るドキュメント(例えばアーキテクチャ設計など)はテストで代替することはできません。それらはチームのルールに従って作成する必要があるでしょう。
⚪︎コメントについて
これも結局ドキュメントと同じで、テストコードを見ればわかるので基本的に不要になるという考え方です。
そもそも、コメントはなんのために書くのでしょうか?
よく、別の人がコードを読みやすくするためだ、という意見を耳にしますが、それはおかしなことだと感じます。
コメントを書いて補足しなければ読み解けないコードを書いていることの方が問題だからです。
また、コメントが必ずしも正しいとは限らないということも理解しておく必要があります。
仕様が変わった等で修正対応した人が、コードだけ直してコメントを変更し忘れたとします。そうするとコメントとコードに乖離が生まれますが、それに誰かが気づくまで永遠にコメントが嘘をつくことになってしまいます。
これはテストケース名についても同じことが言えます。ですので、テストをドキュメントとする場合もその点は気をつける必要があります。
結局、コメントやドキュメントを当てにせず、実際のコード(テストコード)を確認して仕様を把握するスキルは磨いた方が良いでしょう。
ちなみに、プロダクションコードを読むよりは、テストコードを読む方が圧倒的に楽です。(後述しますが、AAAパターンで書いたテストはとてもシンプルになるからです。)
リファクタリングや要件変更対応時の手動テスト
これが自動テストを書く一番の目的と言っても過言ではないかもしれません。
あなたは、要件変更等の既存システムを改修するタスクをこなしたことはあるでしょうか?
そのタスクをこなした影響で関係なさそうなところにバグが発生してしまったことはあるでしょうか?(何かしら変更した影響で、別の場所にバグが発生してしまうことをデグレーションと言います)
自動テストを書いておけば、その心配がなくなります。
これはシステムが大きくなるほどに絶大な効果を発揮します。
なぜなら、デグレーションを防ぐことは手動テストでは限界があるからです。
自分のタスクをこなしたあと、アプリ全体を触ってデグレがないかチェックすることを想像してみてください。抜け漏れなくチェックするなどほぼ不可能です。
これが何を示しているのかというと、"自動テストを書くことで変更に強いシステムが作れる"ということです。
急な仕様変更が来ても、涼しい顔で捌くことができるようになります。
コードを綺麗にしたい時、正しくリファクタリングができているかをチェックしてくれるので、よりアグレッシブにコードを整理することができるようになります。
リファクタリングとは?
もしかしたら聞き馴染みのない人もいるかもしれません。
コードを綺麗に整理することをリファクタリングと言いますが、ただコードを整理すれば良いというわけではなく、しっかりと定義が決まっています。
リファクタリングの定義
「既存のコードの外部からの振る舞いを変えずに、内部の構造を改善すること」
これはリファクタリング提唱者のマーチン・ファウラー(Martin Fowler)という方が出版した、"Refactoring: Improving the Design of Existing Code"という本で定義されています(日本語版もあります)。
要するに、機能を全く変更せず、コードだけを改善する行為なので、リファクタリングによってテストが落ちてはいけない(機能が変わってはいけない)ということに注意してください。
それを担保するためにもデグレチェックが必要なので、自動テストと切っても切り離せない関係なのです。
自動テストの種類
ここまで自動テスト自動テストと言ってきましたが、自動テストにも様々な種類があります。
例)自動テストの種類
・単体テスト
・統合テスト
・ホワイトボックステスト
・ブラックボックステスト
・コンポーネントテスト
・e2eテスト
・VRT
・負荷テスト
・パフォーマンステスト
・セキュリティテスト
・データベーステスト
・スモークテスト
・サニティテスト
etc...
※例の中には手動でテスト可能なものもありますが、あくまで自動テストに落とし込めるテストとしてあげています。
単体テスト(ユニットテスト)
定義は以下になります
- 「単体(unit)」と呼ばれる少量のコードを検証する
- 実行時間が短い
- 隔離された状態で実行される
引用:Vladimir Khorikov(2022). 単体テストの考え方/使い方, 28
明確にunitが何を指すのか、実行時間が短いとはどのくらいか、は定義されていないので、これらはチームで認識を合わせておく必要があります。
例えば、1メソッド単位のテストを単体テストとする、と言うルールをチームで定めると言うことです。チームによっては1メソッド単位でないルールの場合もあるため、初めて参画したプロジェクトではどの単位を単体テストと決めているか確認することをお勧めします。
「隔離された状態」と言うのは中々説明が難しく、ぜひ引用した本を読んでいただきたいのですが、古典学派とロンドン学派で意見が分かれる厄介な定義になります。
正直最初はここまでの理解はいらないと思うので、説明はまたの機会にしたいと思います。(それだけでいくつか記事が書けるくらい説明することが多い)
気になる方はぜひ調べてみてください。論争はありますが、今のところどちらかが正解というようなものではないので、好みの問題かもしれません。
それぞれの学派で「隔離」の定義が違うので、テストの仕方も変わってきます。
統合テスト(インテグレーションテスト)
定義は以下になります
単体テストの性質(定義)を1つでも損なっているテストは統合テストに分類される
引用:Vladimir Khorikov(2022). 単体テストの考え方/使い方, 262
つまり、単体テスト以外は全て統合テストです。
単体テストは、テスト対象を外部依存と切り離し、隔離した状態でテストすることでした。
逆に、統合テストは依存関係ごとテストすることで、それぞれの依存が正しく機能していることを確認することが目的になります。
ここまで説明した上で、知識を少し修正する必要があります。
例に挙げたテストの種類の中に、「単体テスト」「統合テスト」とその他のテストを混ぜて書いていましたが、その他のテストは全てどちらかに分類されます。
正しくは以下のようになります。
例)自動テストの種類(修正)
・単体テスト
・ホワイトボックステスト
・ブラックボックステスト
・統合テスト
・コンポーネントテスト
・e2eテスト
・VRT
・負荷テスト
・パフォーマンステスト
・セキュリティテスト
・データベーステスト・スモークテスト
・サニティテスト
etc...
例に挙げたテストはそれぞれ目的に応じて使い分ける必要があり、プロジェクト単位でどのテストが必要か、優先順位をどうするか、などを話し合ってチームで決める必要があります。
気をつけるべきは、前のプロジェクトでこのテストをやっていたから今回も同じで良い、と安直に決めてしまわないことです。
プロジェクトによって重点的にテストしたいポイントはそれぞれ違うかもしれないので、優先順等は毎回擦り合わせておく必要があります。
この時エンジニアとして提案できるようになるためにも、どういった目的でどんなテストが導入されるのか、今後勉強していくことをお勧めします。
自動テストの導入
実際に自動テストを導入する際は、ほとんどの場合ライブラリを用いることが多いです。
ライブラリを使わず自前でテストを書くことはできなくもないですが、ライブラリに比べてメリットがとても薄いです。
何のライブラリを入れれば良いのかですが、基本的に各言語やフレームワークには主要なテスト用ライブラリが存在するので、それを用いるのが無難です。
例)
- JS(React):Jest、Testing Library、Vitest(最近人気なイメージ)
- Ruby:Minitest(公式)、RSpec(中〜大規模アプリ向け)
- Python:unittest(公式)、pytest(人気なイメージ)
- Go:testing(公式), Testify(人気なイメージ)
などなど
主流なライブラリがいくつかあるケースもあるので、どれがプロジェクトにマッチしているのかを比較して技術選定する必要があります。
ライブラリを選んだらあとは簡単で、それぞれのプロジェクトのパッケージにインストール(jsでいうとnpm install --save-dev jest
的な)してあげれば準備完了です。
それぞれのお作法に関しては、各ドキュメントのチュートリアルを読むことをオススメします。
テストケースはどうやって考えたら良いのか
ようやく自動テストの中身の話に入っていきます。
テストを書くためには、まず何をテストするか、を決める必要があります。
ここ結構取っ付きにくいので何から考えれば良いか分からないという方は多いと思いますが、安心してください。
テストは書いたことないけど機能実装はしたことがあるなら、もうテストケース出せてます。
なぜか?前述した通り、テストケースは仕様書です。
機能を実装したことがあるということは、仕様を理解してコードに落とし込めているということです。
つまり、仕様をテストコードに落とし込めば良いだけです。
そんなことわかっとるけど難しいんじゃい!と言われるかもしれませんが、ちょっとしたテクニックを知れば簡単になります。
テストケースを考える際のテクニック
一旦統合テストのことは置いておき、1メソッド単位の単体テストについて考えます。
テクニック
1. 完成形をいきなりテストしようとしない → シンプルなテストから始める
2. テスト対象メソッドのinput(引数)を考える
3. テスト対象メソッドのoutput(戻り値or実行結果)を考える
* 順番はあまり関係ないです
これだけです。
といってもこれだけ見ても分からないと思うので、次の章で実例をお見せします。
テスト駆動開発(TDD)
テスト駆動開発(TDD)とは、テストを先に書き、テストが通るようにプロダクションコードを実装していく開発手法です。
従来は、プロダクションコードが全て出来上がってからテストコードを書くことが主流でした(今も主流っちゃ主流かも)。
ですが、後からテストコードを書く場合いくつかデメリットがあります。
- バグを早期発見できない(大規模な手戻りが発生する可能性が生じる)
- テストしづらいプロダクションコードが出来上がってしまう(慣れていなければ)
- プロダクションコードを正当化するようにテストを書いてしまう
など、いろいろあります。
テスト実装とプロダクションコード実装が同時進行するので工数が増えると思う人は多く、実際TDDに慣れるまではその通りなのですが、実際TDDを極め始めるとテスト無しで実装するよりも圧倒的に速く正確で綺麗に実装できるようになります。ぶっちゃけ私自身まだその領域に達せていないのですが、前職の先輩とペアプロした際、開発速度が早すぎて驚愕しました。
結局手動で動作確認する時間は減りますし、テストを書いた時点で実装すべきことが明確になるので、手が止まらないというのが早さの要因だと感じています。
テスト駆動開発のステップ
これは簡単で、以下の3つのステップを繰り返しながら実装を進めるだけです。
STEP1:わざと落ちるテストを書く
STEP2:テストをパスするように実装する
STEP3:リファクタリングする
ただし、この1サイクルは5分で行います。
5分で1回転しない場合は、テスト対象が複雑であることを示しているので、テスト対象を細分化してください。
また、1ステップ完了ごとに毎回コミットしてください。
そして、1サイクル終わったらpushしてください。
これは非常に重要で、いくつか利点があります。
- 大きな差分が手元に残らない
- 常に小さくコミットすることで、どこをどう変更したのか行方不明になることを防げる
- コードレビューのしやすさ
- どういった段階を踏んでコードを実装しているかのプロセスが明確になるため
コミットの速度を上げるために、GUIではなくコマンドラインに慣れることもお勧めします。
1サイクル回せば、(価値があるかは置いておき)一旦何かしら動作が担保されたプログラムが完成します。
TDDの説明はこのくらいにしておき、前章で記載したテストケースを考える際のテクニックを交えて実際どのようにテストを書きながら実装していくかを見ていきましょう。
FizzBuzzを実装してみよう
今回はJSで例を記載していきますが、別の言語でも考え方は同じです。
Jestのplaygroundがあったので、試しながら読みたい方はこちらからぜひ!
FizzBuzzの仕様
- 数値を入力する
- 入力した数値が3の倍数の場合は"Fizz"を返す
- 入力した数値が5の倍数の場合は"Buzz"を返す
- 入力した数値が15の倍数の場合は"FizzBuzz"を返す
- それ以外の数値が入力された場合は、入力した数値(文字列型)を返す
これに対してテストを書くとなった時、テストケースはどうすれば良いでしょうか?
以下を読む前に、少し自分なりに考えてみてください。
......
............
.....................
さて、少し考えていただいたところで予言しますが、以下を読んだ時おそらくあなたが考えたテストケースは粒度が大きかったと感じると思います。
では実装していきましょう。
まず、前提として、テクニック1【完成形をいきなりテストしようとしない】を念頭においてください。
どういうことかというと、段階を踏んでテストしていくということです。
TDDのステップにも記載した通り、5分で1サイクル回せるようなレベルまでテスト対象を細分化し、徐々に完成形に近づけていきます。
※このあと紹介するのはあくまで実装プロセスの一例ですので、違うアプローチは無数に存在します。これが絶対正解というわけではありません。
サイクル 1回目
最初に使うのは、テクニック2【テスト対象メソッドのinput(引数)を考える】です。
今回想定されるinputは数値です。
しかし、想定外のinputが入ってくることも考慮する必要があります。
- 数値以外が入ってきたらどうする?
- 値が存在しない場合は?
- 数値の上限・下限は?
- 数値はマイナスでも良い?
- 小数点は許容する?
ざっとこんなところでしょうか。
これらは仕様に関するところなので、分からなければ誰かに相談、あるいは提案する必要があります。
今回は以下の仕様だったことにします。
FizzBuzz 追加仕様
エラーメッセージ:"0以上100以下の整数を入力してください"
- 入力値が数値以外の場合、上記エラーメッセージを返す
- 入力値が存在しない場合、上記エラーメッセージを返す
- 入力値の範囲は、0以上100以下とする
- 入力値が範囲外の場合は、上記エラーメッセージを返す
- 入力値が小数点を含む数値の場合、上記エラーメッセージを返す
このような、本来期待していない動作を処理することを例外処理と言います。
数値を期待しているが、そうでないものがinputとして入ってきたらどうするか、を決めます。
ここが漏れるとバグの原因になります。
例外処理
例外を正しく処理する場合は、returnでエラーメッセージを返すのではなく、適切なエラー処理をさせる必要があります。
JSだとtry/catch
を使用し、エラーだった場合の処理を適宜指定することが一般的です。
その辺りは言語を勉強していけば追々わかると思いますので、詳しい説明は割愛します。
あまり良くはないですが、今回はreturnでエラーメッセージを返す処理で進めます。
※正直なところ、returnでメッセージを返すのであれば例外処理というより普通の仕様なのでは?感はありますが、今回は一旦エラーメッセージを返す部分を例外処理と呼びます。
追記:今回の場合は例外処理ではなくバリデーションと呼ぶのが正しいかもでした
🔸STEP1:テスト
ではまず最初の例外処理のテストから書いていきます。
test('入力値が数値でない場合、エラーメッセージを返す', () => {
// 前提条件を揃える(Given or Arrange)
const invalidInput = "文字列です";
const errorMessage = "0以上100以下の整数を入力してください";
// テスト対象を実行する(When or Act)
const output = FizzBuzz(invalidInput);
// 結果を突合する(Then or Assert)
expect(output).toBe(errorMessage);
// output === errorMessage であることをチェックする構文です(ライブラリによって書き方は違う)
// ※trueであればテストがパスし、falseであればテストが落ちる
})
「入力値が数値でない場合、エラーメッセージを返す」これがテストケース名です。
この時点でFizzBuzz関数はまだ存在していませんが、テストであえて使用します。
✅ 使われないものを先回りして作らない
これはテスト駆動開発を行う上で大事なポイントです。
今テストを実行すると、おそらくFizzBuzz関数が定義されていないことでテストがFailするはずです。
この時点でコミットします。
Given・When・Thenパターン、 Arrange・Act・Assert(AAA)パターン
テストを書くときは、以下3つのフェーズを意識しましょう。
- 前提条件を整える
- テスト対象を実行する
- 結果を期待値と突合する
Given・When・ThenとArrange・Act・Assertはどちらもそのフェーズのことを指しているので、全く同じ意味です。
どちらで覚えても大丈夫ですが、二つ呼び方があることは記憶の片隅に置いておくと混乱しなくてすみます。
フェーズをわかりやすくするためにコメントで記述しておくことをお勧めします。
🔸STEP2:実装
では、テストがパスするようにFizzBuzz関数を作りましょう。
const FizzBuzz = (input) => {
if (typeof input !== "number") {
return "0以上100以下の整数を入力してください";
}
}
これでテストがパスするようになりました。
では、コミットしましょう。
✅ テストを通すためだけの最低限の実装をする
これを心がけてください。テストで期待していないことを先走って実装してはいけません。
TDDの1サイクルの粒度はこのくらい小さなものになります。
さて、ここで気になるのが、上記のテストではinputが文字列の場合しかテストできていません。
配列やオブジェクト、undefined、NaN、nullなどが入ってきた場合も担保できているのか気になります。
その場合はそれぞれテストを追加しましょう。全てパスすると思います。
この時、思いつくもの全てをテストすべきなのか問題が発生するのですが、ここは正直少し難しい問題です。
今回は明らかにnumber以外の型は弾けているので、個人的には気になるものを確認したあとはどれか一つのテストケースを残し、他は削除しても良い気もしますが、安全性をとるなら残しておくべきかもしれません。
判断が難しい場合は残しておきましょう。
テストケースも増えれば実行速度が遅くなっていくので、可能な限りテストの記述も少なくしたいのですが、この辺りのバランスは私も模索中です。
どちらにせよ、気になったら一度テストを書いて確認する癖をつけるのがベストです。
テストカバレッジ
テストを残すかどうかの判断基準として、テストカバレッジが使えます。
トスとカバレッジとは、テストでどのくらいプロダクションコードをカバー(担保)できているかを示すパーセンテージのことです(カバー率)。
例えばif文などで条件が二つに分岐した場合、片方の条件に関するテストだけ実装されていればテストカバレッジは50%となります。
テストを削除してもカバレッジが減らないのであれば、基本的には消してしまっても問題ない場合は多いです。
ただし、例外はあります。
こればかりは慣れないと難しいのですが、残すべきテストというものもあります。
後述しますが、境界値テストなどがそれに該当します。
ミスが発生しやすい箇所などはテストを厚くして安全性を高めておくようなイメージです。
ちなみにTDDを行えば、テストしたことしか実装しないので、理論上テストカバレッジは100%になると言われています。
前述した単体テストの書籍では、とるに足らないテストと複雑すぎるテストは書かないことが推奨されていたりするのですが、それらをどう判断するかは難しいところです。
🔸STEP3:リファクタリング
さて、次はリファクタリングのステップですが、今回は特に改善が必要な部分はないのでスキップします。
この段階でリモートリポジトリにpushします。
サイクル 2回目
では次のテストケースを考えましょう。
🔹STEP1:テスト
追加仕様の「入力値が数値以外の場合」は担保できたので、次は「入力値が存在しない場合」を考えます。
test('入力値が存在しない場合、エラーメッセージを返す', () => {
// Given
const errorMessage = "0以上100以下の整数を入力してください";
// When
const output = FizzBuzz(); // 引数を入れない
// Then
expect(output).toBe(errorMessage);
})
これはおそらく、1サイクル目の実装で担保できているのでテストがパスすると思います。ですので実装は不要です。
このテストは、前回作ったテストケースと違ってinputを渡さない点に差分があるので、残しておきましょう。
ここでまたコミットします。
🔹STEP2:実装
上記の通り、実装は不要です。
🔹STEP3:リファクタリング
リファクタリングの対象は、テストコードも含まれます。
今回のテストを追加したことで、以下の部分が重複していることがわかります。
const errorMessage = "0以上100以下の整数を入力してください";
このメッセージは共通で使うことができそうなので、ファイル直下に定義を移動させ、共通で使えるようにしましょう。
const errorMessage = "0以上100以下の整数を入力してください";
test("入力値が数値でない場合、エラーメッセージを返す", () => {
// Given
const invalidInput = "文字列です";
// When
const output = FizzBuzz(invalidInput);
// Then
expect(output).toBe(errorMessage);
});
// Givenがなくなったので省略する
test("入力値存在しない場合、エラーメッセージを返す", () => {
// When
const output = FizzBuzz();
// Then
expect(output).toBe(errorMessage);
});
テストが通っていることを確認したら、コミットしてpushしましょう。
サイクル 3回目
次のテストケースを考えます。
追加仕様の最後、「入力値の範囲は、0以上100以下」を実装していきましょう。
🔸STEP1:テスト
const errorMessage = "0以上100以下の整数を入力してください";
test('入力値が0以上100以下でない場合、エラーメッセージを返す', () => {
// Given
const outOfRangeInput = 1000;
// When
const output = FizzBuzz(outOfRangeInput);
// Then
expect(output).toBe(errorMessage);
})
テストを回して、意図通りテストが落ちることを確認しましょう。
まだ処理を書いていないので、戻り値が存在しない(undefined)エラーになると思います。
またコミットします。
🔸STEP2:実装
テストが通るように実装します。
const FizzBuzz = (input) => {
if (typeof input !== "number") {
return "0以上100以下の整数を入力してください";
}
if (input < 0 || input > 100) {
return "0以上100以下の整数を入力してください"
}
}
これでテストが通るようになりました!
コミットしましょう。
🔸STEP3:リファクタリング
今回は、実装コードに重複箇所が見られるため、リファクタリングしましょう。
return "0以上100以下の整数を入力してください";
この部分が重複しています。
リファクタリングの方針として、エラーメッセージを変数に切り出すか、条件式をまとめるかの2通り考えられますが、今回はよりコードの記述量を減らせそうな後者で進めます。
const FizzBuzz = (input) => {
const isInvalidType = typeof input !== "number";
const isOutOfRange = input < 0 || input > 100;
if (isInvalidType || isOutOfRange) {
return "0以上100以下の整数を入力してください";
}
}
テストを回して、しっかりとテストが通った状態であることを確認したら、コミットしてpushしましょう。
ちなみに、ifの条件式を||
や&&
で繋ぐ場合は、式を変数に切り出すと可読性が上がるのでお勧めです。
サイクル 4回目
さて、次のケースを実装しにいきたいところですが、3サイクル目にはまだ注意すべき点が残っています。
それは、境界値のチェックです。
境界値分析
テストを書く際に気をつけるべき点の一つに、境界値分析というものがあります。
これはその名の通り、境界値をチェックするということです。
なんの境界か?というと例は様々ありますが、例えば今回で言うと「0以上100以下」の条件にも境界が2つ存在します。
1つ目の境界は「0」を含めた前後、2つ目の境界は「100」を含めた前後です。
イメージ:0より低い値 | 0以上の値、100以下の値 | 100より大きい値
他にもよく目にする境界値に、「日付」があります。
例えばブログ投稿アプリで、投稿予約日の設定ができるとします。今日の日付が予約日より前であれば記事は未公開、後であれば記事が公開される、と言うような仕様の場合は必ず境界値テストをしておきましょう。
ここがバグると、予定より早く記事が公開されたり、遅く公開されたりといった障害を生んでしまいます。
境界値をチェックする意図としては、境界値周りのバグ発生率が高いからです。
以上(≦)・以下(≧)・超過(<)・未満(>)、これらは間違いやすいので、重点的にテストでカバーしましょう。
🔹STEP1:テスト
今回は例外処理のテストをしたい段階なので、0
より小さい最大値と、100
より大きい最小値をチェックします。
今回は整数を期待しているので、-1
と101
をテストします。
また、内側の境界値である0と100も後ほどテストする必要があるので、一旦テストケースだけ追加しておきましょう。
const errorMessage = "0以上100以下の整数を入力してください";
test('-1を入力すると、エラーメッセージが返ること', () => {
// Given
const input = -1;
// When
const output = FizzBuzz(input);
// Then
expect(output).toBe(errorMessage);
});
test('101を入力すると、エラーメッセージが返ること', () => {
// Given
const input = 101;
// When
const output = FizzBuzz(input);
// Then
expect(output).toBe(errorMessage);
});
// まだ実装の中身は考えていないので、XXXととりあえず書いておく
// 思いつくなら期待値を書いておいても良い
// テストを書くことを忘れないようにするのが目的
test('0を入力すると、XXXが返ること', () => {});
test('100を入力すると、XXXが返ること', () => {});
この時点で、すでに実装できているため、テストはパスするはずです。
これらのテストは必要なテストなので残しておきましょう。
コミットします。
🔹STEP2:実装
前のサイクルで担保できているため、スキップします。
🔹STEP3:リファクタリング
リファクタリングが必要な箇所はなさそうなので、pushします。
サイクル 5回目
次に、「入力値が小数点を含む数値」の場合の処理を追加します。
🔸STEP1:テスト
const errorMessage = "0以上100以下の整数を入力してください";
test('入力値が小数の場合、エラーメッセージが返ること', () => {
// Given
const input = 1.1;
// When
const output = FizzBuzz(input);
// Then
expect(output).toBe(errorMessage);
})
この段階では小数はnumberとして処理されるので、エラーメッセージが返らずテストは落ちるでしょう。
意図通りなので、コミットしてこのまま進みます。
🔸STEP2:実装
テストが通るように実装します。
const FizzBuzz = (input) => {
const isInvalidType = typeof input !== "number";
const isOutOfRange = input < 0 || input > 100;
const isNotInteger = !Number.isInteger(input); // 整数かをチェック
if (isInvalidType || isOutOfRange || isNotInteger) {
return "0以上100以下の整数を入力してください";
}
};
テストが通ったことを確認し、コミットします。
🔸STEP3:リファクタリング
一見リファクタリングすべきところはなさそうに見えますが、よく考えるとisInvalidType
のチェックは不要であることがわかります。
前のサイクルで消しておくべきでしたが、今気づいたのでリファクタリングしましょう。
const FizzBuzz = (input) => {
const isOutOfRange = input < 0 || input > 100;
const isNotInteger = !Number.isInteger(input);
if (isOutOfRange || isNotInteger) {
return "0以上100以下の整数を入力してください";
}
};
テストはしっかり全件パスしていることを確認したら、コミット&pushしましょう。
isOutOfRange
を実装した時のテストはこの時点でも有効なので、isOutOfRange
を消したからといって削除してはいけません。
むしろ、そのテストがあることで今のようなリファクタリングをデグレーションの心配なく実行できるわけです。
サイクル 6回目
一旦、今考えられる例外処理の実装は完了したので、次は数値の処理を行っていきます。
🔹STEP1:テスト
まずは、入力した数値をそのまま返すところから段階を進めていきましょう。
test('入力した数値が返ること', () => {
// Given
const input = 1;
// When
const output = FizzBuzz(input);
// Then
expect(output).toBe(input);
});
意図通り落ちていることを確認してコミットします。
🔹STEP2:実装
テストを通すように実装します。
const FizzBuzz = (input) => {
const isOutOfRange = input < 0 || input > 100;
const isNotInteger = !Number.isInteger(input);
if (isOutOfRange || isNotInteger) {
return "0以上100以下の整数を入力してください";
}
return input;
};
テストが通ったことを確認してコミットします。
🔹STEP3:リファクタリング
今回は不要そうなのでスキップします。
pushしましょう。
サイクル 7回目
では次に、3を入力した場合"Fizz"を返す機能を追加しましょう。
と言いたいところですが、慣れてきたのならばもう少しステップを大きくしても構いません(もちろん、5分以内に治るなら)。
3の倍数を入力した場合"Fizz"を返す機能を追加してみましょう。
🔸STEP1:テスト
test('3の倍数を入力すると、Fizzが返ること', () => {
// Given
const inputList = [3, 6, 9, 15, 30, 90]; // 代表値をピックアップ
inputList.forEach(input => {
// When
const output = FizzBuzz(input);
// Then
expect(output).toBe("Fizz");
});
});
ステップの粒度を大きくして、一度に3の倍数をいくつかテストするように書きました。
1~100のレンジであれば全ての3の倍数をチェックすることは可能ですが、もし上限がなかった場合は全ての3の倍数をチェックすることは不可能です。そこで、代表値をピックアップしてテストします。
この代表値の選び方ですが、ここら辺は今後の実装に関わってきそうな臭い値を選んでおくのが吉です。
少し慣れは必要かもしれませんが、次は5の倍数を実装するのでそことバッティングしそうな値をとりあえず含めて入れておくと良いと思われます。
過剰実装をしないとはいえ、わざと未来を見通さないような制限を設ける必要はありません。臭そうな値は積極的にテストしておきましょう。
このステップが大きく感じた場合は、「3を入力したら"Fizz"を返す」、「6を入力したら"Fizz"を返す」、という粒度のテストを書き、リファクタリングで両方を一度にテストできるように整理する、という段階の踏み方でやってみましょう。
テストにロジックを入れるな
一点注意ですが、テストの中にロジックは極力入れないことを心がけるべきです。
今回のテストではforEach
を使っているので正直グレーではありますが、同じ結果を期待する複数のパターンを効率的にテストするために許容して書いています。
チームによってはこれを許容しない可能性もあるので、その場合は一つずつテストケースを書いていくことも視野に入れましょう。
ただし!
if文
などの分岐が発生する処理は絶対に入れてはいけません。
私は新卒の時これをやって上司に笑われてしまいましたが、カバレッジの話を思い出してください。
条件分岐をカバーするにはテストが必要です。
テストの中に条件分岐が入ってしまうと、そのテストのテストを書かなければいけなくなります。
これは非常におかしな話です。
テストのためのテストを書き始めたら、それはもう終わらない戦いの始まりです。
絶対に条件分岐のロジックはテストに含めないようにしましょう。
期待通りテストが落ちたら、コミットしましょう。
🔸STEP2:実装
では、テストが通るようにプロダクションコードを実装します
const FizzBuzz = (input) => {
const isOutOfRange = input < 0 || input > 100;
const isNotInteger = !Number.isInteger(input);
if (isOutOfRange || isNotInteger) {
return "0以上100以下の整数を入力してください";
}
// 3で割った余が0 = 3の倍数
if (input % 3 === 0) {
return "Fizz";
}
return input;
};
これでテストがパスするようになったと思います。
コミットしましょう。
🔸STEP3:リファクタリング
リファクタリングが必要な箇所はなさそうなので、スキップしてpushしましょう。
さて、そろそろテストというものに慣れてきたでしょうか?
まだFizzBuzzは完成していませんが、文章量がとんでもないことになってきたのと、もうアウトプットするネタも尽きてきたのでこの辺で説明は終わりたいと思います。
ぜひ、実際に試しながら読み進めてくださった方は、最後までTDDで実装してみてください。
(保留にしていたテストの実装も忘れずに...)
もしわからないところがあればぜひコメントください!
TDDができる環境にいない方へ
もしかすると、これを読んでいる大半の方の環境では、TDDは行われていないかもしれません。
しかし、安心してください。TDDは一人で始められます。
テストコードが必要とされていない現場なら、pushせずローカル環境でテストを書けば良いのです。
自動テストを導入するというメリットの多くはプロジェクトに波及することはできませんが、自分の書いたコードの品質を担保できるだけでなく、自己研鑽にもつながります。
ぜひ勇気を出して一歩踏み出してみてください。
既存コードに対するテストを書く必要がある方へ
これは非常に大変な道のりです。
自分が実装する分であればダマ4でTDDしておき、テストフェーズになったらドヤ顔でテストコードをpushしましょう。
自分が実装しないプロダクションコードに対してテストを書かなければいけない場合。
おそらくそのプロダクションコードは非常にテストしにくいコードになっているでしょう...
苦行ですが、気合いでテストを書くしかありません。
その際1番やってはいけないことは、プロダクションコードを見ながらテストを書くことです。
これをしてしまうと、無意識のうちにプロダクションコードが正しい前提でテストケースを作ってしまいかねません。
仕様書があるならば仕様書を、なければ詳しい人に仕様を確認しながら、その情報を正としてテストを書くことを心がけてください。
こればっかりは私自身あまり経験がないもので、的確なアドバイスを出すことができず...ごめんなさい。
まとめ
さて、果たしてここまで全部読んでくださった方がどれだけいるのか分かりませんが、テストについて少しは理解が深められたでしょうか?
そうあることを願っている次第です。
余計混乱した的な方がいたらコメントでクレームを入れてください。すみません。
今回の記事では、以下のトピックについてアウトプットしました。
・自動テストとは?
・自動テストの招待
・自動テストは導入すべきなのか
・自動テストの効果
・自動テストの種類
・自動テストの導入
・テストケースはどうやって考えたら良いのか
・テスト駆動開発
後半からどんどん単体テストかつTDDの内容に誘導してしまった感はありますが、TDDに限らず自動テスト全般へ適用できる知識もそこそこ伝えられたのではないかと思っております。(そうであれ)
統合テストに関しても考え方はあまり変わりません。
テストの単位は大きくなるので、テストケースの複雑性は上がっていきますが、落ち着いて条件分岐のパターンや例外処理のパターン、それらの組み合わせをチェックしていけば良いだけです。
そのパターンの組み合わせの分だけテストケースが存在するということです。
膨大な組み合わせを効率的にテストするペアワイズ法なんかもありますが、これらは機会があればまた記事にしたいと思います。(調べれば色々出てきますので気になったらぜひ)
ちなみにTDDを極めていくとATDDというものが見えてくるのですが、1スプリント(1~2週間くらい)で開発する機能の受け入れテストを自然言語ベースで定義することからテスト駆動が始まったりします。まぁそれはまた別のお話し...
こんな感じで纏まらないまとめになってしまいましたが、伝えたかったことは以下になります。
- 自動テストは強力な命綱だよ!
- 自動テストを導入するには、エンジニア以外の人の理解も必要だよ!
- そのためには、エンジニア自身がテストについて詳しくなり、それを持って交渉する必要があるよ!
- 自動テストはいろんな種類があって、目的によって使い分けるよ!
- 導入するテストの優先順位はプロジェクトの特性に合わせて決めるよ!
- あるべき姿(仕様)をテストケースに落とし込むよ!
- TDDにおける自動テストは、設計と一緒だよ!
あとがき
ここまで読んでいただき、ありがとうございます🙇♂️
今回の記事のほとんどは(一応めちゃくちゃ確かめながら書いたつもりですが)私の知識をベースとした内容なので、これが絶対に正しいというわけではありません。
ただ、たまには超具体的な話をする人がいても良いかなと思い、私個人の自動テストに対する見解を綴っています。
この記事を信じすぎず、いい感じに踏み台にしながらテストについての視座を高めていっていただけたら、幸いです!