非同期 JavaScript のテスト

JavaScript コミュニティには、非同期コードのテストには「通常の」同期コードのテストとは異なるアプローチが必要であるという誤解があるようです。この記事では、それが一般的に当てはまらない理由を説明します。非同期動作をサポートするコードのユニットのテストと、本質的に非同期であるコードのテストの違いを強調します。また、Promise ベースの非同期コードが、非同期動作を検証しながら、明確で読みやすい方法でテストできるクリーンで簡潔なユニットテストにどのように役立つかを示します。

2013年9月18日


Photo of Pete Hodgson

Pete Hodgson は Thoughtworks のコンサルタントであり、過去数年間、チームが高品質のソフトウェアを継続的に提供できるようになるのを支援してきました。彼は特に、Web ベースのモバイル、Ruby、アジャイル、ソフトウェアへの関数型アプローチ、そしてビールに情熱を注いでいます。


非同期対応コード vs. 本質的に非同期のコード

JavaScript 開発者として記述する「非同期」コードのほとんどは、本質的に非同期ではありません。たとえば、JQuery を使用して実装された簡単な AJAX 操作を見てみましょう。

var callback = function(){
  alert('I was called back asynchronously');
};

$.ajax({ 
  type: 'GET', 
  url: 'http://example.com', 
  done: callback 
});

それを見て「それは非同期コードだ」と言うかもしれません。コールバックがありますよね?実際には本質的に非同期ではなく、非同期コンテキストで使用される可能性をサポートしているだけです。$.ajaxは、AJAX 呼び出しの結果を戻り値(または例外)を使用するのではなく、コールバックを介して呼び出し元に提供するため、非同期的に操作を実行できますが、実際にはそうしないことを選択することもできます。$.ajaxのこの偽のバージョンを想像してください。

function ajax( opts ){
  opts.done( "fake result" );
}

これは明らかに完全な XHR 実装ではありません ;) が、さらに重要なことに、非同期実装でもありません。メソッド実装自体の中からdoneコールバックを直接呼び出すことで、潜在的に非同期の操作を完全に同期の操作に平坦化しています。ajaxのクライアントにどのような影響があるでしょうか?この偽の ajax メソッドを次のように呼び出した場合

console.log( 'calling ajax...' );
ajax({ 
  done: function(){ console.log( 'callback called' ); } 
});
console.log( '...called ajax' );

次のような出力が表示されます。

calling ajax...
callback called
...called ajax

ご覧のとおり、ログエントリは定義された順序で書き込まれます。これは、使用している偽のajax関数が、潜在的に非同期 API の同期実装であるためです。このコードはすべて、ランタイムによって同期的に、イベントループの 1 回のターンで実行されます[1]

これについて考えるもう 1 つの方法は、同期的に動作するメソッド呼び出しは、より一般的な非同期ケースの特殊なケースであるということです。同期コードは、結果が元の呼び出しのコンテキスト内で返される非同期コードにすぎません。

非同期サポートコードのテスト

うまくいけば、JavaScript コードのほとんどが本質的に非同期ではなく、コールバック (または Promise) を使用する非同期対応 API を呼び出すため、非同期動作をサポートするだけであることを実証できたでしょう。しかし、なぜこれを気にする必要があるのでしょうか?私たちが書くコードは常に非同期コンテキストで使用されるでしょう?いや、そうではありません。非同期サポートコードのユニットテストを書きたい場合はそうではありません。

$.ajaxを使用した例を続けましょう。URL から現在のユーザーの JSON 説明を取得し、その JSON に基づいてローカルの User オブジェクトを作成する関数を作成しているとします。実装は次のようになる可能性があります。

function parseUserJson(userJson) {
  return {
    loggedIn: true,
    fullName: userJson.firstName + " " + userJson.lastName
  };
};

function fetchCurrentUser(callback) {
  function ajaxDone(userJson) {
    var user = parseUserJson(userJson);
    callback(user);
  };

  return $.ajax({
    type: 'GET',
    url: "http://example.com/currentUser",
    done: ajaxDone
  });
};

fetchCurrentUserでは、URL への GET リクエストを開始し、リクエストが応答とともに戻ってきたら非同期的に実行されるajaxDoneコールバックを提供します。そのコールバックでは、応答から返された JSON を取得し、parseUserJson関数を使用してそれを解析して、(かなり貧弱な) User ドメインオブジェクトを作成します。最後に、fetchCurrentUserに最初に渡されたコールバックを呼び出し、ユーザーオブジェクトをパラメーターとして渡します。

