BabelとsnarejsでテストできないJSをテストする

画像






最新のJSアプリケーションの開発プロセスでは、テストに特別な場所が与えられます。 今日のテストカバレッジは、JSコードのほぼ主要な品質指標です。



最近、テストの問題を解決する多数のフレームワークが登場しました:jest、mocha、sinon、chai、jasmine、リストは長い間続くことができますが、テストを書くためのツールのそのような自由の選択があっても、テストするのが難しい場合があります。



一般にテストできないものをテストする方法については、後で説明します。



問題



XHRリクエストを行うブログ投稿を操作するための簡単なモジュールを見てください。



export function createPost (text) { return api('/rest/blog/').post(text); } export function addTagToPost (postId, tag) { return api(`/rest/blog/${postId}/`).post(tag); } export function createPostWithTags (text, tags = []) { createPost(text).then( ({ postId }) => Promise.all(tags.map( tag => addTagToPost(postId, tag) )) }) }
      
      





api関数はxhrリクエストを生成します。

createPost-ブログ投稿を作成します。

addTagToPost-既存のブログ投稿にタグを付けます。

createPostWithTags-ブログ投稿を作成し、すぐにタグ付けします。



createPostおよびaddTagToPost関数のテストは、XHRリクエストをインターセプトし、渡されたURIとペイロード(たとえば、 sinonパッケージのuseFakeXMLHttpRequest()を使用して実行できます)をチェックし、関数がxhrスタブから返された値を含むPromiseを返すことを確認することになります。



 const fakeXHR = sinon.useFakeXMLHttpRequest(); const reqs = []; fakeXHR.onCreate = function (req) { reqs.push(req); }; describe('createPost()', () => { it('URI', () => { createPost('TEST TEXT') assert(reqs[0].url === '/rest/blog/'); }); it('blogpost text', () => { createPost('TEST TEXT') assert(reqs[1].data === 'TEST TEXT'); }); it('should return promise with postId', () => { const p = createPost('TEST TEXT'); assert(p instanceof Promise); reqs[3].respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ postId: 333 }) ); return p.then( ({ postId }) => { assert(postId === 333); }) }); })
      
      





addTagToPostのテストコードは似ているため、ここでは説明しません。 しかし、createPostWithTagsのテストはどのように見えるのでしょうか?



createPostWithTags()はcreatePost()およびaddTagToPost()を使用し、これらの関数の結果に依存するため、createPostWithTags()のテストでcreatePost()およびaddTagToPost()のテストからのコードを複製する必要があります。 ()



 it('should create post', () => { createPostWithTags('TEXT', ['tag1', 'tag2']) //   createPost(text) assert(reqs[0].requestBody === 'TEXT'); reqs[0].respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ postId: 333 }) ); });
      
      





何かが間違っていると感じますか?



createPostWithTags関数をテストするには、引数 'TEXT'を指定してcreatePost()関数を呼び出したことを確認する必要があります。 これを行うには、createPost()自体からテストを複製する必要があります。



 assert(reqs[0].requestBody === 'TEXT');
      
      





関数の実行を続行するには、createPostによって送信された要求にも応答する必要があります。これは、テストコードからのコピーペーストでもあります。



 reqs[0].respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ postId: 333 }) );
      
      





createPostWithTags自体のロジックのチェックに集中する必要がある一方で、createPost関数の機能をチェックするテストからコードをコピーする必要がありました。 また、誰かがcreatePost()関数を中断すると、それを使用する他のすべての関数も中断し、デバッグに時間がかかる場合があります。



