web-dev-qa-db-ja.com

単体テストのアプリケーションロジックと不信のある言語構造の境界はどこにありますか?

このような関数を考えてみましょう:

_function savePeople(dataStore, people) {
    people.forEach(person => dataStore.savePerson(person));
}
_

次のように使用できます。

_myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
_

Storeに独自の単体テストがあるか、ベンダーが提供しているとassumeしてみましょう。いずれにせよ、私たちはStoreを信頼しています。さらに、データベース切断エラーなどのエラー処理はsavePeopleの責任ではないと仮定します。実際、store自体は魔法のデータベースであり、何らかのエラーは発生しないと想定します。 与えられたこれらの仮定、問題は:

savePeople()は単体テストする必要がありますか、そうしたテストは組み込みのforEach言語構成要素をテストすることになりますか?

もちろん、モックdataStoreを渡して、dataStore.savePerson()が各ユーザーに対して1回呼び出されることをアサートすることもできます。たとえば、forEachを従来のforループまたは他の反復方法に置き換えることを決定した場合など、そのようなテストは実装の変更に対するセキュリティを提供するという主張を確かに行うことができます。したがって、テストは完全に簡単ではありません。そして、それはひどく近いようです...


より実りの多い別の例を次に示します。他のオブジェクトまたは関数を調整するだけの関数を考えます。例えば:

_function bakeCookies(dough, pan, oven) {
    panWithRawCookies = pan.add(dough);
    oven.addPan(panWithRawCookies);
    oven.bakeCookies();
    oven.removePan();
}
_

このような関数は、必要だと思った場合、どのように単体テストする必要がありますか? doughpan、およびovenを単にモックするだけではなく、メソッドが呼び出されていると断言するような種類の単体テストを想像するのは難しいです。しかし、そのようなテストは、関数の正確な実装を複製すること以外に何もしていません。

意味のあるブラックボックスの方法で関数をテストできないことは、関数自体に設計上の欠陥があることを示していますか?もしそうなら、それをどのように改善できますか?


bakeCookiesの例を動機付ける質問をさらに明確にするために、より現実的なシナリオを追加します。これは、レガシーコードにテストを追加してリファクタリングしようとしたときに遭遇したシナリオです。

ユーザーが新しいアカウントを作成する場合、いくつかのことが発生する必要があります。1)データベースに新しいユーザーレコードを作成する必要があります。2)ウェルカムメールを送信する必要があります。3)詐欺のためにユーザーのIPアドレスを記録する必要があります。目的。

したがって、「新しいユーザー」のすべてのステップを結び付けるメソッドを作成したいと思います。

_function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.insertUserRecord(validateduserData);
  emailService.sendWelcomeEmail(validatedUserData);
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}
_

これらのメソッドのいずれかがエラーをスローした場合、エラーを呼び出しコードにバブルアップして、適切と思われるエラーを処理できるようにすることに注意してください。 APIコードによって呼び出されている場合、エラーが適切なhttp応答コードに変換される可能性があります。 Webインターフェースから呼び出されている場合は、エラーが適切なメッセージに変換されてユーザーに表示されることがあります。ポイントはこの関数はスローされる可能性のあるエラーを処理する方法を知らないということです。

私の混乱の本質は、そのような関数を単体テストするには、テスト自体で正確な実装を繰り返す必要があり(メソッドがモックで特定の順序で呼び出されることを指定することにより)、それが間違っているように見えることです。

88
Jonah

savePeople()は単体テストする必要がありますか?はい。あなたはそれをテストしていませんdataStore.savePersonが機能する、またはdb接続が機能する、またはforeachが機能する。あなたは、savePeopleが契約を通じて約束を果たすことをテストしています。

このシナリオを想像してみてください。誰かがコードベースの大きなリファクタリングを行い、誤って実装のforEachの部分を削除して、常に最初のアイテムのみを保存するようにします。単体テストでそれをキャッチしたくないですか?

117
Bryan Oakley

通常、この種の質問は、人々が「テスト後」の開発を行うときに発生します。テストが実装の前に行われるTDDの観点からこの問題に取り組み、演習としてもう一度この質問に答えてください。

少なくともTDDのアプリケーションは、通常は外側から内側にあり、savePeopleを実装した後はsavePersonのような関数を実装しません。 savePeople関数とsavePerson関数は1つとして開始され、同じ単体テストからテスト駆動されます。 2つの間の分離は、リファクタリングの段階で、いくつかのテストの後に発生します。この作業モードは、関数savePeopleがどこにあるべきかという問題も提起します-それがフリー関数かdataStoreの一部かどうかです。

最後に、テストはPersonStoreに正しく保存できるかどうかだけでなく、多くの人をチェックします。これは、他のチェックが必要かどうか、たとえば、「savePeople関数がアトミックであることを確認する必要がありますか?保存できなかった人々のために?それらのエラーはどのように見えますか?」など。これは、forEachまたは他の形式の反復の使用をチェックするだけでは不十分です。

ただし、savePersonがすでに提供された後にのみ、一度に複数の人を保存する必要がある場合は、savePersonの既存のテストを更新して、新しい関数savePeople、最初に委任するだけで1人の人を節約できることを確認してから、新しいテストで複数の人の動作をテストし、動作をアトミックにする必要があるかどうかを検討します。

36
MichelHenrich

SavePeople()は単体テストする必要があります

はい、そうです。ただし、実装から独立した方法でテスト条件を記述してください。たとえば、使用例を単体テストに変換します。

_function testSavePeople() {
    myDataStore = new Store('some connection string', 'password');
    myPeople = ['Joe', 'Maggie', 'John'];
    savePeople(myDataStore, myPeople);
    assert(myDataStore.containsPerson('Joe'));
    assert(myDataStore.containsPerson('Maggie'));
    assert(myDataStore.containsPerson('John'));
}
_

このテストは複数のことを行います:

  • 関数のコントラクトを検証しますsavePeople()
  • savePeople()の実装は気にしません
  • savePeople()の使用例が記載されています

データストアを引き続きモック/スタブ/偽造できることに注意してください。この場合、明示的な関数呼び出しをチェックするのではなく、操作の結果をチェックします。このようにして、私のテストは将来の変更/リファクタリングのために準備されます。

たとえば、データストアの実装がsaveBulkPerson()メソッドを将来提供する可能性があります-savePeople()の実装を変更してsaveBulkPerson()を使用しても、ユニットが壊れることはありませんsaveBulkPerson()が期待どおりに機能するかどうかをテストします。そしてsaveBulkPerson()がどういうわけか期待どおりに機能しない場合、ユニットテストwillでそれをキャッチします。

または、そのようなテストは、組み込みのforEach言語構成のテストに相当しますか?

前述のように、実装ではなく、期待される結果と関数インターフェイスをテストしてみてください(統合テストを実行している場合を除いて、特定の関数呼び出しをキャッチすることが役立つ場合があります)。 関数を実装する方法が複数ある場合は、それらすべてが単体テストで機能するはずです。

質問の更新について:

状態の変化をテストします!例えば。生地の一部を使用します。実装に応じて、使用されたdoughの量がpanに収まることをアサートするか、doughが使い果たされたことをアサートします。関数呼び出しの後、panにCookieが含まれていることをアサートします。 ovenが空/前と同じ状態であることをアサートします。

追加のテストについては、エッジのケースを確認します。呼び出しの前にovenが空でない場合はどうなりますか? doughが足りない場合はどうなりますか? panがすでにいっぱいの場合は、

これらのテストに必要なすべてのデータを、生地、パン、オーブンのオブジェクト自体から推測できるはずです。関数呼び出しをキャプチャする必要はありません。実装が利用できないかのように関数を扱います!

実際、ほとんどのTDDユーザーは、関数を作成する前にテストを作成するため、実際の実装に依存しません。


あなたの最新の追加について:

ユーザーが新しいアカウントを作成する場合、いくつかのことが発生する必要があります。1)データベースに新しいユーザーレコードを作成する必要があります。2)ウェルカムメールを送信する必要があります。3)詐欺のためにユーザーのIPアドレスを記録する必要があります。目的。

したがって、「新しいユーザー」のすべてのステップを結び付けるメソッドを作成したいと思います。

_function createNewUser(validatedUserData, emailService, dataStore) {
    userId = dataStore.insertUserRecord(validateduserData);
    emailService.sendWelcomeEmail(validatedUserData);
    dataStore.recordIpAddress(userId, validatedUserData.ip);
}
_

