Transparent unit tests using ES generators

Matt Miller
9 min readOct 16, 2019

It’s not uncommon for JavaScript applications to have modules which depend on other modules. And when it comes time to test these modules, it can be difficult to write isolated unit tests, so we often compensate with integration tests and mocking.

Integration tests not only test the module; they must also assert that the dependent modules are used correctly. For example, you could test an API controller module which depends on a model module by querying a test database to see if the controller read or wrote the correct data. The logic in the controller is tested (e.g. validation business logic), and since the controller module uses methods on the model directly, the model methods are executed as well — which is why you either need to supply a database or mock the interface to the database. Integration tests can be as exhaustive as unit tests, but they incur more complexity because they require more setup and they might depend on external resources (such as a database).

Mocking adheres more to the isolated unit test philosophy, where the only code being tested is the module itself; however, implementations can be tricky, opaque, and fragile. An implicit approach is to use a test library’s stub functionality to replace a module’s methods with dummy methods at test runtime; more explicitly, a module might use dependency injection (i.e. exporting a function which receives its dependencies as arguments). The mock needs to emulate the original module’s interface thoroughly — if the model’s methods throw errors in certain conditions, then the mock must too. In some situations, the mock can grow to rival the original module in complexity and deserves tests of its own. And subtle changes to the original module that aren’t reflected in the mock can lead to confusing test results in other modules whose mocks have outdated behavior.

ECMAScript async generator functions can be another viable alternative to creating testable modules by inversion of control.

To start with, let’s look at a typical controller function. For simplicity, some tasks that might otherwise be handled by other middleware or in a wrapper are included here, so it is explicit what code is under test.

const User = require('../models/user')
const parseQuery = require('../utils/parse-query')
async function listUsers(req, res) {
try {
const query = parseQuery(req.query)
const users = await User.find(query)
res.status(200).json(users)
} catch (err) {
if (err.statusCode && err.statusCode < 500) {
res.status(err.statusCode).json({ message: err.message })
} else {
res.status(500).json({ message: 'Internal Server Error' })
logError(err)
}
}
}
router.get('/users', listUsers)

It does a few jobs:

  1. Parse and sanitize the incoming request query
  2. Perform the query
  3. Set status code to 200
  4. Send the results JSON
  5. If an error occurs and it has a statusCode < 500, set status code to statusCode and send { message } JSON
  6. If an error occurs and it does not have a statusCode or it is >= 500, set status code to 500 and send { message: 'Internal Server Error' } JSON; log the error

Technically all of these steps utilize other modules. But the actual logic in our function is:

  1. Call parseQuery with req.query
  2. Call User.find with the result of parseQuery
  3. Call res.status with 200
  4. Call res.json with the result of User.find
  5. If an error occurs and it has a statusCode < 500, call res.status with statusCode and call res.json with { message }
  6. If an error occurs and it does not have a statusCode or it is >= 500, call res.status with 500 and call res.json with { message: 'Internal Server Error' }; log the error

When writing tests for this function, we are likely to:

  • Test with only one or two query objects in req.query because parseQuery probably has its own tests
  • Either mock User.find or seed a test database
  • Mock the req and res objects
  • Mock logError to capture and assert errors

Because we’re testing from the outside in, testing with precision can be difficult (or at least requires forethought). For example, we should not only assert that User.find and res.json are called, but also what parameters they are called with, that they are each called only once, in the correct order, and that their results are consumed by the function the way it was intended. And ideally, we should even write tests where User.find throws an error that might happen at runtime, such as when the connection fails or a database query returns an unanticipated error.

I’m not going to go into how generator functions work, as other people can explain them better. But to reiterate the essentials, the core concept of a generator is that it can “pause” execution and pass a result to the caller, and the caller can instruct the generator to “resume” execution. The result of calling a generator function is an iterable, and the code within the function pauses when a yield statement is encountered, relinquishing control to the caller until the caller requests the next value from the generator.

You can come across dozens of comprehensive examples of generators and async generators, but finding practical applications for them is another story. How can we leverage them to solve our testing problems?

Well, instead of actually calling the other modules’ functions in our controller, what if we yielded our intention of doing so, and let the caller do the heavy lifting?

async function* listUsersGenerator(req, res) {
try {
const query = yield {
type: 'call',
func: parseQuery,
params: [req.query]
}
const users = await (yield {
type: 'call',
func: User.find,
params: [query]
})
return {
type: 'response',
res,
status: 200,
data: users
}
} catch (err) {
if (err.statusCode && err.statusCode < 500) {
return {
type: 'response',
res,
status: err.statusCode,
data: { message: err.message }
}
} else {
yield {
type: 'response',
res,
status: err.statusCode || 500,
data: { message: 'Internal Server Error' }
}

yield {
type: 'call',
func: logError,
params: [err]
}
}
}
}

So what’s happening here? Rather than actually calling our external functions, each time we need information from another module, we expect the caller to actually call the function for us, and pass us back the result. Let’s look into how we could call our controller. If you’re familiar with Redux or Flux, you’ll probably see a resemblance between the yielded objects and actions.

We won’t be able to iterate over our generator using a simple for..of or for await..of loop. This is because we’ll need to actually pass data to the next invocation of the next function, which we can’t do with a simple loop.

const createHandler = generatorFunc =>
async function handler(req, res) {
const controller = generatorFunc(req, res)
let result = await controller.next() while (!result.done) {
let nextArg
if (result.value) {
const { value } = result
switch (value.type) {
case 'call':
nextArg = value.func(...value.params)
break
case 'response':
value.res.status(value.status).json(value.data)
break
}
}
result = await controller.next(nextArg)
}
}
router.get('/users', createHandler(listUsersGenerator))

