Automated tests catch regressions before they reach production and give you the confidence to refactor. This lesson maps out the different levels of testing for an Express app before diving into each in later lessons.
The Testing Pyramid for Express Apps
Unit tests check a single function in isolation, usually a service or utility function, with no Express server involved at all. Integration tests check that several pieces work together correctly, often a full route handled through a real (in-memory) Express app. End-to-end tests exercise the entire deployed system, including a real database and network calls.
A healthy Express test suite usually has many fast unit tests, a smaller number of integration tests covering key routes, and few, if any, full end-to-end tests, which are the slowest and most brittle.
// unit test (no Express involved)
test('calculateTotal adds tax correctly', () => {
expect(calculateTotal(100, 0.1)).toBe(110);
});
// integration test (real Express app, via supertest)
test('GET /users returns 200', async () => {
const res = await request(app).get('/users');
expect(res.status).toBe(200);
});
The unit test needs nothing but the function itself; the integration test spins up the actual Express app and sends a real HTTP-like request to it.
Common Testing Tools for Express
Jest / Vitest / Mocha -> test runner and assertion library
supertest -> HTTP assertions against an Express app
mongodb-memory-server -> in-memory MongoDB for isolated integration tests
nock -> mocking outbound HTTP calls in tests
Jest is the most common all-in-one test runner and assertion library for Node/Express projects.
supertest lets you send requests directly to your app object without binding a real network port.
Use an in-memory or dedicated test database so tests never touch production data.
Mock external services (payment providers, third-party APIs) rather than calling them in tests.
Testing Levels Cheatsheet
A quick summary of the testing levels covered in this section.
Level
Scope
Speed
Tool
Unit
Single function
Very fast
Jest / Vitest
Integration
Route + middleware + (mock) DB
Fast
Jest + supertest
End-to-end
Full deployed stack
Slow
Playwright, Cypress, etc.
Why Separating app.js and server.js Matters for Tests
Recall the pattern from the Setup lesson: exporting app from app.js without calling listen() there means test files can require('../app') and pass it straight into supertest, with no real network port ever opened.
Tests should run against their own isolated configuration, a separate test database, mocked external services, and NODE_ENV=test, so running the suite never risks touching real data.
Running tests against a real production or shared development database.
Writing only end-to-end tests, resulting in a slow, flaky, hard-to-maintain suite.
Not separating app.js from server.js, making the app hard to test without opening a real port.
Skipping tests entirely for critical business logic like pricing or authentication.
Key Takeaways
Unit, integration, and end-to-end tests each serve a different purpose at a different speed and scope.
A healthy suite favors many fast unit and integration tests over few slow end-to-end ones.
Exporting app without calling listen() makes integration testing with supertest straightforward.
Tests should always run against isolated, disposable configuration, never real production data.
Pro Tip
Add a test script to package.json and run it in CI on every pull request, tests that only run manually on a developer's machine eventually stop running at all.
You now understand the testing landscape for Express apps. Next, write focused unit tests in the Unit Testing lesson.