モッキングはコード臭です

スモークアートキューブからスモーク— MattysFlicks —(CC BY 2.0)
注:これは、JavaScript ES6 +で関数型プログラミングと合成ソフトウェア技術を一から学ぶ「ソフトウェアの作成」シリーズ(現在は本です!)の一部です。乞うご期待。まだまだたくさんあります!
<前へ| <<やり直す

TDDとユニットテストについて聞いた最大の不満の1つは、ユニットを分離するために必要なすべてのモックに苦労していることです。一部の人々は、ユニットテストの意味を理解するのに苦労しています。実際、開発者はモック、偽物、およびスタブに非常に迷い込み、実際の実装コードがまったく実行されていない単体テストのファイル全体を作成するのを見てきました。おっとっと。

スペクトルのもう一方の端では、開発者がTDDの教義に夢中になり、コードベースをより複雑にしなければならない場合でも、必要な手段で、100%のコードカバレッジを絶対に達成しなければならないと考えるのが一般的ですそれをやってのける。

モックはコードの匂いだとよく言われますが、ほとんどの開発者はTDDスキルの段階を経て、100%のユニットテストをカバーしたいと考えています。また、モックをあまり使わない世界は想像できません。モックをアプリケーションに詰め込むには、ユニットの周りに依存性注入関数をラップするか、(さらに悪いことに)依存性注入コンテナーにサービスをパックする傾向があります。

Angularは、依存性注入をすべてのAngularコンポーネントクラスに直接ベイクすることでこれを極限まで進め、ユーザーに依存性注入をデカップリングの主要な手段と見なすように誘います。ただし、依存性注入は、分離を実現する最良の方法ではありません。

TDDはより良い設計につながるはずです

効果的なTDDを学習するプロセスは、よりモジュール化されたアプリケーションを構築する方法を学習するプロセスです。

TDDは、複雑な効果ではなく、コードの単純化効果を持つ傾向があります。コードをテストしやすくしたときにコードの読み取りや保守が困難になった場合、または依存関係注入ボイラープレートでコードを肥大化させる必要がある場合は、TDDが間違っています。

アプリへの依存関係の注入を回避するために時間を無駄にしないでください。そうすれば、世界中をモックできます。助かる以上にあなたを傷つけている可能性が非常に高いです。よりテスト可能なコードを記述すると、コードが簡素化されます。必要なコード行は少なく、読みやすく、柔軟性があり、保守可能な構造が必要です。依存性注入には逆の効果があります。

このテキストは、次の2つのことを説明するためのものです。

  1. 依存性注入なしで分離コードを記述できます。
  2. コードカバレッジを最大化すると、収益が減少します。100%のカバレッジに近づくほど、アプリケーションコードを複雑にして、アプリケーションのバグを減らすという重要な目標を覆すことができます。

より複雑なコードは、多くの場合、より複雑なコードを伴います。あなたはあなたの家をきれいに保ちたいのと同じ理由で整頓されたコードを生成したい:

  • 乱雑になると、バグが隠れやすくなり、バグが増えます。
  • 迷子にならないようにすっきりと整理しておくと、探しているものを見つけやすくなります。

コードのにおいとは何ですか?

「コードのにおいは、通常システムのより深い問題に対応する表面的な兆候です。」〜Martin Fowler

コードの匂いは、何かが間違いであるということでも、何かをすぐに修正する必要があるということでもありません。何かを改善する可能性があることを警告するのは経験則です。

このテキストとそのタイトルは、すべてのモックが悪いこと、またはモックをしてはならないことを意味するものではありません。

さらに、異なるタイプのコードには異なるレベル(および異なる種類)のモックが必要です。いくつかのコードは主にI / Oを促進するために存在します。その場合、テストI / O以外に行うことはほとんどありません。モックを減らすと、ユニットテストのカバレッジが0に近くなる可能性があります。

コードにロジックがない場合(パイプと純粋なコンポジションのみ)、統合または機能テストカバレッジが100%に近いと仮定すると、0%のユニットテストカバレッジが許容される場合があります。ただし、ロジック(条件式、変数への割り当て、ユニットへの明示的な関数呼び出しなど)がある場合は、ユニットテストのカバレッジが必要になる可能性があり、コードを簡素化し、モック要件を減らす機会があります。

モックとは何ですか?

モックは、ユニットテストプロセス中に実際の実装コードを表すテストダブルです。モックは、テスト実行中に被験者がどのように操作したかについてアサーションを生成できます。テストダブルがアサーションを生成する場合、それは言葉の特定の意味でのモックです。

