Saltar para o conteúdo
Testes em Node.js com Jest e Supertest: Guia Prático
Node.js

Testes em Node.js com Jest e Supertest: Guia Prático

10 de junho de 2024·Paulo de Paula

Testes automatizados não são sobre cobertura de 100% — são sobre confiança para refatorar e entregar. Uma suíte bem estruturada pega regressões cedo e documenta o comportamento esperado do sistema.

A pirâmide de testes para APIs

         /\
        /e2e\        ← poucos, lentos, testam fluxos completos
       /------\
      /integração\   ← médios, testam camadas reais (Express + DB)
     /------------\
    / unitários    \ ← muitos, rápidos, testam lógica isolada
   /________________\

Para uma API Node.js, a regra prática:

  • Unitários: lógica de negócio, transformações, validações
  • Integração: endpoints reais com banco de teste
  • E2E: fluxos críticos contra ambiente staging

Configuração do Jest com TypeScript

npm install -D jest @types/jest ts-jest supertest @types/supertest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.spec.ts', '**/*.test.ts'],
  coverageDirectory: 'coverage',
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/server.ts',       // entrypoint, sem lógica
    '!src/migrations/**',
  ],
  coverageThresholds: {
    global: {
      branches:   80,
      functions:  85,
      lines:      85,
      statements: 85,
    },
  },
  // Roda setup antes de cada arquivo de teste
  setupFilesAfterFramework: ['<rootDir>/src/tests/setup.ts'],
};

Teste unitário: lógica isolada

// src/services/pedidoService.ts
export class PedidoService {
  constructor(private readonly repo: PedidoRepository) {}

  async calcularTotal(pedidoId: string): Promise<number> {
    const pedido = await this.repo.findById(pedidoId);
    if (!pedido) throw new Error('Pedido não encontrado');

    return pedido.itens.reduce(
      (acc, item) => acc + item.preco * item.quantidade,
      0
    );
  }
}

// src/services/pedidoService.spec.ts
import { PedidoService } from './pedidoService';

describe('PedidoService', () => {
  const mockRepo = {
    findById: jest.fn(),
  };

  const service = new PedidoService(mockRepo as any);

  beforeEach(() => jest.clearAllMocks());

  describe('calcularTotal', () => {
    it('soma preço × quantidade de cada item', async () => {
      mockRepo.findById.mockResolvedValue({
        id: '1',
        itens: [
          { preco: 10, quantidade: 2 },
          { preco: 5,  quantidade: 3 },
        ],
      });

      const total = await service.calcularTotal('1');

      expect(total).toBe(35); // 20 + 15
      expect(mockRepo.findById).toHaveBeenCalledWith('1');
    });

    it('lança erro quando pedido não existe', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.calcularTotal('inexistente'))
        .rejects.toThrow('Pedido não encontrado');
    });

    it('retorna 0 para pedido sem itens', async () => {
      mockRepo.findById.mockResolvedValue({ id: '1', itens: [] });

      const total = await service.calcularTotal('1');
      expect(total).toBe(0);
    });
  });
});

Mock vs Spy vs Stub

// Mock: substitui completamente a implementação
const emailService = {
  enviar: jest.fn().mockResolvedValue(undefined),
};

// Spy: observa uma função real sem substituí-la
const spy = jest.spyOn(emailService, 'enviar');
await emailService.enviar('teste@email.com', 'Assunto', 'Corpo');
expect(spy).toHaveBeenCalledTimes(1);

// Stub: implementação controlada para um cenário específico
jest.spyOn(Math, 'random').mockReturnValue(0.5); // sempre 0.5

Teste de integração com Supertest

// src/tests/setup.ts
import { prisma } from '../lib/prisma';

beforeAll(async () => {
  // Conecta ao banco de teste (DATABASE_URL_TEST no .env.test)
  await prisma.$connect();
});

afterAll(async () => {
  await prisma.$disconnect();
});

// Limpa tabelas entre testes para isolamento
afterEach(async () => {
  await prisma.$transaction([
    prisma.comentario.deleteMany(),
    prisma.post.deleteMany(),
    prisma.perfil.deleteMany(),
    prisma.usuario.deleteMany(),
  ]);
});
// src/routes/usuarios.spec.ts
import request from 'supertest';
import { app } from '../app';
import { prisma } from '../lib/prisma';

describe('POST /usuarios', () => {
  it('cria usuário com dados válidos', async () => {
    const res = await request(app)
      .post('/usuarios')
      .send({ email: 'teste@email.com', nome: 'Teste', senha: 'Senha@123' })
      .expect(201);

    expect(res.body).toMatchObject({
      email: 'teste@email.com',
      nome: 'Teste',
    });
    expect(res.body).not.toHaveProperty('senha');
    expect(res.body).not.toHaveProperty('senhaHash');

    // Verifica que foi salvo no banco real
    const usuario = await prisma.usuario.findUnique({
      where: { email: 'teste@email.com' },
    });
    expect(usuario).not.toBeNull();
  });

  it('retorna 409 para email duplicado', async () => {
    await prisma.usuario.create({
      data: { email: 'existe@email.com', nome: 'Existe', senhaHash: 'x' },
    });

    await request(app)
      .post('/usuarios')
      .send({ email: 'existe@email.com', nome: 'Outro', senha: 'Senha@123' })
      .expect(409);
  });

  it('retorna 400 para email inválido', async () => {
    const res = await request(app)
      .post('/usuarios')
      .send({ email: 'nao-e-email', nome: 'Teste', senha: 'Senha@123' })
      .expect(400);

    expect(res.body.errors).toContainEqual(
      expect.objectContaining({ field: 'email' })
    );
  });
});

describe('GET /usuarios/:id', () => {
  it('retorna 401 sem token', async () => {
    await request(app).get('/usuarios/qualquer-id').expect(401);
  });

  it('retorna usuário autenticado', async () => {
    const usuario = await prisma.usuario.create({
      data: { email: 'auth@email.com', nome: 'Auth', senhaHash: 'x' },
    });

    const loginRes = await request(app)
      .post('/auth/login')
      .send({ email: 'auth@email.com', senha: 'senha-correta' });

    const { accessToken } = loginRes.body;

    const res = await request(app)
      .get(`/usuarios/${usuario.id}`)
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(200);

    expect(res.body.id).toBe(usuario.id);
  });
});

Banco de dados de teste separado

# .env.test
DATABASE_URL="postgresql://dev:dev@localhost:5432/devdb_test"
// package.json
{
  "scripts": {
    "test": "dotenv -e .env.test -- jest",
    "test:watch": "dotenv -e .env.test -- jest --watch",
    "test:coverage": "dotenv -e .env.test -- jest --coverage",
    "test:ci": "dotenv -e .env.test -- jest --ci --coverage --forceExit"
  }
}

Nunca mocke o banco nos testes de integração. Mocks de banco garantem que o código compila, não que as queries funcionam. Constraints, índices, tipos de coluna, comportamento do ORM — nada disso é testado com mock.

Cobertura e integração com CI/CD

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: dev
          POSTGRES_PASSWORD: dev
          POSTGRES_DB: devdb_test
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }

      - run: npm ci
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://dev:dev@localhost:5432/devdb_test

      - run: npm run test:ci
        env:
          DATABASE_URL: postgresql://dev:dev@localhost:5432/devdb_test
          JWT_ACCESS_SECRET: test-secret-access
          JWT_REFRESH_SECRET: test-secret-refresh

      - uses: codecov/codecov-action@v4
        with:
          file: ./coverage/coverage-final.json

Testes que passam no CI mas falham em produção geralmente têm mocks demais. Se o problema for velocidade, use --runInBand para rodar em série e identifique os testes lentos antes de mockar o banco.