Jest Unit & Integration Test Config for Next.js

As simple setup to start writing good tests in your Next.js app
August 21st, 2019  by Blaine Garrett

Since the new year, I've been doing a lot more JavaScript development and I'm catching up on testing practices. Things are slightly different from the Python and Java world I've primarily lived in for the last 12 years: some simpler, some harder. As I set up a new Next.js project, I figure I'd document the base configuration to allow me to run Unit tests and Integration tests independently for Test Driven Design (TDD) and together for CI/CD pipelines. I will try to write additional guides for various test scenarios in the future. However, despite my best intentions, I seldom get around to "part 2" articles it seems. Hit me up on twitter if you would like more of these. 


Goals

  • Configure Jest with some sane defaults
  • Write an ultra simple integration and unit test
  • Add scripts to package.json to run them independently and together
  • Analyze the Coverage Report

Prerequisites

  • A JavaScript project you want to add tests to. If you don't have one, try my next-gae-node boilerplate
  • You have npm installed and know how to run scripts. eg yarn run test or npm run test
  • You have installed Jest as a devDependency to your project (eg. yarn add jest --dev). As I write this, I am using 24.9.0 of Jest 
  • You know and value the difference between unit and integration tests (otherwise, why bother running them independently?). Your definition of either doesn't have to agree with any specific opinion, but for the most part you value really really fast unit tests  but need to run some slower integration tests as well as run the full suite of tests occasionally.
  • You believe that if unit tests are slow to run, they won't be run very often, won't be maintained, and Test Driven Development (TDD) goes out the window. 

Note: It is out of the scope of this post to talk about specific approaches with Jest, nor concepts like mocking, or other types of testing such as contract or functional testing. 

 

Step 1: Configure Jest with Some Sane Defaults

By default, jest looks for a configuration file named jest.config.js in your project root. We can also override config directives by passing arguments to our scripts. We will do both, but first, lets set up the config file.


// Jest Config - place in ./jest.config.js
module.exports = {
  clearMocks: true,
  // Don't look for tests in these directories
  testPathIgnorePatterns: ['<rootDir>/build/', '<rootDir>/node_modules/'],
  // Define where to output the coverage report
  coverageDirectory: '<rootDir>/coverage',
  // Define what to include in the coverage report
  collectCoverageFrom: [
    // Collect Coverage from:
    '**/*.js', // All Javascript files
    '!**/node_modules/**', //   ... except node modules
    '!**/build/**', //   ... and Next.js build dir
    '!**/coverage/**', //   ... and the coverage dir itself,
    '!**/*.config.js' //   ... nor any config files (eg. next.config.js nor jest.config.js)
  ]
};

Notes:

  • I've had issues with <rootDir> working correctly with coverage and other modules when the jest.config is located outside of the project root (such as in a ./tests folder). Save some hair pulling and put it in your project root adjacent to your package.json
  • I have my Next.js build directory as /build/.  The default for Next.js is  /.next, but I rename my in next.config.js due to upload issues with Google Cloud really wanting to skip folders starting with a dot).

 

Step 2:  Write An Integration Test and A Unit Test

Create a file named cool.integration.test.js and cool.test.js. You can put these files anywhere in your folder structure as long as they're not skipped above in the config.  I'm not getting into testing techniques here, so these simple tests will do.


// cool.test.js
/* eslint-env jest */
describe('Ultra Fast Unit test', () => {
  test('should pass when run', () => {
    expect(1).toBe(1); // Change the later 1 to 2 to ensure test is running (by failing)
  });
});

and 


// cool.integration.test.js
/* eslint-env jest */
 describe('Slow Integration test', () => {
  test('should pass when run', () => {
    expect(1).toBe(1); // Change the later 1 to 2 to ensure test is running (by failing)
  });
});

 

