Configuration, matchers, async testing, mocking, React Testing Library, snapshot testing, and coverage analysis.
Module 2 — Jest Mastery
Who this is for: Developers ready to dive into writing practical tests. Jest is the most widely adopted testing framework in the JavaScript ecosystem. In this module, we move beyond theory and get our hands dirty with real-world test cases, from configuration to React component testing.
Learning Objectives
By the end of this module, you will be able to:
- Configure Jest for modern JavaScript/TypeScript projects
- Write robust unit tests using Jest's extensive matchers
- Handle asynchronous code confidently
- Use mocks and spies to isolate logic
- Test React components effectively with React Testing Library
- Implement and interpret Snapshot tests
- Generate and read coverage reports
Setup & Configuration
Jest is an "all-in-one" powerhouse. It includes a test runner, an assertion library, and a mocking framework natively.
To set up Jest in a TypeScript project:
bashnpm install --save-dev jest typescript ts-jest @types/jest npx ts-jest config:init
This creates a jest.config.js file. A standard robust configuration often looks like this:
javascript/** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', // use 'jsdom' for React frontend clearMocks: true, // Automatically clear mock calls between tests coverageDirectory: 'coverage', collectCoverageFrom: [ 'src/**/*.{js,ts,jsx,tsx}', '!src/**/*.d.ts', ], };
Matchers: Beyond toBe
Jest provides a rich set of "matchers" to validate different types of data. Knowing which matcher to use makes your tests cleaner and failure messages more helpful.
javascript// Exact equality (Primitives) expect(2 + 2).toBe(4); // Deep equality (Objects & Arrays) expect({ name: 'Alice' }).toEqual({ name: 'Alice' }); // Truthiness expect(null).toBeNull(); expect(undefined).toBeUndefined(); expect(isAuthenticated).toBeTruthy(); // Strings expect('team').not.toMatch(/I/); // Arrays/Iterables expect(['apple', 'banana']).toContain('banana'); // Exceptions expect(() => compileCode()).toThrow(/compilation error/i);
Custom Matchers
If you find yourself writing repetitive assertions, you can extend Jest.
javascriptexpect.extend({ toBeEven(received) { const pass = received % 2 === 0; return { message: () => `expected ${received} to be an even number`, pass, }; }, }); expect(4).toBeEven();
Asynchronous Testing
JavaScript relies heavily on Promises and async/await. Jest handles async code seamlessly.
Using async/await (Recommended):
javascriptit('fetches user data successfully', async () => { const data = await fetchUser(1); expect(data.name).toBe('Alice'); });
Testing Promise Rejections:
javascriptit('throws an error for invalid IDs', async () => { await expect(fetchUser(-1)).rejects.toThrow('User not found'); });
Common Mistake: Forgetting to use await before expect().resolves or expect().rejects. Without await, the test will pass immediately before the promise settles.
Mocks & Spies
To achieve true unit tests, we must isolate the function we are testing from external dependencies like databases, networks, or random number generators.
Spies
Spies allow you to track how a function was called without replacing its implementation.
javascriptconst user = { getAge: () => 25 }; const spy = jest.spyOn(user, 'getAge'); console.log(user.getAge()); // Still returns 25 expect(spy).toHaveBeenCalledTimes(1);
Mocks
Mocks replace the implementation entirely. This is crucial for avoiding real network requests.
javascript// Mocking an entire module jest.mock('./api'); import { fetchUser } from './api'; it('mocks an API response', async () => { // Replace implementation fetchUser.mockResolvedValue({ id: 1, name: 'Mocked User' }); const user = await fetchUser(1); expect(user.name).toBe('Mocked User'); });
Timers
Testing setTimeout or setInterval manually is slow and flaky. Jest can mock time itself.
javascriptjest.useFakeTimers(); it('runs the callback after 1 second', () => { const callback = jest.fn(); setTimeout(callback, 1000); expect(callback).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); // Fast-forward time expect(callback).toHaveBeenCalledTimes(1); });
React Component Testing (React Testing Library)
While Jest is the runner, React Testing Library (RTL) is the industry standard for asserting on React components. RTL forces you to test components the way users interact with them, rather than testing implementation details.
tsximport { render, screen, fireEvent } from '@testing-library/react'; import { LoginForm } from './LoginForm'; it('allows a user to log in', () => { const handleLogin = jest.fn(); render(<LoginForm onLogin={handleLogin} />); // Find elements by accessible roles const emailInput = screen.getByRole('textbox', { name: /email/i }); const submitButton = screen.getByRole('button', { name: /submit/i }); // Act: Simulate user behavior fireEvent.change(emailInput, { target: { value: 'user@test.com' } }); fireEvent.click(submitButton); // Assert expect(handleLogin).toHaveBeenCalledWith('user@test.com'); });
Snapshot Testing
Snapshot tests capture the rendered output of a component and save it to a file. On subsequent runs, Jest compares the new output to the saved snapshot. If they differ, the test fails.
javascriptimport renderer from 'react-test-renderer'; import { Button } from './Button'; it('renders correctly', () => { const tree = renderer.create(<Button label="Click Me" />).toJSON(); expect(tree).toMatchSnapshot(); });
Best Practices for Snapshots:
- Keep them small: Giant snapshots of entire pages are impossible to review in Pull Requests.
- Treat them like code: Review snapshot changes carefully. Don't blindly run
jest -uto update them without understanding why they changed.
Coverage Reports
Running jest --coverage generates an interactive HTML report mapping out your exact code execution paths.
Coverage reports are excellent tools for discovering untested paths (e.g., an if statement you forgot to write a test for), but remember Module 1: coverage measures execution, not assertion quality.
Key Takeaways
- Jest is a comprehensive framework combining a runner, assertion library, and mocking.
- Extending matchers (like
.toBeEven()) can make domain-specific tests much cleaner. - Always remember to use
awaitwithexpect().resolvesorexpect().rejects. - Use React Testing Library to test components based on accessibility and user behavior, not internal state.
- Keep snapshots small and treat them like real code during code reviews.
Knowledge Check
Question 1: A junior developer writes a Jest test block containing three tests that verify the logic of an API client. The first test uses jest.spyOn(client, 'fetchData').mockResolvedValue('data'). The developer notices that the second and third tests are randomly failing because they seem to be receiving the mocked data from the first test instead of executing their own logic. What is the most robust way to prevent this test pollution?
- A) Manually call
client.fetchData.mockRestore()at the very end of every single test that uses the spy. - B) Set
clearMocks: truein thejest.config.jsfile to automatically reset mock usage data and implementations between every test. - C) Re-instantiate the
clientobject inside thebeforeAllblock to ensure a fresh instance is shared across all tests. - D) Use
jest.mock('./client')at the top of the file instead ofjest.spyOn, as module mocks are automatically scoped to individualitblocks.
Reveal Answer
Correct Answer: B
Test pollution occurs when state (like mock implementations or call counts) leaks from one test to another. While you could manually restore mocks in an afterEach block, relying on developers to remember this is error-prone. The industry best practice is to configure Jest to handle this globally. Setting clearMocks: true (and often restoreMocks: true) in jest.config.js guarantees that every test starts with a clean slate regarding mock calls and implementations, preventing hard-to-debug cascading failures.
Question 2: You are testing a React LoginForm component using React Testing Library (RTL). You need to simulate a user typing their email address into an input field defined as <input type="email" placeholder="Enter email" className="form-input" />. According to RTL best practices, which selector should you use to find this input?
- A)
screen.getByPlaceholderText('Enter email') - B)
screen.getByRole('textbox', { name: /email/i }) - C)
document.querySelector('.form-input') - D)
screen.getByTestId('email-input')
Reveal Answer
Correct Answer: B
React Testing Library's guiding philosophy is to interact with your DOM the same way users do. Screen readers and users rely on accessible roles and labels, not class names or data-testid attributes. Therefore, getByRole is the highest priority query. It ensures that your input is actually accessible (e.g., properly linked to a <label>). Querying by placeholder or test ID should only be used as fallbacks when roles are impossible to use, and document.querySelector directly violates RTL's encapsulation philosophy.
Question 3: An engineer writes the following test to verify that an asynchronous validation function properly rejects invalid data:
javascriptit('rejects invalid email formats', () => { expect(validateEmail('invalid')).rejects.toThrow('Invalid format'); });
The test passes. Later, someone accidentally changes the application code so that validateEmail silently returns true instead of throwing an error. Shockingly, the test still passes! Why did this false positive occur, and how do you fix it?
- A) The
toThrowmatcher only works on synchronous functions. The fix is to change it to.rejects.toBe('Invalid format'). - B) The test function is missing the
awaitkeyword beforeexpect(). Without it, the test finishes executing synchronously and passes before the Promise even resolves or rejects. The fix is to addasyncto the test callback andawait expect(...). - C) Jest automatically swallows unhandled promise rejections in test environments. The fix is to wrap the expectation in a
try/catchblock. - D) The string matcher
'Invalid format'is too strict and should be replaced with a regular expression/Invalid format/i.
Reveal Answer
Correct Answer: B
This is one of the most common and dangerous mistakes in Jest. When you use .resolves or .rejects, the expect statement itself returns a Promise. If you do not await that Promise (or return it), the test function reaches its end synchronously. Jest assumes that if a test function completes without throwing an error, the test passes. Therefore, the test passes immediately, long before the validateEmail promise actually settles, creating a massive false positive.
You now have a deep understanding of Jest for unit testing and frontend validation. But backend environments have their own challenges. Next, we will explore Backend Testing strategies including API validation and Database mocking.
Next: Module 3 — Backend Testing with Node.js →
Discussion
0Join the discussion