Testabilidade no Node.js com Express e TypeScript: unidades, integração e contratos

Capítulo 15

Tempo estimado de leitura: 13 minutos

+ Exercício

O que significa “testabilidade” no back-end

Testabilidade é a capacidade de verificar o comportamento do seu código de forma rápida, confiável e com baixo custo de manutenção. Em Node.js com Express e TypeScript, isso normalmente depende de três decisões: (1) separar regras de negócio de detalhes de infraestrutura (HTTP, banco, filas), (2) injetar dependências em vez de importá-las diretamente dentro das funções, e (3) manter partes do código como funções puras quando possível (mesma entrada, mesma saída, sem efeitos colaterais). O objetivo é conseguir testar regras e validações sem precisar subir servidor, conectar em banco ou depender de relógio/aleatoriedade.

Estruturando código para ser testável

1) Isolar infraestrutura (Express) das regras de negócio

Um erro comum é colocar lógica de negócio dentro do handler do Express. Em vez disso, crie um serviço (ou use case) que receba dados e dependências e retorne um resultado. O controller/handler fica responsável apenas por: ler request, chamar serviço e traduzir o resultado para HTTP.

// domain/errors.ts
export class DomainError extends Error {
  constructor(message: string, public code: string) {
    super(message);
  }
}

export class ValidationError extends DomainError {
  constructor(message: string) {
    super(message, 'VALIDATION_ERROR');
  }
}

export class NotFoundError extends DomainError {
  constructor(message: string) {
    super(message, 'NOT_FOUND');
  }
}
// domain/validators.ts
import { ValidationError } from './errors';

export function assertEmail(email: string): void {
  const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  if (!ok) throw new ValidationError('Email inválido');
}
// application/userService.ts
import { assertEmail } from '../domain/validators';
import { NotFoundError } from '../domain/errors';

export type User = { id: string; email: string; name: string };

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  updateEmail(id: string, email: string): Promise<User>;
}

export interface Clock {
  now(): Date;
}

export class UserService {
  constructor(private repo: UserRepository, private clock: Clock) {}

  async changeEmail(input: { id: string; email: string }): Promise<{ user: User; changedAt: string }> {
    assertEmail(input.email);

    const existing = await this.repo.findById(input.id);
    if (!existing) throw new NotFoundError('Usuário não encontrado');

    const user = await this.repo.updateEmail(input.id, input.email);
    return { user, changedAt: this.clock.now().toISOString() };
  }
}
// http/userRoutes.ts
import { Router } from 'express';
import { UserService } from '../application/userService';

export function userRoutes(deps: { userService: UserService }) {
  const router = Router();

  router.patch('/users/:id/email', async (req, res, next) => {
    try {
      const result = await deps.userService.changeEmail({
        id: req.params.id,
        email: String(req.body?.email ?? ''),
      });
      res.status(200).json({ data: result });
    } catch (err) {
      next(err);
    }
  });

  return router;
}

Repare que o Express não “sabe” como o serviço funciona; ele só injeta userService. Isso facilita testes de unidade (no serviço) e testes de integração (na rota).

2) Injeção de dependências (DI) na prática

DI aqui não exige framework. Basta receber dependências por parâmetro (construtor ou função). Dependências típicas: repositórios, clientes HTTP, cache, clock, gerador de IDs, feature flags.

  • Evite: import db from './db' dentro do serviço e usar diretamente.
  • Prefira: interface + implementação injetada no bootstrap da aplicação.

3) Funções puras onde possível

Validações, mapeamentos e regras determinísticas devem ser funções puras. Isso reduz a necessidade de mocks e torna os testes rápidos.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

// domain/mappers.ts
export function normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}

Testes de unidade (unit tests): serviços e validações

Teste de unidade verifica uma unidade pequena (função, classe) isolada de infraestrutura. Em Node.js/TS, o foco costuma ser: validações, regras de negócio, serviços e mapeamentos. O critério: o teste não deve precisar de rede, filesystem, banco ou Express.

Ferramentas sugeridas

  • vitest ou jest para runner/assertions/mocks.
  • ts-node ou build prévio (depende do setup), mas o importante é manter o teste rápido.

Passo a passo: testando uma validação (função pura)

// domain/validators.test.ts (Vitest)
import { describe, it, expect } from 'vitest';
import { assertEmail } from './validators';
import { ValidationError } from './errors';

