- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech

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 Info
Table of Contents
-
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 likeexpress
,jest
, andsupertest
, 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.
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. -
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 usingbeforeAll
,afterAll
, andafterEach
.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 theapp
import to theserver
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:
- Import it and pass your Express app to it.
- 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.
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.
-
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 theGET /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 TestsTo test
GET /users
, you’ll again use Supertest, which allows you to simulate HTTP requests in your test suite. Here's how a basicGET
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:
- Check that the data is an array.
- Check how many users were returned.
- 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
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?GET
request to the/users
endpoint using Supertest. You should expect a successful HTTP status code of200 OK
. The response is stored for validation.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:
-
In-memory Setup: Push a user and a post into the in-memory arrays (
users
,posts
), associating the post with the user by ID. -
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 usingnock
:
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 theGET
request to the API and will return the response and status code specified in thereply()
function.-
Calling the Endpoint: Use supertest to make a
GET
request to/posts-with-external-data
. -
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-memoryusers
array. -
A
post
is pushed into theposts
array withauthorId
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 usingsupertest
. 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.
- Make a
-
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 FailureIn 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 anyHTP GET
request 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:
-
Mock the external failure with
nock
. -
Make a request to your local API endpoint (
/posts-with-external-data
) usingsupertest
. -
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 thejsonplaceholder.typicode.com
domain and forces it to respond with anHTTP 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 a500
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.
-
-
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 a404
status -
expect(response.body.message).toBe('Not Found')
: Asserts that the response contains the correct error message, ensuring the error handling logic is functioning
-
-
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
andDELETE
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
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.