Simplify testing async I/O in javascript

· erock's devlog

How to write declarative side-effects

Testing asynchronous I/O sucks. Interacting with the external world, whether it's a database, a remote HTTP server, or the filesystem, it requires mocking what we expect will happen. Sometimes these mocks are rather difficult to construct because some functionality was never intended to be mocked. We have to consider the idea that mocks are code as well and every testing suite has a different way to construct them. It also involves understanding how that IO behaves in order to understand all of its responses.

When we write tests, we naturally gravitate towards testing the easy sets of code first. Coincidentally these are the areas that have relatively low impact. The urge to test pure functions, like ones that accept a string and return another string without side-effects is strong because they are easy to test. The goal of this article is to demonstrate that some of the most difficult things to test (async IO) can become just as easy to test as pure functions.


How would we test the function fetchAndSaveMovie?

 1// version 1
 2const fs = require("fs");
 3const fetch = require("fetch");
 4
 5function writeFile(fname, data) {
 6  return new Promise((resolve, reject) => {
 7    fs.writeFile(fname, data, (err) => {
 8      if (err) {
 9        reject(err);
10      }
11
12      resolve(`saved ${fname}`);
13    });
14  });
15}
16
17function fetchAndSaveMovie(id) {
18  return fetch(`http://localhost/movies/${id}`)
19    .then((resp) => {
20      if (resp.status !== 200) {
21        throw new Error("request unsuccessful");
22      }
23
24      return resp;
25    })
26    .then((resp) => resp.json())
27    .then((movie) => {
28      const data = JSON.stringify(movie, null, 2);
29      const fname = "movie.json";
30      return writeFile(fname, data);
31    });
32}
33
34fetchAndSaveMovie("1").then(console.log).catch(console.error);

Reading the code we are doing the following:

  1. Downloading a movie of id
  2. Once we get the response we then check to see if the request was successful
  3. If the request was unsuccessful, we throw an error
  4. If the request was successful, we convert the response to JSON
  5. Then we save the json to a file and return a success or failure

It seems simple but there are a couple points of failure that need to be accounted for as well as doing the necessary operations to save the data to a file.

For me the best way to test this would be to use a library like nock which will intercept HTTP requests and return a desired response.

 1// version 1 test
 2const nock = require("nock");
 3
 4test("when request is 500", () => {
 5  nock(/localhost/)
 6    .get("/movies/1")
 7    .reply(500, {
 8      error: "something happened",
 9    });
10  const fn = require("./index");
11
12  return expect(fn("1")).rejects.toThrow("request unsuccessful");
13});
14
15describe("when the request is 200", () => {
16  beforeEach(() => jest.resetModules());
17
18  test("when saving the file fails", () => {
19    nock(/localhost/)
20      .get("/movies/1")
21      .reply(200, {
22        name: "a movie",
23      });
24
25    jest.mock("fs", () => {
26      return {
27        writeFile: (f, d, cb) => {
28          cb("some error");
29        },
30      };
31    });
32    const fn = require("./index");
33
34    return expect(fn("1")).rejects.toBe("some error");
35  });
36
37  test("when saving the file succeeds", () => {
38    nock(/localhost/)
39      .get("/movies/1")
40      .reply(200, {
41        name: "a movie",
42      });
43
44    jest.mock("fs", () => {
45      return {
46        writeFile: (f, d, cb) => {
47          cb();
48        },
49      };
50    });
51    const fn = require("./index");
52
53    return expect(fn("1")).resolves.toBe("saved movie.json");
54  });
55});

In these tests we had to figure out how to intercept all the HTTP requests. Then we needed to figure out how to mock the fs module. This turned out to be tricky, because writeFile uses a callback which was hard to automatically mock using jest.

In a perfect world, our function wouldn't have side-effects at all. What if we could test this function synchronously without having to intercept HTTP requests and mock fs? The key to solving this puzzle is to create a function that returns JSON which will describe how to initiate the side-effects instead of the function itself initiating the side-effects.

This technique is very popular, a relavent example is react. Testing react components is easy because the components are functions that accept state and then return data as HTML.

1const view = f(state);

The functions themselves do not mutate the DOM, they tell the react runtime how to mutate the DOM. This is a critical distinction and pivotal for understanding how this works. Effectively the end-developer only concerns itself with the shape of the data being returned from their react components and the react runtime does the rest.