describe('assertEmail', () => {
  it('não lança erro para email válido', () => {
    expect(() => assertEmail('a@b.com')).not.toThrow();
  });

  it('lança ValidationError para email inválido', () => {
    expect(() => assertEmail('invalido')).toThrow(ValidationError);
  });
});

Como é função pura, não há mocks e o teste é extremamente estável.

Passo a passo: testando um serviço com mocks (repositório e clock)

O serviço depende de UserRepository e Clock. Em teste de unidade, você substitui essas dependências por dublês controlados.

// application/userService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { UserService, UserRepository, Clock } from './userService';
import { NotFoundError } from '../domain/errors';

function makeRepo(overrides?: Partial<UserRepository>): UserRepository {
  return {
    findById: vi.fn(),
    updateEmail: vi.fn(),
    ...overrides,
  };
}

function makeClock(dateISO = '2025-01-01T00:00:00.000Z'): Clock {
  return { now: () => new Date(dateISO) };
}

describe('UserService.changeEmail', () => {
  it('atualiza email e retorna changedAt determinístico', async () => {
    const repo = makeRepo({
      findById: vi.fn().mockResolvedValue({ id: '1', email: 'old@x.com', name: 'Ana' }),
      updateEmail: vi.fn().mockResolvedValue({ id: '1', email: 'new@x.com', name: 'Ana' }),
    });
    const clock = makeClock('2030-10-10T10:10:10.000Z');

    const service = new UserService(repo, clock);
    const result = await service.changeEmail({ id: '1', email: 'new@x.com' });

    expect(repo.findById).toHaveBeenCalledWith('1');
    expect(repo.updateEmail).toHaveBeenCalledWith('1', 'new@x.com');
    expect(result.changedAt).toBe('2030-10-10T10:10:10.000Z');
    expect(result.user.email).toBe('new@x.com');
  });

  it('lança NotFoundError quando usuário não existe', async () => {
    const repo = makeRepo({
      findById: vi.fn().mockResolvedValue(null),
      updateEmail: vi.fn(),
    });
    const service = new UserService(repo, makeClock());

    await expect(service.changeEmail({ id: '404', email: 'a@b.com' })).rejects.toBeInstanceOf(NotFoundError);
    expect(repo.updateEmail).not.toHaveBeenCalled();
  });
});

Critérios de um bom unit test: (1) determinístico (clock controlado), (2) sem dependências externas, (3) asserções sobre comportamento observável (retorno/erro e chamadas relevantes), (4) não depende de detalhes internos irrelevantes.

Mocks, fakes e fixtures: como escolher sem criar testes frágeis

Definições práticas

  • Mock: dublê com expectativas de chamadas (ex.: toHaveBeenCalledWith). Útil para verificar interações importantes.
  • Fake: implementação simples funcional (ex.: repositório em memória). Útil quando você quer testar fluxo sem depender de banco real e sem ficar “programando” retornos para cada chamada.
  • Fixture: dados prontos para testes (objetos, payloads, registros). Útil para reduzir repetição e dar clareza.

Regras para evitar fragilidade

  • Não mocke o que você não controla (por exemplo, detalhes internos do Express). Prefira testar via integração quando o valor está no comportamento HTTP.
  • Evite asserções excessivas de interação: se o que importa é o resultado, valide o resultado. Use toHaveBeenCalledWith apenas quando a interação for parte do contrato (ex.: garantir que não chama updateEmail quando validação falha).
  • Fixtures pequenas e explícitas: prefira builders para criar dados com defaults e sobrescritas.
  • Controle fontes de não determinismo: tempo, UUID, random, timezone. Injete Clock e gerador de IDs.

Exemplo: fixture/builder para usuário

// test/builders.ts
import type { User } from '../application/userService';

export function userFixture(overrides?: Partial<User>): User {
  return {
    id: 'u_1',
    email: 'user@example.com',
    name: 'Usuário',
    ...overrides,
  };
}

Exemplo: fake repository em memória

// test/fakes.ts
import type { UserRepository, User } from '../application/userService';

export class InMemoryUserRepo implements UserRepository {
  private items = new Map<string, User>();

  seed(users: User[]) {
    users.forEach(u => this.items.set(u.id, u));
  }

