ユニットテストを作成するときは、モックを使用しないでください

公開: 2018-05-02

注:これは、主任エンジニアであるSethAmmonsによって作成された最新の技術エンジニアリング投稿です。 この投稿を査読してくれたSamNguyen、 Kane Kim、Elmer Thomas、KevinGilletteに特に感謝します。 そして、このような投稿がf以上ある場合は、テクニカルブログロールをチェックしてください。

コードのテスト、特に単体テストを書くのは本当に楽しいです。 それが私に与える自信の感覚は素晴らしいです。 長い間取り組んでいないものを拾い上げ、ユニットと統合テストを実行できることで、必要に応じて容赦なくリファクタリングできるという知識が得られます。また、テストが良好で意味のあるカバレッジを持ち、合格し続ける限り、 、私はその後も機能的なソフトウェアを持っています。

単体テストはコード設計をガイドし、障害モードとロジックフローが意図したとおりに機能することをすばやく確認できるようにします。 それで、私はおそらくもう少し物議を醸す何かについて書きたいと思います:ユニットテストを書くとき、モックを使わないでください。

テーブルのいくつかの定義を取得しましょう

ユニットテストと統合テストの違いは何ですか? モックとはどういう意味ですか?代わりに何を使用する必要がありますか? この投稿はGoでの作業に焦点を当てているので、これらの単語に対する私の傾斜はGoのコンテキストにあります。

単体テストとは、小さなコード単位をテストすることで、適切なエラー処理を保証し、システムの設計をガイドするテストを指します。 ユニットごとに、パッケージ全体、インターフェイス、または個々のメソッドを指す場合があります。

統合テストでは、依存するシステムやライブラリを実際に操作します。 私が「モック」と言うとき、私は特に「モックオブジェクト」という用語を指します。これは、「ドメインコードを、実際の機能をエミュレートし、コードの動作に関するアサーションを強制するダミーの実装に置き換える[1]」という意味です(強調私の)。

少し短く述べます:モックは次のような動作を主張します:

MyMock.Method( "foo")。Called(1).WithArgs( "bar")。Returns( "raz")

私は「モックではなく偽物」を提唱しています。

偽物は、ビジネス行動を含む可能性のある一種のテストダブルです[2]。 偽物は、インターフェイスに適合する単なる構造体であり、動作を制御する依存性注入の形式です。 偽物の主な利点は、コード内の結合度が減少することです。モックは結合度を増加させ、結合度はリファクタリングを困難にします[3]。

この投稿では、偽物が柔軟性を提供し、簡単なテストとリファクタリングを可能にすることを実証するつもりです。 それらはモックと比較して依存関係を減らし、保守が容易です。

この種の典型的な投稿に見られるように、「合計関数のテスト」よりも少し高度な例を見てみましょう。 ただし、この投稿に続くコードをより簡単に理解できるように、いくつかのコンテキストを提供する必要があります。

SendGridでは、従来、システムの1つがローカルファイルシステムにファイルを持っていましたが、より高い可用性とより良いスループットが必要なため、これらのファイルをS3に移動しています。

これらのファイルを読み取ることができる必要があるアプリケーションがあり、構成に応じて「ローカル」または「リモート」の2つのモードで実行できるアプリケーションを選択しました。 多くのコードサンプルで省略されている注意点は、リモート障害の場合、ファイルをローカルで読み取ることにフォールバックすることです。

それが邪魔にならないように、このアプリケーションにはパッケージゲッターがあります。 パッケージゲッターがリモートファイルシステムまたはローカルファイルシステムのいずれかからファイルを取得できることを確認する必要があります。

素朴なアプローチ:ライブラリとシステムレベルの呼び出しを呼び出すだけ

素朴なアプローチは、実装パッケージがgetter.New(...)を呼び出し、リモートまたはローカルファイル取得のセットアップに必要な情報を渡し、 Getterを返すというものです。 戻り値は、リモートファイルまたはローカルファイルを見つけるために必要なパラメーターを使用してMyGetter.GetFile(...)を呼び出すことができます。

