Writing Cleaner Tests with Jest Extensions
Tags: postcss, javascript, codequality, testing
Published: 2020-11-09 15:21:41

## Introduction

While developing [scss-codemods](https://www.npmjs.com/package/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`](https://www.npmjs.com/package/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:

```js
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:

```js
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:

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`:

```js
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.

```js
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`](https://jestjs.io/docs/en/expect#expectaddsnapshotserializerserializer), prettifying the CSS for easy visual comparison.

```js
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.

```js
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](https://jestjs.io/docs/en/expect#expectextendmatchers) to achieve the same matching without the automatic update behavior of snapshot tests.

```js
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`](https://jestjs.io/docs/en/expect#thisutils), 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.

```js
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.

```js
// 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.