  async findById(id: string): Promise<User | null> {
    return this.items.get(id) ?? null;
  }

  async updateEmail(id: string, email: string): Promise<User> {
    const existing = this.items.get(id);
    if (!existing) throw new Error('Invariant: user not found');
    const updated = { ...existing, email };
    this.items.set(id, updated);
    return updated;
  }
}

Use fake quando o comportamento do repositório é simples e ajuda a reduzir mocks encadeados. Use mock quando você precisa verificar interações específicas.

Testes de integração: rotas Express com ambiente controlado

Teste de integração verifica a composição de componentes: rota + middlewares + serialização JSON + tratamento de erros + acesso a um banco em ambiente controlado. Aqui você quer confiança de que o sistema responde corretamente via HTTP.

Estratégia: criar o app como função e injetar dependências

Para testar rotas, é útil expor uma fábrica de app que recebe dependências. Assim, no teste você monta o app com repositório real de teste (ou container) e configurações específicas.

// http/appFactory.ts
import express from 'express';
import { userRoutes } from './userRoutes';
import { DomainError } from '../domain/errors';

export function createApp(deps: { userService: any }) {
  const app = express();
  app.use(express.json());

  app.use(userRoutes({ userService: deps.userService }));

  // error handler padronizado
  app.use((err: any, _req: any, res: any, _next: any) => {
    if (err instanceof DomainError) {
      const status = err.code === 'VALIDATION_ERROR' ? 400 : err.code === 'NOT_FOUND' ? 404 : 422;
      return res.status(status).json({ error: { code: err.code, message: err.message } });
    }
    return res.status(500).json({ error: { code: 'INTERNAL', message: 'Erro inesperado' } });
  });

  return app;
}

Banco em ambiente controlado: opções comuns

  • Banco efêmero por suíte: subir um banco dedicado para testes (ex.: container) e rodar migrações antes. Bom para fidelidade.
  • Transação por teste: iniciar transação no beforeEach e dar rollback no afterEach. Bom para isolamento e velocidade quando suportado.
  • Schema/database por teste: cria um schema único por teste e descarta ao final. Bom quando transação não cobre tudo.

O ponto central é: cada teste deve começar de um estado conhecido e não depender de execução anterior.

Passo a passo: teste de integração de rota com Supertest

Exemplo usando supertest para chamar o app sem abrir porta. O repositório pode ser real (conectado ao banco de teste) ou um fake mais completo, dependendo do objetivo do teste.

// http/userRoutes.int.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from './appFactory';
import { UserService } from '../application/userService';
import { InMemoryUserRepo } from '../test/fakes';
import { userFixture } from '../test/builders';

const clock = { now: () => new Date('2030-10-10T10:10:10.000Z') };

describe('PATCH /users/:id/email (integração)', () => {
  it('200 com payload padronizado', async () => {
    const repo = new InMemoryUserRepo();
    repo.seed([userFixture({ id: 'u_1', email: 'old@x.com' })]);

    const service = new UserService(repo, clock);
    const app = createApp({ userService: service });

    const res = await request(app)
      .patch('/users/u_1/email')
      .send({ email: 'new@x.com' })
      .expect(200);

    expect(res.body).toEqual({
      data: {
        user: { id: 'u_1', email: 'new@x.com', name: 'Usuário' },
        changedAt: '2030-10-10T10:10:10.000Z',
      },
    });
  });

  it('400 quando email inválido (contrato de erro)', async () => {
    const repo = new InMemoryUserRepo();
    repo.seed([userFixture({ id: 'u_1' })]);

    const app = createApp({ userService: new UserService(repo, clock) });

    const res = await request(app)
      .patch('/users/u_1/email')
      .send({ email: 'invalido' })
      .expect(400);

    expect(res.body).toEqual({
      error: { code: 'VALIDATION_ERROR', message: 'Email inválido' },
    });
  });
});

Mesmo usando fake repo, este teste já é integração de HTTP (Express + JSON + error handler + rota + serviço). Se você quer integração com banco real, substitua InMemoryUserRepo por uma implementação conectada ao banco de teste e garanta isolamento por transação/rollback ou reset de dados.

Testes de contrato: garantindo formato de respostas e erros

