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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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
vitestoujestpara runner/assertions/mocks.ts-nodeou 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
toHaveBeenCalledWithapenas quando a interação for parte do contrato (ex.: garantir que não chamaupdateEmailquando 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
Clocke 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
beforeEache dar rollback noafterEach. 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
| Elemento | Por que testar | Exemplo |
|---|---|---|
| Status code | Clientes dependem disso | 400 para validação, 404 para não encontrado |
| Shape do JSON | Evita quebra de front/consumidores | { data: ... } e { error: { code, message } } |
| Códigos de erro | Permite tratamento programático | VALIDATION_ERROR, NOT_FOUND |
| Campos obrigatórios | Compatibilidade | error.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 correta | Unidade | cálculo, validação, fluxo do serviço |
| HTTP + middlewares + serialização | Integração | rota Express retornando JSON esperado |
| Formato estável para consumidores | Contrato | shape de data e error |
| Persistência real | Integração com DB | rollback 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
codeemessagee escreva testes de contrato para esses formatos. - Evite snapshots grandes e frágeis; prefira
toEqualcom 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.