「モック」という用語は、より一般的に、あらゆる種類のテストダブルの使用を指すためにも使用されます。このテキストでは、一般的な使用法に合わせて「モック」と「テストダブル」という言葉を同じ意味で使用します。すべてのテストダブル(ダミー、スパイ、偽物など)は、テスト対象が密結合している実際のコードを表します。したがって、すべてのテストダブルは結合を示しており、実装を簡素化して改善する機会があります。テスト対象のコードの品質。同時に、モックを作成する必要がないため、モックの必要性を排除することで、テスト自体を根本的に簡素化できます。

単体テストとは何ですか?

単体テストでは、個々のユニット(モジュール、関数、クラス)をプログラムの他の部分から隔離してテストします。

2つ以上のユニット間の統合をテストする統合テストと、シミュレートされたUI操作からデータレイヤーの更新までの完全なユーザーインタラクションワークフローを含むユーザーの観点からアプリケーションをテストする機能テストとのユニットテストの対比ユーザー出力(たとえば、アプリの画面上の表現)。機能テストは、実行中のアプリケーションのコンテキストに統合されたアプリケーションのすべてのユニットをテストするため、統合テストのサブセットです。

一般的に、ユニットはユニットのパブリックインターフェイス(「パブリックAPI」または「表面積」)のみを使用してテストされます。これは、ブラックボックステストと呼ばれます。ブラックボックステストは、ユニットの実装の詳細がユニットのパブリックAPIよりも時間とともに変化する傾向があるため、テストの脆弱性が少なくなります。テストが実装の詳細を認識しているホワイトボックステストを使用する場合、パブリックAPIが期待どおりに機能し続けたとしても、実装の詳細を変更するとテストが中断する可能性があります。つまり、ホワイトボックステストは無駄な手直しにつながります。

テストカバレッジとは何ですか?

コードカバレッジとは、テストケースでカバーされるコードの量を指します。カバレッジレポートは、コードを計測し、テスト実行中に実行された行を記録することで作成できます。一般に、高レベルのカバレッジを生成しようとしますが、コードカバレッジは100%に近づくにつれて減少するリターンを提供し始めます。

私の経験では、カバレッジを〜90%を超えて増やしても、バグ密度の低下との相関関係はほとんどないようです。

どうしてですか? 100%テストされたコードとは、コードが意図したとおりに動作することを100%確実に知っているという意味ではありませんか?

結局のところ、それほど単純ではありません。

ほとんどの人が気付いていないのは、2種類のカバレッジがあるということです。

  1. コードカバレッジ:実行されるコードの量、および
  2. ケースカバレッジ:テストスイートでカバーされるユースケースの数

ケースカバレッジとは、ユースケースシナリオのことです。実際のユーザー、実際のネットワーク、さらにはハッカーが故意にソフトウェアの設計を破壊しようとする実際の環境でのコードの動作方法です。

カバレッジレポートは、コードカバレッジの弱点ではなく、ケースカバレッジの弱点を特定します。同じコードが複数のユースケースに適用される場合があり、単一のユースケースは、テスト対象の対象外のコード、または別のアプリケーションやサードパーティのAPIに依存する場合があります。

ユースケースには環境、複数のユニット、ユーザー、およびネットワーク条件が関係する可能性があるため、ユニットテストのみを含むテストスイートですべての必要なユースケースをカバーすることは不可能です。統合ではなく、独立した定義テストユニットによるユニットテスト。つまり、ユニットテストのみを含むテストスイートは、統合および機能ユースケースシナリオのケースカバレッジが常に0%近くになります。

100%のコードカバレッジは、100%のケースカバレッジを保証するものではありません。

100%のコードカバレッジをターゲットにしている開発者は、間違ったメトリックを追いかけています。

密結合とは何ですか?

ユニットテストの目的でユニットを分離するためにモックを作成する必要があるのは、ユニット間の結合が原因です。密結合は、コードをより硬く、もろくします。変更が必要になると壊れやすくなります。一般的に、コードの拡張と保守が容易になるため、結合自体が少ない方が望ましいためです。モックの必要性を排除することでテストを容易にするという事実は、ただのアイシングです。

このことから、何かをモックしている場合、ユニット間の結合を減らすことでコードをより柔軟にする機会があると推測できます。それが完了すると、モックはもう必要なくなります。

カップリングとは、コード単位(モジュール、関数、クラスなど)が他のコード単位に依存する度合いです。密結合、または高度な結合とは、依存関係が変更されたときにユニットが破損する可能性を示します。言い換えれば、カップリングがきつくなるほど、アプリケーションを維持または拡張することが難しくなります。疎結合により、バグを修正し、アプリケーションを新しいユースケースに適応させる複雑さが軽減されます。

