Testing Express APIs, Supertest fundamentals, database testing strategies, mocking repositories/services, Sinon, and Mocha/Chai for legacy projects.
Module 3 — Backend Testing with Node.js
Who this is for: Backend and Full-Stack engineers building APIs. Testing a Node.js backend requires a different strategy than testing a React frontend. You must account for database state, external service dependencies, and HTTP protocol compliance.
Learning Objectives
By the end of this module, you will be able to:
- Test Express/Node.js REST APIs using Supertest
- Apply the Repository Pattern for easier database mocking
- Implement reliable database testing strategies (in-memory vs. real databases)
- Utilize Sinon.js for advanced spying, stubbing, and mocking
- Maintain and work within legacy Mocha/Chai test suites
The Backend Testing Challenge
Unlike frontend components, backend controllers are deeply intertwined with infrastructure: databases, message queues, and external APIs.
To test an API endpoint like POST /users, you must ensure:
- The HTTP status code is correct (e.g.,
201 Created). - The response body matches the expected schema.
- The database was actually updated.
- Edge cases (like duplicate emails) return graceful errors (e.g.,
409 Conflict).
Supertest Fundamentals
Supertest is the industry standard for testing Node.js HTTP servers. It allows you to spin up your Express app in memory and send HTTP requests to it without needing a real port or network stack.
Setup
Ensure your Express app instance is exported separately from your app.listen() call, otherwise your tests will conflict over port bindings.
javascript// app.js const express = require('express'); const app = express(); app.get('/health', (req, res) => res.status(200).json({ status: 'ok' })); module.exports = app; // Export the app! // server.js const app = require('./app'); app.listen(3000, () => console.log('Server running'));
Writing an API Test
Using Supertest with Jest:
javascriptconst request = require('supertest'); const app = require('../../src/app'); describe('GET /health', () => { it('should return 200 OK with status object', async () => { const response = await request(app).get('/health'); expect(response.status).toBe(200); expect(response.body).toEqual({ status: 'ok' }); }); });
Database Testing Strategies
How do you test code that reads and writes to a database? You have three primary strategies:
1. The Mocking Approach (Unit Testing)
Use Jest to mock your database ORM or driver. This is incredibly fast but provides lower confidence, as it doesn't catch SQL syntax errors or constraint violations.
javascript// Testing a controller with mocked Prisma const { prisma } = require('../db'); jest.mock('../db', () => ({ prisma: { user: { create: jest.fn() } } })); it('creates a user', async () => { prisma.user.create.mockResolvedValue({ id: 1, email: 'test@test.com' }); // Call controller and assert });
2. In-Memory Databases
Using tools like sqlite3 in-memory or mongodb-memory-server. This provides a real database engine but runs purely in RAM. It's an excellent middle ground, though it may lack specific features of your production database (like PostgreSQL-specific JSONB queries).
3. Test Containers / Real Databases (Integration Testing)
The most robust approach. Spin up a real PostgreSQL instance using Docker for your test suite.
Best Practices for Real DB Testing:
- Use a dedicated test database (e.g.,
postgres_test). - Run a transaction before each test and roll it back after, ensuring isolated, clean state without slow
TRUNCATEcommands.
javascriptbeforeEach(async () => { await db.query('BEGIN'); // Start transaction }); afterEach(async () => { await db.query('ROLLBACK'); // Undo all changes made during the test });
Mocking Repositories and Services
To make mocking easier, adopt the Repository Pattern. Instead of calling the database directly in your controllers, abstract it behind a service.
Hard to test:
javascript// controller.js app.post('/users', async (req, res) => { const user = await db.query('INSERT INTO users...', [req.body.email]); // ... });
Easy to test:
javascript// userRepository.js const createUser = async (email) => db.query(...); // controller.js app.post('/users', async (req, res) => { const user = await userRepository.createUser(req.body.email); // ... });
Now, you can simply mock userRepository in your tests without worrying about the underlying SQL driver.
Sinon: Spies, Stubs, and Mocks
While Jest has built-in mocking, many backend projects use Sinon.js, especially if they aren't using Jest. Sinon provides standalone test spies, stubs, and mocks.
- Spies: Record arguments, return values, and exceptions thrown by functions.
- Stubs: Spies with pre-programmed behavior (like forcing a function to throw an error).
- Mocks: Fake methods with pre-programmed expectations (e.g., "this function must be called exactly once").
javascriptconst sinon = require('sinon'); const emailService = require('./emailService'); it('should send a welcome email', () => { // Stub the send method to prevent actual emails from sending const stub = sinon.stub(emailService, 'send').resolves(true); registerUser('new@user.com'); sinon.assert.calledOnceWithExactly(stub, 'new@user.com', 'Welcome!'); stub.restore(); // Always restore after testing! });
Mocha & Chai for Legacy Projects
If you join an existing backend team, you will likely encounter Mocha (the test runner) and Chai (the assertion library). Before Jest dominated, this was the standard Node.js testing stack.
Unlike Jest, Mocha doesn't come with assertions or mocks out of the box.
javascriptconst { expect } = require('chai'); describe('Math', function() { it('should add numbers', function() { // Chai provides BDD-style assertions expect(2 + 2).to.equal(4); expect([1, 2]).to.be.an('array').that.includes(2); }); });
If you inherit a Mocha project, you will use:
- Mocha to run the tests (
describe,it) - Chai for assertions (
expect,should) - Sinon for mocking
- Istanbul (nyc) for coverage
Today, Jest is recommended for new projects because it bundles all four of these tools into one cohesive framework.
Key Takeaways
- Backend testing requires careful management of external state like databases and APIs.
- Supertest allows testing Express applications without binding to a physical network port.
- Real test databases running in transactions provide the highest confidence.
- The Repository Pattern makes unit testing controllers trivial by abstracting the database.
- Sinon provides robust spying and mocking capabilities for legacy codebases.
Knowledge Check
Question 1: A team is building an Express.js API and sets up Supertest for integration testing. They notice their test suite frequently crashes with Error: listen EADDRINUSE: address already in use :::3000. What is the fundamental architectural mistake causing this error during tests?
- A) Supertest requires the application to run on port 8080 by default. The fix is to change the port in the Express configuration.
- B) The
app.listen()method is being executed directly within the mainapp.jsfile, meaning every time a test imports the app, it attempts to bind to a real network port. The fix is to export theappinstance separately from thelistencall. - C) The test suite is missing a
beforeAllhook to manually close the database connections, which keeps the port open. - D) Supertest cannot test asynchronous routes. The fix is to use Jest's
done()callback in every test.
Reveal Answer
Correct Answer: B
Supertest is incredibly powerful because it can test your Express routes entirely in-memory without needing to bind to a physical network port. However, if your main app.js file unconditionally calls app.listen(3000), then merely importing that file into your test suite will start a real server. If multiple test files run concurrently, they will all try to bind to port 3000, causing EADDRINUSE errors. The architectural fix is to export the Express app itself (module.exports = app;) and keep the listen call in a separate entry point (like server.js).
Question 2: You are implementing integration tests for an API using a real PostgreSQL test database via Docker. Your test suite runs 500 tests, and you need to ensure the database is reset to a clean state before every single test. Which approach provides the best balance of data isolation and test execution speed?
- A) Run
DROP DATABASE postgres_test; CREATE DATABASE postgres_test;in abeforeEachblock. - B) Run
TRUNCATE TABLE users, orders, products CASCADE;in abeforeEachblock. - C) Connect a mocked instance of Prisma ORM to the test suite and avoid hitting the real database entirely.
- D) Run
BEGIN(start transaction) in abeforeEachblock, perform the test, and then runROLLBACKin anafterEachblock.
Reveal Answer
Correct Answer: D
Using database transactions (BEGIN and ROLLBACK) is the most efficient and robust way to manage state in integration tests. Every test inserts, updates, or deletes data within its own isolated transaction. At the end of the test, rolling back the transaction instantly undoes all changes, guaranteeing a clean slate for the next test without actually writing the changes permanently to disk. Option A and B are extremely slow (dropping/creating databases or truncating tables takes significant time when multiplied by 500 tests). Option C turns the test into a unit test, defeating the purpose of database integration testing.
Question 3: You have been assigned to maintain a legacy Node.js backend. The controllers are tightly coupled to the database driver, containing raw SQL queries inline: await db.query('SELECT * FROM users WHERE email = ?', [req.body.email]). Writing unit tests for these controllers is painful because you have to mock the entire database driver. What architectural pattern should you introduce to make mocking simpler and separate the data logic from the HTTP logic?
- A) The MVC (Model-View-Controller) Pattern
- B) The Repository Pattern
- C) The Singleton Pattern
- D) The Observer Pattern
Reveal Answer
Correct Answer: B
The Repository Pattern abstracts the database access logic behind a dedicated service (e.g., UserRepository.findByEmail(email)). By injecting this repository into your controller, your controller no longer knows or cares whether the data comes from PostgreSQL, MongoDB, or a third-party API. When writing a unit test for the controller, you simply mock the repository's findByEmail method to return a hardcoded object. This isolates the HTTP testing logic (status codes, request bodies) from the database layer entirely.
With unit and integration testing firmly understood, we can look at testing from the perspective of our business stakeholders. Next, we will explore Behavior-Driven Development (BDD) using Cucumber.
Next: Module 4 — BDD with Cucumber →
Discussion
0Join the discussion