📜 ⬆️ ⬇️

Testing untestable JS with Babel and snarejs

image

In the process of developing modern JS applications, a special place is given to testing. Test Coverage today is almost the main quality metric of JS code.

Recently, a huge number of frameworks that solve testing problems have appeared: jest, mocha, sinon, chai, jasmine, the list can be continued for a long time, but even with such freedom to choose tools for writing tests, there are cases that are difficult to test.

How to test what may be untestable in general will be discussed further.

Problem


Take a look at a simple module for working with blog posts that makes XHR requests.
')
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) )) }) } 

The api function spawns the xhr query.
createPost - creates a blog post.
addTagToPost - tags an existing blogpost.
createPostWithTags - creates a blog post and tags it immediately.

Tests for the createPost and addTagToPost functions are reduced to intercepting an XHR request, checking the transmitted URI and payload (which can be done using, for example, useFakeXMLHttpRequest () from the sinon package) and checking that the function returns a promise with the value we returned from the xhr stub .

 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); }) }); }) 

The test code for addTagToPost is similar, so I don’t give it here. But what should a test for createPostWithTags look like?

Because createPostWithTags () uses createPost () and addTagToPost () and depends on the result of executing these functions, we need to duplicate the code for createPost () and addTagToPost () in the test for createPostWithTags () to ensure that the createPostWithTags function works ()

 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 }) ); }); 

Do you feel that something is wrong?

To test the createPostWithTags function, we need to check that it called the createPost () function with the argument 'TEXT'. To do this, we have to duplicate the test from createPost () itself:

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

In order for our function to continue execution, we also need to respond to the request sent by createPost, which is also copy paste from the test code.

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

We had to copy the code from the tests that check the performance of the createPost function while we need to focus on checking the logic of createPostWithTags itself. Also, if someone breaks the createPost () function, all other functions that use it will also break down and this may take more time to debug.

I remind you that in addition to ensuring the work of the createPost () function, we will have to catch XHR requests from addTagToPost which is called in a loop and make sure that addTagToPost returns the promise with exactly the tagId that we passed using reqs [i] .respond ():

 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: You can lock the api module. The example is specially simplified to understand the problem and my code is very confusing. But even if you lock the api module - this does not save us from checking the arguments passed inside.

In my code there are a lot of asynchronous requests to the API, individually they are all covered by tests, but there are functions with complex logic that call these requests - and tests for them turn into something between a spaghetti code and a callback hell.

If the functions are more complicated, or corny are in one file (as is customary to do in flux / redux architectures), your tests will swell so much that the complexity of their work will be much higher than the complexity of your code, which is what happened to me.

Task statement


We should not check the work of createPost and addTagToPost inside the createPostWithTags test.

The task of testing functions like createPostWithTags () is to substitute the function calls inside, check the arguments and call the stub instead of the original functions that will return the desired value in a particular test. This is called monkey patching.

The problem is that JS does not allow us to look inside the scope of the module / function and redefine the calls to addTagToPost and createPost inside createPostWithTags.

If createPost and addTagToPost were in a third-party module, we could use something like jest to intercept calls to them, but in our case this is not a solution to the problem, since functions whose calls we would like to intercept can be hidden deep inside the scope tested function and not exported outside.

Decision


Like many of you, we are also actively using Babel on our project.

Since Babel knows how to parse any JS and gives an API with which you can transform JS into anything, I had an idea to write a plugin that would facilitate the process of writing such tests and give the opportunity to do simple monkey patching despite the isolation of functions that we would like to substitute.

The work of such a plugin is simple and can be decomposed into three steps:

  1. Find a call to our little framework in the test code.
  2. Find the module and function in which we want to intercept something.
  3. Change the code of the tests and the module under test by substituting stubs for the corresponding calls.

The result is a plug-in for Babel called snare (trap) js which can be connected to the project and it will do these three points for you.

Snare.js


First you need to install and connect snare to your project.

 npm install snarejs 

And add it to your .babelrc

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

To explain how snarejs works, let's write a test for our createPostWithTags () right away:

 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); 

Here is the initialization, stumbling on it Babel plugin finds out where the createPostWithTags method (in our example, the module "../actions") is located, and in it it will intercept the corresponding calls.

The variable snare is an object of the function createPostWithTags with a prototype containing methods snarejs.

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

sinon stub for createPost returning promise. Instead of sinon, you can use the usual functions if you do not need anything from what sinon gives.

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

Pay attention to the first argument of the stub, in it snarejs passes the original function in case it is suddenly needed. Next are the postId and tag arguments — these are the original arguments to the function we are hooking.

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

Here we indicate that we need to intercept the createPost () call once and call our stub.

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

Here we indicate that we need to intercept all calls to addTagToPost

 const result = snare(TXT, TAGS); 

We call our createPostWithTags function and write the result to result for verification.

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

Here we check that the second argument of the call to our stub is “TXT”. The first argument is the original function, didn't you forget? :)

 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); }); }); 

With tags, too, everything is simple: since we know the set of passed tags, we need to check that each tag has been passed to the addTagToPost () call along with the POST_ID.

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

Last check on the type of result.

As I said above, snare just finds the calls you need when building your tests and replaces it with your own.

For example, the addTagToPost (postId, tags) call will turn into something like:

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

As you can see - no magic.

API


The API is very simple and consists of 4 methods.

 var snareFn = snare(fn); 

As an argument, a link to the function is passed inside which the plugin will look for other calls.

Babel plugin, meeting snarejs initialization, resolves the argument passed. A link can be any identifier obtained from either ES6 import or commonJS require:

 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'; 

In all cases, the plugin will find the necessary export in a specific module and replace the necessary calls in it. The export itself can also be either in the common.js or ES6 style.

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

The first argument is the string with CallExpression, the second is the intercept function. catchOnce intercepts the corresponding call once, catchAll appropriately intercepts all calls.

 snareFn.reset('fnName()'); 

Cancels interception of a call to the corresponding function.

A couple of subtleties:

In the event you used .catchOnce () and the call in the code was intercepted, subsequent calls will work with the original function until you call catchOnce () / catchAll () again.

If you need to intercept the object's method call, then in this function of the interceptor will be the object itself:

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

.catchOnce () may be several:

 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()'); }); 

Instead of conclusion


So far, snare can only work with functions, but plans to make support for classes.
Modern JS is very diverse and the plug-in works inside the ast tree - hence there may be bugs in cases that I did not take into account (they all write differently :), so if you step on something, do not be lazy to create an issue in github or email me (ip AT nginx. com).

I hope this tool will be useful to you as well as me and your tests will make soft ^ W easier.

Source: https://habr.com/ru/post/320892/


All Articles