カップリングにはさまざまな形式があります。

  • サブクラスの結合:サブクラスは、実装と親クラスの階層全体に依存します。これは、オブジェクト指向設計で利用可能な最も緊密な形式の結合です。
  • コントロールの依存関係:実行する方法を伝えることで依存関係を制御するコード。たとえば、メソッド名を渡すなど…依存関係のコントロールAPIが変更されると、依存コードが壊れます。
  • 可変状態の依存関係:可変状態を他のコードと共有するコード。たとえば、共有オブジェクトのプロパティを変更できます。突然変異の相対的なタイミングが変化すると、依存コードが破壊される可能性があります。タイミングが非決定的である場合、すべての依存ユニットの完全なオーバーホールなしにプログラムの正確性を達成することは不可能な場合があります。たとえば、競合状態の修復不可能な混乱が存在する場合があります。 1つのバグを修正すると、他のバグが他の従属ユニットに表示される可能性があります。
  • 状態形状の依存関係:データ構造を他のコードと共有し、構造のサブセットのみを使用するコード。共有構造の形状が変更されると、依存コードが破損する可能性があります。
  • イベント/メッセージの結合:メッセージの受け渡し、イベントなどを介して他のユニットと通信するコード…

密結合の原因は何ですか?

密結合には多くの原因があります。

  • 突然変異対不変性
  • 副作用と純度/分離された副作用
  • 責任の過負荷とDo One Thing(DOT)
  • 手続き的な指示と構造の記述
  • クラスの継承と構成

命令型のオブジェクト指向コードは、機能コードよりも密結合の影響を受けやすくなっています。それは、関数型スタイルでプログラミングすることでコードが密結合の影響を受けなくなることを意味するものではありませんが、関数コードは純粋な関数を構成の要素単位として使用します。

純粋な機能:

  • 同じ入力が与えられた場合、常に同じ出力を返します。
  • 副作用なし

純関数はどのように結合を減らすのですか?

  • 不変性:純粋な関数は既存の値を変化させません。代わりに、新しいものを返します。
  • 副作用なし:純粋な関数の唯一の観測可能な効果は戻り値です。したがって、画面、DOM、コンソール、標準出力などの外部状態を監視している可能性のある他の関数の動作を妨げる可能性はありません。 、ネットワーク、またはディスク。
  • 1つのことを行う:純粋な関数は1つのことを行う:入力を対応する出力にマッピングし、オブジェクトおよびクラスベースのコードを悩ます傾向がある責任の過負荷を回避します。
  • 命令ではなく構造:純粋な関数は安全にメモできます。つまり、システムに無限のメモリがある場合、関数からの入力をインデックスとして使用してテーブルから対応する値を取得するルックアップテーブルに純粋な関数を置き換えることができます。言い換えれば、純粋な関数はデータ間の構造的関係を説明するものであり、コンピューターが従うべき命令ではないため、同時に実行される2つの異なる競合する命令セットは互いの足指を踏んで問題を引き起こすことはできません。

合成はモッキングと何の関係がありますか?

すべて。すべてのソフトウェア開発の本質は、大きな問題をより小さな独立した部分に分解し(分解)、ソリューションを一緒に構成して大きな問題を解決するアプリケーション(構成)を形成するプロセスです。

分解戦略が失敗した場合、モッキングが必要です。

大きな問題を小さな部品に分解するために使用されるユニットが互いに依存している場合、モッキングが必要です。別の言い方をすれば、想定される構成の原子単位が実際には原子ではなく、分解戦略がより大きな問題をより小さな独立した問題に分解できなかった場合、モッキングが必要です。

分解が成功すると、汎用の合成ユーティリティを使用して、ピースを一緒に合成することができます。例:

  • 関数の構成(lodash / fp / composeなど)
  • コンポーネントの構成(例:関数の構成で高次のコンポーネントを構成する)
  • ステートストア/モデル構成(例:Redux combReducers)
  • オブジェクトまたは工場の構成(例:ミックスインまたは機能ミックスイン)
  • 変換器などのプロセス構成
  • asyncPipe()、composeM()、composeK()などを使用したKleisli構成など、約束または単項構成
  • 等…

汎用のコンポジションユーティリティを使用すると、コンポジションの各要素を他のモックなしで単体で単体テストできます。

コンポジション自体は宣言型であるため、ユニットテスト可能なロジックは含まれません(おそらく、コンポジションユーティリティは、独自のユニットテストを備えたサードパーティライブラリです)。

そのような状況では、単体テストに意味のあることは何もありません。代わりに、統合テストが必要です。

おなじみの例を使用して、命令型と宣言型の構成を比較してみましょう。

//関数構成OR
// 'lodash / fp / flow'からパイプをインポートします;
const pipe =(... fns)=> x => fns.reduce((y、f)=> f(y)、x);
//作成する関数
const g = n => n + 1;
const f = n => n * 2;
//命令的な構成
const doStuffBadly = x => {
  const afterG = g(x);
  const afterF = f(afterG);
  afterFを返します。
};
//宣言的構成
const doStuffBetter = pipe(g、f);
console.log(
  doStuffBadly(20)、// 42
  doStuffBetter(20)// 42
);

