W06 Learning Activity: Testing Express Web Services
Overview
- Purpose: Learn how to test real Express endpoints with Supertest and a temporary MongoDB database.
- Task: Study the examples and prepare to add automated API tests to your team project.
In the previous activity, you learned how to use Vitest to run code and compare actual results with expected results. The same process works for a web service. Instead of calling a small math function, the test sends an HTTP request. Instead of checking a single number, the test can check the response status, JSON body, headers, and database records.
Why Web Services Are Well Suited for Testing
A web service separates the backend from the presentation layer. The backend does not need to know whether a request came from a website, a mobile application, Swagger, or an automated test. It receives an HTTP request and returns an HTTP response.
This separation gives you a useful testing boundary:
- The test sends an HTTP request to the Express application.
- Express runs the real route, middleware, controller, and model code.
- The application returns an HTTP response.
- The test checks whether the response matches the API contract.
These tests check the backend in the same general way that another application uses it. They do not need to open a browser or click through a user interface.
Test observable behavior
Clients care about the request and response contract. A useful API test normally checks the status code and an important part of the response body. It may also check that the correct database change occurred.
Vitest and Supertest
Vitest and Supertest are both test libraries. Vitest is the test runner. It finds the test files, runs each test, provides functions such as test() and expect(), and reports which tests passed or failed.
Supertest is a library for sending requests to a web application from test code. It acts like a small API client inside the test. It can create GET, POST, PUT, PATCH, and DELETE requests, send request data, and return the response for the test to inspect.
Supertest does not replace Vitest, and Vitest does not send HTTP requests by itself. You use the two tools together:
- Vitest starts a test.
- The test asks Supertest to send a request to the Express application.
- Express runs the matching route, middleware, controller, and model code.
- Supertest gives the Express response back to the test.
- Vitest uses
expect()to compare the response with the expected behavior.
const response = await request(app).get('/api/products');
expect(response.status).toBe(200);
In this example, request(app).get('/api/products') belongs to Supertest. It sends the request and captures the response. expect(response.status).toBe(200) belongs to Vitest. It checks whether the response contains the expected status code.
Supertest can work directly with the Express app object. You do not need to start the application manually, choose a test port, or send a request to the deployed Render site. This makes the tests faster and keeps them focused on the local application code.
Preparing Express for Tests
Supertest needs the Express app object. It does not need the application to listen on a network port. For this reason, application creation and server startup should be placed in separate files.
// app.js
import express from 'express';
import router from './src/routes/router.js';
const app = express();
app.use(express.json());
app.use('/api', router);
export default app;
// server.js
import mongoose from 'mongoose';
import app from './app.js';
const port = process.env.PORT || 3000;
await mongoose.connect(process.env.MONGODB_URI);
app.listen(port, () => {
console.log(`Server is listening on port ${port}.`);
});
The production server imports app, connects to the production database, and calls app.listen(). A test imports the same app, connects to a temporary test database, and sends requests directly to Express.
This structure is important. Importing app.js should not start a server or connect to the production database.
Sending Requests with Supertest
Supertest is a JavaScript library that sends HTTP-like requests directly to an Express application. The request still passes through Express routing and middleware, but the test does not need to choose a port or start a separate web server process.
import { describe, expect, test } from 'vitest';
import request from 'supertest';
import app from '../app.js';
describe('GET /api/products', () => {
test('returns the available products', async () => {
const response = await request(app).get('/api/products');
expect(response.status).toBe(200);
expect(response.body.data).toBeInstanceOf(Array);
});
});
Looking at the line that calls the service:
request(app)tells Supertest which Express application to use..get('/api/products')creates a GET request for that URL.awaitwaits until Express finishes processing the request.responsecontains the result returned by the application.
What Is in the Response?
A Supertest response contains the same main parts that other API clients receive:
response.statuscontains the HTTP status code.response.bodycontains the parsed JSON response body.response.headerscontains the response headers.response.textcontains the response body as text when needed.
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('data');
expect(response.body.data).toBeInstanceOf(Array);
expect(response.headers['content-type']).toContain('application/json');
Do not check every field just because it exists. Check the details that prove the behavior required by the test. A focused test is easier to read and less likely to fail because of an unrelated change.
Testing a POST Endpoint
Use .send() to provide a JSON request body. Supertest sets the appropriate JSON request headers.
import { Product } from '../src/models/product.js';
describe('POST /api/orders', () => {
test('creates an order from valid input', async () => {
const product = await Product.findOne({
sku: 'PROD-100'
});
const response = await request(app)
.post('/api/orders')
.send({
items: [{
productId: product._id.toString(),
quantity: 2
}],
shippingAddress: '123 Main Street',
customerEmail: 'customer@example.com'
});
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('orderNumber');
expect(response.body.items[0].quantity).toBe(2);
});
});
A create endpoint normally returns 201 Created. The exact response shape depends on the contract your team has chosen. Your test should use the actual field names and response structure from your project.
Testing Error Responses
Do not test only the successful path. Clients also depend on predictable errors. For example, an order request with missing required data should receive a useful 400 Bad Request response.
test('returns 400 when shippingAddress is missing', async () => {
const product = await Product.findOne({
sku: 'PROD-100'
});
const response = await request(app)
.post('/api/orders')
.send({
items: [{
productId: product._id.toString(),
quantity: 2
}],
customerEmail: 'customer@example.com'
});
expect(response.status).toBe(400);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
field: 'shippingAddress'
})
])
);
});
The two nested matchers say that the errors array must contain an object whose field property is shippingAddress. Other error objects and properties are allowed. This keeps the test focused on the behavior it is meant to prove.
Testing URL Parameters and Query Parameters
Place route parameters in the URL, just as you would in Swagger:
const response = await request(app)
.get(`/api/products/${productId}`);
expect(response.status).toBe(200);
expect(response.body._id).toBe(productId);
Use .query() to add query parameters:
const response = await request(app)
.get('/api/products')
.query({
category: 'electronics',
page: 1,
limit: 5
});
expect(response.status).toBe(200);
expect(response.body.data.length).toBeLessThanOrEqual(5);
Authenticated Requests
If the application uses a session cookie, Supertest can keep that cookie between requests. Create an agent, sign in through the real login endpoint, and then use the same agent for the protected request.
test('allows an administrator to create a product', async () => {
const agent = request.agent(app);
const loginResponse = await agent
.post('/api/auth/login')
.send({
email: 'admin@example.com',
password: 'known test password'
});
expect(loginResponse.status).toBe(200);
const response = await agent
.post('/api/products')
.send({
name: 'Wireless Keyboard',
sku: 'KEY-200',
category: 'electronics',
price: 49.99
});
expect(response.status).toBe(201);
});
The test database must contain the known administrator account before this test runs. Never place a real user's password in a test file.
The Danger of a Live Database
A test must be safe even when the application has a bug. Imagine a test that checks whether an unauthenticated user is blocked from deleting a product. If the authentication middleware is broken, the request could reach the controller and delete the product.
For that reason, automated tests must not use the deployed application's database. Reading from a shared production database is also a problem. Records can change while tests are running, so the test results would not be predictable.
Never run automated tests against production data
A failing test should not be able to change real application records. The test configuration must use a separate database that is safe to create, update, and delete.
A Temporary In-Memory MongoDB Database
One approach to this problem is to use an in-memory database called mongodb-memory-server to provide a safe database for automated tests. The package starts a temporary MongoDB server on the local computer and provides a temporary connection string.
This is not a pretend JavaScript object that only looks like MongoDB. The application still uses Mongoose and real MongoDB behavior. The important difference is that the temporary database is created for the test run and removed afterward.
The complete process looks like this:
- Vitest starts the test run.
- Global setup starts a temporary MongoDB server.
- Mongoose connects to its temporary connection string.
- Before each test, the database is reset and filled with known starting data.
- The test sends a request through the real Express application.
- After the test run, Mongoose disconnects and the temporary server stops.
Starting and Stopping the Temporary Database
The team project includes this configuration. You are not expected to recreate it for your assignment, but you should understand what it does.
// tests/global-setup.js
import { MongoMemoryServer } from 'mongodb-memory-server';
export default async function setup(project) {
const mongoServer = await MongoMemoryServer.create();
project.provide(
'MONGO_URI',
mongoServer.getUri('ecommerce_test')
);
return async () => {
await mongoServer.stop();
};
}
MongoMemoryServer.create() starts the temporary database. getUri() returns its connection string. Vitest provides that string to the test environment. The function returned from global setup runs after all test files finish and stops the server.
Connecting the Setup Files to Vitest
The Vitest configuration tells the test runner which setup files to use:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['./tests/global-setup.js'],
setupFiles: ['./tests/setup.js'],
fileParallelism: false,
hookTimeout: 120_000
}
});
globalSetup identifies the module that starts one temporary MongoDB server for the complete test run. setupFiles identifies the module that registers the Mongoose connection and reset hooks for the test files.
The project turns off file parallelism so test files use the shared database one at a time. This makes database resets predictable. The longer hook timeout also allows time for MongoDB Memory Server to complete its first-time setup.
Connecting, Resetting, and Seeding
A setup file connects Mongoose to the temporary database. It also prepares known data before each test.
// tests/setup.js
import mongoose from 'mongoose';
import {
afterAll,
beforeAll,
beforeEach,
inject
} from 'vitest';
import { initializeDatabase } from '../src/database/initialize.js';
const mongoUri = inject('MONGO_URI');
beforeAll(async () => {
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
await mongoose.connection.db.dropDatabase();
await initializeDatabase();
});
afterAll(async () => {
await mongoose.disconnect();
});
The beforeEach() hook is especially important. Each test starts with the same products, categories, roles, and test users. If one test creates or deletes records, those changes do not affect the next test.
The initialization function must only create the starting records. It should not connect to MongoDB, disconnect from MongoDB, or end the Node process. This allows the normal initialization command and the automated tests to reuse the same function.
The first run may take longer
MongoDB Memory Server may need to download a MongoDB program the first time it runs. Later runs can reuse the downloaded program. Do not cancel the first test run only because it takes longer than expected.
Verifying the Database Result
A response can look correct even when a database operation did not work. For create, update, and delete endpoints, it is useful to check both the HTTP response and the database.
import { Order } from '../src/models/order.js';
import { Product } from '../src/models/product.js';
test('saves a new order', async () => {
const product = await Product.findOne({
sku: 'PROD-100'
});
const response = await request(app)
.post('/api/orders')
.send({
items: [{
productId: product._id.toString(),
quantity: 2
}],
shippingAddress: '123 Main Street',
customerEmail: 'customer@example.com'
});
expect(response.status).toBe(201);
const savedOrder = await Order.findOne({
orderNumber: response.body.orderNumber
});
expect(savedOrder).not.toBeNull();
expect(savedOrder.items[0].quantity).toBe(2);
});
This test proves two related facts: the endpoint returned the expected response, and the application saved the expected record.
Use Known Seed Data
A test should not depend on whichever record happens to appear first in the database. Find a seeded record by a stable value, such as a code or email address.
import { Product } from '../src/models/product.js';
const product = await Product.findOne({
sku: 'PROD-100'
});
Then use the record's generated MongoDB id in the request. This is clearer and safer than copying a database id into the test file.
Recommended Tests for an Endpoint
You do not need to test every possible value. Choose a small set of cases that proves the most important behavior. Depending on the endpoint, useful tests may include:
- A valid request returns the expected success status and response shape.
- A create, update, or delete request makes the correct database change.
- Missing or invalid input returns
400. - A request without authentication returns
401. - An authenticated user with the wrong role receives
403. - A request for a missing record returns
404. - Pagination or filtering returns records that match the request.
Keep Tests Clear
A reader should be able to understand a test without tracing many files. Use a descriptive name and keep the important request and expectations visible.
describe('GET /api/products/:id', () => {
test('returns 404 when the product does not exist', async () => {
const missingId = new mongoose.Types.ObjectId();
const response = await request(app)
.get(`/api/products/${missingId}`);
expect(response.status).toBe(404);
expect(response.body.message).toContain('not found');
});
});
Avoid vague names such as works, test product, or error test. A useful name explains the situation and the expected result.
Running and Investigating Tests
Run the full test suite before creating a pull request:
npm test
If a test fails:
- Read the name of the failing test.
- Compare the expected and received values.
- Check whether the request used the correct URL, method, headers, and body.
- Check whether the seed data contains the records the test expects.
- Decide whether the application code or the test expectation is incorrect.
- Fix the problem and run the complete test suite again.
Activity Instructions
Check your understanding
- Why can Supertest test an Express application without calling
app.listen()?Click to expand the example answer
Supertest sends requests directly to the Express
appobject. Express still runs its routes and middleware, but no network port is required. - What should an API test usually check in addition to the status code?
Click to expand the example answer
It should check an important part of the JSON response. For an operation that changes data, it may also verify that the correct database change occurred.
- Why is it unsafe to test a POST or DELETE request against the production database?
Click to expand the example answer
The feature being tested may be broken. A failed authentication or validation check could allow the test request to change or delete real data.
- Why does the test setup reset and seed the database before each test?
Click to expand the example answer
Each test receives the same known starting data. Changes made by one test cannot affect the result of the next test.
- How does
request.agent(app)help with authenticated endpoint tests?Click to expand the example answer
The agent keeps cookies between requests. A test can sign in and then use the saved session cookie when it sends a request to a protected endpoint.
Other Links:
- Return to: Week Overview | Course Home