Featured resource
2025 Tech Upskilling Playbook
Tech Upskilling Playbook

Build future-ready tech teams and hit key business milestones with seven proven plays from industry leaders.

Check it out
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Guided: Integration Testing in JavaScript

Take your JavaScript testing skills beyond the basics in this hands-on guided lab focused on integration testing. You'll work with a real-world Express.js API and learn how to validate how different parts of your application work together. Using tools like Jest, Supertest, and mongodb-memory-server, you’ll write integration tests that are fast, and reliable.

Lab platform
Lab Info
Level
Beginner
Last updated
Sep 10, 2025
Duration
1h 7m

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.
Table of Contents
  1. Challenge

    Introduction

    Welcome to the Guided: Integration Testing with JavaScript Lab

    In this lab, you'll explore the fundamentals of integration testing by testing a simple RESTful API for a blogging platform. The application is built with Node.js and Express, and it focuses on writing tests that verify the interaction between different components, including endpoints, request/response handling, and external APIs.

    In this lab, you’ll test an API with the following capabilities:

    • View a list of users in the system.
    • Add new users with name and email.
    • View blog posts associated with each user.
    • Retrieve blog posts combined with simulated external data.
    • Handle common error cases such as missing input or failed external requests.

    The core of the application is an Express server with a set of routes that respond to HTTP requests. Throughout this lab, you will write integration tests to ensure those routes work as expected, checking both success and failure scenarios.

    Familiarizing with the Project Structure

    The application is structured as follows:

    • server.js – The main entry point for starting the Express server

    • app.js – The Express app containing routes and in-memory data logic

    • test.js – A test file where you’ll write integration tests using Supertest and Jest

    • package.json – Lists dependencies like express, jest, and supertest, and defines scripts to run the app and tests

    This is a server-side project with a simple front end component, but instead of interacting with a user interface, you’ll interact with the application using HTTP requests and assertions in your test code.

    How to Get Started

    Once the project loads, spend a few minutes reviewing the existing files in the editor. Here’s how you can get started:

    • Open app.js to see how the Express routes are defined and how the in-memory data works.

    • Open test.js to view the test file where you'll be adding and running integration tests.

    • Use the Terminal tab to perform the following command:

    npm start
    

    You should see output like this:

    Server is running on http://localhost:3000
    

    Switching to the Web Browser tab, you should see the frontend of this service.

    The frontend of the service

    Note: Sometimes it's required to refresh the browser. Just press the Refresh icon and the frontend should be displayed.

    You can play with the web app and explore its functionality. It's important to keep in mind that you're not going to focus on the service's frontend when writing your tests, but rather on its backend.

    Throughout the lab, you’ll build out the integration test cases, step by step.

    In the early stages, the tests may fail or be incomplete. That’s okay! You’ll implement the missing logic as you progress through each step.

    If you ever get stuck, refer to the solution directory for guidance or validation.

  2. Challenge

    Test Environment Setup

    Setting Up Your Test Environment and Writing an Integration Test

    Before diving into writing tests, it’s important to understand how the test environment is set up and how each part contributes to the integration testing process.

    In this step, you’ll explore the top section of the test.js file, specifically the imports, and the setup and teardown hooks using beforeAll, afterAll, and afterEach.

    First, you will learn what integration testing is and why it is important.

    What is Integration Testing?

    Integration testing focuses on verifying how different parts of an application work together. Instead of testing a single function in isolation (as in unit testing), you test multiple components interacting, like routes, middleware, data storage, and even external services. For example, sending an HTTP POST request to an Express route and ensuring the data is saved correctly mimics how the pieces integrate in real usage.

    This is different from unit testing, which tests small, isolated pieces of logic (like a single function) with no dependencies. On the other end of the spectrum is end-to-end testing, which simulates real user behavior through the full application stack—UI, backend, and database—often using tools like Cypress or Playwright. Integration testing sits in the middle: it's faster and more focused than E2E tests, but it still ensures that real-world interactions work correctly between components.

    Here is a break down of what is happening at the top of the test script:

    Imports
    const request = require('supertest');
    const { app, users } = require('./app');
    
    • supertest: A library that allows you to simulate HTTP requests to your Express app without starting a real server.
    • app: The Express instance you’re testing. Instead of hitting a running server, Supertest injects requests directly into this app.
    • users: The in-memory data store that holds user objects. You’ll use it to validate the server-side data state after API calls.
    • nock: An HTTP server mocking and expectations library for Node.js.
    Test Hooks
    beforeAll(() => {
      server = app;
    });
    
    afterEach(() => {
      nock.cleanAll();
      resetData();
    });
    
    • beforeAll: Runs once before all tests. This is used to assign the app import to the server variable for better readabilty.

    • afterEach: Runs after each individual test. Here you’re clearing the mocked objects so each test starts fresh with no leftover data.

    Additionally you can also use afterAll, which is performed after all of the tests inside the test file runs.

    Right after the test hooks, you should see a describe code block. Here, a group of tests are definied that ideally should target a single piece of functionality. This is called a test suite.

    In this case, those tests will target your server's user endpoints.

    Before jumping into writing integration tests, you will get familiar with the testing tools you'll be using. Here’s a breakdown of the most common patterns and syntax:

    Writing a Test with Jest

    Jest provides a simple syntax for structuring your tests.

    describe('Some feature or route', () => {
      it('should behave in a certain way', () => {
        // Test logic here
      });
    });
    
    • describe(): Groups related tests together.

    • it(): Defines an individual test case.

    • expect(): Used for assertions, verifying that the code behaved as expected.

    For example:

    expect(2 + 2).toBe(4);
    
    Making HTTP Requests with Supertest

    Supertest is a library that allows you to simulate HTTP requests to an Express app in a test environment.

    To use it:

    1. Import it and pass your Express app to it.
    2. Chain methods to build and send the request.
    const request = require('supertest');
    const response = await request(app)
      .post('/api/some-endpoint')
      .send({ key: 'value' })
      .expect(201); // Asserts the status code is 201
    
    Common Assertions

    After you make a request, you’ll often want to inspect the response and validate certain conditions using Jest’s expect function.

    Some examples:

    expect(response.status).toBe(201); // Check response status
    expect(response.body).toHaveProperty('id'); // Response has an ID
    expect(response.body.name).toBe('Alice'); // Check value
    

    You can also check the internal state of your application, like an in-memory array or mock database:

    const item = dataStore.find(entry => entry.id === response.body.id);
    expect(item).toBeDefined();
    expect(item.name).toBe('Alice');
    

    Now that you have the basics, you will start writing your first integration test with Jest and Supertest.

    Task Breakdown

    Here's the solution for this task:

      it('should create a new user successfully', async () => {
        const newUser = { name: 'John Doe', email: '[email protected]' };
    
        const response = await request(server)
          .post('/users')
          .send(newUser)
          .expect(201);
    
    

    This test verifies the /users endpoint by simulating a real API request to create a new user and checking that everything, from the HTTP response to the internal in-memory state, works as expected.

    it('should create a new user successfully', async () => {
    

    This is the start of a test case definition. The description clearly states the goal: to ensure that a new user can be created through the API.

    const newUser = { name: 'John Doe', email: '[email protected]' };
    
    const response = await request(server)
      .post('/users')
      .send(newUser)
      .expect(201);
    

    Here, you perform the following actions:

    • You define a mock user object that you’ll send in the body of the request.

    • request(server).post('/users') uses Supertest to send a POST request to the /users route.

    • .send(newUser) attaches the payload to the request.

    • .expect(201) asserts that the response status should be 201 Created, which confirms the server accepted and processed the request correctly.

    Now that you've added the action for creating users, you need to add assertions to validate that the test ran successfully and produced the expected outputs.
    Task Breakdown

    Here's the solution for this task:

        expect(response.body).toHaveProperty('id');
        expect(response.body.name).toBe(newUser.name);
        expect(response.body.email).toBe(newUser.email);
    
    

    After the request, you examine the response body:

    expect(response.body).toHaveProperty('id');
    expect(response.body.name).toBe(newUser.name);
    expect(response.body.email).toBe(newUser.email);
    
    • It should include an id property (meaning the user was successfully created and assigned a unique ID).

    • The name and email in the response should match what you sent.

    Integration tests not only check the response, but also verify that the backend logic actually did its job.

    Here, you directly inspect the in-memory users array:

    const storedUser = users.find(u => u.id === response.body.id);
    expect(storedUser).toBeDefined();
    expect(storedUser.name).toBe(newUser.name);
    

    You confirm that:

    • A user was added
    • The user's name matches what you expect

    This final check ensures your API isn’t just returning the right data, but it’s also modifying the system’s internal state correctly.

  3. Challenge

    Testing User and Post API

    Testing Retrieval of All Users

    After learning how to test user creation via POST /users, you're now going to verify that users can be retrieved through the GET /users endpoint.

    In integration testing, this means making a simulated request to your server and checking that it returns the correct list of users, just like a real client would.

    Making GET Requests in Tests

    To test GET /users, you’ll again use Supertest, which allows you to simulate HTTP requests in your test suite. Here's how a basic GET request looks in a test:

    const response = await request(server).get('/users').expect(200);
    

    This line tells Supertest to:

    • Make a GET request to /users.
    • Expect an HTTP 200 OK status.
    • Capture the response into a response variable for assertions.

    Asserting the Response

    Once you have the response, you'll typically want to:

    1. Check that the data is an array.
    2. Check how many users were returned.
    3. Optionally, check that individual objects match expected values.
    expect(response.body).toBeInstanceOf(Array);
    expect(response.body.length).toBe(2);
    expect(response.body[0].name).toBe('Alice');
    

    These assertions confirm:

    • The response is an array
    • It has two elements
    • The first user is named "Alice"
    Preparing the Test Environment

    Before testing the GET request, just like in the previous step, you’ll want to manually populate the in-memory users array with fake data. This mimics having users already created in your application:

    users.push(
      { id: 1, name: 'Alice', email: '[email protected]' },
      { id: 2, name: 'Bob', email: '[email protected]' }
    );
    

    This is important because you’re not hitting a real database, your test needs to control the state of the app directly.

    Task Breakdown

    Here's the full solution for this task:

      it('should fetch all users', async () => {
        users.push(
          { id: 1, name: 'Alice', email: '[email protected]' },
          { id: 2, name: 'Bob', email: '[email protected]' }
        );
    
        const response = await request(server).get('/users').expect(200);
    
      });
    

    This test verifies that the GET /users endpoint correctly returns all the users currently stored in memory. Here is a walk through for each part of the test:

    it('should fetch all users', async () => {
    

    This line defines the test case. It's a good practice to write a descriptive name so that the test output clearly indicates what is being tested.

      users.push(
        { id: 1, name: 'Alice', email: '[email protected]' },
        { id: 2, name: 'Bob', email: '[email protected]' }
      );
    

    Here, you manually add two user objects to the users array. Since you're not using a real database, this is how you simulate existing data before making the request.

     const response = await request(server).get('/users').expect(200);
    

    This line sends a GET request to the /users endpoint using Supertest. You should expect a successful HTTP status code of 200 OK. The response is stored for validation.

    Now that you've explored the `users` endpoint of your blogging platform, you might be wondering: What’s a blogging platform without users being able to post their stories?

    In this step, you'll test an endpoint that not only serves local application data, but also integrates information from an external service. This is a classic use case for integration testing: verifying how your application behaves when internal logic and external dependencies are combined.

    Why This Matters

    Unlike unit tests that isolate functionality, integration tests validate the collaboration between components. In this case, your Express server fetches posts from in-memory data and enriches them with details from an external API. You want to ensure this integration behaves correctly without depending on the actual external service during testing.

    About the External API

    Your application integrates with JSONPlaceholder, a free fake online REST API that’s often used for prototyping and testing. Specifically, the endpoint /todos/1 returns a single "to-do" item:

    {
      "userId": 1,
      "id": 1,
      "title": "delectus aut autem",
      "completed": false
    }
    

    In your Express app, the /posts-with-external-data endpoint combines this to-do item with local post data and appends a new property, externalInfo, to each post like this:

    {
      "id": 1,
      "title": "My First Post",
      "content": "Hello World",
      "authorId": 1,
      "externalInfo": "External task: \"delectus aut autem\""
    }
    

    Since relying on a real network request during testing would introduce flakiness (e.g., failing if offline or if the service changes), you’ll mock this API call using the nock library.

    External Integration Test Breakdown

    Here’s a step-by-step look at how such a test is structured:

    1. In-memory Setup: Push a user and a post into the in-memory arrays (users, posts), associating the post with the user by ID.

    2. Mocking External Call: Use nock to intercept the call to https://jsonplaceholder.typicode.com/todos/1 and return a predefined JSON response. Here's how you can achieve this using nock:

    nock('https://jsonplaceholder.typicode.com')
          .get('/todos/1')
          .reply(200, { id: 1, title: 'delectus aut autem', completed: false });
    

    To decouple yourselves from the actual jsonplaceholder API, nock will fake the GET request to the API and will return the response and status code specified in the reply() function.

    1. Calling the Endpoint: Use supertest to make a GET request to /posts-with-external-data.

    2. Assertions:

      • Confirm that the response is an array.
      • Confirm it has one post.
      • Check that the post title matches the in-memory post.
      • Verify that the mocked external info is correctly appended to the post.

    This is how a complete integration test should look like for an use case where an API combines data supplied by the user with external data.

    Task Breakdown

    Here's the complete solution for this task:

    it('should fetch posts and combine them with mocked external data', async () => {
        // Setup in-memory data
        users.push({ id: 1, name: 'Jane Doe', email: '[email protected]' });
        posts.push({ id: 1, title: 'My First Post', content: 'Hello World', authorId: 1 });
    
        nock('https://jsonplaceholder.typicode.com')
          .get('/todos/1')
          .reply(200, { id: 1, title: 'delectus aut autem', completed: false });
    
        const response = await request(server)
          .get('/posts-with-external-data')
          .expect(200);
    
        expect(response.body).toBeInstanceOf(Array);
        expect(response.body.length).toBe(1);
        expect(response.body[0].title).toBe('My First Post');
        expect(response.body[0].externalInfo).toBe('External task: "delectus aut autem"');
      });
    



    In-memory Setup

    Two data structures are populated:

    • A user object is pushed into the in-memory users array.

    • A post is pushed into the posts array with authorId linked to the user.

    This ensures the server has something to respond with when /posts-with-external-data is hit.



    Mocking the External API

    Instead of making a real HTTP request to the external API, nock is used to intercept the call to https://jsonplaceholder.typicode.com/todos/1. It returns a mocked response containing a task title: "delectus aut autem".

    This is a critical practice to keep tests fast, reliable, and independent from third-party APIs.



    Request and Assertion

    The endpoint /posts-with-external-data is called using supertest. The following assertions are made:

    • The response status is 200 OK.

    • The response body is an Array.

    • The first item has a title matching the mock post title.

    • A new property externalInfo exists and is correctly constructed using the external task title.

  4. Challenge

    Testing API Call Failure

    In real-world applications, your backend services often depend on third-party APIs. These external APIs might sometimes fail due to downtime, network issues, or rate limiting. When that happens, your server should handle the error gracefully and return a meaningful response to the client. In this step, you'll learn how to simulate such a failure and verify your application's behavior using integration tests.

    Introducing nock for Mocking External API Failure

    In the previous step, you used nock to simulate a successful response from an external service (jsonplaceholder.typicode.com). In this step, you’ll simulate a failure instead.

    Here’s how you can mock an external API failure:

    nock('https://jsonplaceholder.typicode.com')
      .get('/todos/1')
      .reply(500, { message: 'External Service Down' });
    

    This tells nock to intercept any HTP GETrequest to /todos/1 on that domain and return a 500 status code instead, along with a failure message.

    Writing the Test

    You want to test that your backend responds appropriately (e.g., with a 500 status and a relevant error message) when the external service fails. Here's how you do it step-by-step:

    1. Mock the external failure with nock.

    2. Make a request to your local API endpoint (/posts-with-external-data) using supertest.

    3. Assert that your server responds with HTTP 500 and the expected error message in the response body.

    You can use: expect(response.body.message).toBe('Error fetching combined data') to confirm that the error handling logic in your backend executed as intended.

    Task Breakdown

    This is the code of the solution for this task:

    it('should handle errors when the external API call fails', async () => {
        nock('https://jsonplaceholder.typicode.com')
          .get('/todos/1')
          .reply(500, { message: 'External Service Down' });
    
        const response = await request(server)
          .get('/posts-with-external-data')
          .expect(500);
    
        expect(response.body.message).toBe('Error fetching combined data');
      });
    

    The test below verifies how the server handles failure when an external API call returns an error. Take a look at the step-by-step breakdown. This part mocks the external API call:

    nock('https://jsonplaceholder.typicode.com')
      .get('/todos/1')
      .reply(500, { message: 'External Service Down' });
    

    It intercepts a GET request to /todos/1 on the jsonplaceholder.typicode.com domain and forces it to respond with an HTTP 500 status and a mock error message. This simulates a failure scenario that your backend needs to handle.

    const response = await request(server)
      .get('/posts-with-external-data')
      .expect(500);
    

    This makes an integration-level GET request to your backend route /posts-with-external-data. Since the external API is mocked to fail, your server should return a 500 as well, indicating an internal failure.

    expect(response.body.message).toBe('Error fetching combined data');
    

    This checks whether the error message returned by your API is descriptive and matches what the backend is supposed to send when it encounters a problem with the external service.

  5. Challenge

    Testing Error Handling

    In this step, you’ll learn how to write an integration test that ensures your application correctly handles unknown routes by returning a 404 Not Found response.

    This type of test is important because users (and clients) may attempt to access endpoints that do not exist. Your server should respond with a meaningful error message rather than crashing or returning a generic 500 error.

    By default, Express.js will not respond with anything if a route is not defined, unless you explicitly handle it. Most applications add a "catch-all" middleware at the end of the route declarations to handle unmatched paths.

    Your server, however, is actually handling non-existent routes as follows:

    app.use((req, res) => {
      res.status(404).json({ message: 'Not Found' });
    });
    

    You want to test that when someone tries to access a non-existent route, the server returns a 404 status and includes the expected message in the response body.

    How to Write This Test

    To build this test, you’ll use the same tools as before:

    • Jest for structuring the test and making assertions

    • Supertest for making requests to the server inside the test

    Here's how to approach it:

    • Use describe() to group the test under a meaningful label, like "Error Handling".

    • Use it() to define what the test should do (e.g., "should return 404 for a non-existent route").

    • Make a GET request using Supertest to an undefined route such as /non-existent-path.

    • Expect the server to respond with HTTP status code 404.

    • Verify that the response body contains a specific error message, and in this case, "Not Found").

    Task Breakdown

    Here’s a working example of the test:

    describe('Error Handling', () => {
      it('should return 404 for a non-existent route', async () => {
        const response = await request(server).get('/non-existent-path').expect(404);
        expect(response.body.message).toBe('Not Found');
      });
    });
    

    Here is a breakdown:

    • describe('Error Handling', () => { ... }): Creates a test suite for error handling behavior

    • it('should return 404 for a non-existent route', async () => { ... }): Defines what this test is verifying

    • await request(server).get('/non-existent-path').expect(404): Sends a request to an undefined route and checks that the server responds with a 404 status

    • expect(response.body.message).toBe('Not Found'): Asserts that the response contains the correct error message, ensuring the error handling logic is functioning

  6. Challenge

    What's Next?

    Congratulations on completing the Guided: Integration Testing with JavaScript lab!

    Throughout this hands-on lab, you’ve built a deeper understanding of how integration testing helps ensure that different parts of your application work together correctly. Here's what you've accomplished:

    • Learned how to write integration tests using Jest and Supertest

    • Created tests to verify core API endpoints like creating and fetching users

    • Practiced mocking external services with nock to simulate API dependencies

    • Handled edge cases such as failing external calls and invalid routes, ensuring robust error handling in your application

    Why Integration Testing is Important

    Integration testing sits between unit and end-to-end testing. It checks how multiple parts of your system interact, such as your route handlers, business logic, and data layers, without spinning up a full browser or relying on complex environments. This level of testing is fast, reliable, and crucial for spotting issues early in development.

    What’s Next?

    You can build on this foundation by:

    • Create PUT and DELETE endpoints and add tests for them.

    • Exploring tools like MSW (Mock Service Worker) for frontend API mocking.

    • Integrating coverage tools to measure test effectiveness.

    • Combining unit, integration, and end-to-end tests into a complete test suite.

    You've taken a major step toward writing reliable, maintainable JavaScript applications. Keep practicing and expanding your test coverage!

About the author

Laurentiu is working in fintech as a software developer engineer in test, with a strong emphasis on pentesting.

Real skill practice before real-world application

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.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight