Writing Cleaner Tests with Jest Extensions
Devin Witherspoon
Posted on November 9, 2020
Introduction
While developing scss-codemods I wrote tests to ensure changes for new features wouldn't break my previous work. As the tests grew in number, I found myself following a familiar pattern: refining tests and extracting boilerplate to further focus each test on the behavior we're testing (the test subject).
The Jest extensions (custom serializers and matchers) below are available in the
jest-postcss
npm package.
Testing PostCSS Plugins
While PostCSS plugins can have countless behaviors that need testing, they have a clear API to test - the input CSS and output CSS. We'll start with a single standalone test:
import postcss, { Result } from "postcss";
import postcssScss from "postcss-scss";
it("should transform the css", async () => {
const result = await postcss(removeNestingSelector).process(
`
.rule {
&-part {}
}
`,
{
parser: postcssScss,
from: "CSS",
}
);
expect(result.css).toMatchInlineSnapshot(`
".rule {
}
.rule-part {}"
`);
});
Note: I'm fond of inline snapshot tests, as long as they're concise.
Stripping out the PostCSS specifics, the test looks like this:
it("should transform the css", async () => {
const RECEIVED = BOILERPLATE(SUBJECT, INPUT);
expect(RECEIVED).MATCHER(EXPECTED);
});
We can look at this test as having 2 steps:
- Apply the
BOILERPLATE
function to theSUBJECT
plugin andINPUT
CSS, giving us theRECEIVED
CSS. - Check
RECEIVED
againstEXPECTED
using aMATCHER
.
1. Extracting the Boilerplate
Pulling out the BOILERPLATE
from our test case gives us the function createProcessor
:
import postcss from "postcss";
import postcssScss from "postcss-scss";
function createProcessor(plugins) {
const configured = postcss(plugins);
return async (css) => {
return await configured.process(css, {
parser: postcssScss,
from: "CSS",
});
};
}
We can now apply this function outside of the tests to avoid unnecessary setup for each test.
2a. Snapshot Serializers as MATCHER
If we use inline snapshots to compare RECEIVED
and EXPECTED
, we'll want to clean up the snapshot.
expect(result.css).toMatchInlineSnapshot(`
".rule {
}
.rule-part {}"
`);
The extra quotes and poor indentation distract from the goal of the test - to check that the RECEIVED
is the same as EXPECTED
. We can reformat the snapshot by adding a snapshot serializer to Jest with expect.addSnapshotSerializer
, prettifying the CSS for easy visual comparison.
import prettier from "prettier";
function serializeCSS(css: string) {
return (
prettier
.format(css, { parser: "scss" })
// keep empty rules compact for simpler testing
.replace(/\{\s*\}/g, "{}")
.trim()
);
}
expect.addSnapshotSerializer({
test: (val) => val instanceof Result,
print: (val) => serializeCSS(val.css),
});
Now any PostCSS Result
will render as prettified CSS when tested using Jest snapshots.
After completing these two steps the test is much easier to read, making it easier to identify whether updates are intentional during code review. This refactor isn't worth it for a single test, but with 48 snapshot tests in scss-codemods
, the value adds up.
const process = createProcessor(removeNestingSelector);
it("should fold out dash ampersand rules", async () => {
expect(
await process(`
.rule {
&-part1 {}
}
`)
).toMatchInlineSnapshot(`
.rule {}
.rule-part1 {}
`);
});
2b. Custom Matchers as MATCHER
As I mentioned before, I really like snapshot tests, but sometimes you want to avoid test behavior automatically changing too easily with a simple command (jest --update
). We can write our own custom matcher using Jest's expect.extend to achieve the same matching without the automatic update behavior of snapshot tests.
function toMatchCSS(result, css) {
const expected = serializeCSS(css);
const received = serializeCSS(result.css);
return {
pass: expected === received,
message: () => {
const matcher = `${this.isNot ? ".not" : ""}.toMatchCSS`;
return [
this.utils.matcherHint(matcher),
"",
this.utils.diff(expected, received),
].join("\n");
},
};
}
expect.extend({ toMatchCSS });
The matcher function uses the same serializeCSS
function to format RECEIVED
and EXPECTED
CSS and Jest's this.utils
, which provides helpers for writing matchers:
-
this.utils.matcherHint
returns a string representing the failed test to help identify what failed. -
this.utils.diff
performs a string diff to identify the difference between the expected and received results.
We can use the custom matcher in the same way as the inline snapshots.
it("should fold out dash ampersand rules", async () => {
expect(
await process(`
.rule {
&-part1 {}
}
`)
).toMatchCSS(`
.rule {}
.rule-part1 {}
`);
});
An example of a failed test:
expect(received).toMatchCSS(expected)
- Expected
+ Received
- .rule {}
- .rule-part1 {}
+ .rule {
+ &-part1 {}
+ }
Snapshots vs. Matchers
Using a snapshot or custom matcher is a personal choice, but here are some heuristics to help you decide.
Snapshot tests are faster to write and work well as regression tests when you know your system already behaves well. They can update automatically, so they're well suited to rapidly changing behavior in tests as long as the snapshot is small enough to review.
Custom matchers are more explicit and can support a more diverse set of checks. They work well when you want to confirm the behavior of a small part of the whole. Matchers also won't change without manual editing, so the risk of unintentional changes is lower.
Conclusion
By extracting boilerplate and writing Jest extensions for PostCSS, we're able to simplify individual tests, focusing more on the test subject and expected behavior.
PostCSS's clear API makes serializers and matchers the ideal tools for cleaning up these tests. Pulling these test extensions out of scss-codemods
and into jest-postcss
can help others write tests for their PostCSS plugins.
I hope you enjoyed this post, and let me know in the comments how you're making Jest extensions work for you!
Appendix: Making Jest Extensions Production-Ready
This is a bonus section in case you're interested in publishing your own Jest extensions and need to write tests for them.
Testing Matchers
Testing serializers and matchers is a little tricky. We are inverting the relationship of our tests - writing plugins to test matchers, instead of matchers to test plugins. For cases when RECEIVED
matches EXPECTED
, it's as simple as writing a test that passes, but we also need to ensure the matcher provides helpful hints when they don't match.
Error: Task Failed Successfully
To test this behavior, we need to verify the error the matcher returns. Wrapping the failing expect
in a expect(() => {...}).rejects
or a try/catch
block resolves this issue.
// We're testing a failure with an identity plugin for simplicity
const process = createProcessor({
postcssPlugin: "identity",
Once() {},
});
it("should fail with a helpful message", async () => {
expect(async () => {
expect(
await process(`
.rule {
&-part1 {}
}
`)
).toMatchCSS(`
.rule {}
.rule-part1 {}
`);
}).rejects.toMatchInlineSnapshot(`
[Error: expect(received).toMatchCSS(expected)
- Expected
+ Received
- .rule {}
- .rule-part1 {}
+ .rule {
+ &-part1 {}
+ }]
`);
});
This test confirms the inner expect
throws an error matching the desired format, ensuring that the matcher provides helpful feedback to developers when tests using it fail.
Posted on November 9, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.