cofx employs the same concept but for async IO operations. This library will allow the end-developer to write declarative functions that only return JSON objects. These JSON objects instruct the cofx runtime how to activate the side-effects.

Instead of fetchMovie calling fetch and fs.writeFile it simply describes how to call those functions and cofx handles the rest.


cofx #

cofx is a way to declaratively write asynchronous IO code in a synchronous way. It leverages the flow control of generators and makes testing even the most complex async IO relatively straight forward. cofx works both in node and in the browser.

 1// version 2
 2const fetch = require("node-fetch");
 3const { task, call } = require("cofx");
 4
 5function* fetchAndSaveMovie(id) {
 6  const resp = yield call(fetch, `http://localhost/movies/${id}`);
 7  if (resp.status !== 200) {
 8    throw new Error("request unsuccessful");
 9  }
10
11  // resp.json() needs proper context `this`
12  // from fetch to work which requires special execution
13  const movie = yield call([resp, "json"]);
14  const data = JSON.stringify(movie, null, 2);
15  const fname = "movie.json";
16
17  let msg = "";
18  try {
19    yield call(writeFile, fname, data);
20    msg = `saved ${fname}`;
21  } catch (err) {
22    msg = err;
23  }
24
25  return msg;
26}
27
28task(fetchAndSaveMovie, "1").then(console.log).catch(console.error);

The first thing to note is how flat the function has become. The original function has a max of 4 levels of indentation. The generator-based function has a max of 2 levels of indentation. Code has a visual design that is important for readability.

flat is better than nested (zen of python)

I'm not going to go into the specifics of how generators work, but the gist is that the code looks synchronous but it behaves asynchrounously.

The key thing to note here is that the only thing we are actually calling inside this function is call. It is a function that returns JSON which is an instruction that cofx can read and understand how to execute. If we aggregated all the yield results in this function it would be a sequence of JSON objects. This is the magic of cofx. Instead of activating side-effects inside this function, we let cofx do that and only describe how the side-effects ought to be executed.

Here is what call returns:

1{
2  "type": "CALL",
3  "fn": [function],
4  "args": ["list", "of", "arguments"]
5}

Testing this function is just a matter of stepping through each yield statement synchronously. Later on I will demonstrate how to simplify this even more with gen-tester. Here is a simple, but still rather vebose way of testing this function:

 1// version 2 test
 2test("when request is 500", () => {
 3  const gen = fetchAndSaveMovie2("1");
 4  gen.next(); // fetch
 5  const t = () => gen.next({ status: 500 });
 6  expect(t).toThrow("request unsuccessful");
 7});
 8
 9describe("when the request is 200", () => {
10  test("when saving the file fails", () => {
11    const gen = fetchAndSaveMovie2("1");
12    gen.next(); // fetch
13    gen.next({ status: 200 }); // json
14    gen.next({ name: "Lord of the Rings" }); // writeFile
15    const val = gen.throw("some error"); // return value
16    expect(val).toEqual({
17      done: true,
18      value: "some error",
19    });
20  });
21
22  test("when saving the file succeeds", () => {
23    const gen = fetchAndSaveMovie2("1");
24    gen.next(); // fetch
25    gen.next({ status: 200 }); // json
26    gen.next({ name: "Lord of the Rings" }); // writeFile
27    const val = gen.next(); // return value
28    expect(val).toEqual({
29      done: true,
30      value: "saved movie.json",
31    });
32  });
33});

As you can see there are no promises to handle, there are no HTTP interceptors to write, and most importantly we don't have to mock fs. We have completely removed all the headache of testing async IO and are able to test our code synchronously.

So it's nice that we can test the function without all of the scaffolding in our first example, but what if we want to test that when we pass in 1 to our function it properly constructs the http request?

Because our function yields JSON objects we can check to see if they match what we are expecting.

 1test("when request is 500 - verbose", () => {
 2  const gen = fetchAndSaveMovie2("1");
 3  expect(gen.next()).toEqual({
 4    done: false,
 5    value: call(fetch, "http://localhost/movies/1"),
 6  }); // fetch
 7  const t = () => gen.next({ status: 500 });
 8  expect(t).toThrow("request unsuccessful");
 9});
