▶ Slides ← MĂłdulo
Módulo 2 · Ciclo Comum · IN02

Back-End II — Endpoints de leitura e escrita

Aula 6 — Material de Leitura Aprofundado

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:

📐
models/ — tipos e interfaces
Apenas o contrato dos dados (User, Order). Sem lĂłgica.
🎨
views/ — templates EJS (.ejs)
SSR no back-end. Hoje vocĂȘ ainda nĂŁo escreve aqui — entram em cena na aula 7.
🎮
controllers/ — borda HTTP
Recebe req, valida payload, chama um service, devolve res com o status correto.
services/ — regras de negócio
Decide, orquestra, valida invariantes. NĂŁo toca em req/res nem em SQL.
🗂
repositories/ — acesso ao banco
SQL puro com pg (pool/client). TransaçÔes vivem aqui.
🧰
helpers/ — utilitários puros
FunçÔes determinísticas e teståveis (validateEmail, parsePositiveInt).
A regra que segura a arquitetura

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.

routes/userRoutes.ts
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;
routes/index.ts — agregador
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:

app.ts
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;
Por que dividir assim?

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.

VerboSignificadoIdempotenteBodyUso tĂ­pico
GETLer recursoSimNĂŁoListar usuĂĄrios, buscar um pedido
POSTCriar recursoNĂŁoSimCriar usuĂĄrio, abrir pedido
PUTSubstituir recurso inteiroSimSimReescrever cadastro completo
PATCHAtualização parcialNão estritamenteSimMudar só o e-mail, só o status
DELETERemover recursoSimNĂŁoExcluir 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.

IdempotĂȘncia: o conceito que separa POST de PUT

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.

200
OK
GET com sucesso, PUT/PATCH que retornam o recurso atualizado.
201
Created
POST que criou recurso. Inclua o header Location: /users/42.
204
No Content
DELETE bem-sucedido. Resposta sem body.
400
Bad Request
JSON malformado, payload sem campos obrigatĂłrios.
404
Not Found
Rota nĂŁo existe ou o recurso (/users/999) nĂŁo foi encontrado.
409
Conflict
Violação de UNIQUE (e-mail jå cadastrado), tentativa de criar duplicata.
422
Unprocessable Entity
JSON Ă© vĂĄlido, mas a regra semĂąntica falhou (estoque insuficiente, idade negativa).
500
Internal Server Error
Algo inesperado quebrou. Logue em detalhe; mostre pouco para o cliente.

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.

controllers/UserController.ts — POST com Location
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:

AbordagemQuando faz sentidoCusto
Validação manual (helpers + if)Projetos muito pequenos, payloads triviaisBoilerplate cresce råpido; erros inconsistentes
zod (recomendado)A maioria dos casos. Schema declarativo, tipo inferido1 dependĂȘncia leve; learning curve curta
class-validatorProjetos NestJS ou que jĂĄ usam decoratorsVerboso 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:

schemas/user.ts
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:

controllers/UserController.ts — parse
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);
};
Schema Ă© o contrato

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/validation.ts
// 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, '');
}
tests/helpers/validation.test.ts (sketch)
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:

  1. 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.
  2. Recebe os repositĂłrios via constructor (DI) — assim vocĂȘ troca por mocks em testes, ou troca a implementação (Postgres → outra base) sem refatorar.
  3. Lança exceçÔes de domĂ­nio quando uma regra falha — nĂŁo devolve { ok: false, error: '...' }. O middleware central converte em status HTTP.
services/OrderService.ts
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 Ă©:

  1. const client = await pool.connect() — pega uma conexão exclusiva do pool
  2. await client.query('BEGIN') — inicia a transação
  3. Faça suas queries usando o mesmo client (não use pool.query aqui)
  4. Se tudo deu certo: await client.query('COMMIT')
  5. Se algo lançou: await client.query('ROLLBACK')
  6. SEMPRE em finally: client.release() — devolve a conexão ao pool
repositories/OrderRepository.ts
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();
    }
  }
}
Erros comuns com transação

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:

repositories/UserRepository.ts — tratamento de UNIQUE
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.

errors/AppError.ts
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.

middlewares/errorHandler.ts
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 });
};
NĂŁo vaze detalhes

É 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.ts — fallback 404
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.

helpers/asyncHandler.ts
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:

SEM asyncHandler — try/catch em cada rota
router.post('/users', async (req, res, next) => {
  try {
    const u = await userService.create(req.body);
    res.status(201).json(u);
  } catch (e) {
    next(e);
  }
});
COM asyncHandler
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.

VerboIdempotente?Por quĂȘ?
GETSimApenas leitura — não modifica nada.
POSTNĂŁoCada chamada cria um novo recurso (novo ID).
PUTSimSubstituir pelo mesmo body N vezes deixa o recurso no mesmo estado.
PATCHDependeSe a operação for absoluta ({name: 'X'}) sim; se for relativa ({counter: increment}) não.
DELETESimRemover 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.

