ユニットテスト機能を作成する方法

Why unit tests and how to make them work for you

この話題について雑談している私の動画へのリンクです

動画が苦手な方はこちらの文章版。

ソフトウェア

ソフトウェアの約束は、それが変化することができるということです。これがソフトウェアと呼ばれる理由です。優れたエンジニアリング・チームは、価値を提供し続けるために、ビジネスと共に進化できるシステムを書き、会社にとって素晴らしい資産となるはずです。

では、なぜ私たちはそれが苦手なのでしょうか?あなたはどれだけのプロジェクトが失敗に終わったと聞いたことがありますか?あるいは、「レガシー」になってしまい、完全に書き直さなければならなくなってしまうこともあるでしょう。

ソフトウェアシステムはどうやって「失敗」するのでしょうか?正しくなるまで変更することはできないのか?それがお約束なんだよ!

多くの人がシステムを構築するためにGoを選んでいるのは、それがよりレガシープルーフになることを望む多くの選択をしているからです。

  • 私の以前のScala生活では、首を吊るのに十分なロープがあることを説明しましたと比較して、Goは25個のキーワードしかなく、標準ライブラリといくつかの小さなライブラリから多くのシステムを構築することができます。Goを使えば、半年後にコードを書いて戻ってきても、まだ意味のあるものになることを期待しています。

  • テスト、ベンチマーク、リント、出荷に関するツールは、他の多くの選択肢と比較しても一級品です。

  • 標準ライブラリは素晴らしいです。

  • タイトなフィードバックループのための非常に速いコンパイル速度

  • Goの下位互換性の約束。Goは将来的にジェネリックやその他の機能を手に入れるようですが、設計者は5年前に書いたGoのコードでもビルドできると約束しています。私は文字通り、プロジェクトをScala 2.8から2.10にアップグレードするのに数週間を費やしました。

これだけの素晴らしい資産を持っていても、ひどいシステムを作ることができます。

ということで、私たちは過去に目を向けて、あなたの言語がどれだけ輝いていても(あるいは輝いていなくても)適用されるソフトウェア工学の教訓を理解しなければなりません。

1974年、Manny Lehmanという賢いソフトウェアエンジニアがリーマンのソフトウェア進化の法則を書きました。

この法則は、一方では新しい開発を推進する力と、他方では進歩を遅らせる力との間のバランスを記述している。

これらの力は、レガシーになって何度も何度も書き直されるシステムを出荷するという終わりのないサイクルに陥らないようにするためには、理解しておくべき重要なことのように思われます。

継続的変化の法則

実世界で使用されているソフトウェアシステムは、環境の中で変化したり、ますます有用でなくなったりしなければならない

システムを変更しなければならないことは明白なように感じますが、それが無視されることはよくあることでしょうか?

多くのチームは、プロジェクトを特定の期日までに完了させ、次のプロジェクトに移るようにインセンティブを与えられています。

もしソフトウェアが「運が良ければ」、少なくとも何らかの形で別の個人にメンテナンスを任せることができますが、もちろん彼らはそれを書いたわけではありません。

人々はしばしば、「迅速な納品」に役立つフレームワークを選ぶことに関心を持ちますが、システムがどのように進化する必要があるかという点では、システムの寿命に焦点を当てていません。

たとえあなたが素晴らしいソフトウェア・エンジニアであっても、システムの将来的なニーズを知らないために犠牲になることがあります。 ビジネスが変化すると、あなたが書いた素晴らしいコードのいくつかは、もはや関連性がないものになってしまいます。

リーマンは70年代に調子に乗っていましたが、彼は私たちに別の法則を与えてくれました。

複雑さが増す法則

システムが進化すると、それを減らすための作業が行われない限り、その複雑さは増加します。

彼がここで言っているのは、ソフトウェアチームを盲目的な機能工場にすることはできないということです。 ソフトウェアが長期的に生き残っていくことを願って、より多くの機能を積み上げていきます。

私たちは、私たちの領域の知識が変化するにつれて、システムの複雑さを管理し続けなければなりません。

リファクタリング

