Skip to main content

Testing with Jest

Testing is non-negotiable for professional software development. Shipping code without tests is like flying a plane without instruments — you might get lucky in clear weather, but when things get turbulent (and they always do), you are flying blind. In this chapter, you’ll learn how to write comprehensive tests for your Node.js applications using Jest and Supertest.

Why Testing Matters

Testing Pyramid

The testing pyramid is a guideline for how to distribute your testing effort. The idea is simple: tests at the bottom of the pyramid are fast, cheap, and isolated. Tests at the top are slow, expensive, and realistic. You want a lot of the cheap fast ones and just enough of the expensive slow ones to feel confident. Think of it like quality control at a factory: you check every individual component (unit tests), test how components fit together (integration tests), and occasionally run the whole assembled product through a real-world trial (E2E tests).

Setup

Node.js tip: Use jest --watch during development — it only re-runs tests related to files you have changed, giving you near-instant feedback. For CI/CD pipelines, use jest --ci which disables interactive features and produces machine-readable output.

Writing Your First Test

Jest Matchers

Matchers are the vocabulary of assertions — they let you express exactly what you expect. Choosing the right matcher makes your test failures more descriptive. When a test fails, Jest tells you what the matcher expected vs. what it received, so a precise matcher gives you a precise error message.

Testing Async Code

Most Node.js code is asynchronous — database queries, HTTP requests, file I/O. Testing async code requires telling Jest to wait for the operation to complete before checking assertions. There are three patterns, matching the three async styles in JavaScript: async/await, promises, and callbacks. Use whichever matches the code you are testing.
Common pitfall: Forgetting await before expect(...).rejects.toThrow(). Without the await, the test function returns immediately and Jest considers it passed — the rejection happens after the test finishes. Always await async assertions.

Mocking

Function Mocks

Mocking is like using a stunt double in a movie. You replace a real dependency (database call, HTTP request, email sender) with a fake that you control, so you can test your code in isolation without side effects. The mock records how it was called, letting you verify your code interacted with it correctly.

Module Mocks

Module mocks replace an entire imported module with mock versions of its exports. This is essential when testing code that depends on side-effect-heavy modules like email senders, payment processors, or third-party APIs — you do not want your test suite sending real emails or charging real credit cards.

Mocking Modules

Testing Express APIs with Supertest

Supertest lets you make HTTP requests against your Express app without starting a real server on a real port. It spins up the app in-process, sends the request, and returns the response — all synchronously within your test. This is much faster and more reliable than starting a server and hitting it with an HTTP client. The key architectural requirement: your app.js must export the app without calling .listen(). The .listen() call belongs in a separate server.js file. This separation lets Supertest create its own ephemeral server for each test.

Database Testing

Database tests are where the rubber meets the road. You need a real database to test queries, validations, and constraints — but you do not want tests touching your development or production data. The solution is an in-memory database that spins up fresh for each test run and disappears when tests finish. mongodb-memory-server downloads and runs a real MongoDB binary in memory. It is not a mock — it is the actual MongoDB engine, just running in a disposable sandbox. This means your tests exercise real database behavior, including indexes, validators, and aggregation pipelines.

Test Coverage

Coverage reports tell you which lines, branches, and functions in your codebase are exercised by your tests. They are a useful diagnostic tool, but they are not a quality metric — 100% coverage does not mean your tests are good, it just means every line was executed. You can hit 100% coverage with meaningless assertions. Coverage tells you what is NOT tested (which is very valuable); it cannot tell you whether what IS tested is tested well. A good rule of thumb: aim for 80%+ line coverage on critical paths (auth, payment, data validation) and do not stress about getting peripheral utilities to 100%.
Node.js tip: Add coverage/ to your .gitignore — coverage reports are generated artifacts, not source code. In CI/CD, use a service like Codecov or Coveralls to track coverage trends over time and catch regressions in pull requests.

Best Practices

  1. Name tests clearly — A test name should read as a specification: “should return 404 when user does not exist.” When it fails, the name alone should tell you what broke.
  2. One assertion per test — Keep tests focused on a single behavior. Multiple assertions are fine if they all verify the same logical outcome, but testing two unrelated things in one test makes failures ambiguous.
  3. Use beforeEach/afterEach — Ensure each test starts with a clean state. Without this, tests become order-dependent — they pass when run alone but fail in sequence (the most frustrating kind of flaky test).
  4. Don’t test implementation — Test what the function does (behavior), not how it does it (internals). If you refactor the implementation, tests should not break unless the behavior changes.
  5. Mock external services — Don’t make real API calls, send real emails, or hit real databases in unit tests. Mocks keep tests fast, deterministic, and free of network dependencies.
  6. Aim for 80%+ coverage — But 100% is not always the goal. Focus coverage on critical business logic, edge cases, and error paths. Trivial getters and setters rarely need tests.
  7. Run tests in CI/CD — Every push should trigger the full test suite. A test that only runs on the developer’s machine is a test that will eventually be ignored.

Test Organization

There are two common approaches to organizing test files, and both are valid. Choose one and be consistent: Co-location places the test file next to the source file it tests. This makes it easy to find the test for any given file and encourages developers to write tests alongside their code. It is the most popular pattern in the Node.js ecosystem. Separate directory places all tests in a __tests__/ folder, mirroring the source structure. This keeps the source tree clean and makes it easy to configure different tooling for test files.
Practical pattern: Use co-located files for unit tests (they test a single module) and a separate __tests__/integration/ directory for integration tests (they test multiple modules working together). This way, jest --testPathPattern=__tests__/integration lets you run integration tests separately from unit tests — useful because integration tests are slower and may require a running database.

Summary

  • Jest is the most popular testing framework for Node.js
  • Use describe/test to organize tests hierarchically
  • Matchers like toBe, toEqual, toThrow validate expectations
  • Mock external dependencies to isolate tests
  • Supertest makes API testing straightforward
  • Use in-memory databases for database tests
  • Aim for high coverage but prioritize critical paths
  • Write tests before or alongside code, not as an afterthought