DEV Community

loading...
Cover image for Writing Cleaner Tests with Jest Extensions

Writing Cleaner Tests with Jest Extensions

dcwither profile image Devin Witherspoon ・5 min read

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 {}"
  `);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

We can look at this test as having 2 steps:

  1. Apply the BOILERPLATE function to the SUBJECT plugin and INPUT CSS, giving us the RECEIVED CSS.
  2. Check RECEIVED against EXPECTED using a MATCHER.

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",
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

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 {}"
`);
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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 {}
  `);
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 {}
  `);
});
Enter fullscreen mode Exit fullscreen mode

An example of a failed test:

expect(received).toMatchCSS(expected)

- Expected
+ Received

- .rule {}
- .rule-part1 {}
+ .rule {
+   &-part1 {}
+ }
Enter fullscreen mode Exit fullscreen mode

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 {}
    + }]
  `);
});
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

Forem Open with the Forem app