- Lab
- Core Tech

Guided: JavaScript Unit Testing with Jest
Supercharge your JavaScript code with confidence! This guided Code Lab will walk you through the essentials of unit testing using Jest. You'll learn how to set up your testing environment, write effective tests for various scenarios, and leverage powerful features like mocking and spying to ensure the reliability and maintainability of your JavaScript applications.

Path Info
Table of Contents
-
Challenge
Introduction
Guided: JavaScript Unit Testing with Jest
In this guided Code Lab, you'll learn the essentials of unit testing using Jest, one of the most popular JavaScript testing frameworks. Throughout the lab, you will develop a simplified checkout system for a web store using Test-Driven Development (TDD).
You'll start by exploring how to set up a project with Jest, then write comprehensive tests for various scenarios, and finally learn how to use advanced features like mocks and spies to ensure your JavaScript applications are reliable and maintainable.
All this while using TDD, a methodology that will help you write better code by writing tests first, and then implementing the code to make the tests pass. You'll learn how to use:
- The Red-Green-Refactor cycle
- The AAA pattern (Arrange, Act, Assert), AKA Given-When-Then
- Test doubles: Mocks, Spies, and Stubs
- Chicago vs London styles of TDD
Prerequisites
Before starting this Code Lab, you should have:
- Basic understanding of JavaScript syntax and concepts (variables, functions, modules)
- Familiarity with the command line interface (terminal) # Introduction to Jest and Automated Testing
When you start a software project, you might be tempted to rely on manual testing: clicking through your application to verify it works correctly. This approach works well when the project is small, but it quickly becomes unmanageable as your codebase grows. Manual testing is error-prone, time-consuming, and difficult to repeat consistently with every code change.
That's why you want to use automated testing, code that tests your code. There are several types of automated testing, each serving different purposes:
- End-to-End (E2E) Tests follow complete user journeys through your application, simulating real user interactions from start to finish. These are comprehensive but slow and can be brittle.
- Integration Tests verify that different components of your application work together correctly, testing the interactions between modules or services.
- Unit Tests isolate a single class, file, or function to test it in complete isolation. They mock (more on this later) external dependencies to control their behavior, ensuring you're only testing the specific unit of code. Unit tests are fast, reliable, and provide immediate feedback during development.
- Regression Tests ensure that changes to the codebase do not break existing functionality.
- Snapshot tests capture the output of a component and compare it to a previous version, to catch unintended changes.
Test-Driven Development (TDD) is a methodology that introduces tests from the very beginning of development. TDD follows a "Red-Green-Refactor" cycle: write a failing test, write minimal code to make it pass, then refactor while keeping tests passing. This approach ensures you write more testable, modular code and forces you to think about requirements before implementation.
Behavior-Driven Development (BDD) is a methodology that focuses on describing the behavior of the system in a way that is easy for non-technical stakeholders to understand.
Mocking is a technique that allows you to replace a real object with a fake one, to control its behavior and ensure that the unit under test is tested in complete isolation.
Introduction to Jest
Jest is a comprehensive JavaScript testing framework developed by Meta (Facebook). It has become the most popular choice for JavaScript testing because it provides:
Key Features
- Zero Configuration - Works out of the box with sensible defaults
- Built-in Test Runner - No need for additional tools to run tests
- Assertion Library - Rich set of matchers for making assertions
- Mocking Capabilities - Powerful mocking and spying features
- Code Coverage - Built-in coverage reports without additional setup
- Snapshot Testing - Capture component output for regression testing
- Parallel Execution - Runs tests in parallel for better performance
Why Choose Jest?
- Developer Experience - Excellent error messages and debugging support
- Community Adoption - Widely used in the JavaScript ecosystem
- Framework Agnostic - Works with React, Vue, Angular, Node.js, and vanilla JavaScript
- Active Development - Regularly updated with new features and improvements
- Comprehensive Documentation - Well-documented with extensive examples
info> While working through this lab, if you get stuck or would like to compare solutions, a
solution
directory has been provided for you in the filetree. -
Challenge
Step 0: Setting up the project
Step 1: Setting up the project
Installing Jest
Note: In this lab environment, Jest is already installed and configured since the environment doesn't have internet access. This allows you to focus on learning testing concepts rather than setup procedures. However, the installation instructions are included for completeness.
In a typical project, you would install Jest as a development dependency using npm:
# Install Jest npm install --save-dev jest
After installation, you would add a test script to your
package.json
:{ "scripts": { "test": "jest" } }
Then you could run tests using:
npm run test
-
Challenge
Step 1: Basic Calculator with TDD
Step 2: Basic Calculator with TDD
What is TDD?
Many developers use automated tests, but they write them after the code is written, which has a few limitations:
- It's easy to come up with code that's hard to test
- Some tests might not end up making meaningful assertions, or might not be testing edge cases or complete features.
The idea behind TDD is that you use tests to guide the development of your code:
- Red: Write a test that fails. This is key to ensure you're using meaningful assertions. Never trust a test you haven't seen fail.
- Green: Write the simplest code that makes the test pass. This ensures that you do not add features that are not tested: to add a new complex feature you should break it down into smaller parts, each one covered by a test.
- Refactor: Improve the code without changing the behavior. This ensures that your code is readable and maintainable, and that you are not adding unnecessary complexity. Both tests and code evolve over time, tests will be deleted or updated as the code evolves.
This is a cycle that you repeat until you have a working solution.
Many developers use TDD, and many use variations of it. There are two main styles of TDD:
- Chicago style (inside-out): start from the domain and work your way out to the edges.
- London style (outside-in): start from the edges (API, UI, etc.) and work your way in to the domain, relying on mocks and stubs.
Project Setup
The project is already set up with Jest configured. You can run the tests with:
npm test
Basic Calculator
In this step, you will start building a checkout system using the Chicago style of TDD. You will write a simple module to calculate the total price of a shopping cart, including taxes and discounts. info> You can create new files by clicking the three dots next to the desired folder and selecting New file. Alternatively you can navigate to your project folder via the Terminal tab and enter
touch file_name
. Please note, sometimes new files do not automatically appear in the filetree. If you are experiencing this, collapse the containing folder within the filetree and expand it to refresh it's contents. -
Challenge
Step 2: London Style TDD and Test Doubles
Step 2: London Style TDD and Test Doubles
So far, you've been using the Chicago style of TDD (inside-out), where you start with the core domain logic and work your way out to the edges. Now you'll learn the London style of TDD (outside-in), where you start from the edges (like APIs, user interfaces, or service boundaries) and work your way in to the domain.
What are Test Doubles?
When testing code that depends on other components (like databases, APIs, or external services), you don't want your tests to actually use those real components. This is because:
- Real components are slow (database queries, network calls)
- Real components can fail (network issues, service downtime)
- Real components have side effects (sending emails, charging credit cards)
- Real components are hard to control (you can't easily simulate errors or ensure initial state)
Test doubles are fake implementations that replace real components during testing. They allow you to:
- Control the behavior of dependencies
- Test different scenarios (success, failure, edge cases)
- Make tests fast and reliable
- Avoid side effects
Types of Test Doubles
- Mocks: Verify that methods are called with specific arguments
- Spies: Track method calls without changing behavior
- Stubs: Return predefined values without executing real logic
- Fakes: Simplified implementations that work like the real thing
What You'll Build
You'll create a
CheckoutService
that uses yourCartCalculator
to process orders. This service will:- Get cart items from a repository (which you'll mock)
- Calculate the total using your CartCalculator
- Save the order back to the repository
This demonstrates how to test services that depend on other components using test doubles.
Understanding Dependencies and Mocks
Your
CheckoutService
will need to interact with other components:- A cart repository to get the user's cart items
- A calculator to compute totals
Instead of using real implementations, you'll use mocks - fake objects that simulate the behavior of real dependencies.
Jest provides several ways to create mocks:
Method 1: jest.fn() - creates a mock function
const mockFunction = jest.fn(); mockFunction.mockReturnValue("fake result");
When you call
mockFunction
, it will return the value you set withmockReturnValue
.Method 2: jest.fn() with implementation
const mockCalculator = { calculateTotal: jest.fn().mockReturnValue(100), };
This example creates a mock object, and uses
jest.fn()
to provide a mock implementation for thecalculateTotal
method. When you callmockCalculator.calculateTotal
, it will return the value you set withmockReturnValue
.Method 3: jest.spyOn() - spies on existing objects
const realObject = { method: () => "real" }; jest.spyOn(realObject, "method").mockReturnValue("fake");
This example creates a real object, and uses
jest.spyOn()
to spy on themethod
method. When you callrealObject.method
, it will return the value you set withmockReturnValue
. To reset the mock, you can usejest.restoreAllMocks()
.Asserting on mocks
The advantage of using a mock or spy rather than implementing a fake object (for example with an arrow function that returns a value) is that you can assert on the mock or spy, to verify that the method was called with the correct arguments.
Jest provides several matchers to assert on mocks and spies:
toHaveBeenCalledWith
- checks if the mock or spy was called with specific argumentstoHaveBeenCalled
- checks if the mock or spy was calledtoHaveBeenCalledTimes
- checks if the mock or spy was called a specific number of timestoHaveBeenLastCalledWith
- checks if the mock or spy was called with the last set of arguments
expect(mockFunction).toHaveBeenCalledWith(1, 2, 3); expect(mockFunction).toHaveBeenCalled(); expect(mockFunction).toHaveBeenCalledTimes(1); expect(mockFunction).toHaveBeenLastCalledWith(1, 2, 3); ``` # A word on London style TDD Notice how you wrote the tests before implementing the service (TDD), and how you designed the service interface while writing the tests without needing concrete implementations of the dependencies. This demonstrates the power of London style TDD: you can mock dependencies like databases and APIs, or complex business logic components, and develop your service incrementally. This approach to mocking emerges naturally when you write tests before the service, and the service before its dependencies. Many developers criticize excessive mocking in testing, and they have a valid point: if you mock too much, you're testing the mocks rather than the real code. This problem typically occurs when you write tests after implementing the code and dependencies, and simply mock dependencies to simplify testing. In that scenario, you're not practicing TDD or London style TDD, and the mocks aren't helping you design the code. When practicing London style TDD, you mock dependencies while still making meaningful assertions about your service's implementation and its interactions with dependencies. Later, you can add integration tests using real dependencies to ensure your mocks accurately reflect the actual dependency interfaces. ## The Given-When-Then Pattern When writing tests, it's helpful to structure them in a way that clearly communicates the intent. The **Given-When-Then** pattern (also known as **Arrange-Act-Assert**) helps you organize your test code into three clear sections: - **Given** (Arrange): Set up the test data and preconditions - **When** (Act): Execute the code you're testing - **Then** (Assert): Verify the results and behavior This pattern makes tests more readable and helps you think about what you're actually testing. For example: ```javascript test("should calculate total for user's cart", () => { // Given (Arrange) const mockCartRepo = { getCart: jest.fn().mockReturnValue([{ price: 10, quantity: 2 }]), }; const mockCalculator = { calculateTotal: jest.fn().mockReturnValue(25.5), }; const checkoutService = new CheckoutService(mockCartRepo, mockCalculator); // When (Act) const total = checkoutService.checkout("user123", 10, 8.25); // Then (Assert) expect(total).toBe(25.5); expect(mockCartRepo.getCart).toHaveBeenCalledWith(userId); expect(mockCalculator.calculateTotal).toHaveBeenCalledWith( [{ price: 10, quantity: 2 }], 10, 8.25 ); });
Testing Error Conditions
In real applications, things don't always go as planned. Your code needs to handle errors gracefully, and your tests should verify this behavior.
Jest provides several ways to test for exceptions:
// Method 1: expect().toThrow() - tests that a function throws an error expect(() => { someFunctionThatThrows(); }).toThrow("Expected error message"); // Method 2: expect().toThrowError() - tests for specific error types expect(() => { someFunctionThatThrows(); }).toThrowError(TypeError); // Method 3: expect().rejects.toThrow() - for async functions that throw await expect(asyncFunction()).rejects.toThrow("Error message");
You can also test that functions do NOT throw errors:
expect(() => { someFunctionThatShouldNotThrow(); }).not.toThrow();
If you need one of your mocks to throw an error, you can use
mockImplementation
to throw an error. This is useful for testing error handling:const mockFunction = jest.fn().mockImplementation(() => { throw new Error("some error message"); });
-
Challenge
Step 3: Advanced Testing Techniques
Step 3: Advanced Testing Techniques
So far, you've learned the fundamentals of TDD and test doubles. Now you'll explore advanced testing techniques that you can use to write more maintainable, efficient, and comprehensive tests.
Advanced Techniques Overview
In this step, you'll learn about:
- Test Fixtures and Factories: Creating reusable test data
- Parameterized Tests: Testing multiple scenarios with a single test
- Custom Matchers: Creating domain-specific assertions
- Snapshot Testing: Capturing and comparing output snapshots
These techniques help you:
- Write tests faster with reusable components
- Cover more scenarios with less code
- Make tests more readable and maintainable
- Handle complex testing scenarios efficiently
Test Fixtures and Factories
When writing tests, you often need to create test data. Instead of repeating the same data creation code, you can use test fixtures (predefined test data) and factories (functions that create test data).
Test Fixtures are static data objects that represent common test scenarios:
const testUsers = { regularUser: { id: "user1", name: "John Doe", email: "[email protected]" }, adminUser: { id: "admin1", name: "Admin User", email: "[email protected]", role: "admin", }, newUser: { id: "user2", name: "Jane Smith", email: "[email protected]" }, };
Factories are functions that create test data with default values that can be overridden:
const createUser = (overrides = {}) => ({ id: "user" + Math.random().toString(36).substr(2, 9), name: "Test User", email: "[email protected]", role: "user", ...overrides, }); // Usage: const user = createUser({ name: "John", role: "admin" }); ``` ## Parameterized Tests When you have multiple test scenarios that follow the same pattern but with different input data, **parameterized tests** allow you to write a single test that covers all scenarios. This reduces code duplication and makes it easier to add new test cases. Jest provides several ways to create parameterized tests: 1. **Using `test.each()`**: The most common approach 2. **Using `describe.each()`**: For grouping related parameterized tests 3. **Using `it.each()`**: Alternative syntax for `test.each()` Here's an example of parameterized tests: ```javascript test.each([ [2, 3, 5], [0, 0, 0], [-1, 1, 0], [10, 20, 30], ])("adds %i + %i to equal %i", (a, b, expected) => { expect(add(a, b)).toBe(expected); });
As you can see, you pass an array of arrays to
test.each
. Each inner array contains the arguments for a single test case. These arguments are then passed to the test function, and you can use them to test the function with different inputs. You can also use printf-style format specifier (e.g.%i
for integers,%s
for strings,%f
for floats, etc.) in template literals using%
to make the test cases more readable.The advantage of using
test.each
is that you still see each case as a single separate test when you run the tests withnpm run test
, so you can see which cases fail and which pass in the output. ## Custom MatchersJest's built-in matchers like
toBe()
,toEqual()
, andtoContain()
are powerful, but sometimes you need domain-specific assertions that make your tests more readable and expressive. Custom matchers allow you to create your own assertion functions that encapsulate complex validation logic.Why Use Custom Matchers?
Custom matchers can:
- Make tests more readable:
expect(user).toBeValidUser()
is clearer than checking multiple properties - Encapsulate complex validation: Put complex business logic in one place
- Provide better error messages: Give domain-specific feedback when tests fail
- Reduce code duplication: Reuse validation logic across multiple tests
How Custom Matchers Work
Jest provides the
expect.extend()
function to add custom matchers. Each custom matcher:- Receives the actual value being tested
- Returns an object with
pass
(boolean) andmessage
(function). The message will be shown when the assertion fails. This can be a little bit confusing, but whenpass
istrue
, you should return the message to be shown whenfalse
was expected, and vice versa. - Can accept additional parameters for comparison
expect.extend({ toBeValidUser(received) { const pass = received.name.length > 0; /* a boolean indicating whether the user is valid*/ return { pass, // If pass is true, the message should indicate you expected an invalid user, and vice versa message: () => `expected ${received} ${pass ? "not" : ""} to be a valid user`, }; }, });
Example: Built-in vs Custom Matchers
Without custom matchers: Each time you want to make assertions on a user, you need to write multiple assertions, duplicating the same logic across all test cases.
test("should validate user", () => { const user = { id: "123", name: "John", email: "[email protected]" }; expect(user).toBeDefined(); expect(typeof user.id).toBe("string"); expect(user.id.length).toBeGreaterThan(0); expect(typeof user.name).toBe("string"); expect(user.name.length).toBeGreaterThan(0); expect(typeof user.email).toBe("string"); expect(user.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); });
With custom matchers: You can create a custom matcher that encapsulates the validation logic and use it in all test cases. This makes the code more readable and maintainable.
test("should validate user", () => { const user = { id: "123", name: "John", email: "[email protected]" }; expect(user).toBeValidUser(); });
expect.extend({ toBeValidUser(received) { const pass = received && typeof received.id === "string" && received.id.length > 0 && typeof received.name === "string" && received.name.length > 0 && typeof received.email === "string" && received.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); return { pass, message: () => `expected ${received} ${pass ? "not" : ""} to be a valid user`, }; }, });
The custom matcher encapsulates all the validation logic and provides a clear, domain-specific assertion. ## Snapshot Testing
Snapshot testing is a powerful technique for testing the output of functions that return complex data structures. Instead of manually specifying expected values, you capture a "snapshot" of the current output and compare future runs against it.
How Snapshot Testing Works
- First Run: When you run a snapshot test for the first time, Jest captures the output and saves it to a
.snap
file - Subsequent Runs: Jest compares the current output with the saved snapshot
- Changes: If the output changes, Jest shows you exactly what changed and asks if you want to update the snapshot
When to Use Snapshot Testing
Snapshot testing is particularly useful for:
- Testing complex object structures - API responses, configuration objects
- Testing UI components - React components, HTML output
- Testing data transformations - When the exact structure matters
- Regression testing - Catching unexpected changes
Example: Manual vs Snapshot Testing
Manual testing (complex and error-prone):
test("should generate user profile", () => { const profile = generateUserProfile(user); expect(profile.id).toBe("user123"); expect(profile.name).toBe("John Doe"); expect(profile.email).toBe("[email protected]"); expect(profile.preferences.theme).toBe("dark"); expect(profile.preferences.language).toBe("en"); expect(profile.settings.notifications.email).toBe(true); expect(profile.settings.notifications.push).toBe(false); // ... many more assertions });
Snapshot testing (simple and comprehensive):
test("should generate user profile", () => { const profile = generateUserProfile(user); expect(profile).toMatchSnapshot(); });
The snapshot automatically captures all properties and their values, making it much easier to maintain.
Note that the snapshot test will fail if the output changes, and you will need to update the snapshot, so you need to:
- Ensure the initial snapshot is correct
- Update the snapshot when the output changes
- Review the changes and ensure they are intentional
This means that snapshot tests are not a good fit for every case, for example, if the output is changing frequently, or if the output is not deterministic.
Handling Dynamic Values
Since snapshots are exact matches, you need to handle dynamic values like timestamps, IDs, or random values:
test("should generate order with dynamic values", () => { const order = generateOrder(user, items); // Remove or replace dynamic values const orderForSnapshot = { ...order, id: expect.any(String), timestamp: expect.any(String), }; expect(orderForSnapshot).toMatchSnapshot(); }); ``` ## Step 3 Summary You've now learned several advanced testing techniques: 1. **Test Fixtures and Factories** - Reusable test data for maintainable tests 2. **Parameterized Tests** - Efficient testing of multiple scenarios 3. **Custom Matchers** - Domain-specific assertions for better readability 4. **Snapshot Testing** - Testing complex output structures These techniques help you write more maintainable, efficient, and comprehensive test suites. They're commonly used in professional development to handle real-world testing challenges.
-
Challenge
Conclusion
Conclusion
Congratulations! You've successfully completed this journey through JavaScript unit testing with Jest. Throughout this lab, you've learned essential testing concepts and practical techniques that will help you write more reliable, maintainable, and robust JavaScript applications.
What You've Accomplished
You've built a complete checkout system using Test-Driven Development (TDD), mastering both Chicago style (inside-out) and London style (outside-in) approaches. You've learned Jest's powerful features including assertions, matchers, test doubles (mocks, spies, stubs), parameterized tests, custom matchers, and snapshot testing. You've practiced writing tests first, following the Red-Green-Refactor cycle, and using test doubles to isolate units under test.
Next Steps
To continue your testing journey, explore integration testing, end-to-end testing with tools like Cypress, performance testing, and integrating tests into your development workflow. There are many other courses and labs in Plurlasight covering these topics.
Remember to write tests first, keep them focused, and use appropriate test doubles to maintain fast, reliable test suites. Happy testing!
What's a lab?
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Provided environment for hands-on practice
We will provide the credentials and environment necessary for you to practice right within your browser.
Guided walkthrough
Follow along with the author’s guided walkthrough and build something new in your provided environment!
Did you know?
On average, you retain 75% more of your learning if you get time for practice.