このような関数の場合、dataStoreパラメータとemailServiceパラメータをモック/スタブ/偽装します(より一般的なように見えます)。この関数は、それ自体ではパラメーターの状態遷移を行わず、一部のパラメーターのメソッドに委譲します。関数の呼び出しが4つのことを行ったことを確認しようとします。

  • ユーザーをデータストアに挿入した
  • ウェルカムメールを送信した(または少なくとも対応するメソッドと呼ばれた)
  • ユーザーのIPをデータストアに記録した
  • 発生した例外/エラー(ある場合)を委任した

最初の3つのチェックは、dataStoreemailServiceのモック、スタブ、または偽物を使用して実行できます(テスト時にメールを送信したくない場合)。私はいくつかのコメントについてこれを調べなければならなかったので、これらは違いです:

  • 偽物とは、元のオブジェクトと同じように動作し、ある程度区別がつかないオブジェクトです。そのコードは通常、テスト全体で再利用できます。これは、たとえば、データベースラッパー用の単純なインメモリデータベースにすることができます。
  • スタブは、このテストに必要な操作を実行するために必要なだけ実装します。ほとんどの場合、スタブは元のメソッドの小さなセットのみを必要とするテストまたはテストのグループに固有です。この例では、insertUserRecord()recordIpAddress()の適切なバージョンを実装するだけのdataStoreを使用できます。
  • モックは、それがどのように使用されるかを確認できるオブジェクトです(ほとんどの場合、そのメソッドへの呼び出しを評価させることによって)。それらを使用することで、実際にはインターフェースの順守ではなく、関数の実装をテストしようとするので、単体テストではそれらを控えめに使用しようとしますが、それらにはまだ用途があります。多くのモックフレームワークは、必要なモックだけを作成するのに役立ちます。

これらのメソッドのいずれかがエラーをスローした場合、エラーを呼び出しコードにバブルアップして、適切と思われるエラーを処理できるようにすることに注意してください。 APIコードによって呼び出されている場合、エラーが適切なHTTP応答コードに変換される可能性があります。 Webインターフェースから呼び出されている場合は、エラーが適切なメッセージに変換されてユーザーに表示されることがあります。ポイントは、この関数はスローされる可能性のあるエラーを処理する方法を知らないということです。

予期される例外/エラーは有効なテストケースです。そのようなイベントが発生した場合に、関数が期待どおりに動作することを確認します。これは、対応するモック/フェイク/スタブオブジェクトに必要なときにスローさせることで実現できます。

私の混乱の本質は、そのような関数を単体テストするには、テスト自体で正確な実装を繰り返す必要があり(メソッドがモックで特定の順序で呼び出されるように指定することにより)、それが間違っているように見えることです。

場合によっては、これを実行する必要があります(ただし、統合テストではこれをほとんど気にしますが)。多くの場合、予想される副作用/状態の変化を確認する他の方法があります。

正確な関数呼び出しを検証すると、ユニットテストがやや壊れやすくなります。元の関数を少し変更するだけで失敗します。これは望ましい場合と望ましくない場合がありますが、関数を変更するたびに、対応する単体テストを変更する必要があります(リファクタリング、最適化、バグ修正など)。

悲しいことに、その場合、単体テストはその信頼性の一部を失います。変更されたため、変更後の機能は以前と同じように動作しません。

例として、誰かがoven.preheat()(最適化!)への呼び出しをCookieのベーキングの例に追加したとします。

  • もしあなたがオーブンオブジェクトをモックしたなら、それはその呼び出しを期待してテストに失敗しませんが、メソッドの観察可能な振る舞いは変化しませんでした(あなたはうまくいけばクッキーのパンがあります)。
  • テストするメソッドのみを追加したか、いくつかのダミーメソッドを使用してインターフェイス全体を追加したかによって、スタブは失敗する場合と失敗しない場合があります。
  • (インターフェースによると)メソッドを実装する必要があるため、フェイクは失敗しないはずです。

単体テストでは、できるだけ一般的になるようにしています。実装が変更されても、(呼び出し元の観点から)目に見える動作が同じであれば、テストに合格するはずです。理想的には、既存の単体テストを変更する必要がある唯一のケースは、(テスト対象の関数ではなく)テストのバグ修正でなければなりません。

21
hoffmale

このようなテストが提供する主な価値は、実装をリファクタリング可能にすることです。

私はキャリアの中で多くのパフォーマンス最適化を行っていましたが、実証した正確なパターンに問題を見つけることがよくありました。Nエンティティをデータベースに保存するには、N挿入を実行します。通常、単一のステートメントを使用して一括挿入を行う方が効率的です。

