Transparent unit tests using ES generators
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:
- Parse and sanitize the incoming request query
- Perform the query
- Set status code to 200
- Send the results JSON
- If an error occurs and it has a
statusCode < 500
, set status code tostatusCode
and send{ message }
JSON - 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:
- Call
parseQuery
withreq.query
- Call
User.find
with the result ofparseQuery
- Call
res.status
with 200 - Call
res.json
with the result ofUser.find
- If an error occurs and it has a
statusCode < 500
, callres.status
withstatusCode
and callres.json
with{ message }
- If an error occurs and it does not have a
statusCode
or it is>= 500
, callres.status
with500
and callres.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
becauseparseQuery
probably has its own tests - Either mock
User.find
or seed a test database - Mock the
req
andres
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.
handler
callslistUsersGenerator
with the originalreq
andres
argshandler
callsawait controller.next
, signaling the generator to start executing its code. (Note thatnext
itself returns a promise if the generator is an async function — notnext().value
.)listUsersGenerator
executes until the first yield. It yields an object back tohandler
with instructions. In this first case, the instructions are to call theparseQuery
function withreq.query
as its argument.handler
receives this request as the result ofnext
. The firstcase
statement is matched. It then proceeds to actually call the passed function with the given arguments.- In this case,
handler
then callsnext
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 theyield
statement). listUsersGenerator
yields another function call, this time to fetch users matching the query.handler
calls the function.handler
couldawait
the call toUser.find
, but instead we’ll pass the promise to the generator toawait
. This is a little more flexible, because (a) promise rejection can be handled in the generator and (b) the generator can usePromise.all
to process things concurrently.- As a refresher,
return
andyield
will behave identically in ourhandler
— the only difference is that the generator will be “done” next timenext
is invoked.listUsersGenerator
yields another action, which indicates that the response should be sent. handler
's secondcase
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 callres.status
andres.json
respectively.- When the iterator has no more results, the next invocation to
next
returns{ done: true, value: undefined }
. The handler’swhile
loop is therefore terminated and bothhandler
andlistUsersGenerator
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 andres
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 })
, orclass Call {}
make instantiating our actions easier and less prone to typos. Using symbols over string literals (or checkinginstanceof
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.