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).
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
Writing an API Test
Using Supertest with Jest:
javascript
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
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 TRUNCATE commands.
javascript
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
Easy to test:
javascript
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").
javascript
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.
javascript
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
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?
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?
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?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
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
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
const request =require('supertest');const app =require('../../src/app');describe('GET /health',()=>{it('should return 200 OK with status object',async()=>{const response =awaitrequest(app).get('/health');expect(response.status).toBe(200);expect(response.body).toEqual({status:'ok'});});});
// Testing a controller with mocked Prismaconst{ 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});
beforeEach(async()=>{await db.query('BEGIN');// Start transaction});afterEach(async()=>{await db.query('ROLLBACK');// Undo all changes made during the test});
// controller.jsapp.post('/users',async(req, res)=>{const user =await db.query('INSERT INTO users...',[req.body.email]);// ...});
// userRepository.jsconstcreateUser=async(email)=> db.query(...);// controller.jsapp.post('/users',async(req, res)=>{const user =await userRepository.createUser(req.body.email);// ...});
const sinon =require('sinon');const emailService =require('./emailService');it('should send a welcome email',()=>{// Stub the send method to prevent actual emails from sendingconst 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!});