1. Recap â MVC com 6 camadas
Na aula 5 consolidamos a arquitetura do back-end como um MVC estendido com seis camadas claras. O objetivo daquele dia foi montar a estrutura, tipar tudo com TypeScript, conectar o pg e responder os primeiros GETs. Hoje o foco Ă© o resto do CRUD: POST, PUT, PATCH e DELETE â com toda a coreografia que cerca uma escrita real (validação, status codes, transaçÔes e tratamento de erro centralizado).
Antes de avançar, uma revisĂŁo rĂĄpida das responsabilidades â para vocĂȘ nĂŁo confundir o que vai onde quando o cĂłdigo crescer:
User, Order). Sem lĂłgica.req, valida payload, chama um service, devolve res com o status correto.req/res nem em SQL.pg (pool/client). TransaçÔes vivem aqui.validateEmail, parsePositiveInt).O fluxo de chamada Ă© sempre controller â service â repository. Camada superior nunca pula a inferior, e camada inferior nunca conhece a superior. O service nĂŁo importa req; o repository nĂŁo importa o service. Quando esse contrato quebra, o cĂłdigo volta a ser uma "ball of mud" â exatamente o que MVC tenta evitar.
2. Roteamento modular
Conforme o nĂșmero de recursos cresce, deixar todas as rotas em um Ășnico app.ts vira um pesadelo. A solução Ă© o roteamento modular: cada recurso (users, orders, products) ganha seu prĂłprio arquivo, com seu prĂłprio Router(), e um arquivo agregador une todos eles.
import { Router } from 'express';
import { UserController } from '../controllers/UserController';
import { asyncHandler } from '../helpers/asyncHandler';
const router = Router();
const ctrl = new UserController();
router.get('/', asyncHandler(ctrl.list));
router.get('/:id', asyncHandler(ctrl.show));
router.post('/', asyncHandler(ctrl.create));
router.put('/:id', asyncHandler(ctrl.replace));
router.patch('/:id', asyncHandler(ctrl.update));
router.delete('/:id', asyncHandler(ctrl.remove));
export default router;
import { Router } from 'express';
import userRoutes from './userRoutes';
import orderRoutes from './orderRoutes';
import productRoutes from './productRoutes';
const api = Router();
api.use('/users', userRoutes);
api.use('/orders', orderRoutes);
api.use('/products', productRoutes);
export default api;
No app.ts, basta plugar o agregador em um prefixo Ășnico, normalmente /api:
import express from 'express';
import api from './routes';
import { errorHandler } from './middlewares/errorHandler';
const app = express();
app.use(express.json());
app.use('/api', api);
app.use(errorHandler); // SEMPRE por Ășltimo
export default app;
Cada arquivo vira a "tabela de conteĂșdos" do recurso. Onboarding fica trivial: novo dev abre userRoutes.ts e vĂȘ em 30 segundos os 6 endpoints de usuĂĄrio. Testes ficam mais simples tambĂ©m â vocĂȘ importa sĂł o Router que estĂĄ testando, sem montar a aplicação inteira.
3. REST e verbos HTTP
REST Ă© uma convenção, nĂŁo uma lei: ele alinha a operação que vocĂȘ estĂĄ fazendo com o verbo HTTP que melhor representa essa intenção. Seguir a convenção tem um valor concreto â qualquer dev novo no time consegue prever o comportamento de uma rota sĂł de olhar para o verbo. E ferramentas (proxies, caches, observabilidade) tomam decisĂ”es com base nessa semĂąntica.
| Verbo | Significado | Idempotente | Body | Uso tĂpico |
|---|---|---|---|---|
| GET | Ler recurso | Sim | NĂŁo | Listar usuĂĄrios, buscar um pedido |
| POST | Criar recurso | NĂŁo | Sim | Criar usuĂĄrio, abrir pedido |
| PUT | Substituir recurso inteiro | Sim | Sim | Reescrever cadastro completo |
| PATCH | Atualização parcial | Não estritamente | Sim | Mudar só o e-mail, só o status |
| DELETE | Remover recurso | Sim | NĂŁo | Excluir usuĂĄrio, cancelar pedido |
Repare em PUT vs PATCH: o PUT diz "este Ă© o estado completo do recurso, sobrescreva". O PATCH diz "mude apenas estes campos". Em projetos pequenos Ă© comum usar PATCH para qualquer atualização, mas o PUT formaliza o contrato â Ăștil quando o cliente envia um formulĂĄrio inteiro de cadastro.
Uma operação Ă© idempotente se executĂĄ-la 2, 3 ou N vezes deixa o sistema no mesmo estado final que executĂĄ-la uma Ășnica vez. PUT /users/42 com o mesmo body Ă© idempotente; POST /orders nĂŁo â cada chamada cria um novo pedido. Isso afeta o que clientes (e proxies) podem ou nĂŁo retentar com segurança.
4. Status codes â o ponto de origem da resposta
O status HTTP Ă© a primeira informação que o cliente recebe â antes mesmo de olhar o body. Devolver o cĂłdigo certo nĂŁo Ă© cosmĂ©tico: Ă© o que permite que browsers, proxies, caches, gateways e clientes automatizados saibam o que aconteceu sem precisar interpretar a mensagem em texto.
Location: /users/42./users/999) nĂŁo foi encontrado.400 vs 422 â a diferença que importa
Muita gente joga tudo em 400. A distinção Ăștil: 400 Ă© "nĂŁo consegui sequer entender o que vocĂȘ mandou" (JSON quebrado, content-type errado, campo obrigatĂłrio ausente em nĂvel de schema). 422 Ă© "entendi perfeitamente, mas a regra de negĂłcio nĂŁo permite" (estoque insuficiente, transferĂȘncia maior que o saldo, data no passado). Adotar essa convenção faz com que erros de cliente fiquem rastreĂĄveis: 400 Ă© bug do cliente, 422 Ă© regra do domĂnio.
O Location header no 201
ApĂłs um POST bem-sucedido, devolva 201 com o header Location apontando para o recurso recĂ©m-criado. Isso permite que o cliente saiba imediatamente onde buscar o recurso completo, sem ter que adivinhar a URL â um padrĂŁo REST clĂĄssico que muita API ignora e depois sofre com integraçÔes.
create = async (req: Request, res: Response): Promise<void> => {
const input = createUserSchema.parse(req.body);
const user = await this.userService.create(input);
res
.status(201)
.location(`/api/users/${user.id}`)
.json(user);
};
5. Validação de payload com zod
O req.body chega no controller como any â uma estrutura sem garantias de tipo, formato ou conteĂșdo. Validar antes de chamar qualquer regra de negĂłcio Ă© uma defesa em profundidade: vocĂȘ protege o service de inputs malformados, o banco de tipos errados e o cliente de respostas confusas.
No ecossistema TypeScript existem trĂȘs caminhos principais:
| Abordagem | Quando faz sentido | Custo |
|---|---|---|
Validação manual (helpers + if) | Projetos muito pequenos, payloads triviais | Boilerplate cresce råpido; erros inconsistentes |
| zod (recomendado) | A maioria dos casos. Schema declarativo, tipo inferido | 1 dependĂȘncia leve; learning curve curta |
class-validator | Projetos NestJS ou que jĂĄ usam decorators | Verboso para Express puro; depende de reflect-metadata |
Para o mĂłdulo 2 a recomendação Ă© o zod: o schema Ă© declarativo, os erros vĂȘm estruturados, e â o ponto que decide a maioria â o tipo TS sai inferido com z.infer, eliminando duplicação:
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email('email invĂĄlido'),
name: z.string().min(2, 'nome muito curto'),
age: z.number().int().positive().optional()
});
// Para PATCH: todos os campos opcionais
export const updateUserSchema = createUserSchema.partial();
// O TIPO sai inferido â uma fonte da verdade
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
No controller, use safeParse quando quiser controlar a resposta manualmente, ou parse (que lança exceção) combinado com o middleware de erro:
create = async (req: Request, res: Response) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
throw new ValidationError(
parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')
);
}
const user = await this.userService.create(parsed.data);
res.status(201).location(`/api/users/${user.id}`).json(user);
};
Pense no schema zod como a versão executåvel da especificação do endpoint. Mudou um campo? Atualize o schema, e o tipo TS migra junto, e o erro volta para o cliente automaticamente. Isso é o oposto da documentação que envelhece silenciosamente.
6. Helpers de validação â pequenos e puros
Nem toda validação merece um schema zod. ConversĂ”es bĂĄsicas (string â nĂșmero), formatos simples (e-mail, CPF, slug) e checagens determinĂsticas vivem bem como funçÔes puras em helpers/. CaracterĂsticas de um bom helper:
- Puro â mesma entrada produz a mesma saĂda sempre
- Sem efeitos colaterais â nĂŁo loga, nĂŁo acessa banco, nĂŁo lĂȘ variĂĄveis de ambiente
- Tipado explicitamente â assinatura clara, retorno previsĂvel
- TestĂĄvel em isolamento â sem mocks, sem fixtures
// Helpers puros â entrada â saĂda, nada mais
const EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function validateEmail(email: string): boolean {
return EMAIL_RX.test(email);
}
export function parsePositiveInt(raw: string | undefined): number | null {
if (!raw) return null;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
export function slugify(input: string): string {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
import { parsePositiveInt, validateEmail } from '../../src/helpers/validation';
describe('parsePositiveInt', () => {
it('aceita inteiro positivo', () => expect(parsePositiveInt('42')).toBe(42));
it('rejeita zero', () => expect(parsePositiveInt('0')).toBeNull());
it('rejeita negativo', () => expect(parsePositiveInt('-1')).toBeNull());
it('rejeita não-numérico', () => expect(parsePositiveInt('abc')).toBeNull());
it('rejeita undefined', () => expect(parsePositiveInt(undefined)).toBeNull());
});
7. Service â onde a regra de negĂłcio mora
O service Ă© o coração do CRUD de escrita. Ă ele quem decide: "este pedido Ă© vĂĄlido?", "tem estoque?", "qual o total final?", "qual a sequĂȘncia de operaçÔes que mantĂ©m o sistema consistente?". O controller sĂł traduz HTTP; o repository sĂł toca SQL. O service amarra os dois orquestrando a regra.
Um service bem escrito segue trĂȘs disciplinas:
- Recebe e devolve dados, nĂŁo
req/resâ assim ele pode ser chamado por outros services, jobs em background ou testes unitĂĄrios sem montar uma request fake. - Recebe os repositĂłrios via constructor (DI) â assim vocĂȘ troca por mocks em testes, ou troca a implementação (Postgres â outra base) sem refatorar.
- Lança exceçÔes de domĂnio quando uma regra falha â nĂŁo devolve
{ ok: false, error: '...' }. O middleware central converte em status HTTP.
import { OrderRepository } from '../repositories/OrderRepository';
import { ProductRepository } from '../repositories/ProductRepository';
import { NotFoundError, ValidationError } from '../errors/AppError';
import type { Order, CreateOrderInput } from '../models/order';
export class OrderService {
constructor(
private orders: OrderRepository,
private products: ProductRepository
) {}
async create(input: CreateOrderInput): Promise<Order> {
// 1. valida estoque para cada item
let total = 0;
for (const it of input.items) {
const p = await this.products.findById(it.productId);
if (!p) throw new NotFoundError(`produto ${it.productId}`);
if (p.stock < it.quantity) {
throw new ValidationError(
`estoque insuficiente para ${p.name}`
);
}
total += p.price * it.quantity;
}
// 2. delega persistĂȘncia atĂŽmica ao repository
return this.orders.createWithItems({ ...input, total });
}
}
Note como o service nĂŁo sabe nem se importa que existe HTTP por trĂĄs. Ele recebe um objeto, valida regras, lança exceção ou devolve o resultado. Esse Ă© o tipo de cĂłdigo que dura â independente de framework, do banco, do front-end.
8. Repository com transação no pg
Quando uma operação envolve mais de um INSERT ou UPDATE que precisam acontecer juntos (ou nenhum), vocĂȘ precisa de uma transação. Criar um pedido Ă© o exemplo clĂĄssico: precisamos inserir 1 linha em orders e N linhas em order_items. Se a 2ÂȘ query falhar, a 1ÂȘ nĂŁo pode permanecer no banco â senĂŁo sobra um pedido ĂłrfĂŁo sem itens.
No pg (driver oficial PostgreSQL para Node) o padrĂŁo Ă©:
const client = await pool.connect()â pega uma conexĂŁo exclusiva do poolawait client.query('BEGIN')â inicia a transação- Faça suas queries usando o mesmo
client(nĂŁo usepool.queryaqui) - Se tudo deu certo:
await client.query('COMMIT') - Se algo lançou:
await client.query('ROLLBACK') - SEMPRE em
finally:client.release()â devolve a conexĂŁo ao pool
import { pool } from '../db/pool';
import type { Order, CreateOrderInput } from '../models/order';
export class OrderRepository {
async findById(id: number): Promise<Order | null> {
const { rows } = await pool.query<Order>(
'SELECT * FROM orders WHERE id = $1', [id]
);
return rows[0] ?? null;
}
async createWithItems(input: CreateOrderInput & { total: number }): Promise<Order> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const orderRes = await client.query<Order>(
`INSERT INTO orders(user_id, total)
VALUES ($1, $2) RETURNING *`,
[input.userId, input.total]
);
const order = orderRes.rows[0];
for (const it of input.items) {
await client.query(
`INSERT INTO order_items(order_id, product_id, qty)
VALUES ($1, $2, $3)`,
[order.id, it.productId, it.quantity]
);
}
await client.query('COMMIT');
return order;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}
NĂŁo chame pool.query dentro de um bloco BEGIN/COMMIT â vocĂȘ estaria pegando outra conexĂŁo do pool, fora da transação. Use sempre o mesmo client entre BEGIN e COMMIT/ROLLBACK. Esquecer o release() tambĂ©m Ă© fatal: o pool se esgota silenciosamente em produção e tudo trava.
Tratando UNIQUE como ConflictError
Quando o banco recusa um INSERT por violação de UNIQUE (e-mail duplicado, por exemplo), o pg lança um erro com code === '23505'. Capture isso no repository e converta em uma exceção de domĂnio:
async create(input: CreateUserInput): Promise<User> {
try {
const { rows } = await pool.query<User>(
`INSERT INTO users(email, name) VALUES ($1, $2) RETURNING *`,
[input.email, input.name]
);
return rows[0];
} catch (e: any) {
if (e.code === '23505') throw new ConflictError('email jĂĄ cadastrado');
throw e;
}
}
9. Erros de domĂnio com AppError
Em vez de retornar objetos do tipo { ok: false, code: 'NOT_FOUND', message: '...' } de qualquer função do sistema, adotamos o padrão de exceçÔes tipadas. Uma classe base AppError carrega status e message, e subclasses cobrem os casos comuns. Qualquer camada (service, repository, helper) pode throw e o middleware central traduz para HTTP.
export class AppError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class NotFoundError extends AppError {
constructor(resource = 'recurso') {
super(404, `${resource} nĂŁo encontrado`);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, message);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(422, message);
}
}
export class BadRequestError extends AppError {
constructor(message = 'requisição invålida') {
super(400, message);
}
}
O ganho dessa hierarquia Ă© semĂąntico: throw new NotFoundError('produto') Ă© muito mais expressivo do que throw new Error('produto nĂŁo encontrado') â e o middleware central pode tratar cada caso de forma especĂfica se precisar (logar com nĂvel diferente, gerar mĂ©trica, etc).
10. Middleware de erro centralizado
Express reconhece um middleware como sendo de erro pela aridade: ele tem 4 parĂąmetros (err, req, res, next) em vez de 3. Esse middleware deve ser o Ășltimo a ser registrado no app, depois de todas as rotas.
import type { ErrorRequestHandler } from 'express';
import { AppError } from '../errors/AppError';
import { logger } from '../helpers/logger';
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const status = err instanceof AppError ? err.status : 500;
// log estruturado SERVER-side com contexto
logger.error({
status,
msg: err.message,
stack: err.stack,
method: req.method,
path: req.path,
ip: req.ip
});
// resposta ENXUTA para o cliente â sem stack, sem detalhes do banco
const publicMessage = status === 500
? 'erro interno do servidor'
: err.message;
res.status(status).json({ error: publicMessage });
};
Ă tentador devolver err.stack ou a mensagem original do banco no JSON de erro â facilita o debug. Em produção, isso Ă© uma falha de segurança: stack traces revelam estrutura do cĂłdigo, mensagens de banco revelam nomes de tabelas, schema, e atĂ© dados. Logue tudo no servidor; mostre o mĂnimo ao cliente.
Erro 404 explĂcito para rotas inexistentes
Express nĂŁo dispara erro automaticamente quando nenhuma rota casa â ele simplesmente entrega uma pĂĄgina padrĂŁo. Para rotas REST, Ă© melhor capturar com um middleware explĂcito antes do errorHandler:
app.use('/api', api);
// 404 explĂcito para tudo que nĂŁo casou
app.use((_req, _res, next) => next(new NotFoundError('rota')));
// SEMPRE por Ășltimo
app.use(errorHandler);
11. asyncHandler â eliminando try/catch repetido
Express 4 nĂŁo captura automaticamente exceçÔes lançadas em handlers async. Sem proteção, uma promise rejeitada some silenciosamente â a request fica pendurada atĂ© o timeout do cliente. A solução padrĂŁo Ă© envolver cada handler em um wrapper que captura a rejeição e chama next(err), ativando o middleware de erro central.
import type { Request, Response, NextFunction, RequestHandler } from 'express';
type AsyncFn = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export const asyncHandler = (fn: AsyncFn): RequestHandler =>
(req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
O ganho Ă© estilĂstico mas profundo. Compare:
router.post('/users', async (req, res, next) => {
try {
const u = await userService.create(req.body);
res.status(201).json(u);
} catch (e) {
next(e);
}
});
router.post('/users', asyncHandler(async (req, res) => {
const u = await userService.create(req.body);
res.status(201).location(`/api/users/${u.id}`).json(u);
}));
Sem o boilerplate de try/catch em cada handler, fica Ăłbvio o que cada rota faz â e o erro vai sempre direto para o middleware central, sem chance de esquecimento.
12. IdempotĂȘncia â segurança em retentativas
IdempotĂȘncia Ă© a propriedade de uma operação que, executada N vezes, deixa o sistema no mesmo estado que se tivesse sido executada uma sĂł. Isso importa muito quando hĂĄ retentativas: redes caem, clientes desistem e tentam de novo, proxies fazem retry. Se a operação nĂŁo Ă© idempotente, retentativa = duplicação.
| Verbo | Idempotente? | Por quĂȘ? |
|---|---|---|
| GET | Sim | Apenas leitura â nĂŁo modifica nada. |
| POST | NĂŁo | Cada chamada cria um novo recurso (novo ID). |
| PUT | Sim | Substituir pelo mesmo body N vezes deixa o recurso no mesmo estado. |
| PATCH | Depende | Se a operação for absoluta ({name: 'X'}) sim; se for relativa ({counter: increment}) não. |
| DELETE | Sim | Remover algo que jĂĄ nĂŁo existe Ă© no-op. |
POST nĂŁo idempotente: por que isso Ă© um problema?
Imagine um app mobile que envia POST /orders, perde a conexĂŁo antes de receber a resposta, e tenta de novo. Resultado: dois pedidos idĂȘnticos no banco, um cliente irritado. Para resolver isso em sistemas crĂticos existe o header Idempotency-Key (cliente envia um UUID e o servidor armazena junto com a resposta â segundo POST com a mesma key retorna a resposta original sem duplicar). NĂŁo Ă© trivial de implementar â para o mĂłdulo 2, basta saber que o problema existe e que retentativas em POST sĂŁo perigosas.
DELETE: 204 ou 404 na segunda chamada?
Se vocĂȘ Ă© estritamente idempotente, DELETE /users/42 chamado 5 vezes seguidas deve retornar 204 sempre â o estado final ("user 42 nĂŁo existe") Ă© o mesmo. Outros sistemas preferem retornar 404 a partir da 2ÂȘ chamada para sinalizar "esse recurso nĂŁo existe mais". Ambas as posturas sĂŁo defensĂĄveis; o que importa Ă© escolher uma e documentar.
Algumas APIs envelopam: { data: ..., meta: { page, total } }. Outras retornam direto: { id, name, ... }. Para erros, o quase-padrĂŁo Ă© { error: 'mensagem curta' }. NĂŁo importa qual escolher â importa aplicar a mesma convenção em toda a API. InconsistĂȘncia Ă© o que sangra a experiĂȘncia do cliente.
13. Exemplo end-to-end: POST /orders
Vamos amarrar tudo: do clique no front-end (que só virå na aula 7) até o COMMIT no banco. Esse é o caminho que toda escrita não-trivial percorre nas seis camadas.
Modelos (tipos)
export interface Order {
id: number;
userId: number;
total: number;
createdAt: Date;
}
export interface OrderItemInput {
productId: number;
quantity: number;
}
export interface CreateOrderInput {
userId: number;
items: OrderItemInput[];
}
Schema de validação
import { z } from 'zod';
export const createOrderSchema = z.object({
userId: z.number().int().positive(),
items: z.array(z.object({
productId: z.number().int().positive(),
quantity: z.number().int().positive()
})).min(1, 'pedido precisa de ao menos 1 item')
});
Controller
import type { Request, Response } from 'express';
import { OrderService } from '../services/OrderService';
import { createOrderSchema } from '../schemas/order';
import { ValidationError } from '../errors/AppError';
export class OrderController {
constructor(private orderService: OrderService) {}
create = async (req: Request, res: Response): Promise<void> => {
const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) {
throw new ValidationError(
parsed.error.issues.map(i => i.message).join('; ')
);
}
const order = await this.orderService.create(parsed.data);
res
.status(201)
.location(`/api/orders/${order.id}`)
.json(order);
};
}
Caminho feliz vs caminho de erro
Quando tudo dĂĄ certo:
- Cliente envia
POST /api/orderscom body - Controller faz parse zod â ok
- Service consulta produtos, valida estoque â ok
- Repository abre transação, insere order, insere itens, COMMIT
- Service retorna o pedido criado
- Controller responde
201+Location: /api/orders/42+ JSON
Se o estoque Ă© insuficiente:
- Cliente envia
POST /api/orders - Parse zod â ok
- Service descobre estoque insuficiente â
throw new ValidationError('estoque insuficiente para X') - asyncHandler captura â
next(err) - errorHandler central detecta
err.status === 422â responde422+{ error: 'estoque insuficiente para X' } - Nada foi gravado no banco â a transação nem chegou a abrir, porque a falha aconteceu no service.
Se a inserção do item falhar a meio caminho (suponha um FK violation):
- BEGIN
- INSERT em
ordersâ ok - INSERT em
order_itemsâ falha (produto deletado entre a leitura e a escrita) catchdisparaROLLBACKâ o INSERT noordersĂ© desfeito- O erro original sobe; middleware retorna 500 (ou 422 se vocĂȘ converter)
14. Logging båsico de requisiçÔes
Todo back-end precisa de duas camadas de log: o access log (quem bateu em qual rota, quanto tempo levou, qual o status) e o error log (o que deu errado e por quĂȘ). No Express, o pacote morgan resolve o access log com uma linha de configuração:
import morgan from 'morgan';
if (process.env.NODE_ENV !== 'production') {
app.use(morgan('dev')); // colorido, conciso
} else {
app.use(morgan('combined')); // formato Apache padrĂŁo
}
Para o error log, em projetos pequenos um console.error com objeto estruturado jĂĄ cumpre. Em projetos sĂ©rios, troque por pino ou winston e configure nĂveis (info, warn, error) e formato JSON â assim ferramentas de observabilidade conseguem indexar.
Um log "erro ao criar pedido" sem mais nada Ă© inĂștil. Logue: status, mensagem, mĂ©todo HTTP, path, IP, request ID (se houver), e o stack quando for 500. Esses 6 campos respondem a 90% das investigaçÔes em produção.
15. Testes manuais â curl, .http, Postman
Antes de qualquer teste automatizado, vocĂȘ precisa de feedback rĂĄpido em dev. TrĂȘs ferramentas dominam o dia-a-dia:
curl â sempre disponĂvel
# GET â listar usuĂĄrios
curl http://localhost:3000/api/users
# GET â buscar um
curl http://localhost:3000/api/users/42
# POST â criar (com -i para ver headers)
curl -i -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"email":"ana@inteli.edu","name":"Ana"}'
# PUT â substituir
curl -X PUT http://localhost:3000/api/users/42 \
-H "Content-Type: application/json" \
-d '{"email":"ana.lima@inteli.edu","name":"Ana Lima"}'
# DELETE
curl -i -X DELETE http://localhost:3000/api/users/42
Arquivo .http (REST Client / VS Code)
Mais legĂvel que curl, versionĂĄvel no git, fica junto do cĂłdigo. Crie um requests.http na raiz:
### Listar usuĂĄrios
GET http://localhost:3000/api/users
### Criar usuĂĄrio
POST http://localhost:3000/api/users
Content-Type: application/json
{
"email": "ana@inteli.edu",
"name": "Ana"
}
### Criar pedido (e2e)
POST http://localhost:3000/api/orders
Content-Type: application/json
{
"userId": 1,
"items": [
{ "productId": 10, "quantity": 2 },
{ "productId": 11, "quantity": 1 }
]
}
Postman / Insomnia
Para times com QAs ou stakeholders nĂŁo-devs que precisam testar manualmente, Postman e Insomnia oferecem coleçÔes compartilhĂĄveis, ambientes (dev/staging/prod), prĂ©-scripts e testes assertivos. Em projetos do mĂłdulo 2, o arquivo .http versionado no repo Ă© suficiente â Ă© mais leve e cabe no fluxo do dev.
Os testes manuais sĂŁo Ăłtimos para iteração rĂĄpida, mas nĂŁo substituem regressĂŁo automatizada. A prĂłxima seção mostra como cobrir cada endpoint com Jest + supertest ou Playwright Test â incluindo setup do banco, fixtures e CI. A regra continua valendo: cada endpoint precisa de pelo menos 2 testes â um caminho feliz e um caminho de erro.
16. Testes End-to-End â Jest + Playwright
Testes manuais cobrem vocĂȘ no momento em que escreve o cĂłdigo. Mas duas semanas depois, quando outro dev (ou vocĂȘ mesmo) refatora o OrderService, ninguĂ©m vai lembrar de re-rodar 20 chamadas curl. Sem teste automatizado de ponta-a-ponta, regressĂ”es silenciosas viram bug em produção. Esta seção mostra como cobrir cada endpoint com testes E2E que sobem o servidor real, fazem requisiçÔes HTTP de verdade e verificam tanto a resposta quanto o estado do banco depois â o tipo de teste que dĂĄ confiança para mudar cĂłdigo sem medo.
Existem dois caminhos consagrados no ecossistema Node/TypeScript: Jest + supertest (combinação clĂĄssica, ampla adoção, integração simples) e Playwright Test (mais novo, runner prĂłprio, fixture request nativa, paralelismo automĂĄtico). Ambos funcionam e se completam: vocĂȘ pode usar Jest para unit/integration e Playwright para E2E full-stack â cobrindo backend e frontend no mesmo runner. Vamos ver os dois lado a lado para vocĂȘ escolher com base no contexto do seu projeto.
Antes de escrever a primeira linha de teste, lembre-se da regra de ouro: teste E2E confiĂĄvel depende de banco isolado. Rodar contra o mesmo PostgreSQL onde vocĂȘ desenvolve Ă© a fonte nĂșmero um de "passou na minha mĂĄquina". Vamos discutir trĂȘs estratĂ©gias â schema descartĂĄvel, transação por teste com rollback e Testcontainers â e quando cada uma faz sentido.
16.1 A pirĂąmide de testes â onde os E2E entram
A pirĂąmide de testes de Mike Cohn descreve a proporção saudĂĄvel entre tipos de teste: muitos unit (rĂĄpidos, baratos, focados em uma função pura), alguns integration (testam dois ou trĂȘs componentes juntos â service + repository + DB) e poucos E2E (sobem o servidor inteiro, fazem HTTP real, validam o comportamento observĂĄvel de fora). A inversĂŁo dessa pirĂąmide â o famoso "ice-cream cone anti-pattern" â gera suĂtes lentas, frĂĄgeis e caras de manter, em que toda mudança de cĂłdigo quebra dezenas de E2E.
| Camada | O que testa | Velocidade tĂpica | Quando criar |
|---|---|---|---|
| Unit | Função/classe isolada, sem I/O. Helpers, validaçÔes, regras de negócio puras. | ~1 ms / teste | Sempre que houver lógica condicional não-trivial. |
| Integration | Service + Repository + DB real (mas sem HTTP). Verifica que SQL e transação funcionam. | ~50â200 ms / teste | Sempre que o service tiver SQL nĂŁo-trivial ou transação. |
| E2E (API) | Servidor real + DB real + HTTP real. Cobre o contrato completo do endpoint. | ~200 ms â 2 s / teste | Pelo menos 1 happy + 1 erro por endpoint. |
| E2E (UI) | Browser real + frontend + backend. Valida fluxo do usuĂĄrio ponta a ponta. | ~2â10 s / teste | Apenas para fluxos crĂticos de negĂłcio (checkout, login). |
Para o projeto da disciplina, mire em ~70% unit + ~25% integration/E2E API + ~5% E2E UI. Cada endpoint precisa de pelo menos 1 happy path e 1 caminho de erro testados via HTTP â esse Ă© o piso para considerar a feature "pronta".
16.2 Jest + supertest vs Playwright Test â qual escolher?
Os dois caminhos chegam ao mesmo destino â fazer requisiçÔes HTTP contra o seu Express e validar a resposta â, mas tĂȘm trade-offs distintos. Jest + supertest Ă© a combinação histĂłrica do ecossistema Node: Jest Ă© um runner maduro com mocking poderoso, e supertest faz in-process HTTP (nĂŁo abre porta TCP â chama o handler do Express diretamente), o que Ă© rĂĄpido e simples. Playwright Test Ă© o runner do time da Microsoft pensado para testar tudo: tem fixture request que faz HTTP real (ABRE porta), suporta paralelismo automĂĄtico com isolamento, e permite no mesmo arquivo testar API e UI â Ăștil se vocĂȘ jĂĄ vai cobrir o frontend.
| Critério | Jest + supertest | Playwright Test |
|---|---|---|
| Maturidade no ecossistema Node | Muito alta â padrĂŁo de fato | Crescente â popular desde 2022 |
| Forma da requisição HTTP | In-process (sem porta TCP) | Out-of-process (porta TCP real) |
| Velocidade por teste | Mais rĂĄpido (sem overhead de rede) | Levemente mais lento |
| Paralelismo | Manual via --maxWorkers | AutomĂĄtico por arquivo |
| Mocking de mĂłdulos | Excelente (jest.mock) | Limitado â prefere injeção |
| Fixtures | beforeAll/beforeEach manuais | Sistema de fixtures nativo |
| Cobre frontend tambĂ©m | NĂŁo â precisa de outra ferramenta | Sim â mesmo runner |
| Curva de aprendizado | Baixa se jĂĄ conhece Jest | MĂ©dia â conceitos prĂłprios |
Use Jest + supertest para o trabalho da disciplina. RazĂ”es: Ă© o stack mais documentado, o supertest dispensa subir servidor em porta separada (um problema a menos no setup), e Jest Ă© o que vocĂȘ verĂĄ em qualquer projeto Node profissional. Quando chegar Ă Aula 8 (Front-End II) e Ă Aula 9 (Testes e Automação), o Playwright Test entra para cobrir o frontend e os fluxos completos â aĂ vocĂȘ terĂĄ os dois.
16.3 Setup com Jest + ts-jest + supertest
O caminho mais curto para testar uma API Express em TypeScript: instale Jest, ts-jest (transpila TS na hora) e supertest. Em seguida, exporte o app Express (sem app.listen) de um arquivo separado para que os testes possam importar a instĂąncia sem subir o servidor.
npm install -D jest ts-jest @types/jest supertest @types/supertest
npx ts-jest config:init
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.spec.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
testTimeout: 10000, // E2E pode passar de 1s â nĂŁo use 5s default
forceExit: true, // força sair se algum handler ficou pendurado
detectOpenHandles: true, // avisa se conexĂŁo de banco nĂŁo foi fechada
};
export default config;
import express from 'express';
import { router } from './routes';
import { errorHandler } from './middlewares/error';
export function createApp() {
const app = express();
app.use(express.json());
app.use('/api', router);
app.use(errorHandler);
return app;
}
// src/index.ts â bootstrap separado, NĂO importado pelos testes
// import { createApp } from './app';
// createApp().listen(process.env.PORT ?? 3000);
import request from 'supertest';
import { createApp } from '../src/app';
import { resetDb, seedClient } from './helpers/db';
const app = createApp();
describe('POST /api/orders', () => {
beforeEach(async () => {
await resetDb(); // TRUNCATE em todas as tabelas
});
it('cria pedido vĂĄlido e devolve 201 com Location header', async () => {
const client = await seedClient({ email: 'ana@inteli.edu' });
const res = await request(app)
.post('/api/orders')
.send({ clientId: client.id, items: [{ productId: 1, qty: 2 }] })
.set('Content-Type', 'application/json');
expect(res.status).toBe(201);
expect(res.headers.location).toMatch(/^\/api\/orders\/\d+$/);
expect(res.body).toMatchObject({ clientId: client.id, status: 'PENDING' });
});
it('devolve 422 quando items Ă© vazio', async () => {
const res = await request(app)
.post('/api/orders')
.send({ clientId: 1, items: [] });
expect(res.status).toBe(422);
expect(res.body.error).toBe('ValidationError');
});
});
16.4 Setup com Playwright Test (apenas API)
Playwright Test nĂŁo precisa de browser para testar API â basta a fixture request. A diferença em relação ao Jest+supertest: aqui o servidor sobe de verdade em porta TCP, e os testes batem nele via HTTP "real". A vantagem Ă© que vocĂȘ estĂĄ testando exatamente o mesmo binĂĄrio/processo que vai rodar em produção; a desvantagem Ă© que cada teste paga ~50â100 ms a mais de overhead.
npm init playwright@latest -- --quiet --browser=chromium
# JĂĄ temos Playwright Test + fixtures + config gerados
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 15000,
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
// Sobe o servidor antes dos testes; mata depois.
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000/api/health',
timeout: 30000,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:3000',
extraHTTPHeaders: { 'Content-Type': 'application/json' },
},
});
import { test, expect } from '@playwright/test';
import { resetDb, seedClient } from './helpers/db';
test.beforeEach(async () => { await resetDb(); });
test('POST /api/orders cria pedido e devolve 201', async ({ request }) => {
const client = await seedClient({ email: 'ana@inteli.edu' });
const res = await request.post('/api/orders', {
data: { clientId: client.id, items: [{ productId: 1, qty: 2 }] },
});
expect(res.status()).toBe(201);
expect(res.headers()['location']).toMatch(/^\/api\/orders\/\d+$/);
const body = await res.json();
expect(body).toMatchObject({ clientId: client.id, status: 'PENDING' });
});
test('POST /api/orders sem itens devolve 422', async ({ request }) => {
const res = await request.post('/api/orders', {
data: { clientId: 1, items: [] },
});
expect(res.status()).toBe(422);
});
Em projetos maiores, criar uma fixture authenticatedRequest que jĂĄ loga e devolve um request com cookie/token elimina boilerplate em dezenas de testes. Ver a doc oficial em test.extend() â Ă© o mesmo padrĂŁo que o Jest resolve via beforeAll mas declarativo.
16.5 Banco de teste isolado e estratégias de cleanup
Toda dor de cabeça em testes E2E começa quando dois testes pisam nos mesmos dados. HĂĄ trĂȘs estratĂ©gias maduras, em ordem crescente de isolamento (e custo):
// tests/helpers/db.ts
import { Pool } from 'pg';
export const testPool = new Pool({
connectionString: process.env.DATABASE_URL_TEST,
});
export async function resetDb() {
await testPool.query(`
TRUNCATE TABLE orders, order_items, clients, products
RESTART IDENTITY CASCADE;
`);
await seedFixtures(); // re-popula dados de referĂȘncia (produtos, etc.)
}
// Cada teste roda dentro de uma transação que faz ROLLBACK no final.
// Ganho: ~10x mais rĂĄpido que TRUNCATE. Limite: o app precisa aceitar
// uma conexĂŁo prĂ©-aberta â exige injeção de dependĂȘncia.
let tx: PoolClient;
beforeEach(async () => {
tx = await testPool.connect();
await tx.query('BEGIN');
setRequestScopedClient(tx); // app usa este client em vez do pool
});
afterEach(async () => {
await tx.query('ROLLBACK');
tx.release();
});
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
process.env.DATABASE_URL = container.getConnectionUri();
await runMigrations(); // aplica seu schema
}, 60_000);
afterAll(async () => { await container.stop(); });
| Estratégia | Velocidade | Isolamento | Quando usar |
|---|---|---|---|
| TRUNCATE em beforeEach | ~30 ms / teste | Bom (entre testes do mesmo arquivo) | PadrĂŁo para o mĂłdulo 2. |
| Transação + ROLLBACK | ~3 ms / teste | Excelente | SuĂtes grandes (>200 testes) e cĂłdigo que aceita injeção de client. |
| Testcontainers | ~10 s setup + ~50 ms / teste | Total â DB descartĂĄvel por suĂte | CI sem banco preinstalado, ou mĂșltiplas versĂ”es do PostgreSQL. |
Use variĂĄvel de ambiente separada (DATABASE_URL_TEST) e tenha um guard no helpers/db.ts: se a URL nĂŁo terminar com _test, aborte o processo. Um TRUNCATE acidental no banco errado destrĂłi horas de trabalho.
const url = process.env.DATABASE_URL_TEST ?? '';
if (!url.endsWith('_test') && !url.includes('localhost')) {
throw new Error('DATABASE_URL_TEST invĂĄlida â recusando subir testes contra prod/staging');
}
16.6 Casos de teste essenciais para cada endpoint
Para um endpoint REST tĂpico, hĂĄ 6 casos que cobrem 90% dos bugs. Pense neles como o "kit mĂnimo" â qualquer endpoint que vai para produção deveria passar por essa lista. Os exemplos abaixo usam Jest+supertest, mas o esqueleto Ă© idĂȘntico em Playwright.
describe('POST /api/orders â kit mĂnimo', () => {
// 1. HAPPY PATH â o caminho que deve funcionar
it('201 Created com Location e body coerente', async () => {
const client = await seedClient();
const res = await request(app).post('/api/orders')
.send({ clientId: client.id, items: [{ productId: 1, qty: 1 }] });
expect(res.status).toBe(201);
expect(res.headers.location).toMatch(/\/api\/orders\/\d+/);
});
// 2. VALIDAĂĂO DE PAYLOAD â schema falha
it('422 quando clientId estĂĄ ausente', async () => {
const res = await request(app).post('/api/orders').send({ items: [] });
expect(res.status).toBe(422);
expect(res.body.error).toBe('ValidationError');
expect(res.body.details).toContainEqual(
expect.objectContaining({ path: ['clientId'] })
);
});
// 3. ENTIDADE NĂO ENCONTRADA â FK aponta para nada
it('404 quando clientId nĂŁo existe', async () => {
const res = await request(app).post('/api/orders')
.send({ clientId: 99999, items: [{ productId: 1, qty: 1 }] });
expect(res.status).toBe(404);
});
// 4. CONFLITO DE NEGĂCIO â regra Ă© violada
it('409 quando produto estĂĄ sem estoque', async () => {
const client = await seedClient();
const prod = await seedProduct({ stock: 0 });
const res = await request(app).post('/api/orders')
.send({ clientId: client.id, items: [{ productId: prod.id, qty: 1 }] });
expect(res.status).toBe(409);
expect(res.body.error).toBe('ConflictError');
});
// 5. EFEITO COLATERAL NO BANCO â verifica o estado depois
it('persiste pedido + itens e decrementa estoque', async () => {
const client = await seedClient();
const prod = await seedProduct({ stock: 10 });
await request(app).post('/api/orders')
.send({ clientId: client.id, items: [{ productId: prod.id, qty: 3 }] });
const { rows: orders } = await testPool.query(
'SELECT * FROM orders WHERE client_id = $1', [client.id]
);
const { rows: stock } = await testPool.query(
'SELECT stock FROM products WHERE id = $1', [prod.id]
);
expect(orders).toHaveLength(1);
expect(stock[0].stock).toBe(7); // 10 - 3
});
// 6. ROLLBACK â falha no meio da transação nĂŁo persiste nada
it('rollback: pedido invĂĄlido nĂŁo persiste itens jĂĄ inseridos', async () => {
const client = await seedClient();
const p1 = await seedProduct({ stock: 5 });
const p2 = await seedProduct({ stock: 0 }); // vai falhar no segundo item
const res = await request(app).post('/api/orders').send({
clientId: client.id,
items: [{ productId: p1.id, qty: 1 }, { productId: p2.id, qty: 1 }],
});
expect(res.status).toBe(409);
const { rows } = await testPool.query('SELECT count(*) FROM orders');
expect(rows[0].count).toBe('0'); // nada persistiu â ROLLBACK funcionou
});
});
IdempotĂȘncia â testar PUT/DELETE chamados duas vezes
IdempotĂȘncia nĂŁo Ă© uma propriedade que aparece em logs â ela sĂł se manifesta quando alguĂ©m retenta. Cobrir esses casos Ă© o que separa um endpoint que "passa nos testes" de um endpoint que "sobrevive na produção".
it('PUT /api/clients/:id Ă© idempotente', async () => {
const client = await seedClient({ name: 'Antigo' });
const payload = { name: 'Novo', email: client.email };
const r1 = await request(app).put(`/api/clients/${client.id}`).send(payload);
const r2 = await request(app).put(`/api/clients/${client.id}`).send(payload);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
expect(r1.body).toEqual(r2.body); // estado final idĂȘntico
});
it('DELETE /api/clients/:id devolve 204 na primeira e 404 na segunda', async () => {
const client = await seedClient();
const r1 = await request(app).delete(`/api/clients/${client.id}`);
const r2 = await request(app).delete(`/api/clients/${client.id}`);
expect(r1.status).toBe(204);
expect(r2.status).toBe(404); // alternativa vĂĄlida: 204 â escolha uma e mantenha
});
16.7 Rodando no CI (GitHub Actions)
Localmente, npm test roda os E2E contra o seu banco de dev. No CI, vocĂȘ precisa de um PostgreSQL efĂȘmero por job â o GitHub Actions oferece service containers exatamente para isso. O snippet abaixo sobe Postgres 16 antes da suĂte, aplica migrations e roda os testes; ao fim do job, o container Ă© destruĂdo.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL_TEST: postgres://test:test@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run db:migrate # aplica schema no banco do CI
- run: npm test # roda Jest E2E
- run: npx playwright test # roda Playwright (se houver)
1. Sempre paralelize entre arquivos, nunca entre testes do mesmo arquivo que tocam o mesmo recurso. 2. Nomeie testes pelo comportamento esperado ("devolve 422 quando..."), nĂŁo pelo mĂ©todo ("testCreateOrder1"). 3. Falha intermitente Ă© bug â investigue, nĂŁo retry. 4. O teste E2E Ă© documentação: alguĂ©m lendo a suĂte deveria entender o contrato da API sem abrir o cĂłdigo.
A Aula 9 â Testes e Automação aprofunda esse mesmo tema: cobertura de cĂłdigo, mocking estratĂ©gico, testes de carga e a integração com pipelines mais elaborados. O que vocĂȘ cobre aqui (E2E API com Jest/Playwright) Ă© o piso para a entrega do mĂłdulo. O que vocĂȘ verĂĄ lĂĄ Ă© o teto.
17. Checklist de Estudo
Marque os conceitos Ă medida que vocĂȘ os domina:
- ✓Sei quando usar GET, POST, PUT, PATCH e DELETE â e por que importa.
- ✓Sei a diferença entre 400 e 422 e quando usar cada um.
- ✓Devolvo
201 Createdcom headerLocationem todo POST. - ✓Tenho um
Router()por recurso e umroutes/index.tsagregador. - ✓Valido todo
req.bodyantes de chamar o service â com zod. - ✓Uso
z.infer<typeof schema>para gerar o tipo TS automaticamente. - ✓Sei escrever helpers puros e testĂĄ-los em isolamento.
- ✓Meu service nĂŁo importa
req/rese recebe os repositĂłrios via constructor. - ✓Sei usar
BEGIN/COMMIT/ROLLBACKcompool.connect()eclient.release()emfinally. - ✓Tenho uma classe
AppErrorcom subclasses para 404, 409, 422 e 400. - ✓Tenho um middleware central que converte
AppErrorem resposta HTTP. - ✓Uso
asyncHandlerpara evitar try/catch repetido. - ✓NĂŁo vazo
err.stacknem mensagens do banco para o cliente. - ✓Sei que PUT/DELETE sĂŁo idempotentes e POST nĂŁo â e o que isso implica.
- ✓Adotei UMA convenção de envelope de resposta para o projeto.
- ✓Tenho um
requests.httpversionado com exemplos do CRUD. - ✓Configurei Jest com
ts-jeste exportocreateApp()separado dolisten. - ✓Cada endpoint tem pelo menos os 6 testes do "kit mĂnimo" (happy, validação, 404, conflito, efeito no banco, rollback).
- ✓Uso
DATABASE_URL_TESTseparada do dev/prod, com guard que aborta se a URL for suspeita. - ✓Sei a diferença entre Jest+supertest (in-process) e Playwright Test (out-of-process) e quando usar cada um.
- ✓Tenho um
ci.ymlcom service container do PostgreSQL e os testes E2E rodando a cada push.
ReferĂȘncias
- Express â Routing Guide â documentação oficial de roteamento e
Router - Express â Error Handling â middleware de erro de 4 parĂąmetros
- Zod â TypeScript-first schema validation â documentação oficial
- node-postgres â Transactions â uso de
pool.connect()e BEGIN/COMMIT - MDN â HTTP Status Codes â referĂȘncia completa de status
- MDN â HTTP Methods â verbos, idempotĂȘncia e segurança
- RFC 7231 â HTTP/1.1 Semantics and Content â referĂȘncia normativa
- morgan â HTTP request logger middleware
- Jest â Getting Started â runner de testes, assertions e mocks
- supertest â Super-agent driven HTTP assertions â chamadas in-process Ă app Express
- ts-jest â TypeScript preprocessor para Jest
- Playwright Test â API Testing â fixture
requestpara testar APIs sem browser - Playwright Test â Fixtures â sistema declarativo de setup/teardown
- Testcontainers â PostgreSQL module â bancos descartĂĄveis em Docker
- GitHub Actions â PostgreSQL Service Containers â referĂȘncia oficial
- Martin Fowler â The Practical Test Pyramid â fundação conceitual da pirĂąmide