関数合成とは、関数を別の関数の戻り値に適用するプロセスです。つまり、関数のパイプラインを作成してから、パイプラインに値を渡すと、値は各ラインを通過してアセンブリラインのステージのようになり、値を次の関数に渡す前に何らかの方法で変換しますパイプライン。最終的に、パイプラインの最後の関数は最終値を返します。

initialValue-> [g]-> [f]->結果

これは、パラダイムに関係なく、すべての主流言語でアプリケーションコードを編成する主要な手段です。 Javaでさえ、異なるクラスインスタンス間の主要なメッセージ受け渡しメカニズムとして関数(メソッド)を使用します。

関数を手動で(命令的に)構成することも、自動的に(宣言的に)構成することもできます。一流の機能を持たない言語では、あまり選択肢がありません。あなたは命令に固執しています。 JavaScript(および他のほとんどすべての主要な人気言語)では、宣言的構成を使用することでより適切に行うことができます。

命令型とは、コンピューターにステップごとに何かをするように命令していることを意味します。ハウツーガイドです。上記の例では、命令型のスタイルは次のとおりです。

  1. 引数を取り、それをxに割り当てます
  2. afterGというバインディングを作成し、それにg(x)の結果を割り当てます
  3. afterFというバインディングを作成し、それにf(afterG)の結果を割り当てます
  4. afterFの値を返します。

命令型バージョンには、テストする必要のあるロジックが必要です。これらは単純な割り当てであることは知っていますが、間違った変数を渡したり返したりするバグを頻繁に見た(そして書いた)ことがあります。

宣言的なスタイルとは、物事の関係をコンピューターに伝えることです。等式推論を使用した構造の説明です。宣言的な例は次のとおりです。

  • doStuffBetterは、gとfのパイプ構成です。

それでおしまい。

fとgには独自の単体テストがあり、pipe()には独自の単体テスト(Lodashのflow()またはRamdaのpipe()を使用します)があると仮定すると、単体テストに新しいロジックはありません。

このスタイルが正しく機能するためには、構成するユニットを分離する必要があります。

カップリングを削除するにはどうすればよいですか?

カップリングを削除するには、まずカップリングの依存関係がどこから来ているかをよりよく理解する必要があります。カップリングの強さの大まかな順序で、主なソースを次に示します。

密結合:

  • クラスの継承(カップリングは、継承の各レイヤーと各子孫クラスで乗算されます)
  • グローバル変数
  • その他の可変グローバル状態(ブラウザDOM、共有ストレージ、ネットワークなど)
  • 副作用のあるモジュールのインポート
  • コンポジションからの暗黙的な依存関係、たとえば、const enhancedWidgetFactory = compose(eventEmitter、widgetFactory、Enhancements); widgetFactoryはeventEmitterに依存します
  • 依存性注入コンテナ
  • 依存性注入パラメーター
  • 制御パラメーター(外部ユニットが何をすべきかを伝えることで対象ユニットを制御しています)
  • 可変パラメーター

疎結合:

  • 副作用のないモジュールのインポート(ブラックボックステストでは、すべてのインポートを分離する必要はありません)
  • メッセージパッシング/ pubsub
  • 不変のパラメーター(状態の形状に依存関係を共有する可能性があります)

皮肉なことに、カップリングの原因のほとんどは、もともとカップリングを減らすために設計されたメカニズムです。それは理にかなっています。小さな問題の解決策を完全なアプリケーションに再構成するには、何らかの形で統合して通信する必要があるからです。良い方法と悪い方法があります。密結合の原因となるソースは、実用的な場合は避ける必要があります。一般に、健全なアプリケーションでは疎結合オプションが望ましいです。

非常に多くの書籍やブログの投稿で「疎結合」と分類されている場合、依存性注入コンテナーと依存性注入パラメーターを「密結合」グループに分類したと混同されるかもしれません。カップリングはバイナリではありません。グラデーションスケールです。つまり、グループ化は多少主観的でarbitrary意的なものになります。

単純な客観的なリトマステストで線を引きます。

依存関係をモックせずにユニットをテストできますか?できない場合は、モックされた依存関係と密接に結びついています。

ユニットの依存関係が多いほど、カップリングに問題がある可能性が高くなります。カップリングがどのように発生するかがわかったので、それについて何ができますか?

  1. クラス、命令型手続き、または変更関数とは対照的に、純粋な関数を構成の原子単位として使用します。
  2. 副作用をプログラムロジックの残りの部分から分離します。つまり、ロジックとI / Oを混在させないでください(ネットワークI / O、レンダリングUI、ロギングなどを含む)。
  3. 命令型コンポジションから依存ロジックを削除して、独自の単体テストを必要としない宣言型コンポジションにできるようにします。ロジックがない場合、単体テストには意味がありません。