このコードのユニットテストをどのように記述するかを見てみましょう(この記事の最後に、テスト中に使用されるツールとライブラリのリストがあります)。たとえば、fetchCurrentUserが JSON を適切な User オブジェクトに解析することをどのようにテストするでしょうか?本質的に非同期のコードと非同期をサポートするコードの違いを認識する前に、この非同期コードをテストするには何らかの非同期ユニットテストが必要だと考えるかもしれません。しかし、ここでテストしているコードは本質的に非同期ではないことを理解しています。テスト目的でその実行を同期フローに平坦化できます。それがどのようなものか見てみましょう。

describe('fetchCurrentUser', function() {
    var simulatedAjaxResponse = {
      firstName: "Tomas",
      lastName: "Jakobsen"
    };

    $.ajax = function(ajaxOpts) {
      var doneCallback = ajaxOpts.done;
      doneCallback(simulatedAjaxResponse);
    };

    function fetchCallback(user) {
      expect(user.fullName).to.equal("Tomas Jakobsen");
    };

    fetchCurrentUser(fetchCallback);
  });
});

最初に行うことは、$.ajaxを、ajax 応答が戻ってくるのをシミュレートできるスタブ関数で置き換えることです($.ajaxを直接いじるのは非常に不快ですが、この記事では依存関係管理について触れたくないので、ここは我慢します)。次に、テスト対象であるfetchCurrentUser関数を呼び出します。fetchCurrentUserは非同期フェッチをサポートする必要があるため、コールバックを受け取ります。このテストの目標は、fetchCurrentUserの呼び出しの最終的な結果を検査することです。つまり、最終的に作成されるユーザーオブジェクトを受け取るコールバックを提供する必要があります。そのコールバックは、Chai の expect スタイルのアサーションを使用して、ユーザーのfullNameプロパティが正しく初期化されていることを検証します。

このテストは完全に同期的に実行されることに注意することが重要です。前の例と同様に、イベントループの 1 回のターン内で完了します。

落とし穴

このテストアプローチには落とし穴があります。次のように、本番コードの重要な行を誤ってコメントアウトした場合に何が起こるでしょうか。

function fetchCurrentUser(callback) {
  function ajaxDone(userJson) {
    var user = parseUserJson(userJson);
    //callback(user);
  };

  return $.ajax({
    type: 'GET',
    url: "http://example.com/currentUser",
    done: ajaxDone
  });
};

この関数は、現状では正しく動作しません。fetchCurrentUserに渡されたコールバックを呼び出すことは決してないため、ユーザーオブジェクトが返されることはありません。ユーザーのfullNameプロパティの値を明示的にチェックしているので、テストでこれが検証されることを期待するでしょう。

ただし、そうではありません。テストは引き続きパスします。それはなぜですか?まあ、アサーションは決して実行されないコールバックの中に入れているからです!バグは、テストの一部が呼び出されないため、実行されないことを意味します。

これを解決するための単純なアプローチは、次のようになる可能性があります。

describe('fetchCurrentUser', function() {
  it('creates a parsed user', function() {
    var simulatedAjaxResponse = {
      firstName: "Tomas",
      lastName: "Jakobsen"
    };

    $.ajax = function(ajaxOpts) {
      var doneCallback = ajaxOpts.done;
      doneCallback(simulatedAjaxResponse);
    };

    function fetchCallback(user) {
      expect(user.fullName).to.equal("Tomas Jakobsen");
      callbackCalled = true;
    };

    var callbackCalled = false;

    fetchCurrentUser(fetchCallback);

    expect(callbackCalled).to.be.true;
  });
});

これは以前と同じテストですが、コールバックが呼び出されたかどうかを追跡している点だけが異なります。これで、このテストはコールバックが実行されていないことを正しく検出し、失敗しますが、少し扱いにくいです。Jasmineではなく、テストランナーとしてMochaを使用している場合、わずかに優れたオプションがあります。

describe('fetchCurrentUser', function() {
  it('creates a parsed user', function(done) {
    var simulatedAjaxResponse = {
      firstName: "Tomas",
      lastName: "Jakobsen"
    };

    $.ajax = function(ajaxOpts) {
      var doneCallback = ajaxOpts.done;
      doneCallback(simulatedAjaxResponse);
    };

    function fetchCallback(user) {
      expect(user.fullName).to.equal("Tomas Jakobsen");
      done();
    };

    fetchCurrentUser(fetchCallback);
  });
});