一方で、時期尚早に最適化したくもありません。通常、一度に1〜3人しか節約できない場合、最適化されたバッチを作成するのはやり過ぎかもしれません。

適切な単体テストを使用すると、上記で実装した方法でそれを記述できます。最適化する必要がある場合は、エラーをキャッチする自動テストのセーフティネットを使用して自由に実行できます。当然、これはテストの品質に基づいて変化するため、十分にテストして十分にテストしてください。

この動作を単体テストすることの2番目の利点は、その目的が何であるかを文書化することです。この簡単な例は明白かもしれませんが、以下の次の点を考えると、非常に重要になる可能性があります。

他の人が指摘した3番目の利点は、統合テストや受け入れテストでテストするのが非常に困難な詳細をテストできることです。たとえば、すべてのユーザーをアトミックに保存する必要がある場合は、そのためのテストケースを作成できます。これにより、期待どおりに動作することを確認でき、明確でない要件のドキュメントとしても機能します。新しい開発者に。

TDDインストラクターから頂いた感想を追加します。メソッドをテストしないでください。動作をテストします。つまり、savePeopleが機能するかどうかはテストせず、1回の呼び出しで複数のユーザーを保存できるかどうかをテストします。

プログラムが機能していることを確認するユニットテストについて考えるのをやめたとき、質の高いユニットテストとTDDを実行する能力が向上しましたが、コードのユニットが期待どおりであることを確認しました。それらは異なります。彼らはそれが機能することを確認しませんが、私が思うように機能することを確認します。そのように考え始めたとき、私の見方は変わりました。

13
Brandon

bakeCookies()をテストする必要がありますか?はい。

このような関数は、必要だと思った場合、どのように単体テストする必要がありますか?生地、パン、オーブンを単に模擬するだけではなく、メソッドが呼び出されていると断言できるような種類の単体テストを想像するのは難しいです。

あんまり。関数が何を行うことになっているのか、ovenオブジェクトを特定の状態に設定することに注意してください。コードを見ると、panオブジェクトとdoughオブジェクトの状態はそれほど重要ではないように見えます。したがって、ovenオブジェクトを渡す(またはそれを模擬する)必要があり、関数呼び出しの最後に特定の状態にあることを表明する必要があります。

言い換えれば、bakeCookies()がcookieを焼いたことを表明する必要がありますです。

非常に短い関数の場合、単体テストはトートロジーにすぎないように見えます。しかし、忘れないでください。あなたのプログラムは、あなたがそれを書いている時間よりもずっと長く続くでしょう。その機能は将来変更される可能性があります。

単体テストは2つの機能を提供します。

  1. すべてが機能することをテストします。これは最も有用でない関数ユニットテストが提供するものであり、質問をするときにこの機能のみを検討しているようです。

  2. プログラムの将来の変更によって、以前に実装された機能が損なわれないことを確認します。これは単体テストの最も便利な機能であり、大きなプログラムへのバグの導入を防ぎます。これは、プログラムに機能を追加するときの通常のコーディングで役立ちますが、プログラムの実装可能なコアアルゴリズムがプログラムの観測可能な動作を変更せずに劇的に変更されるリファクタリングと最適化でより役立ちます。

関数内のコードをテストしないでください。代わりに、関数が言うとおりに機能することをテストします。このようにユニットテスト(コードではなく関数をテスト)を見ると、言語構造やアプリケーションロジックさえテストしていないことがわかります。 APIをテストしています。

6
slebetman

SavePeople()は単体テストする必要がありますか、それともそのようなテストは組み込みのforEach言語構成要素をテストすることになりますか?

はい。しかし、あなたはcould構成を再テストするだけの方法でそれを行います。

ここで注意すべきことは、savePersonが途中で失敗したときにこの関数がどのように動作するかです。それはどのように機能するはずですか?

Thatは、関数が提供する一種の微妙な動作であり、単体テストで強制する必要があります。

3
Telastyn

ここで重要なのは、特定の機能についての些細なことに対するあなたの見方です。ほとんどのプログラミングは取るに足らないことです。値を割り当て、いくつかの計算を行い、決定を下します。これがそうであれば、それまでループを続けます。プログラミング言語を教える本の最初の5つの章を読み終えました。

テストを書くのがとても簡単であるという事実は、あなたのデザインがそれほど悪くないことのしるしであるべきです。テストが容易でないデザインを好みますか?