つまり、ネットワークリクエストとリクエストハンドラを設定するために使用するコードは、ユニットテストを必要としません。代わりに、それらの統合テストを使用してください。

繰り返しになります:

I / Oを単体テストしないでください。
I / Oは統合用です。代わりに、統合テストを使用してください。

統合テストのモックや偽造はまったく問題ありません。

純粋な関数を使用する

純粋な関数を使用するには少し練習が必要です。その練習がなければ、純粋な関数を作成して目的の操作を行う方法が必ずしも明確ではありません。純粋な関数は、グローバル変数、渡される引数、ネットワーク、ディスク、または画面を直接変更することはできません。彼らができることは、値を返すことだけです。

配列またはオブジェクトを渡され、そのオブジェクトの変更されたバージョンを返したい場合、オブジェクトに変更を加えて返すだけではいけません。必要な変更を加えたオブジェクトの新しいコピーを作成する必要があります。それには、配列アクセサーメソッド(ミューテーターメソッドではない)、Object.assign()、新しい空のオブジェクトをターゲットとして使用するか、配列またはオブジェクトスプレッド構文を使用します。例えば:

//純粋ではない
const signInUser = user => user.isSignedIn = true;
const foo = {
  名前:「Foo」、
  isSignedIn:false
};
// Fooが変異しました
console.log(
  signInUser(foo)、// true
  foo // {名前: "Foo"、isSignedIn:true}
);

対…

//純粋
const signInUser = user =>({... user、isSignedIn:true});
const foo = {
  名前:「Foo」、
  isSignedIn:false
};
// Fooは変異していません
console.log(
  signInUser(foo)、// {name: "Foo"、isSignedIn:true}
  foo // {名前: "Foo"、isSignedIn:false}
);

または、MoriやImmutable.jsなどの不変データ型のライブラリを試すことができます。 JavaScriptのClojureのような不変のデータ型のセットをいつか入手できることを期待していますが、息を止めていません。

既存のオブジェクトを再利用する代わりに新しいオブジェクトを作成しているため、新しいオブジェクトを返すとパフォーマンスが低下する可能性があると思われるかもしれませんが、幸いな副作用として、ID比較(= == check)、したがって、何かが変更されたかどうかを検出するためにオブジェクト全体を走査する必要はありません。

レンダーパスごとに深くトラバースする必要がない複雑な状態ツリーがある場合、このトリックを使用してReactコンポーネントのレンダリングを高速化できます。 PureComponentから継承し、shouldComponentUpdate()を実装し、浅い支柱と状態の比較を行います。同一性の平等を検出すると、状態ツリーのその部分で何も変化していないことを認識し、状態の深いトラバーサルなしで先に進むことができます。

また、純粋な関数をメモすることもできます。つまり、以前に同じ入力を見たことがある場合は、オブジェクト全体を再構築する必要はありません。メモリの計算の複雑さと引き換えに、事前に計算された値をルックアップテーブルに保存できます。制限のないメモリを必要としない計算コストの高いプロセスの場合、これは優れた最適化戦略かもしれません。

純粋な関数のもう1つの特性は、副作用がないため、分割統治戦略を使用して、複雑な計算をプロセッサの大きなクラスターに分散しても安全であることです。この戦術は、もともとグラフィックス向けに設計された超並列GPUを使用して画像、ビデオ、またはオーディオフレームを処理するためによく使用されますが、現在では科学計算などの多くの他の目的に使用されています。

言い換えると、突然変異は常に高速であるとは限らず、マクロ最適化を犠牲にしてミクロ最適化を行うため、しばしば桁違いに遅くなります。

副作用をプログラムロジックの残りから分離する

副作用をプログラムロジックの残りの部分から分離するのに役立ついくつかの戦略があります。それらのいくつかを次に示します。

  1. pub / subを使用して、I / Oをビューおよびプログラムロジックから分離します。 UIビューまたはプログラムロジックで副作用を直接トリガーするのではなく、イベントまたはインテントを記述するイベントまたはアクションオブジェクトを発行します。
  2. I / Oからロジックを分離します。たとえば、asyncPipe()を使用してプロミスを返す関数を作成します。
  3. I / Oで直接計算をトリガーするのではなく、将来の計算を表すオブジェクトを使用します。たとえば、redux-sagaのcall()は実際には関数を呼び出しません。代わりに、関数への参照とその引数を含むオブジェクトを返し、サガミドルウェアはそれを呼び出します。これにより、call()およびそれを使用するすべての関数が純粋な関数になり、モックを必要とせずに単体テストが簡単になります。

pub / subを使用

