Logo
Logo

Programming by Stealth

A blog and podcast series by Bart Busschots & Allison Sheridan.

PBS 136 of X — More Jesting

In the previous instalment we met Jest, the testing toolkit we’ll be using for the JavaScript port of the Crypt::HSXKPasswd Perl module. While we did get Jest to actually run some tests for us, we really did only scratch the very surface of Jest’s capacities. As is the norm on this series, we’re not going to go too deep into Jest, but we are still missing some critical concepts, so we’ll spend this instalment getting up to speed with those. By the end of this instalment we’ll know enough about Jest to start using it on real projects.

Matching Podcast Episode

Listen along to this instalment on episode 717 of the Chit Chat Across the Pond Podcast.

You can also Download the MP3

Episode Resources

A Quick Jest Refresher

In the previous instalment we learned that in Jest we use the test() function to define tests, and each of those tests contains one or more expectations, which we implement with the expect() function.

As a reminder of the syntax, here’s an overly simplistic example:

test('JavaScript can add', ()=>{
  expect(1 + 1).toBe(2);
});

We also learned that Jest tests should be defined in separate JavaScript files named *.test.js, and that we should configure NPM to run our test suite for us using the command:

npm run test

We can do that by adding the following entry to the scripts array in package.json:

"test": "NODE_OPTIONS=--experimental-vm-modules npx jest"

Optionally Play Along

The examples in this instalment all improve on the basic test suite we created in the previous instalment for a simple module that joins an array of strings in a human-friendly way. If you’d like to play along you can use the folder pbs135b-joiner-after as your starting point. Remember to run npm ci in the folder before you start!

The final version of the test suite is included in this instalment’s ZIP file in the folder pbs136a-joiner, and again, remember to run npm ci before attempting to run the test suite.

Grouping Tests with describe()

The first thing we can do to improve our test suite is group related tests. When we left our test suite last time (src/joiner.test.js) the tests were interspersed with big eye-catching multi-line comments to group them visually within the code, e.g.:

 //
 // === Test the modifiers ===
 //

test('ampersand modifier', ()=>{
  expect(joiner().ampersand.join(['a', 'b', 'c'])).toBe('a, b & c');
});

test('and modifier', ()=>{
  expect(joiner().and.join(['a', 'b', 'c'])).toBe('a, b and c');
});

test('or modifier', ()=>{
  expect(joiner().or.join(['a', 'b', 'c'])).toBe('a, b or c');
});

test('single quote modifier', ()=>{
  expect(joiner().quote.join(['a', 'b', 'c'])).toBe("'a', 'b' & 'c'");
});

test('double quote modifier', ()=>{
  expect(joiner().doubleQuote.join(['a', 'b', 'c'])).toBe('"a", "b" & "c"');
});

test('sort modifier', ()=>{
  expect(joiner().sort.join(['a', 'c', 'b'])).toBe('a, b & c');
});

Rather than grouping them only visually, we should group them logically, and Jest provides that functionality through its describe() function. This function takes two arguments, a string with a label for the group of tests, and a function that defines the tests. The function is generally written as an arrow function, so the usual syntax looks like this:

describe('JavaScript can do basic arithmetic', ()=>{
  test('JavaScript can add', ()=>{
     expect(1 + 1).toBe(2);
   });
   test('JavaScript can subract', ()=>{
     expect(2 - 1).toBe(1);
   });
   test('JavaScript can multiply', ()=>{
     expect(2 * 2).toBe(4);
   });
   test('JavaScript can divide', ()=>{
     expect(4 / 2).toBe(2);
   });
});

Let’s update our test suite accordingly:

describe('test modifiers', ()=>{
  test('ampersand modifier', ()=>{
    expect(joiner().ampersand.join(['a', 'b', 'c'])).toBe('a, b & c');
  });
  test('and modifier', ()=>{
    expect(joiner().and.join(['a', 'b', 'c'])).toBe('a, b and c');
  });
  test('or modifier', ()=>{
    expect(joiner().or.join(['a', 'b', 'c'])).toBe('a, b or c');
  });
  test('single quote modifier', ()=>{
    expect(joiner().quote.join(['a', 'b', 'c'])).toBe("'a', 'b' & 'c'");
  });
  test('double quote modifier', ()=>{
    expect(joiner().doubleQuote.join(['a', 'b', 'c'])).toBe('"a", "b" & "c"');
  });
  test('sort modifier', ()=>{
    expect(joiner().sort.join(['a', 'c', 'b'])).toBe('a, b & c');
  });
});

When we run our test suite Jest will label and indent this related group of tests in its output:

 PASS  src/joiner.test.js
  ✓ Module exports expected value (2 ms)
  ✓ Default  Join (2 ms)
  test modifiers
    ✓ ampersand modifier
    ✓ and modifier (1 ms)
    ✓ or modifier
    ✓ single quote modifier (1 ms)
    ✓ double quote modifier
    ✓ sort modifier (1 ms)

Test Suites: 1 passed, 1 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        0.179 s, estimated 1 s

Jest allows groupings to be nested, so as your code becomes more complex, your groupings can too. You can uses nested groupings to have the structure of your tests mirror the structure of your code — e.g. a group for all the tests related to a class, and inside that, a group for all tests related to a specific function within that class.

Repeatable Tests with describe.each()()

It’s very common to need to repeat a test many times over with different inputs. You could duplicate your code, like we do in this overly large test in our test suite:

test('Default  Join', ()=>{
   // typical case - joins with an & when passed a regular list of strings
   expect(joiner().join(['a', 'b', 'c'])).toBe('a, b & c');

   // edge case - empty array
   expect(joiner().join([])).toBe('');

   // edge case - one string
   expect(joiner().join(['a'])).toBe('a');

   // edge case - two strings
   expect(joiner().join(['a', 'b'])).toBe('a & b');

   // invalid data - not an array
   expect(()=>{joiner().join('pancakes')}).toThrow(TypeError);
 });

This test was structured this way primarily as an example to show that a single test can contain multiple expectations. However, this is not a well written test, there are three things wrong with it:

  1. I’ve been forced to describe the actual things being checked in comments because I’ve put too much into one test. Jest allows you to label tests and groups of tests, not individual expectations.
  2. There are two separate concepts being tested in a single test — does the function work as expected when handed valid inputs, and does the function throw an error when handed invalid input.
  3. There is a lot of repetition — if you ignore the last expectation, the other four are all identical apart from the description, the inputs to the function under test (joiner().join()), and the expected outcome.

We can fix the first and second problems using Jest features we’ve already met, so let’s do that first:

describe('test default join (no modifiers)', ()=>{
  describe('with valid data', ()=>{
    test('typical case - joins with an & when passed a regular list of strings', ()=>{
      expect(joiner().join(['a', 'b', 'c'])).toBe('a, b & c');
    });
    test('edge case - empty array', ()=>{
      expect(joiner().join([])).toBe('');
    });
    test('edge case - one string', ()=>{
      expect(joiner().join(['a'])).toBe('a');
    });
    test('edge case - two strings', ()=>{
      expect(joiner().join(['a', 'b'])).toBe('a & b');
    });
  });
  
  test('with invalid data', ()=>{
    expect(()=>{joiner().join('pancakes')}).toThrow(TypeError);
  });
});

Note the nesting of the groupings — we have a describe() inside a describe().

Before we made this change all those expectations were reported in the output as simply:

 ✓ Default  Join (2 ms)

Now that we have added the groupings and moved each expectation into its own test we get much better output:

test default join (no modifiers)
 with invalid data (2 ms)
with valid data
   typical case - joins with an & when passed a regular list of strings
   edge case - empty array (1 ms)
   edge case - one string
   edge case - two strings (1 ms)

This still leaves us with a lot of repetition, but that repetition is now entirely contained within a group of tests:

