Collaboration Tests in Node.js

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:

  1. Does the client call the dependency correctly?
    • Correct method?
    • Correct arguments?
  2. 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 🚀

Share this article

Similar Posts