Pub / subは、パブリッシュ/サブスクライブパターンの略です。パブリッシュ/サブスクライブパターンでは、ユニットは互いに直接呼び出しません。代わりに、他のユニット(サブスクライバー)が聞くことができるメッセージを公開します。サイト運営者は、ユニットがサブスクライブするユニット(存在する場合)を知りません。また、サブスクライバーは、パブリッシャーが発行するユニット(存在する場合)を知りません。

Pub / subは、ドキュメントオブジェクトモデル(DOM)にベイク処理されます。アプリケーションのすべてのコンポーネントは、マウスの動き、クリック、スクロールイベント、キーストロークなど、DOM要素からディスパッチされたイベントをリッスンできます。誰もがjQueryを使用してWebアプリを作成したとき、jQueryカスタムイベントはDOMをpub / subイベントバスに変え、ビューレンダリングの懸念を状態ロジックから切り離すのが一般的でした。

Pub / subもReduxに焼き付けられます。 Reduxでは、アプリケーション状態(ストアと呼ばれる)のグローバルモデルを作成します。モデルを直接操作する代わりに、ビューとI / Oハンドラーがアクションオブジェクトをストアにディスパッチします。アクションオブジェクトには、さまざまなレデューサーがリッスンして応答できるtypeと呼ばれる特別なキーがあります。さらに、Reduxはミドルウェアをサポートしています。ミドルウェアは特定のアクションタイプをリッスンして応答することもできます。このようにして、ビューはアプリケーションの状態がどのように処理されるかについて何も知る必要がなく、状態ロジックはビューについて何も知る必要がありません。

また、ミドルウェアを介してディスパッチャにパッチを適用し、アクションロギング/分析、ストレージまたはサーバーとの状態の同期、サーバーおよびネットワークピアとのリアルタイム通信機能のパッチなどの横断的な関心事をトリガーすることも簡単になります。

I / Oからロジックを分離する

モナドコンポジション(Promiseなど)を使用して、コンポジションから依存ロジックを削除できる場合があります。たとえば、次の関数には、すべての非同期関数をモックせずに単体テストを実行できないロジックが含まれています。

非同期関数uploadFiles({user、folder、files}){
  const dbUser = await readUser(user);
  const folderInfo = await getFolderInfo(folder);
  if(await haveWriteAccess({dbUser、folderInfo})){
    return uploadToFolder({dbUser、folderInfo、files});
  } else {
    新しいエラーをスロー(「そのフォルダーへの書き込みアクセス権なし」);
  }
}

いくつかのヘルパー擬似コードを実行して、実行可能にします。

const log =(... args)=> console.log(... args);
//これらを無視します。インポートする実際のコードで
//本物。
const readUser =()=> Promise.resolve(true);
const getFolderInfo =()=> Promise.resolve(true);
const haveWriteAccess =()=> Promise.resolve(true);
const uploadToFolder =()=> Promise.resolve( 'Success!');
//開始変数を意味不明なものにする
const user = '123';
constフォルダー= '456';
constファイル= ['a'、 'b'、 'c'];
非同期関数uploadFiles({user、folder、files}){
  const dbUser = await readUser({user});
  const folderInfo = await getFolderInfo({folder});
  if(await haveWriteAccess({dbUser、folderInfo})){
    return uploadToFolder({dbUser、folderInfo、files});
  } else {
    新しいエラーをスロー(「そのフォルダーへの書き込みアクセス権なし」);
  }
}
uploadFiles({ユーザー、フォルダー、ファイル})
  .then(log)
;

そして、asyncPipe()を介してpromiseコンポジションを使用するようにリファクタリングします。

const asyncPipe =(... fns)=> x =>(
  fns.reduce(async(y、f)=> f(await y)、x)
);
const uploadFiles = asyncPipe(
  readUser、
  getFolderInfo、
  haveWriteAccess、
  uploadToFolder
);
uploadFiles({ユーザー、フォルダー、ファイル})
  .then(log)
;

Promiseには条件分岐が組み込まれているため、条件ロジックは簡単に削除できます。ロジックとI / Oがうまく混ざらないという考え方です。そのため、I / O依存コードからロジックを削除する必要があります。

この種の構成を機能させるには、2つのことを確認する必要があります。

  1. ユーザーが書き込みアクセス権を持っていない場合、haveWriteAccess()は拒否します。これにより、条件付きロジックがプロミスコンテキストに移動するため、ユニットテストを行う必要も、まったく心配する必要もありません(プロミスには、独自のテストがJSエンジンコードに組み込まれています)。
  2. これらの各関数は、同じデータ型を使用して解決します。次のキーを含むオブジェクトだけであるこのコンポジションのpipelineDataタイプを作成できます:{user、folder、files、dbUser ?, folderInfo? }。これにより、コンポーネント間の依存関係を共有する構造が作成されますが、これらの関数のより一般的なバージョンを他の場所で使用し、シンラッピング関数を使用してこのパイプラインに特化することができます。