ソフトウェアエンジニアリングには、ソフトウェアの可鍛性を維持するために、以下のような多くの面があります。

  • 開発者のエンパワーメント

  • 一般的に「良い」コード。懸念事項の賢明な分離など

  • コミュニケーション能力

  • 建築

  • 観測性

  • デプロイアビリティ

  • 自動化されたテスト

  • フィードバックループ

今回はリファクタリングに焦点を当ててみたいと思います。これは「リファクタリングが必要だ」とよく言われるフレーズで、プログラミングを始めた初日に何気なく開発者に言われた言葉です。

このフレーズはどこから来たのでしょうか?リファクタリングはコードを書くこととどう違うのでしょうか?

私や他の多くの人がリファクタリングをしていると思っていたことを知っていますが、それは間違いでした。

マーティン・ファウラーは、人々がどのようにしてそれを間違っているかを説明しています

しかし、"リファクタリング "という言葉は、適切でない場合によく使われます。もし誰かがリファクタリングをしている間に、システムが数日壊れていると話していたら、彼らはリファクタリングをしていないと確信できるでしょう。

では、リファクタリングとは何でしょうか?

因数分解

学校で数学を習っているとき、因数分解について学んだことがあるでしょう。とても簡単な例を挙げてみましょう。

1/2 + 1/4を計算する

これを行うには、分母を因数分解して式を

2/4 + 1/4とすると、3/4になります。

このことからいくつかの重要な教訓を得ることができます。式を因数分解するとき、式の意味は変えていません。1/22/4に変更することで、1/22/4に変更することで、私たちの「ドメイン」にフィットしやすくなります。

コードをリファクタリングするとき

あなたは自分のコードをより理解しやすくし、システムが何をする必要があるかについての現在の理解に「適合」させる方法を見つけようとしています。重要なのは、動作を変更してはいけないということです。

Goの例です。

特定のlanguagenameを迎える関数は以下の通りです。

func Hello(name, language string) string {
if language == "es" {
return "Hola, " + name
}
if language == "fr" {
return "Bonjour, " + name
}
// imagine dozens more languages
return "Hello, " + name
}

何十個ものif文を持つのは気分が悪いし、言語固有の挨拶を,name.で連結するという重複があるので、コードをリファクタリングしてみます。

func Hello(name, language string) string {
return fmt.Sprintf(
"%s, %s",
greeting(language),
name,
)
}
var greetings = map[string]string {
es: "Hola",
fr: "Bonjour",
//etc..
}
func greeting(language string) string {
greeting, exists := greetings[language]
if exists {
return greeting
}
return "Hello"
}

このリファクタリングの性質は実際には重要ではなく、重要なのは私が動作を変更していないことです。

リファクタリングでは、インターフェースの追加、新しい型の追加、関数、メソッドの追加など、好きなことをすることができます。唯一のルールは、動作を変更しないことです。

コードをリファクタリングする際には、挙動を変えてはいけません。

これはとても重要なことです。もしあなたが同時に行動を変えているのであれば、同時に二つのことをしていることになります。ソフトウェアエンジニアとして、私たちはシステムを別のファイル、パッケージ、機能などに分割することを学びます。

一度にたくさんのことを考えなければならないのは、間違いを犯すときだからです。私は、多くのリファクタリングの試みが失敗に終わるのを目の当たりにしてきました。

数学の授業で因数分解を紙とペンでやっていたときは、頭の中の式の意味を変えていないかどうかを手動でチェックしなければなりませんでした。コードを扱うときにリファクタリングをするときに、特にトリビアルではないシステムでは、どのようにして挙動を変えていないことを知ることができるだろうか?

テストを書かないことを選択した人は、典型的には手動テストに依存することになります。小さなプロジェクト以外では、これはとてつもなく時間を浪費することになり、長期的にはスケールしません。

安全にリファクタリングを行うためには、ユニットテストが必要です。

  • 挙動の変更を気にせずにコードをリシェイプできる自信がある

  • システムがどのように動作するかについての人間向けのドキュメント

  • 手動テストよりもはるかに速く、信頼性の高いフィードバック

Goでの例

Hello関数のユニットテストは次のようになります。

func TestHello(t *testing.T) {
got := Hello(“Chris”, es)
want := "Hola, Chris"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}