これにより、基本的な構造がわかります。 新しいGetterを作成するとき、潜在的なリモートファイルフェッチに必要なパラメーター(アクセスキーとシークレット)を初期化します。また、コードに試行するように指示するuseRemoteFSなど、アプリケーション構成に由来するいくつかの値を渡します。リモートファイルシステム。

いくつかの基本的な機能を提供する必要があります。 ここで素朴なコードをチェックしてください[4]; 以下は縮小版です。 これは未完成の例であり、リファクタリングを行うことに注意してください。

ここでの基本的な考え方は、リモートファイルシステムから読み取るように構成されていて、リモートファイルシステムの詳細(ホスト、バケット、およびキー)を取得する場合、リモートファイルシステムからの読み取りを試みる必要があるということです。 システムがリモートで読み取ることに自信を持ったら、すべてのファイルの読み取りをリモートファイルシステムにシフトし、ローカルファイルシステムからの読み取りへの参照を削除します。

このコードは、単体テストにはあまり適していません。 それがどのように機能するかを確認するには、実際にはローカルファイルシステムだけでなく、リモートファイルシステムにもアクセスする必要があることに注意してください。 これで、統合テストを実行し、Dockerマジックを設定して、コード内のハッピーパスを検証できるs3インスタンスを作成できます。

ユニットテストは、代替コードと障害パスを簡単にテストすることで、より堅牢なソフトウェアを設計するのに役立つため、統合テストだけを行うことは理想的とは言えません。 より大規模な「実際に機能する」種類のテストの統合テストを保存する必要があります。 とりあえず、単体テストに焦点を当てましょう。

