It's hard to write unit tests that are worth the costs our employers pay for them. I'll share two mostly-made-up diagrams then use them to justify writing code as data transformation.
There are four types of code:
**Diagram 1: Types of Code**
high
^ ||
^ ||
^ boring I/O || interesting I/O
^ ||
cost of testing ^ ================================
^ ||
^ ||
^ boring || interesting
^ transformation || transformation
^ ||
low >>>>>>>>>>>>>>>>>>>>>>>>>>>>> high
benefit of testing
Here's how to interpret the diagram:
- testing boring I/O is high cost and low benefit
- testing interesting tranformation is low cost and high benefit
Here is what words in the diagram mean:
- "interesting" can include things like:
- "has important business logic" or
- "is costly to mess up."
- "boring" includes things that are routine to write and orthogonal to the business logic.
- "I/O" here means things that are heavy in side-effects and coeffects such as fetching, writing, calling, printing, etc.
- "transformation" here refers to work that primarily involves turning data into other data. Data transformation code can often be written using pure functions, which are relatively easy to test.
Hhere's some dogma I made up:
**Diagram 2: Should I unit test?**
high
^ ||
^ ||
^ don't test || maybe test
^ ||
cost of testing ^ ================================
^ ||
^ ||
^ maybe test || should test
^ ||
^ ||
low >>>>>>>>>>>>>>>>>>>>>>>>>>>>> high
benefit of testing
I'll now provide a contrived example. The point is that we can sometimes write code in a way that improves the Value per Hour (VPH) of maintaing our tests.
Here's an example of a function that does interesting I/O. It uses fetch
to call an HTTP API, which means it must either run in a browser or in an environment that is good at pretending to be a Web browser.
// Architecture 1
function callApi(x, y, z) {
manipulate params
manipulate params some more ...
return fetch(...)
.then(result => {
if (result.status === ...) {
handle status stuff
}
manipulate result
manipulate result some more ...
return manipulatedResult
})
}
The test for code written using Architecture 1 will probably need things like mocks, spies, and maybe even a refactor involving a dependency injection framework.
Here's an example of what these test look like:
// Test for Architecture 1
fetch = spy().andMockWith(function (args) {
// pattern matching and pretend games
if (args.method === 'POST') {
if (JSON.parse(args.body) === .... ) {
return Promise.resolve({
status: 200,
json: () => {
return Promise.resolve(RESPONSE_PAYLOAD)
}
)
}
}
})
callApi(x, y, z)
.then(res => {
assertSpy.wasCalledWith(...)
assert.deepEqual(response, whatever)
})
But we have a choice. We can write our code to separate out the I/O from the data transformation.
// Architecture 2
function callApi(x, y, z) {
const request = buildRequest(x, y, z)
return fetch(request)
.then(assertStatus('callApi', x, y, z))
.then(parseResponse)
}
In the new architecture, instead of one big callAPI
function that does interesting I/O, we have:
callApi
, boring I/O that we don't need to testbuildRequest
, transformation code that is easy to testparseResponse
, transformation code that is easy to test
Testing Architecture 2 looks something like this:
// Test for Architecture 2
assert.deepEqual(
buildRequest(x, y, z),
expectedRequest
)
assert.deepEqual(
parseResponse(response),
expectedParsedResponse
)
Back to the diagram:
**Types of Code**
high
^ ||
^ ||
^ boring I/O || interesting I/O
^ ||
cost of testing ^ ================================
^ ||
^ ||
^ boring || interesting
^ transformation || transformation
^ ||
low >>>>>>>>>>>>>>>>>>>>>>>>>>>>> high
benefit of testing
There are often ways to solve problems that keep code in the bottom half of the diagram, where stuff is easier to read, maintain, debug, test, etc.