Notes:

  • There are lots and lots and lots of opinions of where tests should live. I used to place them in a parallel tree structure under a ./tests/ folder in the root directory. However, as your project grows, imports (especially with node's relative imports) can be cumbersome and very guess and check. I now tend to put the tests in the same folder as the code under test. However, the above config doesn't care - just put them under the same common project root. Otherwise, stick your tests where you want and argue best practices with your coworkers over lunch.
  • If you use eslint to keep your code consistent (and you should), you will get undefined var errors on describe and test in the above test code even though the tests run fine. Adding the /* eslint-env jest */ comment tells jest to, among other things, assume these functions are defined globally and available to use. 

 

Step 3: Run Your Tests 

Next we will run our tests - first just the unit tests, then just the integration tests, and finally all the tests. 

Add the following bold lines to your package.json in the scripts section:


...
"scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js",
    "lint": "eslint .",
    "test": "jest --collectCoverage true",
    "unit": "jest --testRegex '(?<!integration\\.)test\\.js$'",
    "integration": "jest --testRegex 'integration\\.test\\.js$'",
}
...

Run the Unit Test

In your terminal, run npm run unit or yarn run unit
If all goes well, you should see output that includes: 1 passed, 1 total.
Go ahead and change your test to expect 1 to be 2. Re-run your tests and watch them fail to see how that looks.


Run the Integration Test

In your terminal, run npm run integration or yarn run integration
If all goes well, you should see output that includes: 1 passed, 1 total.
Go ahead and change your test to expect 1 to be 2. Re-run your tests and watch them fail to see how that looks.





Run All the Tests and Generate Coverage

In your terminal, run npm run test or yarn run test
If all goes well, you should see output that includes: 2 passed, 2 total.

You will also see a coverage report that looks like this: 
 


Notes:

  • The --testRegex argument tells Jest the naming convention of files to look for using regular expressions. By default Jest looks for files ending with .test.js. Since we want to isolate integration and unit tests, we add integration.test.js to the end of our integration test filenames. Then when we want to run just the unit tests, our regular expression does a negative lookbehind to match files that end in .test.js but only if not preceded by integration. When we want to run all tests, we leave off the regexp argument to leverage Jest's default behavior of matching all files ending in test.js
  • Running unit tests supports TDD and should be run as you develop. Integration tests are slower, and might be run before you commit your code or make a Pull Request. All tests (including functional and contract tests out of scope of this exercise) tend to be run as part of CI/CD and are run before merging or deploying. Hence the three distinct setups.
  • Since coverage is disabled by default in Jest, our report is only generated when we run the full test suite since we passed in the --collectCoverage true argument. This helps keep the unit tests fast and keeps the screen clutter free when diagnosing failures.
  • A common CI/CD practice is to use the report to flag a Pull Request as potentially dangerous if it has less than x% coverage or by merging the Pull Request lowers the entire project's test coverage lower than some threshold. 


Step 4: Analyze the Coverage Report

In the previous step, a mini coverage report was printed to the screen when running the full test suite. However, in your root directory a new folder was generated called coverage. Open the index.html file in this directory for a more interactive report. Here's a handy article on how to get the most out of this report. 

While 100% coverage is unreasonable to attain, you should be shooting for an overall target range somewhere between 60% to 85%. If you drop below the lower end of the range, it probably means your code is increasingly unstable overtime. Personally, I try to live by the idea that if I change some code I didn't originally write and a test doesn't break, then I have no idea what the code was supposed to do nor if it continues to do it. Sure manual testing might catch a drastic change in functionality, but it is poor at catching edge cases or changes that break unrelated functionality that otherwise used to work. More importantly, the coverage report now tells you which lines of code do not have tests around them. This is a good time to determine if some absolutely critical business logic is covered by tests or not. Who cares about commonly used display logic, if you have code that can lead to customer data loss, catastrophic failure, or just must absolutely work - this report will give you confidence (or lack thereof) in your code.
 

Conclusion

If you made it through the above steps, you should be set for writing stable testable code. 

Want to learn more? Check out this jest cheat sheet, React Snapshot testing, Sinon for gooder mocking, and finally, read this good article about testing at AirBnB, which has some really good dev practices. 

Found a typo? Am I wrong? Hit me up on twitter