「それは決して変わらない」ほとんどの失敗したプロジェクトがどのように始まるかです。ユニットテストは、ユニットが特定の状況下で期待どおりに機能するかどうかを判断するだけです。合格すれば、実装の詳細を忘れてそのまま使用できます。その脳空間を次のタスクに使用します。

物事が期待どおりに機能することを知ることは非常に重要であり、大規模なプロジェクト、特に大規模なチームでは簡単ではありません。プログラマーに共通していることが1つあるとすれば、私たち全員が他の誰かのひどいコードに対処しなければならなかったという事実です。私たちができる最低限のことは、いくつかのテストを受けることです。疑問がある場合は、テストを記述して次に進みます。

3
JeffO

あなたの質問は次のように要約されていると思います:

統合テストでなくてもvoid関数を単体テストするにはどうすればよいですか?

たとえば、Cookieベーキング関数を変更してCookieを返すようにすると、テストの内容がすぐにわかります。

関数を呼び出した後にpan.GetCookiesを呼び出す必要がある場合は、「本当に統合テスト」なのか、それとも「panオブジェクトをテストするだけなのか」を疑問視できます。

すべてをモックにして単体テストを行い、関数x yとzをチェックするだけで値が不足していたのは正しいと思います。

だが!これらの場合、テスト可能な結果を​​返すためにvoid関数をリファクタリングする必要があると私は主張しますOR実際のオブジェクトを使用し、統合テストを行う

--- createNewUserサンプルの更新

  • 新しいユーザーレコードをデータベースに作成する必要があります
  • ウェルカムメールを送信する必要があります
  • 詐欺目的でユーザーのIPアドレスを記録する必要があります。

今回は関数の結果が簡単に返されないようになりました。パラメータの状態を変更します。

これは私が少し物議を醸す場所です。 ステートフルパラメータの具体的なモック実装を作成します

どうぞよろしくお願いいたします。

そう...

var validatedUserData = new UserData(); //we can use the real object for this
var emailService = new MockEmailService(); //a simple mock which saves sentEmails to a List<string>
var dataStore = new MockDataStore(); //a simple mock which saves ips to a List<string>

//run the test
target.createNewUser(validatedUserData, emailService, dataStore);

//check the results
Assert.AreEqual(1, emailService.EmailsSent.Count());
Assert.AreEqual(1, dataStore.IpsRecorded.Count());
Assert.AreEqual(1, dataStore.UsersSaved.Count());

これにより、テスト対象のメソッドの実装の詳細が、目的の動作から分離されます。代替実装:

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.bulkInsedrtUserRecords(new [] {validateduserData});
  emailService.addEmailToQueue(validatedUserData);
  emailService.ProcessQueue();
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}

単体テストには引き続き合格します。さらに、テスト間でモックオブジェクトを再利用でき、UIまたは統合テストのためにそれらをアプリケーションに注入できるという利点もあります。

1
Ewan

SavePeople()は単体テストする必要がありますか、それともそのようなテストは組み込みのforEach言語構成要素をテストすることになりますか?

これはすでに@BryanOakleyによって回答されていますが、私はいくつかの追加の引数があります(私は推測します)。

まず、ユニットテストは契約の履行をテストするためのものであり、APIの実装ではありません。テストでは、前提条件を設定してから呼び出し、効果、副作用、不変条件、およびポスト条件を確認します。何をテストするかを決めるとき、APIの実装は重要ではありません(すべきではありません)

次に、関数が変更されたときに不変条件をチェックするテストがあります。それが変化しないという事実nowは、テストを受けるべきではないという意味ではありません。

第三に、TDDアプローチ(それを義務付ける)とその外部の両方で、簡単なテストを実装したことに価値があります。

C++を書くとき、私のクラスでは、オブジェクトをインスタンス化し、不変(割り当て可能、通常など)をチェックする簡単なテストを書く傾向があります。このテストが開発中に何度も失敗したのは驚くべきことでした(たとえば、クラスに移動不可能なメンバーを誤って追加したため)。

1
utnapistim

また、bakeCookiesをテストする必要があります。たとえば、bakeCookies(Egg, pan, oven)は何を生成する/すべきですか?目玉焼きか例外か?本来はpanovenも実際の成分を気にすることはないので、どちらも本来は気にしませんが、bakeCookiesは通常Cookieを生成するはずです。より一般的には、doughがどのように取得されるか、およびisが単なるEggになる可能性があるかどうかに依存します。代わりにwater

0
Tobias Kienzler