10
11describe("when the request is 200 - verbose", () => {
12  test("when saving the file fails", () => {
13    const gen = fetchAndSaveMovie2("2");
14    expect(gen.next()).toEqual({
15      done: false,
16      value: call(fetch, "http://localhost/movies/2"),
17    });
18    const resp = { status: 200 };
19    expect(gen.next(resp)).toEqual({
20      done: false,
21      value: call([resp, "json"]),
22    });
23    const data = { name: "Lord of the Rings" };
24    expect(gen.next(data)).toEqual({
25      done: false,
26      value: call(writeFile, "movie.json", JSON.stringify(data, null, 2)),
27    });
28    const val = gen.throw("some error"); // return value
29    expect(val).toEqual({
30      done: true,
31      value: "some error",
32    });
33  });
34
35  test("when saving the file succeeds", () => {
36    const gen = fetchAndSaveMovie2("3");
37    expect(gen.next()).toEqual({
38      done: false,
39      value: call(fetch, "http://localhost/movies/3"),
40    });
41    const resp = { status: 200 };
42    expect(gen.next(resp)).toEqual({
43      done: false,
44      value: call([resp, "json"]),
45    });
46    const data = { name: "Lord of the Rings" };
47    expect(gen.next(data)).toEqual({
48      done: false,
49      value: call(writeFile, "movie.json", JSON.stringify(data, null, 2)),
50    });
51    const val = gen.next();
52    expect(val).toEqual({
53      done: true,
54      value: "saved movie.json",
55    });
56  });
57});

After each step we are able to confirm that we are getting the correct response from each yield.

Matching yields with expected values is a little confusing. You have to know when to mock the return value from a yield at the right gen.next which is a tedious endeavor and error prone. Instead we can leverage a library like gen-tester to line up the yields and their response values properly. This library adds a nicer API to deal with testing generators.


gen-tester #

gen-tester is a small API for testing generators.

 1const { call } = require("cofx");
 2const {
 3  genTester,
 4  yields,
 5  throws,
 6  finishes,
 7  stepsToBeEqual,
 8} = require("gen-tester");
 9const fetch = require("node-fetch");
10
11expect.extend({
12  stepsToBeEqual,
13});
14
15test("when request is 500 - verbose", () => {
16  const tester = genTester(fetchAndSaveMovie, "1");
17  const actual = tester(
18    yields(call(fetch, "http://localhost/movies/1"), {
19      status: 500,
20    }),
21    throws((err) => err.message === "request unsuccessful"),
22    finishes(),
23  );
24
25  expect(actual).stepsToBeEqual();
26});
27
28describe("when the request is 200 - gen-tester", () => {
29  test("when saving the file fails", () => {
30    const tester = genTester(fetchAndSaveMovie, "1");
31    const resp = { status: 200 };
32    const data = { name: "Lord of the Rings" };
33    const actual = tester(
34      yields(call(fetch, "http://localhost/movies/1"), resp),
35      yields(call([resp, "json"]), data),
36      yields(
37        call(writeFile, "movie.json", JSON.stringify(data, null, 2)),
38        throws("some error"),
39      ),
40      finishes("some error"),
41    );
42
43    expect(actual).stepsToBeEqual();
44  });
45
46  test("when saving the file succeeds", () => {
47    const tester = genTester(fetchAndSaveMovie, "1");
48    const resp = { status: 200 };
49    const data = { name: "Lord of the Rings" };
50    const actual = tester(
51      yields(call(fetch, "http://localhost/movies/1"), resp),
52      yields(call([resp, "json"]), data),
53      call(writeFile, "movie.json", JSON.stringify(data, null, 2)),
54      finishes("saved movie.json"),
55    );
56
57    expect(actual).stepsToBeEqual();
58  });
59});

Don't care about checking all the calls for a test?

 1const { genTester, skip, finishes, throws } = require("gen-tester");
 2
 3test("when request is 500 - verbose", () => {
 4  const tester = genTester(fetchAndSaveMovie, "1");
 5  const actual = tester(
 6    skip({ status: 500 }),
 7    throws((err) => err.message === "request unsuccessful"),
 8    finishes(),
 9  );
10
11  expect(actual).stepsToBeEqual();
12});

So what have we accomplished? Using two relatively small libraries, we were able to describe side-effects as data which vastly improves both readability and testability.

references #

cofx ecosystem #


I have no idea what I'm doing. Subscribe to my rss feed to read more posts.