Naming Your Jest Callbacks Improves Navigability

ยท

3 min read

The RSpec-style DSL is popular among test frameworks. JavaScript test frameworks like Mocha and Jest use this style to express test suites. It's usually written in the following style.

describe("User password update process", () => {
  test("rejects the last 3 recent passwords", async () => {
    // test case goes here
  });
});

You'll find several such examples on the internet and in codebases in the wild. But there's a problem with this style. Not with the DSL, but with how we write the callback functions.

The problem with this style is that the callback functions are anonymous. This makes it harder to navigate a spec file with several test cases. If you've ever had to find the test case you want using Ctrl+F, you'll understand that it's not particularly nice, given that editors and IDEs already know how to navigate to symbols in your code.

You may have guessed the solution already โ€” name the callback functions. Notice the improvement in the following code.

describe("User password update process", () => {
  test("rejects the last 3 recent passwords", async function rejects_the_last_3_recent_passwords() {
    // test case goes here
  });
});

That's better. You can now use the symbol navigation feature of your editor to locate the test case you want. You can even extract rejects_the_last_3_recent_passwords to a top-level function if you want to.

Although we've solved the navigation problem, we've introduced duplication of intent. The test case description repeats the callback function name: "rejects the last 3 recent passwords" vs rejects_the_last_3_recent_passwords ๐Ÿ˜’. We can improve this with a little indirection. How about the following?

interface TestCases {
  [name: string]: jest.ProvidesCallback;
}

const user_password_update_process: TestCases = {
  "rejects the user's last 3 recent passwords"() {}
}

describe("User password update process", () => {
  test(
    user_password_update_process["rejects the user's last 3 recent passwords"].name,
    user_password_update_process["rejects the user's last 3 recent passwords"],
  );
});

Hideous! This isn't looking like jest anymore ๐Ÿ˜ฌ. But we still have excellent symbol navigation for our test cases, and we've grouped related test cases in an object. The arguments to test(...) look like a lot of typing, but auto-completion is available there. Still it's repetitive to read. We've declared the test cases already, we just want to pass them to test. We can do the reading by looking at user_password_update_process; test(...) should only register them. Let's do that.

First we'll introduce a function to extract the name and the callback from a TestCases property. We'll curry it so that we can apply it many times for different test cases in a TestCases object.

const testCase =
  <Cases extends TestCases>(cases: Cases) =>
  <Name extends keyof Cases>(name: Name) =>
    [cases[name].name, cases[name]] as const;

Now we can declare our test cases and register them without repetition.

describe('User password update process', () => {
  const cases = testCase({
    async "rejects the user's last 3 recent passwords ๐Ÿ‘ป"() {},
    async "invalidates the user's current session"() {}
  });

  test(...cases("invalidates the user's current session"));
  test(...cases("rejects the user's last 3 recent passwords ๐Ÿ‘ป"))
});

We get some nice things with this technique.

  1. Symbol navigation works
  2. We can use unicode strings in the test descriptions and method names
  3. There's no duplication of intent
  4. Auto-completion works jest_test_cases_refactoring_cropped.png

Nice and tidy, isn't it? ๐Ÿ˜ Tests are hard to manage. You may already have snapshot files and test data to manage. Navigating your tests shouldn't be another problem to deal with. I hope this helps you with that.