describe('with valid data', ()=>{
  test('typical case - joins with an & when passed a regular list of strings', ()=>{
    expect(joiner().join(['a', 'b', 'c'])).toBe('a, b & c');
  });
  test('edge case - empty array', ()=>{
    expect(joiner().join([])).toBe('');
  });
  test('edge case - one string', ()=>{
    expect(joiner().join(['a'])).toBe('a');
  });
  test('edge case - two strings', ()=>{
    expect(joiner().join(['a', 'b'])).toBe('a & b');
  });
});

Notice that there are only three things varying from test to test — the description, the input, and the expected output. That seems overly verbose, and annoyingly reparative to type, surely there must be a better way?

Of course there is, Jest provides a mechanism for looping over a collection of tests with variables via its describe.each()() function. This is a function that returns a function, so you call it with two sets of arguments — one for the describe.each() itself, and one for the function it returns. describe.each expects just a single argument, an array of arrays of values. Each inner array defines values for a desired number of variables for use within the repeated tests, and you just keep adding arrays to add more tests to the loop.

The function returned from describe.each() also expects two arguments, a description of the looped test that can contain place-holders for the variables, and a function that defines the tests. The function will be called with the values in the arrays, so you can name the values by naming the arguments to this function. The function is generally passed as an arrow function.

Note that the placeholders are specified using the so-called printf syntax from the C programming language. The substitutions start with a % symbol and are inserted in the order they appear in the array. You tell Jest how to convert the value to a string by changing the letter that comes after the %, .e.g %s inserts a string, %i an integer, and %j JASON representation of a complex piece of data. You’ll find the full list of available placeholders in Jest’s documentation for describe.each()().

This all sounds very complicated, but it makes more sense when you see it in action:

describe.each([
  ['typical case - 3 arguments', ['a', 'b', 'c'], 'a, b & c'],
  ['edge case - empty array',    [],              ''        ],
  ['edge case - one string',     ['a'],           'a'       ],
  ['edge case - two strings',    ['a', 'b'],      'a & b'   ]
])('with valid data', (desc, input, result)=>{
  test(desc, ()=>{
    expect(joiner().join(input)).toBe(result);
  });
});

When we run our test suite we see this group as:

with valid data
  ✓ typical case - 3 arguments (1 ms)
  ✓ edge case - empty array
  ✓ edge case - one string
  ✓ edge case - two strings

Note that each inner array contains three elements, a description, an array to use as input to the join function, and the expected output as a string.

Each array defines arguments that will be passed to the arrow function that defines the tests, so we name the values when we define that arrow function, in this case, we name the first argument desc, the second input, and the third output. We can then use those names when defining our test.

Note that adding an additional test is as simple as adding another inner array with the needed data.

Something Allison pointed out in the previous instalment is that we should have more than one test to see if passing something other than an array throws an error. We can now do that without a lot of code duplication:

describe.each([
  ['string',         'pancakes'],
  ['number',         42],
  ['boolean',        true],
  ['plain object',   {a: 'b'}],
  ['class instance', new Date()]
])('with invalid data', (desc, val)=>{
  test(`with a ${desc}`, ()=>{
    expect(()=>{joiner().join(val)}).toThrow(TypeError);
  });
});

Test Setup and Tear-down

It’s quite common for tests to depend on something having been set up before they start, or shut down in an orderly fashion when they finish. It’s also common for tests to require some kind of resource to be re-set between tests.

Sometimes this could be as simple as initialising some dummy data for use by your tests, but in more advanced scenarios it’s likely to be things like database connections which need to be both established and closed in an orderly fashion.

For all these uses and more, Jest provides a suite of functions for specifying code to run before and after tests:

All of these functions expect a function as their only argument, and that function is usually an arrow function.

These functions confine their action to the group of tests (describe()) they are called within. If you call these functions in the global scope they will affect all your tests in the file.

In our example test suite we’re not doing anything that needs setup or teardown, but we can illustrate the concept by using beforeAll() inside one of our groups to initialise an array with dummy data before the tests run. Note that if the tests were destructive we would use beforeEach() instead of beforeAll() to re-create the dummy array before each test.

