Mastering Collaboration Tests in Node.js
Think your API is well-tested? Think again.
Most Node.js developers write state-based unit tests and stop there. While those tests are important, they don’t tell the full story. Real-world applications are made of collaborating modules, and if those collaborations break, your app breaks — even if every module passes its own unit tests.
In this post, we’ll dive deep into collaboration tests using a real Express + Jest setup and walk through practical examples you can apply immediately.
🚀 Complete JavaScript Guide (Beginner + Advanced)
🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)
Understanding the Dependency Chain
When building an application, your code usually forms a dependency chain:
Controller → Use Case → Repository → ORM / External Library
For example:
- Module A depends on Modules B and C
- Module B depends on
axios - Module C depends on Module D
- Module D depends on
sequelize
Each dependency adds complexity to testing.
State Tests vs Collaboration Tests
State Tests
State tests verify what a function returns given a certain input, without considering external dependencies.
These are simple, fast, and essential — but insufficient on their own.
Collaboration Tests
Collaboration tests verify how modules interact with their dependencies.
They answer two critical questions:
- Does the client call the dependency correctly?
- Correct method?
- Correct arguments?
- Does the client handle the dependency’s response correctly?
- Success responses
- Edge cases
- Errors
Why We Use Mocks and Stubs
Using real databases or services in unit tests leads to:
❌ Slow tests
❌ Flaky behavior
❌ Environment coupling
Instead, we use test doubles:
- Mock → verifies interactions
- Stub → controls return values
Example Project Setup
We’ll use a task management API built with Express, TypeScript, and Jest.
Our controller:
src/routes/v1/tasks/controller.ts
Controller Code
export async function createTask(req: Request, res: Response) {
const useCase = new CreateTaskUseCase(req, mailer);
const task = await useCase.execute();
return res.status(201).json({ task });
}
The controller depends on CreateTaskUseCase, so this is a perfect candidate for a collaboration test.
Testing the Controller (Collaboration Test)
Test File
src/tests/taskController.test.ts
Test Setup
import { Request, Response } from 'express';
import { createTask } from '@/routes/v1/tasks/controller';
import { CreateTaskUseCase } from '@/use-cases/create-task';
import mailer from '@/services/mailer';
jest.mock('@/use-cases/create-task');
jest.mock('@/services/mailer');
Mocking Dependencies
let req: Partial<Request>;
let res: Partial<Response>;
let executeMock: jest.Mock;
let statusMock: jest.Mock;
let jsonMock: jest.Mock;
beforeEach(() => {
req = {
body: { name: 'New Task' },
auth: { userId: 1 },
};
jsonMock = jest.fn();
statusMock = jest.fn().mockReturnValue({ json: jsonMock });
res = {
status: statusMock,
};
executeMock = jest.fn().mockResolvedValue({ id: 1, name: 'New Task' });
(CreateTaskUseCase as jest.Mock).mockImplementation(() => ({
execute: executeMock,
}));
});
The Test
it('should create a task and return 201 with task data', async () => {
await createTask(req as Request, res as Response);
expect(CreateTaskUseCase).toHaveBeenCalledWith(req, mailer);
expect(executeMock).toHaveBeenCalled();
expect(statusMock).toHaveBeenCalledWith(201);
expect(jsonMock).toHaveBeenCalledWith({
task: { id: 1, name: 'New Task' },
});
});
This test verifies:
- The use case is instantiated correctly
- The dependency method is called
- The response is handled properly
Testing the Use Case
Now the use case becomes the client, and its dependencies become the server.
Use Case Dependencies
- Repository (
createTask) - Mailer (
send)
Test File
src/tests/CreateTaskUseCase.test.ts
Mocking Dependencies
jest.mock('@/repositories/task-repository');
jest.mock('@/services/logger');
Successful Case
it('should create a task and send an email', async () => {
const taskStub = { id: 1, name: 'New Task' };
repository.createTask.mockResolvedValue(taskStub);
mailer.send.mockResolvedValue(undefined);
const useCase = new CreateTaskUseCase(req as Request, mailer);
const result = await useCase.execute();
expect(repository.createTask).toHaveBeenCalledWith(req.body, 1);
expect(mailer.send).toHaveBeenCalled();
expect(result).toEqual(taskStub);
});
Error Handling Case
it('should log an error if email fails', async () => {
const error = new Error('Email failed');
repository.createTask.mockResolvedValue({ id: 1, name: 'New Task' });
mailer.send.mockRejectedValue(error);
const useCase = new CreateTaskUseCase(req as Request, mailer);
await useCase.execute();
expect(logger.error).toHaveBeenCalledWith(error);
});
Testing External Integrations (Mailer)
Even infrastructure code can be unit-tested by mocking libraries like nodemailer.
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({
sendMail: jest.fn().mockResolvedValue('Email sent'),
})),
}));
This verifies:
- Correct configuration
- Correct payload
- Correct library usage
Moving Up the Dependency Chain
The pattern repeats:
- Controller → test with mocked use case
- Use case → test with mocked repository
- Repository → test with mocked ORM
Stop when you reach:
- External libraries
- 3rd-party APIs
Those are already tested by their maintainers.
When to Use Integration Tests
Unit tests should be the majority of your test suite.
Use integration tests when:
- Middleware interactions matter
- Multiple modules must work together
For Express APIs, Supertest is a great choice.
Final Thoughts
By combining:
✅ State tests
✅ Collaboration tests
✅ Contract awareness
You can build fast, reliable, and maintainable Node.js applications with confidence.
Happy testing 🚀