コマンドラインでgo testを実行して、リファクタリングの努力が挙動を変えたかどうかのフィードバックをすぐに得ることができます。 実際には、エディタ/IDE内でテストを実行するための魔法のボタンを覚えるのがベストです。

以下のような状態にしたいと思います。

  • 小さなリファクタ

  • テストの実行

  • リピート

すべてが非常にタイトなフィードバックループの中で行われるので、うさぎの穴に落ちてミスをすることはありません。

すべての主要な動作がユニットテストされ、1秒以内にフィードバックが得られるプロジェクトを持つことは、必要なときに大胆なリファクタリングを行うための非常に強力なセーフティネットとなります。これは、Lehman氏が説明するような複雑さが押し寄せてくるのを管理するのに役立ちます。

ユニットテストがそれほど素晴らしいものであるならば、なぜユニットテストを書くことに抵抗があるのでしょうか?

一方では、(私のように)ユニットテストはシステムの長期的な健全性を保つために重要だと言っている人がいます。

一方では、ユニットテストが実際にリファクタリングを妨げているという経験を述べている人がいます。

自問自答してみてください。リファクタリングを行う際に、どれくらいの頻度でテストを変更しなければならないのでしょうか? 私は長年にわたり、非常に良いテストカバレッジを持つ多くのプロジェクトに参加してきましたが、エンジニアはテストを変更する労力を認識しているため、リファクタリングには消極的です。

これは私たちが約束していることとは正反対です。

なぜこんなことになったの?

正方形の開発を依頼されて、それを達成するための最良の方法は、2つの三角形をくっつけることだと考えたとします。

直角三角形を2つくっつけて正方形を作る

正方形の周りにユニットテストを書いて、辺が等しいことを確認し、三角形の周りにテストを書きます。三角形が正しく表示されることを確認したいので、角度の合計が180度であることを確認したり、2つの三角形を作成しているかどうかを確認したりします。テストのカバレッジは本当に重要で、これらのテストを書くのはとても簡単です。

数週間後、「継続的変更の法則」がシステムを襲い、新しい開発者がいくつかの変更を行いました。彼女は、2つの三角形ではなく、2つの長方形で正方形を形成した方が良いと考えています。

2つの長方形で正方形を作る

彼女はこのリファクタリングをしようとすると、いくつかの失敗したテストから複雑なシグナルを得ます。彼女は実際にここで重要な動作を壊してしまったのでしょうか? 彼女は三角形のテストを掘り下げて、何が起こっているのかを理解しようとしなければなりません。

正方形が三角形から形成されたことは実際には重要ではありませんが、あなたのテストは実装の詳細の重要性を不当に高めています

実装の詳細よりもテストの振る舞いを優先する

ユニットテストについて文句を言う人の話を聞くと、それはテストの抽象化レベルが間違っているからだということがよくあります。彼らは、実装の詳細をテストしたり、共同作業者を過度にスパイしたり、過剰にコケにしたりしています。

私は、ユニットテストとは何かを誤解して、虚栄心を煽ったメトリクス(テストカバレッジ)を追いかけていることに起因すると考えています。

もし私がテストの振る舞いだけを言っているのであれば、システム/ブラックボックステストだけを書くべきではないでしょうか?この種のテストはキーとなるユーザージャーニーを検証するという点で多くの価値を持っているが、一般的に書くのにはコストがかかり、実行にも時間がかかる。

そのため、フィードバックループが遅いため、リファクタリングにはあまり役に立ちません。さらに、ブラックボックステストは、ユニットテストと比較して根本原因についてはあまり役に立たない傾向があります。

では、正しい抽象化レベルとは何でしょうか?

効果的なユニットテストを書くことは設計上の問題です。

テストのことはちょっと忘れて、システム内には、ドメイン内の重要な概念を中心とした、自己完結的で分離された「ユニット」があることが望ましいです。

私は、これらのユニットを単純なレゴブロックのように想像したいのですが、このレゴブロックには首尾一貫した API があり、他のブロックと組み合わせてより大きなシステムを作ることができます。これらのAPIの下には、何十ものもの(型や関数など)があり、それらが必要なように動作するように連携している可能性があります。

