DEV Community

piratematt
piratematt

Posted on

Micro0001—Pattern: Custom Jest Error Messages

Series: Micro Blogs
Entry: Micro0001—Pattern: Custom Jest Error Messages
First Published: September 2023
Tags: microblog, jest, tdd, react

Micro Blogs SeriesShort posts of lessons learned, patterns, revelations, tips, missteps, etc. from the trenches of crafting software.

🔴 TL;DR

Wrap your Jest expect statements with a try/catch block and add additional context to errors then re-throw them. This way the test failure itself is sufficient to understand what went wrong. Example from asserting a reducer deep-copies state reference values:

for (const [key, value] of Object.entries(reducedState)) {
  // we can ignore any non-reference values
  if (typeof value === 'object') {
    try {
      expect(reducedState[key]).not.toBe(initialState[key]);
    }
    catch(err) {
      err.message = `state.${key} —> ${err.message}`;
      throw err;
    }
  }
}
\
Enter fullscreen mode Exit fullscreen mode

Props to Aart den Braber for the inspiration! Note: this test only checks one-level deep; it is not a true deep-copy.

🌆 Background: TDDing a Reducer

Recently I was developing a module for use with React.useReducer. I was using a few objects within my state: an array and a couple of Set’s. Long story short, as TDD was driving the design of my module, I encountered failing tests resulting from one the reference object values persisting longer than expected. If I recall correctly, one of my state manipulations ended up modifying a set that I was reusing across tests as expected data, rather than cleanly manipulating a set from a deep-copy of state as a reducer should. Oops! 🙈

I successfully found a bug in my code. What’s next? Following TDD, the first step was writing a failing test covering the situation: test that equality (Object.is because that’s what React leverages) fails for any reference values within state (typeof === 'object') when running the reducer.

With my test written and failing, I took the simplest path to get started—hardcoding each key that was storing a reference value. Taking the next TDD step, I looked to refactor. I realized my current implementation would require me to manually update my test and solution anytime I added a new reference value to state, and I knew future-me was highly unlikely to remember to do so. Refactoring, I modified my test to loop through each key-value pair and check for reference values. Now if/when I add new reference values to state my test will continue to serve its purpose! My test code ended up looking something like this:

for (const [key, value] of Object.entries(reducedState)) {
  // we can ignore any non-reference values
  if (typeof value === 'object') {
    expect(reducedState[key]).not.toBe(initialState[key]);
  }
}
Enter fullscreen mode Exit fullscreen mode

This worked great, but it created a new pain. When a value fails to pass the assertion, no information about its associated key is included in the error message, requiring additional work upon failure to diagnose the culprit.

🦓 Pattern: Custom Jest Error Messages

I’ve wanted custom jest error messages for a while. It’s not a frequent desire, but it comes up almost every time I write assertions inside loops. So, every now and then I search the interwebz to see what advice is out there. Most frequently, I find others advising me to add an additional package that provides this functionality. Sure this works, but I try to avoid increasing the attack surface and license management burden when I can. Custom Jest error messages never felt like enough functionality to justify the additional work. Particularly when adding a simple log statement—in the infrequent event of test failure—is sufficient to track down the specific scenario.

Still… I’d rather have the test failure itself tell me exactly what was wrong, so once again I dove into the wilds of the internet. This time I found a pattern that was simple to parse and didn’t require an entirely new package! Props to Aart den Braber for the inspiration!

The pattern: wrap your Jest expect statements with a try/catch block and add additional context to errors then re-throw them. Now my test now looks something like this:

for (const [key, value] of Object.entries(reducedState)) {
  // we can ignore any non-reference values
  if (typeof value === 'object') {
    try {
      expect(reducedState[key]).not.toBe(initialState[key]);
    }
    catch(err) {
      // Pattern props to: https://aartdenbraber.medium.com/custom-error-messages-with-jest-for-assertions-821c69e72389
      err.message = `state.${key} —> ${err.message}`;
      throw err;
    }
  }
}
\
Enter fullscreen mode Exit fullscreen mode

This way I know exactly which referential value is causing the failure directly from the error message, while simultaneously retaining all the inbuilt goodness of the error jest already throws.

Top comments (0)