“Contrato” aqui significa: para um endpoint e cenário, o status code, o shape do JSON e os códigos de erro são estáveis. Isso previne regressões em middlewares, validações e handlers de erro, especialmente quando o time evolui o código.

O que vale a pena fixar em contrato

ElementoPor que testarExemplo
Status codeClientes dependem disso400 para validação, 404 para não encontrado
Shape do JSONEvita quebra de front/consumidores{ data: ... } e { error: { code, message } }
Códigos de erroPermite tratamento programáticoVALIDATION_ERROR, NOT_FOUND
Campos obrigatóriosCompatibilidadeerror.code sempre presente

Passo a passo: “contract tests” com tabela de casos

Uma forma simples e eficaz é criar testes orientados a casos (inputs → outputs esperados). Isso reduz repetição e deixa claro o contrato.

// http/contracts/userEmail.contract.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../appFactory';
import { UserService } from '../../application/userService';
import { InMemoryUserRepo } from '../../test/fakes';
import { userFixture } from '../../test/builders';

const clock = { now: () => new Date('2030-10-10T10:10:10.000Z') };

type Case = {
  name: string;
  seed?: any[];
  req: { id: string; body: any };
  expected: { status: number; body: any };
};

const cases: Case[] = [
  {
    name: 'email inválido retorna 400 e error padronizado',
    seed: [userFixture({ id: 'u_1' })],
    req: { id: 'u_1', body: { email: 'x' } },
    expected: {
      status: 400,
      body: { error: { code: 'VALIDATION_ERROR', message: 'Email inválido' } },
    },
  },
  {
    name: 'usuário inexistente retorna 404 e error padronizado',
    seed: [],
    req: { id: 'missing', body: { email: 'a@b.com' } },
    expected: {
      status: 404,
      body: { error: { code: 'NOT_FOUND', message: 'Usuário não encontrado' } },
    },
  },
];

describe('Contrato: PATCH /users/:id/email', () => {
  for (const c of cases) {
    it(c.name, async () => {
      const repo = new InMemoryUserRepo();
      repo.seed(c.seed ?? []);
      const app = createApp({ userService: new UserService(repo, clock) });

      const res = await request(app)
        .patch(`/users/${c.req.id}/email`)
        .send(c.req.body);

      expect(res.status).toBe(c.expected.status);
      expect(res.body).toEqual(c.expected.body);
    });
  }
});

Esse estilo de teste é excelente para prevenir regressões em middlewares e validações, porque falha quando o formato de erro muda, quando o status code muda ou quando o handler deixa de capturar um erro de domínio corretamente.

Checklist de estratégia de testes (unidade, integração e contratos)

Distribuição recomendada

  • Unidade: regras de negócio, validações, mapeamentos, serviços (rápidos, muitos).
  • Integração: rotas críticas, error handler, middlewares de validação/autorização, integração com banco em ambiente controlado (menos, porém mais abrangentes).
  • Contrato: endpoints públicos e seus cenários de erro/sucesso mais importantes (foco em status + shape).

Critérios para decidir o tipo de teste

Se você quer garantir...Use...Exemplo
Regra de negócio corretaUnidadecálculo, validação, fluxo do serviço
HTTP + middlewares + serializaçãoIntegraçãorota Express retornando JSON esperado
Formato estável para consumidoresContratoshape de data e error
Persistência realIntegração com DBrollback por teste, seeds controladas

Como prevenir regressões em middlewares e validações

  • Teste integração cobrindo: body inválido, parâmetros ausentes, tipos errados, e erros de domínio mapeados para status corretos.
  • Padronize erros com code e message e escreva testes de contrato para esses formatos.
  • Evite snapshots grandes e frágeis; prefira toEqual com objetos pequenos e explícitos, ou valide apenas campos essenciais quando o payload for extenso.
  • Garanta determinismo: injete clock/ID generator e fixe timezone no ambiente de teste quando datas estiverem no contrato.

Agora responda o exercício sobre o conteúdo:

Qual prática torna um serviço mais testável ao evitar não determinismo e dependências externas em testes de unidade?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Ao injetar dependências (ex.: repositório e Clock), o teste controla o tempo e substitui infraestrutura por dublês, mantendo o teste determinístico e sem precisar de servidor, banco ou rede.

Próximo capitúlo

Operação do back-end Node.js: health checks, encerramento gracioso e robustez

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
94%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.