これらの条件が満たされていれば、他の機能をモックすることなく、これらの各機能を互いに分離してテストするのは簡単です。パイプラインからすべてのロジックを抽出したため、このファイルには単体テストに意味のあるものは残っていません。テストするのは、統合だけです。

要確認:ロジックとI / Oは別々の問題です。
ロジックは考えています。効果はアクションです。行動する前に考えてください!

将来の計算を表すオブジェクトを使用する

redux-sagaで使用される戦略は、将来の計算を表すオブジェクトを使用することです。アイデアは、常に返されるモナドである必要はないことを除いて、モナドを返すことに似ています。モナドはチェーン操作で関数を構成できますが、代わりに命令型コードを使用して手動で関数をチェーンできます。 redux-sagaがどのようにそれを行うかの大まかなスケッチは次のとおりです。

// console.logの砂糖は後で使用します
const log = msg => console.log(msg);
const call =(fn、... args)=>({fn、args});
const put =(msg)=>({msg});
// I / O APIからインポート
const sendMessage = msg => Promise.resolve( 'some response');
//状態ハンドラー/リデューサーからインポート
const handleResponse = response =>({
  タイプ: 'RECEIVED_RESPONSE'、
  ペイロード:応答
});
const handleError = err =>({
  タイプ: 'IO_ERROR'、
  ペイロード:err
});
function * sendMessageSaga(msg){
  {
    const response = yield call(sendMessage、msg);
    yield put(handleResponse(response));
  } catch(err){
    yield put(handleError(err));
  }
}

ネットワークAPIをモックしたり、副作用を引き起こしたりすることなく、ユニットテストで行われているすべての呼び出しを確認できます。ボーナス:これにより、非決定的なネットワーク状態などを心配することなく、アプリケーションを非常に簡単にデバッグできます。

ネットワークエラーが発生したときにアプリで何が起こるかをシミュレートしたいですか? iter.throw(NetworkError)を呼び出すだけです

他の場所では、一部のライブラリミドルウェアが機能を実行しており、実際に運用アプリケーションで副作用を引き起こしています。

const iter = sendMessageSaga( 'Hello、world!');
//ステータスと値を表すオブジェクトを返します:
const step1 = iter.next();
log(step1);
/ * =>
{
  完了:false、
  値:{
    fn:sendMessage
    args:["こんにちは、世界!"]
  }
}
* /

生成された値からcall()オブジェクトを分解して、将来の計算を検査または呼び出します。

const {値:{fn、args}} = step1;

エフェクトは実際のミドルウェアで実行されます。テストとデバッグを行うときは、この部分をスキップできます。

const step2 = fn(args);
step2.then(log); //「何らかの応答」

APIまたはhttp呼び出しをモックせずにネットワーク応答をシミュレートする場合は、シミュレートされた応答を.next()に渡すことができます。

iter.next(simulatedNetworkResponse);

そこからdoneがtrueになり、関数の実行が完了するまで.next()を呼び出し続けることができます。

単体テストでジェネレーターと計算の表現を使用すると、実際の副作用の呼び出しを除くすべてをシミュレートできます。 .next()呼び出しに値を渡して偽の応答を返すか、イテレーターでエラーをスローして、偽のエラーを作成して拒否を約束することができます。

このスタイルを使用すると、多くの副作用を伴う複雑な統合ワークフローの場合でも、単体テストでモックを作成する必要はありません。

「コードのにおい」は、法律ではなく警告標識です。モックは悪ではありません。

優れたアーキテクチャを使用することに関するこれらすべては素晴らしいことですが、現実の世界では、他の人のAPIを使用し、レガシーコードと統合する必要があり、純粋ではないAPIがたくさんあります。そのような場合には、孤立したテストダブルが役立つ場合があります。たとえば、エクスプレスは共有の可変状態を渡し、継続パスを介して副作用をモデル化します。

一般的な例を見てみましょう。エクスプレスアプリに含まれるすべてのものを単体で他の方法で単体テストするため、エクスプレスサーバー定義ファイルには依存性注入が必要であると言われますか?例えば。:

const express = require( 'express');
const app = express();
app.get( '/'、function(req、res){
  res.send( 'Hello World!')
});
app.listen(3000、function(){
  console.log( 'ポート3000でリッスンするサンプルアプリ!')
});

このファイルを「単体テスト」するには、依存性注入ソリューションを作成し、すべてにモックを渡す必要があります(おそらくexpress()自体を含む)。これが非常に複雑なファイルであり、異なる要求ハンドラーが異なるExpressの機能を使用しており、そこにそのロジックを当てにしている場合、おそらくそれを機能させるにはかなり洗練された偽物を考え出す必要があります。開発者が、エクスプレス、セッションミドルウェア、ログハンドラー、リアルタイムネットワークプロトコルなど、手の込んだ偽物やモックを作成するのを見てきました。私は難しいモック質問に自分で直面しましたが、正しい答えは簡単です。