Let’s walk through what’s happening step by step.

  1. handler calls listUsersGenerator with the original req and res args
  2. handler calls await controller.next, signaling the generator to start executing its code. (Note that next itself returns a promise if the generator is an async function — not next().value.)
  3. listUsersGenerator executes until the first yield. It yields an object back to handler with instructions. In this first case, the instructions are to call the parseQuery function with req.query as its argument.
  4. handler receives this request as the result of next. The first case statement is matched. It then proceeds to actually call the passed function with the given arguments.
  5. In this case, handler then calls next on the iterator, signaling it to continue execution. It passes the result from the previous step to the generator (which becomes the return value of the yield statement).
  6. listUsersGenerator yields another function call, this time to fetch users matching the query. handler calls the function. handler could await the call to User.find, but instead we’ll pass the promise to the generator to await. This is a little more flexible, because (a) promise rejection can be handled in the generator and (b) the generator can use Promise.all to process things concurrently.
  7. As a refresher, return and yield will behave identically in our handler — the only difference is that the generator will be “done” next time next is invoked. listUsersGenerator yields another action, which indicates that the response should be sent.
  8. handler's second case statement is matched, and it does the work of actually sending the response. This is demonstrative, but we could’ve also accomplished the same thing by just yielding two function call actions instead, to call res.status and res.json respectively.
  9. When the iterator has no more results, the next invocation to next returns { done: true, value: undefined }. The handler’s while loop is therefore terminated and both handler and listUsersGenerator have finished executing.

Note: this code example doesn’t handle uncaught errors from the generator. We should wrap the value.func and controller.next calls in try/catch blocks.

Is this ugly code? That’s up for you to decide. There are many deviations we could’ve made in this design, like:

  • We don’t need to yield every external function call. parseQuery is probably a pure function and res can be mocked using Express mocking libraries. User.find is the only portion of the controller that requires either a custom mock that can be injected at runtime or a test database.
  • type: 'call' is a very generic action. This model might fit your use case perfectly, or it could be too abstract.
  • Other abstractions would also clean up the code of the generator. For example, const call = (func, params, thisArg = null) => ({ type: 'call', params, thisArg }), or class Call {} make instantiating our actions easier and less prone to typos. Using symbols over string literals (or checking instanceof if a class is used) can also prevent typos.
  • Instead of ignoring yielded results that don’t match our action signature, we could throw an error in handler.

Enough talk about what could be done differently — let’s see how trivial our generator is to test:

describe('listUsersGenerator', () => {
it('returns all users matching the query', async () => {
const req = { query: { active: 'true' } }
const res = {}
const controller = generatorFunc(req, res) let result = await controller.next() assert(result.value.type === 'call')
assert(result.value.func === parseQuery)
assert(result.value.args.length === 1)
assert(result.value.args[0] === req)
const query = { active: true } result = await controller.next(query) assert(result.value.type === 'call')
assert(result.value.func === User.find)
assert(result.value.args.length === 1)
assert(result.value.args[0] === query)
const users = [] result = await controller.next(Promise.resolve(users)) assert(result.value.type === 'response')
assert(result.value.res === res)
assert(result.value.status === 200)
assert(result.value.data === users)
result = await controller.next() assert(result.done === true)
})
})

Here’s where we can see the real power of testing generator functions. Each time the function under test needs to request outside information, by yielding that request, we can analyze the parameters of that request and, without any special mocking code, completely control the value passed back to the function. We can assert that the controller is calling User.find with query and simulate the result without ever calling User.find, mocked or not.

Notice how we didn’t need to mock res, parseQuery, or User.find. If we’d yielded requests for information from the req object as well, we wouldn’t even have had to mock req.query. However, given how little is read from the req object in the function (and no methods are called on it), creating a fake req seemed easier. In other words, you don’t need to yield actions every time, but it does make sense to yield actions that require mocking — just like when writing any other unit tests, you don’t usually mock every dependency.

In addition, rather than performing deep equality checks, we can often assert references directly (unless they are cloned in the generator), since we can pass references directly into next, instead of those values coming from mock implementations.

Ultimately, this solution is a matter of preference. In a sense, we have polluted our production code to make it testable. But on the flip side, we have made our unit tests much more transparent, without the opaque behavior of mocks. We are testing exactly the logic of the function under test, meaning our integration tests can be pretty minimal — one happy path and one sad path, maybe.

We also might find use cases outside of testing. By inverting control to the caller, we can also modify the call. For example, a handler for HTTP requests might accept calls to User.find but implicitly add more parameters to the query (orgId: 'foo'), while a second handler used internally in other areas of the application would not mutate the query. While arguably there are other ways to accomplish this, they generally involve adding more parameters (and therefore complexity) to the controller. For example:

if (isApiRequest) {
query = { ...query, orgId: req.user.orgId }
}
const users = await User.find(query)

versus a generator approach:

const users = await (yield {
type: 'query',
model: User,
query
})

where the caller can add filtering based on context:

if (result.value.type === 'query') {
nextArg = result.value.model.find({
...query,
orgId: req.user.orgId
})
}

Again, this is a pattern to use at your discretion. You could say that by mutating the query, we’re obscuring the logic of the controller; on the other hand, we’ve delegated that controller logic to another handler, so it can be used on many controller methods without all its paths being retested.

If you find this pattern to be useful in your application, explore open source generator libraries that abstract this kind of logic, like co and redux-saga. But also be reminded that rolling your own functions like these isn’t particularly complicated, and in doing so, your code becomes more digestible and readable, without having to consult outside documentation or conforming to the specific design patterns and opinions of another library.

--

--