I really liked snapshot testing as implemented in
Jest. Once you have a complex object,
compare it with its previous value using expect(value).toMatchSnapshot()
.
If there is no previous snapshot, it will be saved under the test name.
If there is a snapshot file already, the assertion compares the given
value with the saved one and throws a well formatted exception if the two
values differ.
Other frameworks have implemented the snapshot matching feature, for example Ava v0.18.0. Yet I always found that the snapshot testing is too closely tied to the Jest framework itself. This issue #2497 discusses the work that went into adding snapshots to Ava (see commit ee65b6d) and how the snapshot testing could be better separated from Jest framework.
Goal
I wanted to design a snapshot assertion that is separate from any particular testing framework. I love Mocha and would love to be able to bring snapshot assertions to my unit, API and DOM tests. I would also like to be able to use the same library with other test runners, like Jest, Ava and QUnit. A stretch goal is to make this snapshot testing work inside end to end tests inside Cypress tool.
I wanted the simplest API possible. A single function that takes just a value. No test names, no arguments, nothing but a value. Everything else should be figured out automatically.
Snap-shot
This is what I wanted (and it got ultimately done in snap-shot).
1 | const snapshot = require('snap-shot') |
Notice the snap-shot
is a 3rd party module, independent of Mocha / Jasmine
or any other BDD testing framework. Let us run this test using Mocha.
1 | $ npm install -D mocha snap-shot |
There is a new file in our folder.
1 | $ cat __snapshots__/spec.js.snap-shot |
Notice how snap-shot
has discovered the right test name "adds two numbers"
and saved the sum. If we have more snapshots in the same test,
they will be saved in the same file with different index.
1 | const snapshot = require('snap-shot') |
1 | $ npm test -s |
If the value changes, the snapshot difference will be clearly shown using variable-diff.
1 | const snapshot = require('snap-shot') |
1 | $ npm test -s |
Beautiful. snap-shot supports multiple values per test (as shown above), dynamic test names and asynchronous tests. It even works with transpiled code, React + JSX and Vue.js libraries.
The snapshot testing was plugged into our Node server API tests and literally collapsed the test boilerplate code into nothing. Combined with Ramda pipeline that cleans the returned data and makes it invariant from dynamic values, it became a simple and zero maintenance way to use real world data in our unit tests.
The rest of this blog post is dedicated to the way snap-shot
is implemented.
I will finish with recipes for cleaning up data and making it suitable for
snapshot testing.
Who called me?
When a test function calls snapshot()
passing the value, we need to figure
out the caller test name. Without test runner context to read it from
it turns out to be hard :)
1 | function snapshot() { |
We need to walk up the stack and find the caller callback function (in the case above it will be an anonymous arrow function expression). Hopefully the call information in the stack has meaningful line number we could save for the next step. I have looked how to grab the accurate stack locations in previous blog post Accurate call sites. In short, you can grab the call sites from V8 api or from an exception
1 | try { |
From the stack we will get spec filename and line number, for example
spec.js
, line 5. We will use this information to find the actual test
that called snapshot
.
Finding the spec
Given filename and line number, let us find the it(<name>, callback)
where the callback
function covers the given line number. In order to
do this, we will parse the source of the file into an Abstract Syntax Tree
(AST). Then we will visit each node in tree to find
a call expression node where the name of the function called
is "it" and the callback function argument (position 2) is the
caller callback function we just found.
Hiding all details and using falafel finding the spec that calls
the callback that calls snapshot
is below
1 | /* |
Note the "options.locations" - we need to keep track of source line numbers for each node in the AST.
The above code becomes a little bit hairy if the source file is transpiled
by the loader, for example if it has JSX and cannot be parsed by falafel
directly. In snap-shot
this is detected and the source file is
transformed using babel-core
. We just have to preserve the source line
numbers, which we can do
1 | function transpile (filename) { |
The name of the rose
Things become even trickier when the test function is called not with a literal string, but with a variable. Often we generate the tests for each item in an array like this
1 | const names = ['test A', 'test B', 'test C'] |
The static inspection of the call expression it(name, ...)
does not provide
a unique name to use. In this situation snap-shot
does the following.
It takes the source string of the test callback
() => { snapshot(name + ' works') }
and computes the SHA-256 hash.
This hash is used to save the snapshot values instead of the name.
In the above case the snapshot file will have something like
1 | exports['7464af... 1'] = "test A works" |
Notice that it is equivalent to a single test with 3 snapshot calls like
1 | it('7464af...', () => { |
Even better is to give snap-shot
something to work with. Instead of
arrow function, give it a named function as a callback. snap-shot
will
use the name to save the snapshot, instead of SHA hash.
1 | const tests = ['test A', 'test B', 'test C'] |
I believe this is enough for the purpose of bookkeeping. The above approach only breaks for test frameworks that randomize the test order (like rocha or Jest when running the latest modified tests first).
Promises are either a pain or easy
Nodejs has a problem. Asynchronous call stacks from promises are non-existent.
Thus the best we can do is to make sure the outside function around the
snapshot(value)
call can be found. This requires a function just for the
purpose of calling snapshot
1 | // does not work |
I hate non-implicit calls like data => snapshot(data)
and prefer
point-free code. Luckily we have an easy solution - pass the entire promise
chain to snapshot
and let is grab the resolved value.
1 | it('snapshot can wrap promise', () => { |
We use the last solution in our asynchronous tests.
Snapshot maintenance
Even carefully stored data snapshots become obsolete with time and will need
to be updated. The simplest way to update all stored values is to run
snap-shot
with an environment variable UPDATE=1
. Combined with
Mocha grep feature, it is also very simple to update just a particular
test.
1 | $ UPDATE=1 mocha -g "test name pattern" spec.js |
In the future I plan to add snapshot pruning and other nice to have features.
Snapshot testing recipes
In this section I would like to give examples of snapshot testing an API using snap-shot. A lot of examples rely on massaging the data using Ramda library. I recommend watching this video playlist to learn about Ramda functions that are super useful for this type of data processing.
Saving the test name
If the tests are generated from data, like describe above, we can still "remember" the real test name. I can do this as the last step in the data transformation
1 | const tests = ['/a', '/b', '/c'] |
The above code is simple enough to write without Ramda.
See the snapshot
As I refactored the unit tests to use the snapshot, I have hit the stride. First, I would take a test that checked many properties at once. It could be individual checks (better error messages) or object equality assertion (shorter).
1 | // parsing commit messages in |
The above tests are very verbose. Yet, they make is simple to see what the
expected parsed
value is. When we switch this code to snap-shot
we want
the same experience - we want to see what the saved value is before
committing it to the repository. This is simple to do and the best way
is to run the desired spec by itself with environment variable SHOW=1
set.
1 | it.only('handles "break" type', () => { |
1 | saving snapshot "handles "break" type 1" for file src/valid-message-spec.js |
Everything looks good, we do not need to revert the snapshot file and the test looks much cleaner. Similarly we can transform the second test into a snapshot test, see the saved result and commit the changes.
Handle undefined values
snap-shot
does NOT save "undefined" value as a snapshot because it
does not make a good expected value. What if a server sometimes
returns "undefined" and that is ok?
Also, what if the actual value we want to store is a nested property inside a returned result? We have to handle the following responses
1 | { |
Luckily we can access nested path, or provide a default value if any values along the path are "undefined" using Ramda.pathOr function.
1 | it('names', () => { |
If the server returns nothing, the snapshot will be exports['names'] = 'N/A'
.
If the server returns a valid names list, the snapshot will be
exports['names'] = [...]
value.
Saving invariant snapshots
Imagine we fetch a list of people, which returns something like
1 | { |
The list of people might change, so we do not want to save the list directly. Instead we want to save a transformed data that will be invariant to the actual numbers and names. The snapshot will allow us to test if the server returns a valid list of people, without knowing what the actual values should be.
Each person in the list should have a name - which should be a non-empty string. Each person should have an age - a positive number. If the server returns a list with a person record that does not pass these predicates, than something is wrong.
Let us transform the list into "invariant" snapshot (we assume the returned list will always have same number of items).
1 | const R = require('ramda') |
The R.map(R.evolve(person))
goes through the returned list, changing each
property listed in const peson = {...}
object. Each named property passes
through the function and the value is placed into the returned object.
For a single object it will produce
1 | R.map(person)({ |
The list we store in the snapshot thus ensures that all returned objects have valid name and age
1 | exports['returns people'] = [ |
Assemble the pipeline separately
The above code is a little hard to read, because each processing step happens as a promise callback.
1 | return snapshot( |
Luckily, we can assemble the single function from each step separately
and use it as a single .then()
callback. Since each step happens from
top to bottom, I prefer to use Ramda.pipe function.
1 | const toInvariant = R.pipe( |
Short and clear, I hope. I even recommend unit testing the above
toInvariant
function to make sure it behaves as expected. You can use
comment-value to understand the behavior of each composed
function along the pipeline.
Conclusions
This is my 3 tool and blog post in a row that used Abstract Syntax Trees to achieve something cool. The other ones were
- Accurate values in comments - where I kept comments in sync with the code
- jscodeshift example - where we could transform client code using our library automatically after a breaking API change
Overall I must say getting it right was not an easy task and it might not prove
useful after all. We will wait and see.
The way the testing code is written in the real world might
prevent snap-shot
from finding the correct spec function.
If that happens it will fail to save and load the proper snapshot value.
Yet it will succeed if the testing code follows the my favorite principle
Testing code should be simple
The production code should be simple and elegant, but it is hard to achieve.
The testing code on the other hand MUST be as simple as possible.
Setup data, call an action, validate the result. That should be it, otherwise
the tests become a drag and impossible to maintain. When tests are simple,
the snap-shot
should work no matter what testing framework is used.