How to install and configure Jest

How to install and configure Jest

The first step in any performance tuning exercise is to establish a solid baseline. Without a repeatable, reliable measurement of the current state, any changes we make are shots in the dark. We can’t know if we’ve made things better or, more insidiously, worse. This baseline doesn’t need to represent the absolute fastest the tests can run; its primary characteristic must be consistency. It is our scientific control, the fixed point against which we measure all subsequent changes.

We’ll begin with our existing test runner, which for this project is Jest. The choice of runner is less important than our ability to control its execution environment. Most modern test runners, including Mocha or Vitest, have configuration flags that can significantly impact run times, such as parallel execution or various caching mechanisms. For our initial baseline, we want to disable these features to get a clean, single-threaded measurement. This isolates the performance of the test code itself from the optimisations of the runner, giving us a clearer picture of the work being done.

Let’s consider a very simple test suite. It doesn’t need to do much, but it serves as a vehicle for our measurement process. We’ll create a file named initial-suite.test.js which contains a handful of trivial tests. The content of the tests is not the focus here; the structure and the act of running them is what matters.

describe('Baseline Performance Suite', () => {
  it('should execute a simple assertion', () => {
    expect(true).toBe(true);
  });

  // Imagine this suite contains hundreds of similar, simple tests
  it('is another placeholder test for measurement', () => {
    const a = 1;
    const b = 2;
    expect(a + b).toBe(3);
  });
});

To execute this and measure the time, we will use a specific command from our shell. The --runInBand flag is crucial here as it forces Jest to run all tests serially in the same process. This prevents the overhead and variability of spawning multiple worker threads, which is a common default behaviour. We also add --no-cache to ensure we aren’t getting a deceptively fast run due to Jest’s transform cache. The time utility prefixes the command to provide us with the execution duration.

time npx jest --runInBand --no-cache

After running this command several times-I recommend at least five to ten runs to start-we look at the real time output by the time utility. We might see results like 12.5s, 12.8s, 12.4s. We can take the average of these to establish our baseline: roughly 12.6 seconds. This number, however raw it may seem, is our foundation. It’s the stake we drive into the ground against which all future efforts will be judged. Any change we introduce, whether it’s refactoring application code or tweaking the test setup, must be validated against this initial figure. The goal is to see this number go down, and to understand precisely why it did. With this baseline established, our next step is to analyze where that time is being spent.

Tuning the test execution environment

To improve the performance of our test execution environment, we need to delve deeper into the factors that contribute to the overall time taken for our tests to run. This involves examining not just the tests themselves, but also the environment in which they execute. The configuration of our system, the resources allocated to the test runner, and even the state of the underlying dependencies can all play a significant role in execution time.

One of the first areas to investigate is the hardware and software environment where the tests are executed. Ideally, tests should be run on a dedicated machine or container that mirrors the production environment as closely as possible. This reduces variability caused by other processes or services that may interfere with the timing of our tests. In addition, ensuring that the machine is not under heavy load during test execution can yield more consistent results.

Another critical aspect is the configuration of the test runner itself. Beyond simply running tests in sequence, we can adjust the settings related to resource allocation. For example, increasing the memory available to the test process may help if tests are being limited by resource constraints. Most runners allow you to specify memory limits or even the number of worker threads, which, while we are avoiding parallel execution for our baseline, may be worth investigating once we have a clearer picture of the performance profile.

Additionally, the state of external dependencies can affect performance. For instance, tests that rely on a database should be mindful of connection pooling and transactional overhead. If tests are continually hitting a database for reads or writes, the time taken for these operations can accumulate quickly. Utilizing in-memory databases or mocking database interactions can drastically reduce the time spent on these operations during the test run.

Once we’ve tuned the environment, we can start looking into the tests themselves. This is where defining seams with test doubles becomes useful. Test doubles allow us to isolate units of code and control the environment in which they run. By using mocks or stubs, we can simulate the behavior of complex dependencies without incurring the overhead of actual interactions. This can lead to faster, more reliable tests.