createPost()関数の動作を保証することに加えて、ループで呼び出されるaddTagToPostからXHRリクエストをキャッチし、addTagToPostがreqs [i] .respond()を使用して渡したtagIdでpromiseを返すことを確認する必要があります。



 it('should create post', () => { createPostWithTags('TEXT', ['tag1', 'tag2']) assert(reqs[0].requestBody === 'TEXT'); // Response for createPost() reqs[0].respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ postId: 333 }) ); // Response for first call of addTagToPost() reqs[1].respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ tagId: 1 }) ); // Response for second call of addTagToPost() reqs[2].respond(200, { 'Content-Type': 'application/json' }, JSON.stringify({ tagId: 2 }) ); });
      
      





inb4:APIモジュールをロックできます。 この例は問題を理解するために特別に単純化されており、私のコードはそれよりもはるかに複雑です。 ただし、apiモジュールをロックしても、渡された引数を内部でチェックすることはできません。



私のコードにはAPIへの非同期リクエストがたくさんありますが、それぞれ個別にテストで覆われていますが、これらのリクエストを呼び出す複雑なロジックを持つ関数があります-そして、それらのテストはスパゲッティコードとコールバック地獄の間の何かに変わります。



関数がより複雑であるか、またはフラックス/リデュースアーキテクチャで行うのが通常のように)1つのファイルで厄介な場合、テストは非常に大きくなり、その作業の複雑さはコードの複雑さよりもはるかに高くなります。



タスクステートメント



createPostWithTagsテスト内でcreatePostおよびaddTagToPostの動作をテストしないでください。



createPostWithTags()のような関数をテストするタスクは、内部の関数呼び出しを置き換え、引数をチェックし、特定のテストに必要な値を返す元の関数の代わりにスタブを呼び出すことになります。 これは、モンキーパッチングと呼ばれます。



問題は、JSがモジュール/関数のスコープ内を調べ、createPostWithTags内でaddTagToPostおよびcreatePost呼び出しを再定義することを許可しないことです。



createPostとaddTagToPostがサードパーティのモジュールにある場合、 jestのようなものを使用して呼び出しをインターセプトできますが、この場合、インターセプトしたい呼び出しの関数がスコープの奥深くに隠れるため、これは解決策ではありません機能をテストし、エクスポートされません。



解決策



多くの皆さんのように、私たちのプロジェクトではバベルも積極的に使用しています。



Babelは任意のJSを解析でき、JSを任意のものに変換できるAPIを提供するため、このようなテストを記述するプロセスを容易にし、置き換えたい関数の分離にもかかわらず、単純なモンキーパッチを実行できるプラグインを作成するというアイデアがありました。



このようなプラグインの作業は簡単で、3つのステップに分解できます。



  1. テストコードで、小さなフレームワークへのアピールを見つけてください。
  2. 何かを傍受するモジュールと関数を見つけます。
  3. 対応する呼び出しの代わりにスタブを置き換えることにより、テストおよびテスト対象モジュールのコードを変更します。


その結果、プロジェクトに接続できるsnare(trap)jsと呼ばれるBabel用のプラグインが作成され、これらの3つのポイントが自動的に実行されます。



Snare.js



まず、スネアをインストールしてプロジェクトに接続する必要があります。



 npm install snarejs
      
      





そして、それを.babelrcに追加します



 { "presets": ["es2015", "react"], "plugins": [ "snarejs/lib/plugin" ] }
      
      





snarejsの仕組みを説明するために、すぐにcreatePostWithTags()のテストを作成しましょう。



 import snarejs from 'snarejs'; import {spy} from 'sinon'; import createPostWithTags from '../actions'; describe('createPostWithTags()', function () { const TXT = 'TXT'; const POST_ID = 346; const TAGS = ['tag1', 'tag2', 'tag3']; const snare = snarejs(createPostWithTags); const createPost = spy(() => Promise.resolve({ postId: POST_ID })); const addTagToPost = spy((addTagToPost, postId, tag) => Promise.resolve({ tag, id: TAGS.indexOf(tag) }) ); snare.catchOnce('createPost()', createPost); snare.catchAll('addTagToPost()', addTagToPost); const result = snare(TXT); it('should call createPost with text', () => { assert(createPost.calledWith(TXT)); }); it('should call addTagToPost with postId and tag name', () => { TAGS.forEach( (tagName, i) => { // First argument is post id assert(addTagToPost.args[i][1] == POST_ID); // Second argument assert(addTagToPost.args[i][2] == tagName); }); }); it('result should be promise with tags', () => { TAGS.forEach( (tagName, i) => { assert(result[i].tag == tagName); assert(result[i].id == TAGS.indexOf(tagName)); }); }) })
      
      





 const snare = snarejs(createPostWithTags);
      
      





これは、初期化が行われた場所で、つまずき、BabelプラグインはcreatePostWithTagsメソッドが置かれている場所を見つけます(この例では「../actions」モジュールです)、対応する呼び出しをインターセプトします。



snare変数には、snarejsメソッドを含むプロトタイプを持つcreatePostWithTags関数オブジェクトが含まれます。



 const createPost = spy(() => Promise.resolve({ postId: POST_ID }));
      
      





sinonは、promiseを返すcreatePostのスタブです。 sinonの代わりに、sinonが提供するものが必要ない場合は、通常の関数を使用できます。



 const addTagToPost = spy((addTagToPost, postId, tag) =>
      
      





スネアは必要な場合に元の関数を渡すため、スタブの最初の引数に注意してください。 引数postIdおよびtagは次のとおりです。これらは、インターセプトする関数呼び出しの元の引数です。



 snare.catchOnce('createPost()', createPost);
      
      





ここでは、createPost()呼び出しを1回インターセプトし、スタブを呼び出す必要があることを示します。



 snare.catchAll('addTagToPost()', addTagToPost);
      
      





ここでは、addTagToPostへのすべての呼び出しをインターセプトする必要があることを示します



 const result = snare(TXT, TAGS);
      
      





createPostWithTags関数を呼び出し、結果を検証のために結果に書き込みます。



 it('should call createPost with text', () => { assert(createPost.args[0][1] == TXT); });
      
      





ここで、スタブを呼び出すための2番目の引数が「TXT」であることを確認します。 最初の引数は元の関数です、忘れていませんか? :)



 it('should call addTagToPost with postId and tag name', () => { TAGS.forEach( (tagName, i) => { assert(addTagToPost.args[i][1] == POST_ID); assert(addTagToPost.args[i][2] == tagName); }); });
      
      





タグもすべて簡単です。渡されたタグのセットを知っているので、各タグがPOST_IDとともにaddTagToPost()呼び出しに渡されたことを確認する必要があります。



 it('result should be promise with tags', () => { assert(result instanceof Promise); });
      
      





結果タイプの最終確認。



上で述べたように、スネアはテストをビルドするときに必要な呼び出しを見つけて、それを自分の呼び出しに置き換えます。



たとえば、addTagToPost(postId、tags)の呼び出しは次のようになります。



 __g__.__SNARE__.handleCall({ fn: createPost, context: null, path: '/path/to/module/module.js/addTagToPost()' }, postId, tags)
      
      





ご覧のとおり、魔法はありません。



API



APIは非常にシンプルで、4つのメソッドで構成されています。



 var snareFn = snare(fn);
      
      





引数として、プラグインが他の呼び出しを探す関数内に参照が渡されます。



Babelプラグインは、スネアの初期化を満たすと、渡された引数を解決します。 リンクには、ES6インポートまたはcommonJSのいずれかから取得した任意の識別子を使用できます。



 let fn = require('./module'); let {fn} = require('./module'); let {anotherName: fn} = require('./module'); let fn = require('./module').anotherName; import fn from './module'; import {fn} from './module'; import {anotherName as fn} from './module';
      
      





すべての場合において、プラグインは特定のモジュールで目的のエクスポートを見つけ、その中の必要な呼び出しを置き換えます。 エクスポート自体は、common.jsまたはES6のスタイルにすることもできます。



 snareFn.catchOnce('fnName()', function(fnName, …args){}); snareFn.catchAll('fnName()', function(fnName, …args){});
      
      





最初の引数はCallExpressionを含む文字列で、2番目はインターセプター関数です。 catchOnceは対応する呼び出しを1回インターセプトし、catchAllはすべての呼び出しを適宜インターセプトします。



 snareFn.reset('fnName()');
      
      





対応する関数の呼び出しのインターセプトをキャンセルします。



いくつかの微妙な点:



.catchOnce()を使用し、コード内の呼び出しがインターセプトされた場合、catchOnce()/ catchAll()を再度呼び出すまで、後続の呼び出しは元の関数で機能します。



オブジェクトのメソッドへの呼び出しをインターセプトする必要がある場合、この関数ではインターセプターはオブジェクト自体になります。



 snare.catchOnce('obj.api.helpers.myLazyMethod()', function(myLazyMethod, …args){ // this === obj.api.helpers // myLazyMethod -   // args -    })
      
      





.catchOnce()は複数の場合があります。



 snare.catchOnce('fnName()', function(fnName, …args){ console.log('first call of fnName()'); }); snare.catchOnce('fnName()', function(fnName, …args){ console.log('second call of fnName()'); }); snare.catchOnce('fnName()', function(fnName, …args){ console.log('third call of fnName()'); });
      
      





結論の代わりに



これまでのところ、スネアは関数でのみ機能しますが、クラスをサポートする予定です。

現代のJSは非常に多様であり、内部のプラグインはastツリーで動作します-したがって、私が考慮しなかった場合にはバグがある可能性があります(誰もが異なって書いています:)ので、何かに踏み込んだ場合、 githubで問題を作成したり、私に書いたりしないでくださいcom)。



このツールがあなただけでなく私にとっても有用であり、テストが使いやすくなることを願っています。



All Articles