On one hand, integrated tests are a scam because the number of state permutations measure in the millions and billions, and any realistic integrated test suite can only cover a small percentage of it. It is slow, and often does not reveal the reason for any failures, severely limiting its value.
On the other hand, most unit testing is a waste because any complex code can be broken down into smaller modules where isolated testing is trivial. Without context, or meaningful sequencing and inter-relationships, the small units live in a hypergalactic goto
space.
So, what makes a good test? Arguably, it's the same as what makes good code — it delivers business value. In that vein, tests should be written by the business; a unit of test should be a business unit, not a code unit. As a bonus, tests written this way often encourages code to be written the same.
In this article, we consider how jest can be used to perform semantic Javascript testing.
Testing involves making assertions:
expect(observation).toEqual(expectation)
Where the observation & expectation are simple values, we can simply compare directly. Where they are object-like, we can use the following, where anything not explicitly described is assumed irrelevant.
expect(observation).toMatchObject({
id: expect.any(Number),
createdAt: expect.any(Date)
})
Where the observation & expectation are complex values, we need to deep-inspect them. For example, packages like enzyme
or redux-mock-store
, were made respectively for deep-inspection of react
and redux
code. This can be done in two different styles:
expect(deepInspect(observation, aspectOfInterest)).toEqual(expectation)
or, if the assertion is made multiple times:
expect.extend({
toMatchCustom(observation, expectation, aspectOfInterest) {
if (isEqual(deepInspect(observation, aspectOfInterest), expectation)) {
return { pass: true, message: "expected to pass" }
} else {
return { pass: false, message: "expected to fail" }
}
}
})
expect(observation).toMatchCustom(expectation, aspectOfInterest)
In many cases, deepInspect()
can feel like a mirror of the code under test, and we've lost the intent of the test. This suggests a different test strategy is needed.
When a complex object can be serialised to a textual representation, a different kind of testing, called snapshot testing can be performed. The current snapshot can be compared to a previous one, to ensure that it either hasn't changed, or that only the expected changes has occured. Jest has built-in support for serialising JSON-like objects, React, HTML, and many more.
expect(observation).toMatchSnapshot()
If not carefully constructed, snapshot testing can be brittle in the presence of non deterministic IDs, dates, random values, etc. To improve the resilience of the snapshots, resort is taken to shallow renders or mocks, lest developers get desensitized to the changes and blindly accept updated snapshots.
Since Jest 23.0 (May 2018), snapshots can have property matchers too, provided the snapshot is a plain object. These property matchers not only improve the resilience of the snapshots, but also increase the semantic value of the assertions.
expect(observation).toMatchSnapshot({
id: expect.any(Number),
createdAt: expect.any(Date)
})
Further, where assertions are non-serializable, a custom serializer can be used to convert it to an object representing the significant properties of the observation. From there, it can be asserted on just like any other observation, with property matchers.
expect.addSnapshotSerializer({
test: function (value) {
return value && isMeantForMe(value)
},
print: function (value, serializer) {
return serializer(objectRepresentationOf(value))
}
})
expect(observation).toMatchSnapshot({
customRepresentation: expect.anything()
})
Converting a non-serializable assertion, into a serializable one is usually a lot less effort than writing a deepInspect()
function!
Used this way, jest makes testing anything from simple variables to complex non-serializable objects easy. We gain more context than unit tests, but suffer less state permutations than integrated tests. In backend applications, this means testing SQL queries and any success/failure responses together. In frontend applications, this means testing redux
actions, reducers, and selectors together. We can finally focus on testing business semantics, rather than code semantics.
Finally, do not be obsessed with test coverage. The purpose of tests is to deliver confidence. Confidence that the business value will be delivered without bugs. Some times, large scale refactoring of code can deliver great value, and overly prescriptive tests can hamper the refactor.