このファイルには単体テストは必要ありません。

エクスプレスアプリのサーバー定義ファイルは、定義上、アプリの主要な統合ポイントです。エクスプレスアプリファイルのテストは、定義により、プログラムロジック、エクスプレス、およびそのエクスプレスアプリのすべてのハンドラー間の統合をテストします。 100%の単体テストのカバレッジを達成できる場合でも、統合テストを絶対にスキップしないでください。

このファイルを単体テストする代わりに、プログラムロジックを個別のユニットに分離し、それらのファイルを単体テストします。サーバーファイルの実際の統合テストを作成します。つまり、実際にネットワークにアクセスするか、少なくとも実際のhttpメッセージを作成し、スーパーテストなどのツールを使用してヘッダーを完成させます。

Hello World Expressの例をリファクタリングして、テストしやすくします。

helloハンドラーを独自のファイルにプルし、単体テストを作成します。残りのアプリコンポーネントをモックする必要はありません。これは明らかに純粋な関数ではないため、応答オブジェクトをスパイまたはモックして、.send()を確実に呼び出す必要があります。

const hello =(req、res)=> res.send( 'Hello World!');

次のようにテストできます。お気に入りのテストフレームワークの期待値のifステートメントを交換します。

{
  const expected = 'Hello World!';
  const msg = `$ {expected}`で.send()を呼び出すべきです;
  const res = {
    送信:(実際の)=> {
      if(実際の!==予想){
        新しいエラーをスロー( `NOT OK $ {msg}`);
      }
      console.log( `OK:$ {msg}`);
    }
  }
  hello({}、res);
}

リスンハンドラーを独自のファイルにプルし、単体テストも記述します。ここにも同じ問題があります。 Expressハンドラーは純粋ではないため、ロガーが呼び出されることを確認するためにロガーをスパイする必要があります。テストは前の例に似ています:

const handleListen =(log、port)=>()=> log( `ポート$ {port}!でリッスンするアプリの例);

サーバーファイルに残っているのは統合ロジックのみです。

const express = require( 'express');
const hello = require( './ hello.js');
const handleListen = require( './ handleListen');
const log = require( './ log');
const port = 3000;
const app = express();
app.get( '/'、hello);
app.listen(port、handleListen(port、log));

このファイルにはまだ統合テストが必要ですが、さらに単体テストを行ってもケースカバレッジが有意に向上することはありません。ロガーをhandleListen()に渡すために、非常に最小限の依存性注入を使用しますが、エクスプレスアプリ用の依存性注入フレームワークは必要ありません。

モックは統合テストに最適です

統合テストはユニット間の協調的統合をテストするため、CPUクラスターまたはネットワーク上の別々のマシン。

ユニットがサードパーティAPIとどのように通信するかをテストしたい場合があります。また、これらのAPIを実際にテストするのは非常に高価な場合があります実際のサービスに対して実際のワークフロートランザクションを記録し、偽のサーバーからそれらを再生して、実際に別のネットワークプロセスで実行されているサードパーティサービスとユニットがどの程度統合されるかをテストできます。多くの場合、これは「正しいメッセージヘッダーが表示されましたか?」などをテストする最良の方法です。

ネットワーク帯域幅の調整、ネットワークラグの導入、ネットワークエラーの生成など、通信層を模擬する単体テストを使用してテストすることが不可能な他の多くの条件をテストする便利な統合テストツールが多数あります。

統合テストなしで100%のケースカバレッジを達成することは不可能です。 100%の単体テストのカバレッジを達成できたとしても、それらをスキップしないでください。 100%が100%ではない場合もあります。

次のステップ

  • すべての開発チームがCross Cutting ConcernsポッドキャストでTDDを使用する必要があると思う理由をご覧ください。
  • JSチアリーダーがInstagramで私たちの冒険を記録しています。

EricElliottJS.comで詳細を見る

EricElliottJS.comのメンバーは、ユニットテストに関するビデオレッスンを利用できます。メンバーでない場合は、今すぐサインアップしてください。

エリックエリオットは、「JavaScriptアプリケーションのプログラミング」(O’Reilly)、および「エリックエリオットでJavaScriptを学ぶ」の著者です。彼は、Adobe Systems、Zumba Fitness、The Wall Street Journal、ESPN、BBC、およびUsher、Frank Ocean、Metallicaなどのトップレコーディングアーティストのソフトウェアエクスペリエンスに貢献しています。

彼は世界で最も美しい女性とどこからでも離れて働いています。