itブロックがdoneパラメーターを受け取るようになったことに注意してください。Mocha がitブロックがパラメーターを期待していることを認識すると、そのテストを非同期テストとして扱います。テストにdone関数を提供し、その代わりに、テストはMochaにテストが完了したことを伝えるためにdone()を呼び出す必要がありますdone()の呼び出しは、テストが制御フローの終了をマークします。これは、Mocha がテストが制御フローの終わりに達したかどうかを検出し、そうでない場合にテストを失敗させることができることを意味します。これは、必要に応じてテストを真に非同期にして、ランループの複数のターンを実行できることも意味します。

コールバック中心のテストコードがいつ完了したかを明示的に示すこの簡単な方法が、一部の人が Jasmine よりも Mocha を好む理由の 1 つです。Jasmine も非同期テストをサポートしていますが、そのメカニズムはdone()関数よりもかなり扱いにくいです。

良いが、最高ではない

さて、コールバック指向の非同期サポートコードのユニットテストを作成することが非常に可能であることがわかりました。ただし、テストはあまり読みやすくないことを最初に認めます。多くの配管コードがあるように感じられ、コードのコールバック中心の性質がテストに漏れています。これは、テストが「コードの臭い」を知らせるのに役立つ典型的な例だと考えています。テストの見栄えが悪いか、書きにくい場合は、テスト対象のコードの設計方法に問題がある可能性があります。

次に、コールバックから Promise に切り替えることで、このコードの臭いを減らすのに役立ち、結果として、対応する快適なテストを備えたよりクリーンなコードにつながることを主張します。

Promise の簡単な紹介

この記事の残りの部分では、コールバック指向の非同期コードから Promise 指向の非同期コードに切り替えます。Promise は、非同期サポートコードの制御フローを宣言的な方法でモデル化する実装につながると考えています。同様に、Promise は非同期サポートコードのユニットテストについて推論するのを容易にすると考えています。ここでは Promise について簡単に概説します。まだ使用したことがない場合は、独自の非同期コードを改善するための最初の一歩として、Promise について詳しく学ぶことを強くお勧めします。

Promise は、いくつかのおまけ機能を追加した、コールバックのオブジェクト指向バージョンだと考えています。従来のコールバック指向コードでは、非同期関数を呼び出すときに、非同期操作が完了したら呼び出されるコールバックを渡します。1 回の関数呼び出しで、非同期作業を実行するように要求すると同時に、作業が完了したら次に何をするかを指定します。Promise では、非同期操作の要求は、その後何をするかとは切り離されます。前と同様に非同期操作を呼び出しますが、呼び出し元は非同期関数への引数としてコールバックを提供しません。代わりに、非同期関数は呼び出し元に Promise オブジェクトを返します。呼び出し元は、その Promise にコールバックを登録します。関数を呼び出して非同期操作を呼び出し、関数が返す Promise と対話することで、操作が完了した後に何をしたいかを個別に指定します。

したがって、このコールバック指向のコードの代わりに

var callback = function(){
  alert('I was called back asynchronously');
};

someAsyncFunction( "some", "args", callback );

Promise 指向のコードでは、これを行います。

var callback = function(){
  alert('I was called back asynchronously');
};

var promise = someAsyncFunction( "some", "args" );
promise.done( callback );

ほとんどの場合、jQuery スタイルのメソッドチェーンと匿名関数を使用すると、次のようになります。

someAsyncFunction( "some", "args" ).done( function(){
    alert('I was called back asynchronously');
  });

これは、Promise ライブラリが提供する非常に基本的な機能にすぎません。活用できる機能は他にもたくさんあります。Promise については、以前のブログ投稿で少し詳しく説明しました。詳細については、そちらを読むことをお勧めします。その投稿には、Promise ライブラリの高度な機能が、コードから退屈な非同期配管を削除するのにどのように役立ち、解決されている実際の問題に焦点を当て続けるのに役立つかを示す、より複雑な例も含まれています。

Dominic Denicola は、この投稿で、Promise が非常に便利な理由をうまく説明しています。強くお勧めします。

非同期コードを Promise に移植する

前の例では、次のコールバック指向の実装がありました。

function parseUserJson(userJson) {
  return {
    loggedIn: true,
    fullName: userJson.firstName + " " + userJson.lastName
  };
};

function fetchCurrentUser(callback) {
  function ajaxDone(userJson) {
    var user = parseUserJson(userJson);
    callback(user);
  };

  return $.ajax({
    type: 'GET',
    url: "http://example.com/currentUser",
    done: ajaxDone
  });
};

以下は、Promise 指向の実装です。

