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:
- Downloading a movie of
id
- Once we get the response we then check to see if the request was successful
- If the request was unsuccessful, we throw an error
- If the request was successful, we convert the response to JSON
- 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 call
s 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.