Snapshot Testing
The Deno standard library comes with a snapshot module, which enables developers to write tests which assert a value against a reference snapshot. This reference snapshot, is a serialized representation of the original value and is stored alongside the test file.
Snapshot testing can be useful in many cases, as it enables catching a wide array of bugs with very little code. It is particularly helpful in situations where it is difficult to precisely express what should be asserted, without requiring a prohibitive amount of code, or where the assertions a test makes are expected to change often. It therefore lends itself especially well to use in the development of front ends and CLIs.
Basic usage
The assertSnapshot
function will create a snapshot of a value and compare it
to a reference snapshot, which is stored alongside the test file in the
__snapshots__
directory.
// example_test.ts
import { assertSnapshot } from "https://deno.land/std/testing/snapshot.ts";
Deno.test("isSnapshotMatch", async function (t): Promise<void> {
const a = {
hello: "world!",
example: 123,
};
await assertSnapshot(t, a);
});
// __snapshots__/example_test.ts.snap
export const snapshot = {};
snapshot[`isSnapshotMatch 1`] = `
{
example: 123,
hello: "world!",
}
`;
Calling assertSnapshot
in a test will throw an AssertionError
, causing the
test to fail, if the snapshot created during the test does not match the one in
the snapshot file.
Creating and updating snapshots
When adding new snapshot assertions to your test suite, or when intentionally
making changes which cause your snapshots to fail, you can update your snapshots
by running the snapshot tests in update mode. Tests can be run in update mode by
passing the --update
or -u
flag as an argument when running the test. When
this flag is passed, then any snapshots which do not match will be updated.
deno test --allow-all -- --update
Additionally, new snapshots will only be created when this flag is present.
Permissions
When running snapshot tests, the --allow-read
permission must be enabled, or
else any calls to assertSnapshot
will fail due to insufficient permissions.
Additionally, when updating snapshots, the --allow-write
permission must also
be enabled, as this is required in order to update snapshot files.
The assertSnapshot
function will only attempt to read from and write to
snapshot files. As such, the allow list for --allow-read
and --allow-write
can be limited to only include existing snapshot files, if so desired.
Version Control
Snapshot testing works best when changes to snapshot files are committed alongside other code changes. This allows for changes to reference snapshots to be reviewed along side the code changes that caused them, and ensures that when others pull your changes, their tests will pass without needing to update snapshots locally.
Advanced Usage
Options
The assertSnapshot
function can also be called with an options object which
offers greater flexibility and enables some non standard use cases.
import { assertSnapshot } from "https://deno.land/std/testing/snapshot.ts";
Deno.test("isSnapshotMatch", async function (t): Promise<void> {
const a = {
hello: "world!",
example: 123,
};
await assertSnapshot(t, a, {
// options
});
});
serializer
The serializer
option allows you to provide a custom serializer function. This
will be called by assertSnapshot
and be passed the value being asserted. It
should return a string. It is important that the serializer function is
deterministic i.e. that it will always produce the same output, given the same
input.
The result of the serializer function will be written to the snapshot file in update mode, and in assert mode will be compared to the snapshot stored in the snapshot file.
// example_test.ts
import { assertSnapshot, serialize } from "https://deno.land/std/testing/snapshot.ts";
import { stripColor } from "https://deno.land/std/fmt/colors.ts";
/**
* Serializes `actual` and removes ANSI escape codes.
*/
function customSerializer(actual: string) {
return serialize(stripColor(actual));
}
Deno.test("Custom Serializer", async function (t): Promise<void> {
const output = "\x1b[34mHello World!\x1b[39m";
await assertSnapshot(t, output, {
serializer: customSerializer,
});
});
// __snapshots__/example_test.ts.snap
export const snapshot = {};
snapshot[`Custom Serializer 1`] = `"Hello World!"`;
Custom serializers can be useful in a variety of cases. One possible use case is to discard information which is not relevant and/or to present the serialized output in a more human readable form.
For example, the above code snippet shows how a custom serializer could be used to remove ANSI escape codes (which encode font color and styles in CLI applications), making the snapshot more readable than it would be otherwise.
Other common use cases would be to obfuscate values which are non-deterministic or which you may not want to write to disk for other reasons. For example, timestamps or file paths.
Note that the default serializer is exported from the snapshot module so that its functionality can be easily extended.
dir
and path
The dir
and path
options allow you to control where the snapshot file will
be saved to and read from. These can be absolute paths or relative paths. If
relative, the they will be resolved relative to the test file.
For example, if your test file is located at /path/to/test.ts
and the dir
option is set to snapshots
, then the snapshot file would be written to
/path/to/snapshots/test.ts.snap
.
As shown in the above example, the dir
option allows you to specify the
snapshot directory, while still using the default format for the snapshot file
name.
In contrast, the path
option allows you to specify the directory and file name
of the snapshot file.
For example, if your test file is located at /path/to/test.ts
and the path
option is set to snapshots/test.snapshot
, then the snapshot file would be
written to /path/to/snapshots/test.snapshot
.
If both dir
and path
are specified, the dir
option will be ignored and the
path
option will be handled as normal.
mode
The mode
option can be either assert
or update
. When set, the --update
and -u
flags will be ignored.
If the mode
option is set to assert
, then assertSnapshot
will always
behave as though the update flag is not passed i.e. if the snapshot does not
match the one saved in the snapshot file, then an AssertionError
will be
thrown.
If the mode
option is set to update
, then assertSnapshot
will always
behave as though the update flag has been passed i.e. if the snapshot does not
match the one saved in the snapshot file, then the snapshot will be updated
after all tests have run.
name
The name
option specifies the name of the snapshot. By default, the name of
the test step will be used. However, if specified, the name
option will be
used instead.
// example_test.ts
import { assertSnapshot } from "https://deno.land/std/testing/snapshot.ts";
Deno.test("isSnapshotMatch", async function (t): Promise<void> {
const a = {
hello: "world!",
example: 123,
};
await assertSnapshot(t, a, {
name: "Test Name"
});
});
// __snapshots__/example_test.ts.snap
export const snapshot = {};
snapshot[`Test Name 1`] = `
{
example: 123,
hello: "world!",
}
`;
When assertSnapshot
is run multiple times with the same value for name
, then
the suffix will be incremented as normal. i.e. Test Name 1
, Test Name 2
,
Test Name 3
, etc.
msg
Allows setting a custom error message to use. This will overwrite the default error message, which includes the diff for failed snapshots.
Default Options
You can configure default options for assertSnapshot
.
// example_test.ts
import { createAssertSnapshot } from "https://deno.land/std/testing/snapshot.ts";
const assertSnapshot = createAssertSnapshot({
// options
});
When configuring default options like this, the resulting assertSnapshot
function will function the same as the default function exported from the
snapshot module. If passed an optional options object, this will take precedence
over the default options, where the value provided for an option differs.
It is possible to "extend" an assertSnapshot
function which has been
configured with default options.
// example_test.ts
import { createAssertSnapshot } from "https://deno.land/std/testing/snapshot.ts";
import { stripColor } from "https://deno.land/std/fmt/colors.ts";
const assertSnapshot = createAssertSnapshot({
dir: ".snaps",
});
const assertMonochromeSnapshot = createAssertSnapshot<string>(
{ serializer: stripColor },
assertSnapshot,
);
Deno.test("isSnapshotMatch", async function (t): Promise<void> {
const a = "\x1b[32mThis green text has had it's colours stripped\x1b[39m";
await assertMonochromeSnapshot(t, a);
});
// .snaps/example_test.ts.snap
export const snapshot = {};
snapshot[`isSnapshotMatch 1`] = `This green text has had it's colours stripped`;
Serialization with Deno.customInspect
The default serialization behaviour can be customised in two ways. The first is
by specifying the serializer
option. This allows you to control the
serialisation of any value which is passed to a specific assertSnapshot
call.
See the above documentation on the correct usage of this option.
The second option is to make use of Deno.customInspect
. Because the default
serializer used by assertSnapshot
uses Deno.inspect
under the hood, you can
set property Symbol.for("Deno.customInspect")
to a custom serialization
function.
Doing so will ensure that the custom serialization will, by default, be used
whenever the object is passed to assertSnapshot
. This can be useful in many
cases. One example is shown in the code snippet below.
// example_test.ts
import { assertSnapshot } from "https://deno.land/std/testing/snapshot.ts";
class HTMLTag {
constructor(
public name: string,
public children: Array<HTMLTag | string> = [],
) {}
public render(depth: number) {
const indent = " ".repeat(depth);
let output = `${indent}<${this.name}>\n`;
for (const child of this.children) {
if (child instanceof HTMLTag) {
output += `${child.render(depth + 1)}\n`;
} else {
output += `${indent} ${child}\n`;
}
}
output += `${indent}</${this.name}>`;
return output;
}
public [Symbol.for("Deno.customInspect")]() {
return this.render(0);
}
}
Deno.test("Page HTML Tree", async (t) => {
const page = new HTMLTag("html", [
new HTMLTag("head", [
new HTMLTag("title", [
"Simple SSR Example",
]),
]),
new HTMLTag("body", [
new HTMLTag("h1", [
"Simple SSR Example",
]),
new HTMLTag("p", [
"This is an example of how Deno.customInspect could be used to snapshot an intermediate SSR representation",
]),
]),
]);
await assertSnapshot(t, page);
});
This test will produce the following snapshot.
// __snapshots__/example_test.ts.snap
export const snapshot = {};
snapshot[`Page HTML Tree 1`] = `
<html>
<head>
<title>
Simple SSR Example
</title>
</head>
<body>
<h1>
Simple SSR Example
</h1>
<p>
This is an example of how Deno.customInspect could be used to snapshot an intermediate SSR representation
</p>
</body>
</html>
`;
In contrast, when we remove the Deno.customInspect
method, the test will
produce the following snapshot.
// __snapshots__/example_test.ts.snap
export const snapshot = {};
snapshot[`Page HTML Tree 1`] = `
HTMLTag {
children: [
HTMLTag {
children: [
HTMLTag {
children: [
"Simple SSR Example",
],
name: "title",
},
],
name: "head",
},
HTMLTag {
children: [
HTMLTag {
children: [
"Simple SSR Example",
],
name: "h1",
},
HTMLTag {
children: [
"This is an example of how Deno.customInspect could be used to snapshot an intermediate SSR representation",
],
name: "p",
},
],
name: "body",
},
],
name: "html",
}
`;
You can see that this snapshot is much less readable. This is because:
- The keys are sorted alphabetically, so the name of the element is displayed after its children
- It includes a lot of extra information, causing the snapshot to be more than twice as long
- It is not an accurate serialization of the HTML which the data represents
Note that in this example it would be entirely possible to achieve the same result by calling:
await assertSnapshot(t, page.render(0));
However, depending on the public API you choose to expose, this may not be practical in other cases.
It is also worth considering that this will have an impact beyond just snapshot
testing. For example, Deno.customInspect
is also used to serialize objects
when calling console.log
and in some other cases. This may or may not be
desirable.