function parseUserJson(userJson) {
  return {
    loggedIn: true,
    fullName: userJson.firstName + " " + userJson.lastName
  };
};

function fetchCurrentUser() {
  return Q.when($.ajax({
    type: 'GET',
    url: "http://example.com/currentUser"
  })).then(parseUserJson);
};

前と同様に、fetchCurrentUserは URL への GET リクエストを開始しますが、$.ajax関数にコールバックを直接渡すのではなく、その関数から返された Promise を取得して、parseUserJson関数をそれにチェーンします。このようにして、$.ajax呼び出しから返される JSON 応答がパーサー関数に流れ込み、そこで解析された User オブジェクトに変換され、さらにfetchCurrentUserの呼び出し元によって設定された他の Promise パイプラインに流れ込むように調整します。

ここで、私はJQueryの$.ajax(...)呼び出しから返される、あまり完璧とは言えない$.Deferredプロミスの実装を強化するために、優れたQライブラリを使用していることに注意してください。私が以前に参照したドミニクの投稿でも、$.Deferredに何が欠けているのかがより詳細に議論されています。

私はこのプロミス指向の実装がコールバック指向のコードよりも読みやすく、プリミティブなコールバックをプロミスオブジェクトで置き換えることで、その動作を拡張するためのオプションが大幅に増えることに気づきました。$.ajaxのようなものから返されたプロミスを取得し、それを基にして、値に対して操作を行うパイプラインを構築し、パイプラインを通過するにつれて値を変換することができます。

このプロミス指向の実装のテストは、それがより読みやすいコードにつながることも示すはずです

describe('fetchCurrentUser', function() {
  it('creates a parsed user', function(done) {
    var simulatedAjaxResponse = {
      firstName: "Tomas",
      lastName: "Jakobsen"
    };
    $.ajax = function(){ return Q(simulatedAjaxResponse); }

    var userPromise = fetchCurrentUser();

    userPromise.then(function(user) {
      expect(user.fullName).to.equal("Tomas Jakobsen");
      done();
    });
  });
});

このテストは、概念的には以前のコールバック指向のテストと同一です。$.ajaxを、事前に解決されたQプロミスでラップされたハードコードされたシミュレートされた応答を返すだけの偽の実装に置き換えます。次に、fetchCurrentUser関数を呼び出します。最後に、プロミスのパイプラインの反対側から出てくるものが適切な.fullNameプロパティを持っていることを確認します。

私は、このプロミス指向の形式の方が読みやすく、リファクタリングしやすいと主張します。しかし、それだけではありません!プロミスは非同期操作のカプセル化として機能するため、chai-as-promisedのような優れたライブラリでテストランナーを強化することもできます。これにより、テストコードを次のようにリファクタリングできます。

describe('fetchCurrentUser', function() {
  it('creates a parsed user', function(done) {
    var simulatedAjaxResponse = {
      firstName: "Tomas",
      lastName: "Jakobsen"
    };
    $.ajax = function(){ return Q(simulatedAjaxResponse); }

    var fetchResult = fetchCurrentUser();

    return expect( fetchResult ).to.eventually
      .have.property('fullName','Tomas Jakobsen')
      .notify(done);
  });
});

これをさらに進めることができます。mocha-as-promisedを追加することで、テストの制御フローがいつ完了したかをMochaに明示的に伝える必要がなくなります。

describe('fetchCurrentUser', function() {
  it('creates a parsed user', function() {
    var simulatedAjaxResponse = {
      firstName: "Tomas",
      lastName: "Jakobsen"
    };
    $.ajax = function(){ return Q(simulatedAjaxResponse); }

    var fetchResult = fetchCurrentUser();

    return expect( fetchResult ).to.eventually
      .have.property('fullName','Tomas Jakobse');
  });
});

ここでは、done関数を使ったトリックを削除しました。代わりに、テストの非同期制御フローを表すプロミスチェーンをMochaテストランナーに渡し、mocha-as-promised拡張機能は、プロミスチェーンが完了してから次のテストに進むようにするためのバックグラウンドの配管を行うことができます。これは非常に賢明な機能であり、Busterなどの他のテストランナーに組み込まれています。

これは、プロミスが非常に強力である理由の良い例です。非同期制御フローの概念を具体化することで、実際に制御フローをテストランナーに返し、そこでその制御フローを直接操作することができます。重要なのは、私たちのテストはフレームワークが何をしているかを実際には知る必要がないということです。彼らは単に制御フローを呼び出し元に返し、それを引き継がせます。