jest.mock('module-name', () => {
  return {
    methodName: jest.fn(() => 'mocked value'),
  };
});

By strategically placing test doubles, we can reduce the load on our system and focus on the performance of the code under test. This not only speeds up execution but also allows us to pinpoint whether performance issues arise from the unit itself or from its dependencies. It’s essential to strike a balance; while test doubles can provide speed, overusing them may lead to a false sense of security regarding the correctness of the code.

As we refine our test suite, we should also consider the granularity of our tests. Fine-grained tests that check small units of functionality can be beneficial, but they may also result in a large number of tests that collectively take up significant time to execute. Here, we must evaluate the trade-off between the speed of execution and the confidence that our tests provide. Sometimes, consolidating tests that cover similar functional areas can yield a more efficient testing process without sacrificing coverage.

With these strategies in place, we can begin to iterate on our test suite with a keen eye on performance. Each adjustment should be measured against our established baseline, allowing us to observe the impact of our tuning efforts. As we make these changes, we should remain vigilant for any unexpected consequences that might arise, ensuring that our tests not only run faster but also maintain their reliability and accuracy.

Defining seams with test doubles

With our execution environment tuned, the most significant performance gains often come from changing what the tests themselves do. The key is to isolate the code under test from its slow or unpredictable dependencies. To do this, we must identify and exploit the “seams” in our application-places where we can alter behaviour without modifying the code in that place. In an object-oriented system, a method call on another object is a common seam. If we can control the object being called, we can control the behaviour of our system during a test.

This is the role of a Test Double. It’s a generic term for any object we substitute for a real one for testing purposes. When a test runs, instead of our code calling a real database, a real web service, or a complex domain object, it calls our test double. The double doesn’t do any real work; it provides just enough behaviour for the test to run, typically returning a canned response almost instantaneously. This breaks the dependency on slow external systems, which are a frequent cause of long test runs.

Consider a service that generates a user report. It depends on a data access object to fetch user data from a database. The real data access object might take hundreds of milliseconds to execute a query.

// services/user-service.js
import { fetchUserData } from '../data/database-connector';

export class ReportGenerator {
  async generateFor(userId) {
    const userData = await fetchUserData(userId); // This is a slow network/DB call
    // Complex report generation logic...
    return Report for ${userData.name};
  }
}

// data/database-connector.js
export async function fetchUserData(userId) {
  // Simulates a slow database query
  await new Promise(res => setTimeout(res, 500));
  return { id: userId, name: 'Alex' };
}

Testing ReportGenerator directly would mean each test incurs that 500ms delay. For a suite with hundreds of such tests, the runtime quickly becomes untenable. By using a test double, we can create a seam around the database-connector module. We instruct our test runner to substitute it with a controlled, in-memory fake that returns data immediately.

// tests/report-generator.test.js
import { ReportGenerator } from '../services/user-service';
import { fetchUserData } from '../data/database-connector';

jest.mock('../data/database-connector');

describe('ReportGenerator', () => {
  it('should generate a report with the user name', async () => {
    fetchUserData.mockResolvedValue({ name: 'Alex' });

    const generator = new ReportGenerator();
    const report = await generator.generateFor('user-123');

    expect(report).toBe('Report for Alex');
  });
});

In this test, the call to fetchUserData no longer triggers a database query. Jest intercepts the call and redirects it to our mock implementation, which resolves instantly with the data we specified. The test now measures the performance of the ReportGenerator‘s logic in isolation, not the latency of our infrastructure. The test becomes faster, more reliable, and completely independent of external state.

Of course, this introduces a significant trade-off. Our fast test now tells us nothing about whether ReportGenerator can correctly integrate with the *real* database-connector. We are trading integration fidelity for unit-level speed and isolation. This is often a worthwhile trade, but it implies that we need a smaller, separate suite of integration tests that do exercise the real components together. The goal is not to eliminate slow tests entirely, but to be deliberate about when and why we pay their cost.

I would be interested to learn about the ways you have used test doubles to define seams and manage the performance of your own test suites.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *