Semantic Javascript Testing With Jest

Testing ensures that our observations meets our expectations. But often the observations, and thus expectations are highly complex, and describing them in tests becomes complex. I discuss how to increase the semantic value of tests. 5 July 2019

# Background

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.

# Making simple assertions

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)
})

# Making complex assertions

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.

# Serialisable assertions

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)
})

# Non serializable assertions

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!

# Testing business semantics

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.

# Above all, it's about business value

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.