本質的に、プロミスを使用すると、操作を呼び出すことの関心事と、その操作の結果を処理することの関心事を分離できます。つまり、テストコードで一方をシミュレートし、本番コードがもう一方をどのように処理するかをテストできます。

本質的に非同期のコードのテスト

非同期をサポートするコードを本質的に同期的な方法でテストできることを説明しましたが、本当に本質的に非同期であるコードをテストしたい場合はどうすればよいでしょうか?

Mochaの非同期テストのサポートは、同期的な方法で非同期をサポートするコードをテストするために示してきたのと同じ手法を使用して、真に非同期のコードをテストすることが非常に可能であることを意味します。ただし、「通常の」JavaScriptコードが本質的に非同期であることは実際にはまれです。本質的に非同期とは、たとえばsetTimeoutを呼び出すなど、イベントループでのターンを明示的に放棄するコード、またはXMLHttpRequestなどのネイティブなノンブロッキングAPIを呼び出すコードを意味します。そのようなコードを直接書くことはどれくらいありますか?あまりないでしょう[2]。JQueryのようなライブラリの形でそれを行うコードと統合しますが、この投稿で説明したように、その統合コードは本質的に非同期ではないため、同期的な方法でその統合をテストできます。

作成および保守する真に非同期のテストの数を積極的に制限します

低レベルのライブラリを作成する場合を除いて、本質的に非同期のコードを単体テストする必要があるケースはほとんどないと思います。本質的に非同期であるライブラリと擦り合わせるコードをテストする必要があるかもしれませんが、そのようなコードを自分で作成することはあまりないため、そのようなコードのテストを作成する必要があることはあまりありません。実際、私の助言は、作成および保守する真に非同期のテストの数を積極的に制限することです。マーティン・ファウラーの非決定性テストに関する優れた記事では、これらのタイプのテストがテストスイート全体の健全性に悪影響を及ぼす傾向がある理由を徹底的に説明しています。

もしあなたが、本質的に非同期のコードの大部分を書いていることに気づいた場合は、一歩下がって、その厄介な非同期処理をすべて処理する、小さくて閉じ込め可能なコード領域を特定する必要があるでしょう。それは書くのもテストするのも難しいコードです。それを隔離し、別のタイプのテスト(つまり、統合テスト)で徹底的にテストします。ジェラルド・メスザロスのHumble Objectパターンのドキュメントでは、真に非同期のコードをクリーンな方法で分離するためのいくつかの方法を適切に説明しています。この種のトリッキーなコードのテストと包含に関するアドバイスのもう1つの素晴らしい情報源は、GOOSの本であり、さまざまなレベルでの非同期コードのテストについて詳しく説明しています。

結局のところ、非同期機能を必要とするほとんどのJavaScript「単体テスト」は、実際にはデータベース、DOM、Web APIなどを呼び出している高レベルの統合テストであると私は推測します。そのようなテストは適切で価値がありますが、それらは別のタイプのテストであり、より隔離された単体テストの大規模なスイートからほぼ確実に価値が得られることを理解することが重要です。しかし、それは別の日のための投稿です。


奥付:使用したツールとライブラリ

コード例では、Qプロミス実装を使用しました。テストには、MochaテストランナーChaiテストアサーションライブラリと組み合わせて使用しました。このテスト設定を、mocha-as-promisedおよびchai-as-promisedライブラリで強化しました。node.jsでテストを実行し、npmを使用して、上記で言及したツールとライブラリを宣言およびインストールしました。

すべてのテストコードは、DOMが存在する必要がないように分離され、JQueryも必要ありませんでした(常に$.ajaxをテストダブルに置き換えたため)。

脚注

1:常に非同期にする

非同期操作をインラインで解決できる場合でも、イベントループでの後続のターンに応答を延期することにより、一貫した呼び出しシーケンスを維持する必要があるという強力な主張があります(詳細については、次の脚注を参照してください)。コールバックの実行の延期は、通常、setImmediateなどを使用して実現されます。

2:Promises/A+仕様への準拠

QなどのPromises/A+に準拠するプロミスの実装は、必要とされるため、イベントループの単一のターン内で解決しないという事実をこっそりと誤魔化しています。

それが何を意味するのかわからない場合は、あまり気にしないでください。もし知っているなら、そのネズミの穴に入り込まなかったことを許してください。約束された指向のコードは、1ターンではなく2ターンで完了した場合でも、概念的には同期順序に平坦化されると私は主張します。

重要な改訂

2013年9月18日:第2版、プロミスのカバレッジを追加

2013年9月3日:初版リリース