describe('test modifiers', ()=>{
  let testArray;
  beforeAll(()=>{
    testArray = ['waffles', 'pancakes', 'popcorn'];
  });

  test('ampersand modifier', ()=>{
    expect(joiner().ampersand.join(testArray)).toBe('waffles, pancakes & popcorn');
  });
  test('and modifier', ()=>{
    expect(joiner().and.join(testArray)).toBe('waffles, pancakes and popcorn');
  });
  test('or modifier', ()=>{
    expect(joiner().or.join(testArray)).toBe('waffles, pancakes or popcorn');
  });
  test('single quote modifier', ()=>{
    expect(joiner().quote.join(testArray)).toBe("'waffles', 'pancakes' & 'popcorn'");
  });
  test('double quote modifier', ()=>{
    expect(joiner().doubleQuote.join(testArray)).toBe('"waffles", "pancakes" & "popcorn"');
  });
  test('sort modifier', ()=>{
    expect(joiner().sort.join(testArray)).toBe('pancakes, popcorn & waffles');
  });
});

Focusing on Specific Tests with the .only Modifier

As your codebase groups and your test suite gets bigger and bigger it will take ever longer to run. While that’s not a problem for doing a final check before you commit code, it can become very frustrating when you’re working to solve a particular bug. In that scenario there will only be one test or group of tests that matter to you, so it would be great of there was an easy way to force Jest to run only a specific test or group of tests, and you can!

Jest provides a modifier named .only that can be temporarily prefixed before test or group definitions to have Jest execute only that test or group, it can be used in three ways:

// limit to the execution of just one test
test.only('JavaScript can add', ()=>{ expect(1 + 1).toBe(2) });

// limit the execution to just one group of tests
describe.only('nested tests', ()=>{
  test('JavaScript can multiply', ()=>{ expect(2 * 1).toBe(2) });
  test('JavaScript can divide', ()=>{ expect(2 / 1).toBe(2) });
});

// limit the execution to just one repeated group of tests
describe.only.each([
  [2, 1, 1],
  [3, 2, 1]
])('JavaScript can subtract', (a, b, e)=>{
  test(`${b} from ${a}`, ()=>{ expect(a - b).toBe(e) });
});

Note that the .only modifier is global to an entire file, no matter how deeply nested within describe() blocks you insert it.

Skipping Tests with the .skip Modifier

In the real world it’s also quite common to have a bug, and hence a failing test, that you know about, but are intentionally ignoring because there’s something more urgent that needs to be done first. You can mentally block out the test you expect to fail, but that’s frustrating, and a waste of mental energy. It would be much better to temporarily mark a test or group of tests to be skipped. That’s where the .skip modifier comes in. The syntax is the same as for .only:

// skip a single test
test.skip('JavaScript can add', ()=>{ expect(1 + 1).toBe(2) });

// skip a group of tests
describe.skip('nested tests', ()=>{
  test('JavaScript can multiply', ()=>{ expect(2 * 1).toBe(2) });
  test('JavaScript can divide', ()=>{ expect(2 / 1).toBe(2) });
});

// skip a repeated group of tests
describe.skip.each([
  [2, 1, 1],
  [3, 2, 1]
])('JavaScript can subtract', (a, b, e)=>{
  test(`${b} from ${a}`, ()=>{ expect(a - b).toBe(e) });
});

An Optional Challenge

For this worked example we tested just the basic functionality of the join() function exported from our module. We did not write any tests for the class inside the module that does all the work.

If you’d like to get some practice in, you can do the following:

  1. Update the package so it exports the class as a named export in addition to the existing default export (some practice with ES 6 modules!).
  2. Add a new test file src/joiner.class.test.js that imports the class and add an appropriate suite of tests.

Note that this is an optional challenge, and there will be no sample solution.

Final Thoughts

While we’ve come nowhere near covering everything Jest can do, we now know enough to start using it.

In the next instalment we’ll make a start on learning about the one development tool still missing from our toolbox that we’ll need before we can make a start on the JavaScript version of HSXKPasswd — a bundler, specifically, webpack.

Join the Community

Find us in the PBS channel on the Podfeet Slack.

Podfeet Slack