このコードをよりユニットテスト可能にするにはどうすればよいですか? 2つの考え方があります。 1つは、モックをテストするときに使用するボイラープレートコードを作成するモックジェネレーター(https://github.com/vektra/mockeryやhttps://github.com/golang/mockなど)を使用することです。

このルートを使用して、ファイルシステム呼び出しとMinioクライアント呼び出しを生成できます。 または、依存関係を避けたいので、手作業でモックを生成します。 具体的に型指定されたオブジェクトを返す具体的に型指定されたクライアントがあるため、Minioクライアントのモックは簡単ではないことがわかりました。

私は、あざけるよりも良い方法があると言います。 コードをよりテストしやすいように再構築すれば、モックや関連する雑多なものを追加でインポートする必要はなく、インターフェースを自信を持ってテストするために追加のテストDSLを知る必要もありません。 コードが過度に結合されないように設定できます。テストコードは、Goのインターフェイスを使用した通常のGoコードになります。 やってみましょう!

インターフェースアプローチ:より優れた抽象化、より簡単なテスト

テストする必要があるのは何ですか? これは、いくつかの新しいGopherが物事を間違えるところです。 人々はインターフェースを活用することの価値を理解しているのを見てきましたが、彼らが使用しているパッケージの具体的な実装に一致するインターフェースが必要だと感じています。

彼らは私たちがMinioクライアントを持っているのを見るかもしれないので、彼らはMinioクライアント(または他のs3クライアント)のすべての方法と使用法に一致するインターフェースを作ることから始めるかもしれません。 彼らは、「インターフェースが大きいほど、抽象化が弱くなる」という囲碁の箴言[5][6]を忘れています。

Minioクライアントに対してテストする必要はありません。 リモートまたはローカルでファイルを取得できることをテストする必要があります(そして、リモート障害などのいくつかの障害パスを確認します)。 その最初のアプローチをリファクタリングして、Minioクライアントをリモートゲッターに引き出しましょう。 その間、ローカルファイルを読み取るためのコードにも同じことを行い、ローカルゲッターを作成しましょう。 基本的なインターフェースは次のとおりです。それぞれを実装するためのタイプがあります。

これらの抽象化が適切に行われると、最初の実装をリファクタリングできます。 localFetcherremoteFetcherGetter構造体に配置し、 GetFileをリファクタリングしてそれらを使用します。 ここでリファクタリングされたコードのフルバージョンをチェックしてください[7]。 以下は、新しいインターフェースバージョンを使用したわずかに簡略化されたスニペットです。

この新しいリファクタリングされたコードは、 Getter構造体のパラメーターとしてインターフェースを取り、偽物の具象型を変更できるため、ユニットテストがはるかに容易になります。 OS呼び出しをモックしたり、Minioクライアントや大規模なインターフェイスを完全にモックしたりする代わりに、 fakeLocalFetcherfakeRemoteFetcherの2つの単純な偽物が必要です。

これらの偽物には、何を返すかを指定できるいくつかのプロパティがあります。 ファイルデータまたは必要なエラーを返すことができ、呼び出し元のGetFileメソッドが意図したとおりにデータとエラーを処理することを確認できます。

これを念頭に置いて、テストの中心は次のようになります。

この基本構造を使用して、すべてをテーブル駆動型テストにまとめることができます[8]。 テストの表の各ケースは、ローカルまたはリモートのファイルアクセスをテストします。 リモートまたはローカルのファイルアクセスでエラーを挿入できるようになります。 伝播されたエラー、ファイルの内容が渡されたこと、および予期されたログエントリが存在することを確認できます。

私は先に進み、ここで利用可能な1つのテーブル駆動テストにすべての潜在的なテストケースと順列を含めました[9](一部のメソッドシグネチャは少し異なることに気付くかもしれません。これにより、ロガーを挿入したり、ログステートメントに対してアサートしたりできます。 )。

気の利いた、え? GetFileの動作を完全に制御でき、結果に対してアサートできます。 単体テストに適したコードを設計し、 GetFileメソッドに実装された成功パスとエラーパスを検証できるようになりました。

コードは緩く結合されており、将来のリファクタリングは簡単です。 これは、Goに精通している開発者なら誰でも、必要に応じて理解して拡張できる、単純な古いGoコードを作成することで実現しました。

モック:本質的でざらざらした実装の詳細はどうですか?

提案されたソリューションでは得られないモックが私たちに何を買うでしょうか? 従来のモックの利点を示す素晴らしい質問は、「正しいパラメータでs3クライアントを呼び出したことをどうやって知っていますか? モックを使用すると、バケットパラメータではなく、キーパラメータにキー値を確実に渡すことができます。」

これは有効な懸念事項であり、どこかでテストの対象となる必要があります。 ここで提唱するテストアプローチでは、バケットとキーのパラメーターを正しい順序で使用してMinioクライアントを呼び出したことを確認していません。

私が最近読んだ素晴らしい引用は、「モッキングは仮定を導入し、それはリスクを導入します[10]」と述べました。 クライアントライブラリが正しく実装されていること、すべての境界がしっかりしていること、ライブラリが実際にどのように動作するかを知っていることを前提としています。

ライブラリをモックすることは、仮定をモックするだけであり、コードを更新するときにテストがより脆弱になり、変更される可能性があります(これは、MartinFowlerがMocksAre n't Stubs [3]で結論付けたものです)。 ラバーが道路に出会ったら、実際にMinioクライアントを正しく使用していることを確認する必要があります。これは統合テストを意味します(これらはDockerセットアップまたはテスト環境に存在する可能性があります)。 単体テストと統合テストの両方があるため、統合テストでカバーされるため、正確な実装をカバーする単体テストは必要ありません。

この例では、単体テストがコード設計をガイドし、エラーとロジックフローが設計どおりに機能し、必要なことを正確に実行できることをすばやくテストできるようにします。
一部の人にとっては、これではユニットテストのカバレッジが十分ではないと感じています。 上記の点が気になります。 次のように、あるインターフェイスが別のインターフェイスを返す別のインターフェイスを返すロシアの人形スタイルのインターフェイスを主張する人もいます。

そして、Minioクライアントの各部分を各ラッパーに引き出し、モックジェネレーターを使用する場合があります(ビルドとテストに依存関係を追加し、仮定を増やし、物事をより脆弱にします)。 最後に、モキストは次のようなことを言うことができます。

myClientMock.ExpectsCall( "GetObject")。Returns(mockObject).NumberOfCalls(1).WithArgs(key、bucket) –これは、この特定のDSLの正しい呪文を思い出せる場合です。

これは、Minioクライアントを使用する実装の選択に直接関連する多くの余分な抽象化になります。 これにより、クライアントに関する仮定を変更する必要がある場合、または完全に別のクライアントが必要であることがわかった場合に、脆弱なテストが発生します。

これにより、現在および将来のエンドツーエンドのコード開発時間が増加し、コードの複雑さが増し、読みやすさが低下し、モックジェネレーターへの依存度が高まる可能性があり、バケットとキーパラメーターを混同したかどうかを知ることの疑わしい追加の価値が得られますとにかく統合テストで発見したはずです。

より多くのオブジェクトが導入されるにつれて、カップリングはますます緊密になります。 ロガーのモックを作成した可能性があり、後でメトリックのモックを作成し始めます。 あなたがそれを知る前に、あなたはログエントリまたは新しいメトリクスを追加していて、追加のメトリクスが通過することを期待していなかった多数のテストを破っただけです。

前回Goでこれに噛まれたとき、モックフレームワークは、新しいメトリックに遭遇したためにパニックになり、恐ろしい死を遂げたため、どのテストまたはファイルが失敗したかさえ教えてくれませんでした(これには、テストをコメント化してバイナリ検索する必要がありましたモックの動作を変更する必要がある場所を見つけることができるようにするため)。 モックは付加価値を付けることができますか? もちろん。 それは費用の価値がありますか? ほとんどの場合、私は確信していません。

インターフェース:勝利のためのシンプルさと単体テスト

Goのインターフェースを使用するだけで、設計をガイドし、適切なコードとエラーパスを確実にたどることができることを示しました。 インターフェイスに準拠する単純な偽物を作成することで、テスト用に設計されたコードを作成するために、モック、モックフレームワーク、またはモックジェネレーターが不要であることがわかります。 また、単体テストがすべてではないことにも注意しました。システムが相互に適切に統合されていることを確認するには、統合テストを作成する必要があります。

将来、統合テストを実行するためのいくつかのきちんとした方法について投稿したいと思います。 乞うご期待!

参考文献

1:Endo-Testing:モックオブジェクトを使用した単体テスト(2000):モックオブジェクトの定義については「はじめに」を参照してください。
2:リトルモッカー:偽物の部分を参照してください。具体的には、「偽物にはビジネス上の振る舞いがあります。 偽物にさまざまなデータを与えることで、偽物をさまざまな方法で動作させることができます。」
3:モックはスタブではありません:「では、私は古典主義者かモック主義者か」のセクションを参照してください。 Martin Fowler氏は、「モキストTDDに説得力のあるメリットは見当たらず、テストと実装の結合の結果について懸念しています」と述べています。
4:素朴なアプローチ:コードの簡略化されたバージョン。 [7]を参照してください。
5:https://go-proverbs.github.io/:トークへのリンクを含む囲碁の箴言のリスト。
6:https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s:インターフェースのサイズと抽象化に関してRobPikeが話す直接リンク。
7:デモコードのフルバージョン:リポジトリのクローンを作成して`gotest`を実行できます。
8:テーブル駆動型テスト:重複を減らすためにテストコードを整理するためのテスト戦略。
9:デモコードのフルバージョンをテストします。 `gotest`で実行できます。
10:Michal Charemzaによるテストを書くときに自問する質問:モッキングは仮定を導入し、仮定はリスクを導入します。