Padrão de resposta — escolha UMA convenção

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)

models/order.ts
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

schemas/order.ts
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

controllers/OrderController.ts
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:

  1. Cliente envia POST /api/orders com body
  2. Controller faz parse zod → ok
  3. Service consulta produtos, valida estoque → ok
  4. Repository abre transação, insere order, insere itens, COMMIT
  5. Service retorna o pedido criado
  6. Controller responde 201 + Location: /api/orders/42 + JSON

Se o estoque Ă© insuficiente:

  1. Cliente envia POST /api/orders
  2. Parse zod → ok
  3. Service descobre estoque insuficiente → throw new ValidationError('estoque insuficiente para X')
  4. asyncHandler captura → next(err)
  5. errorHandler central detecta err.status === 422 → responde 422 + { error: 'estoque insuficiente para X' }
  6. 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):

  1. BEGIN
  2. INSERT em orders → ok
  3. INSERT em order_items → falha (produto deletado entre a leitura e a escrita)
  4. catch dispara ROLLBACK — o INSERT no orders Ă© desfeito
  5. 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:

app.ts — morgan em dev
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.

Logue com contexto

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

curl — exemplos
# 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:

requests.http
### 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.

PrĂłximo passo: testes automatizados

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.

CamadaO que testaVelocidade tĂ­picaQuando 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).
HeurĂ­stica prĂĄtica

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érioJest + supertestPlaywright Test
Maturidade no ecossistema NodeMuito alta — padrão de fatoCrescente — popular desde 2022
Forma da requisição HTTPIn-process (sem porta TCP)Out-of-process (porta TCP real)
Velocidade por testeMais rĂĄpido (sem overhead de rede)Levemente mais lento
ParalelismoManual via --maxWorkersAutomĂĄtico por arquivo
Mocking de módulosExcelente (jest.mock)Limitado — prefere injeção
FixturesbeforeAll/beforeEach manuaisSistema de fixtures nativo
Cobre frontend tambĂ©mNĂŁo — precisa de outra ferramentaSim — mesmo runner
Curva de aprendizadoBaixa se jĂĄ conhece JestMĂ©dia — conceitos prĂłprios
Recomendação para o módulo 2

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.

Instalação
npm install -D jest ts-jest @types/jest supertest @types/supertest
npx ts-jest config:init
jest.config.ts
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;
src/app.ts — separar app de listen
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);
tests/orders.spec.ts — primeiro teste E2E
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.

Instalação
npm init playwright@latest -- --quiet --browser=chromium
# JĂĄ temos Playwright Test + fixtures + config gerados
playwright.config.ts
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' },
  },
});
tests/e2e/orders.spec.ts
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);
});
Fixture customizada

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):

EstratĂ©gia 1 — TRUNCATE em beforeEach (simples e rĂĄpida)
// 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.)
}
EstratĂ©gia 2 — Transação por teste com ROLLBACK (mais rĂĄpida ainda)
// 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();
});
EstratĂ©gia 3 — Testcontainers (isolamento total)
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égiaVelocidadeIsolamentoQuando usar
TRUNCATE em beforeEach~30 ms / testeBom (entre testes do mesmo arquivo)PadrĂŁo para o mĂłdulo 2.
Transação + ROLLBACK~3 ms / testeExcelenteSuítes grandes (>200 testes) e código que aceita injeção de client.
Testcontainers~10 s setup + ~50 ms / testeTotal — DB descartĂĄvel por suĂ­teCI sem banco preinstalado, ou mĂșltiplas versĂ”es do PostgreSQL.
⚠ NUNCA aponte o teste para o banco de dev

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.

Guard anti-acidente em helpers/db.ts
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.

Kit mínimo — 6 casos para POST /orders
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".

Teste de idempotĂȘncia
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.

.github/workflows/ci.yml
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)
Boas prĂĄticas que economizam horas

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.

ConexĂŁo com a Aula 10

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 Created com header Location em todo POST.
  • Tenho um Router() por recurso e um routes/index.ts agregador.
  • Valido todo req.body antes 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/res e recebe os repositĂłrios via constructor.
  • Sei usar BEGIN/COMMIT/ROLLBACK com pool.connect() e client.release() em finally.
  • Tenho uma classe AppError com subclasses para 404, 409, 422 e 400.
  • Tenho um middleware central que converte AppError em resposta HTTP.
  • Uso asyncHandler para evitar try/catch repetido.
  • NĂŁo vazo err.stack nem 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.http versionado com exemplos do CRUD.
  • Configurei Jest com ts-jest e exporto createApp() separado do listen.
  • Cada endpoint tem pelo menos os 6 testes do "kit mĂ­nimo" (happy, validação, 404, conflito, efeito no banco, rollback).
  • Uso DATABASE_URL_TEST separada 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.yml com service container do PostgreSQL e os testes E2E rodando a cada push.

ReferĂȘncias

Inteli Logo