例えば、Goで銀行を書いている場合、"account"パッケージがあるかもしれません。これは、実装の詳細を漏らさず、統合しやすいAPIを提供します。

これらのプロパティに従ったユニットがあれば、それらの公開APIに対してユニットテストを書くことができます。定義により、これらのテストは有用な動作のみをテストすることができます。 これらのユニットの下では、必要に応じて実装を自由にリファクタリングすることができますし、ほとんどの部分でテストが邪魔になることはありません。

これらはユニットテストですか?

はいです。ユニットテストは、私が説明したように「ユニット」に対して行われます。ユニットテストは、単一のクラス/関数/何かに対してのみ行われるものではありません。

これらの概念を一緒にする

私たちはカバーしてきました

  • リファクタリング

  • ユニットテスト

  • ユニット設計

このように、ソフトウェア設計のこれらの側面は互いに強化し合っていることがわかります。

リファクタリング

  • ユニットテストについてのシグナルを提供してくれます。手動チェックをしなければならないなら、より多くのテストが必要です。テストが間違って失敗している場合、テストは間違った抽象化レベルにあります(または、価値がないので削除されるべきです)。

  • ユニット内とユニット間の複雑さを処理するのに役立ちます。

ユニットテスト

  • リファクタリングのためのセーフティネットを提供する。

  • ユニットの動作を検証し、文書化する。

(よく設計された)ユニット

  • 意味のあるユニットテストを簡単に書ける。

  • リファクタリングが簡単。

複雑さを管理し、システムを柔軟に保つために、コードを常にリファクタリングできるようなポイントに到達するのを助けるプロセスはありますか?

なぜテスト駆動開発(TDD)なのか?

ソフトウェアは変化しなければならず、精巧な設計を考えすぎて、「完璧な」拡張性のあるシステムを作ろうとして多くの時間を無駄にしてしまい、結果的にそれが間違っていてどこにも進まないというリーマンの名言を鵜呑みにする人もいるかもしれません。

これは昔のソフトウェアの悪い時代の話で、アナリストチームが半年間かけて要件書を書き、アーキテクトチームがさらに半年間かけて設計を考え、数年後にはプロジェクト全体が失敗してしまうというものです。

私は「昔は悪かった」と言っていますが、このようなことは今でも起こっています。

アジャイルでは、ソフトウェアの設計と実際のユーザーとの間でどのように動作するかについての迅速なフィードバックを得るために、小さく始めてソフトウェアを進化させ、反復的に作業する必要があることを教えてくれます。

TDD はこのアプローチを強制します。TDD は、常にリファクタリングを行い、反復的に納品する方法論を奨励することで、リーマンが語った法則や、歴史の中で学んだその他の教訓に対応しています。

小さな一歩を踏み出す

  • 少量の望ましい動作のための小さなテストを書く

  • クリアエラー(赤)でテストが失敗したことを確認してください。

  • テストを通過させるための最小限のコードを書く(緑)。

  • リファクタリング

  • リピート

熟練してくると、この働き方が自然と早くなってきます。

このフィードバックループにそれほど時間がかからないことを期待するようになり、システムが「緑」ではない状態になると、ウサギの穴に落ちているかもしれないという不安を感じるようになります。

テストのフィードバックに裏打ちされた、小さくて便利な機能を常に快適に走らせることができるようになります。

まとめ

  • ソフトウェアの強みは、我々がそれを変えることができるということです。ほとんどのソフトウェアは、予測不可能な方法で時間の経過とともに変化を必要とします。しかし、将来を予測するのはあまりにも難しいので、過剰なエンジニアリングはしないでください。

  • しかし、将来を予測するのはあまりにも難しいので、オーバーエンジニアリングをしようとしないでください。ソフトウェアを変更するためには、進化に合わせてリファクタリングしなければなりません。

  • 良いテストスイートは、リファクタリングを迅速かつストレスの少ない方法で行うことができます。

  • 良いユニットテストを書くことは設計上の問題なので、レゴブロックのように統合できる意味のあるユニットを持つようにコードを構造化することを考えてください。

  • テスト駆動開発(TDD)は、テストに裏打ちされた十分なファクトリー・ソフトウェアを反復的に設計するのに役立ちますし、強制的に設計することができます。