server-testes
Como escrever testes do server do Smaug — unit espelhando src, títulos em inglês it("should …"), dublês vi.fn das portas, describeControllerContract (com successBody/decorateResponse) para controllers
SKILL.md.claude/skills/server-testes/SKILL.md
GitHubTestes do server
Quando usar esta skill
Ao escrever qualquer teste em server/tests/.
Estrutura
server/tests/unit/ espelha server/src/ 1:1, e o nome do arquivo é o do fonte + .test.ts:
tests/
helpers/ controller-contract.ts · http-mocks.ts · repository-mocks.ts · setup.ts ·
sqlite-test-database.ts · auth-session-fixtures.ts
integration/
presentation/ um arquivo por recurso (+ auth*.test.ts)
infrastructure/database/repositories/ repositórios que dependem do banco real (auth)
unit/
application/{csv,dtos,services}/
domain/{entities,errors,use-cases/<agregado>,value-objects}/
infrastructure/{auth,config,database,database/repositories,http,logging}/
presentation/{controllers,middlewares}/
Config: server/vitest.config.ts (o único do pacote). Alias @src, environment: "node",
globals: true, cobertura 100/100/100/100 com src/**/ports/** excluído (são interfaces).
O test.env do config fixa os segredos de teste que o env.ts exige no import (JWT_SECRET,
JWT_KEY_ID, PASSWORD_PEPPERS, PASSWORD_PEPPER_CURRENT_ID) — o CI não tem server/.env.
Os ARGON2_* mínimos não ficam lá (há testes que importam o env.ts com
NODE_ENV=development, onde o piso OWASP os recusaria): quem faz hash os fixa no próprio arquivo.
test:unit e test:integration filtram por caminho (vitest run tests/unit), não por project.
npm run --prefix server test # unit + integração
npm run --prefix server test:unit
npm run --prefix server test:integration
npx vitest run tests/unit/domain/entities/user.entity.test.ts --root server # arquivo único
Convenções de escrita
Títulos em inglês:
it("should <verbo> ... when <condição>"). 969 dos 994itdo pacote começam comshould.describeé o nome da classe sob teste, verbatim:describe("InstallmentExpense"),describe("CreateInstallmentExpenseUseCase"),describe("authenticate"). Quando um segundo conjunto de casos precisa de outro fixture, abra um segundodescribeirmão com sufixo —describe("InstallmentExpense validation guards")— em vez de aninhar. Aninhamento por método só existe no teste do value object.Imports nomeados do vitest, mesmo com
globals: true:import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";Fonte via
@src; helpers por caminho relativo (../../../../helpers/repository-mocks).AAA separado por linha em branco, sem comentários
// Arrange. O "act" é uma linha só, normalmente atribuída aconst result.Literais compartilhados viram
constSCREAMING_SNAKE no módulo:const BASE_DATE = new Date("2026-03-01T00:00:00.000Z"); const USER_ID = "user-1"; const CATEGORY_ID = "category-1"; const VALID_UUID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301";Input válido é um
const validProps = { ... } as const;(entidade) ouconst input = { ... };(use case) dentro dodescribe, e cada caso espalha e sobrescreve um campo:InstallmentExpense.create({ ...validProps, totalAmount: 10.123 }).Builders locais são consts arrow:
const buildRevenue = (end?: {...}) => FixedRevenue.create({...});Cada arquivo tem as suas fixtures. A única compartilhada étests/helpers/auth-session-fixtures.ts(NOW,at(seconds),insertUser(prisma),startSession,issueToken), usada pelos testes de repositório de auth.Como o domínio lê
new Date(), todo teste com data usa o par:beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(BASE_DATE); }); afterEach(() => { vi.useRealTimers(); });e prova o
updatedAtcomvi.advanceTimersByTime(1). Entidades e casos de uso de auth recebemnowpor parâmetro: passe datas derivadas de uma constante (at(30)) e teste as bordas (now == expiresAt, janela de graça inclusiva).Argon2 real é lento: teste de adapter (
argon2-password-hasher.test.ts) e qualquer integração que faça hash usam os parâmetros mínimos (memoryCost/ARGON2_MEMORY_KIB1024,timeCost1,parallelism1). Use case e service usam dublê doPasswordHasher.Log nunca vaza segredo: teste que exercita evento
auth.*asserta que o contexto não tem senha, hash, token nem e-mail (auth-logs.test.tscaptura a saída do Pino comvi.mock("pino")).
Dublês de repositório
Três estilos coexistem; prefira o (a) e crie a factory quando ela não existir.
(a) Factory compartilhada — tests/helpers/repository-mocks.ts
export type Mocked<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R ? Mock<(...args: A) => R> : T[K];
};
export function createFixedRevenueRepositoryMock(): Mocked<FixedRevenueRepository> {
return { findById: vi.fn(), findByIdWithVersions: vi.fn(), ... } as unknown as Mocked<FixedRevenueRepository>;
}
Uso — repositório e use case construídos uma vez no escopo do describe, resetados por teste:
const repository = createFixedRevenueRepositoryMock();
const useCase = new TerminateFixedRevenueUseCase(repository);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(BASE_DATE);
repository.update.mockImplementation(async (revenue) => revenue);
});
Vantagem: o call site fica sem cast — repository.findById.mockResolvedValue(buildRevenue());
(b) Tipo mapeado inline + reconstrução no beforeEach (fatias de despesa). Custa um cast em
cada arranjo: (categoryRepository.findById as ReturnType<typeof vi.fn>).mockResolvedValue(category).
(c) Objeto literal no escopo do describe (services e middlewares). Colaborador não exercitado
entra como descartável anônimo: { execute: vi.fn() } as unknown as CreateInstallmentExpenseUseCase.
Outras regras de mock:
vi.fn()por método da porta.vi.mock()de módulo só eminfrastructure/,main.test.tse nos dois casos de integração abaixo:vi.mock("dotenv", () => ({ default: { config: vi.fn() } }))quando o teste depende de uma variável ausente (ex.:TRUST_PROXYemauth-rate-limit.test.ts— senão odotenv.config()doenv.tsa repõe doserver/.envlocal e o teste passa/falha conforme a máquina), evi.mock("pino", …)para capturar log.mockResolvedValue/mockRejectedValuepara arranjar;mockImplementation(async (x) => x)para update pass-through.- Reset com
vi.clearAllMocks()nobeforeEach. vi.spyOn(Classe.prototype as unknown as {...}, "metodoPrivado")só quando não há outro caminho, e sempre commockRestore()explícito ao final.
Vocabulário de expect
| Intenção | Idioma |
|---|---|
| Primitivo | expect(x).toBe(...) — nunca toEqual para escalar |
| Objeto/array | expect(result.expense).toEqual(createdExpense) |
| Data | expect(x.createdAt.toISOString()).toBe(BASE_DATE.toISOString()) |
| Throw síncrono | expect(() => X.create({...})).toThrow("Amount must have at most 2 decimal places") — string da mensagem |
| Rejeição assíncrona | await expect(useCase.execute(input)).rejects.toThrow(ExpenseCategoryNotFoundError) — classe |
| Efeito colateral ausente | expect(repository.create).not.toHaveBeenCalled() — em quase todo caso de erro |
| Argumento frouxo | expect(repo.create).toHaveBeenCalledWith(expect.any(InstallmentExpense), expect.any(Array)) |
| Metadado de erro | it("should expose the error codes") assertando .code e .name |
Teste de controller — use describeControllerContract
tests/helpers/controller-contract.ts gera a bateria padrão. Um arquivo de teste de controller
não tem describe, it nem beforeEach próprios — é uma única chamada.
API:
export interface MethodContract {
/** Método do controller exercitado. */
method: string;
/** Método do service ao qual o controller delega. */
delegatesTo: string;
/** Campos do request usados na chamada. */
request?: Partial<Request>;
/** Argumentos esperados na chamada ao service. */
expectedArgs?: unknown[];
/** Status HTTP do caminho feliz. */
successStatus: number;
/** Retorno do service; `undefined` significa resposta sem corpo (`res.send()`). */
successResult?: unknown;
/** Corpo esperado quando o controller não devolve o retorno do service inteiro. */
successBody?: unknown;
/** Erros de domínio tratados pelo controller e o status que produzem. */
mappedErrors?: Array<{ error: Error & { code?: string }; status: number }>;
}
export function describeControllerContract(
title: string,
setup: () => {
controller: ControllerMock;
service: ServiceMock;
/** Acrescenta ao `res` o que o controller usa além de `status`/`json`/`send` (ex.: `cookie`). */
decorateResponse?: (res: ResponseMock) => void;
},
contracts: MethodContract[],
): void;
export function createServiceMock(methods: string[]): ServiceMock;
Uso real (tests/unit/presentation/controllers/installment-expense.controller.test.ts):
const expense = { id: "expense-1", description: "Notebook" };
describeControllerContract(
"InstallmentExpenseController",
() => {
const service = createServiceMock(["create", "get", "list", "update", "terminate", "delete"]);
return {
service,
controller: new InstallmentExpenseController(
service as unknown as InstallmentExpenseService,
) as unknown as ControllerMock,
};
},
[
{
method: "create",
delegatesTo: "create",
request: { body: { description: "Notebook", totalAmount: 1000 } },
expectedArgs: ["user-1", { description: "Notebook", totalAmount: 1000 }],
successStatus: 201,
successResult: expense,
mappedErrors: [
{ error: new InstallmentExpensePastStartError(), status: 409 },
{ error: new ExpenseCategoryNotFoundError("cat-1"), status: 404 },
],
},
{
method: "delete",
delegatesTo: "delete",
request: { params: { id: "expense-1" } },
expectedArgs: ["user-1", "expense-1"],
successStatus: 204, // sem successResult ⇒ espera res.send()
mappedErrors: [
/* ... */
],
},
],
);
Ele gera três tipos de teste por contrato: caminho feliz, um por erro mapeado
(should respond with <status> for <ErrorName>) e should forward unexpected errors to next.
O "user-1" de expectedArgs vem do userId default de createRequestMock (e "session-1" do
sessionId default).
Quando o controller não devolve o retorno do service inteiro ou usa mais do que
status/json/send no res, use successBody e decorateResponse. Uso real
(tests/unit/presentation/controllers/auth.controller.test.ts):
const decorateResponse = (res: ResponseMock): void => {
Object.assign(res, { cookie: vi.fn(() => res), clearCookie: vi.fn(() => res) });
};
describeControllerContract(
"AuthController",
() => {
const service = createServiceMock([
"register",
"loginWithPassword",
"refresh",
"logout",
"logoutAll",
]);
return { service, controller: buildController(service), decorateResponse };
},
[
{
method: "register",
delegatesTo: "register",
request: { body: registerBody },
expectedArgs: [registerBody],
successStatus: 201,
successResult: result, // { response, refreshToken, refreshTokenExpiresAt }
successBody: response, // só o corpo vai no res.json
mappedErrors: [{ error: new EmailAlreadyExistsError("ana@exemplo.com"), status: 409 }],
},
// …
],
);
O que o contrato não cobre (atributos do cookie, clearCookie só no 401, Max-Age) vai num
describe irmão com sufixo — describe("AuthController refresh cookie") — escrito à mão.
Helpers auxiliares em tests/helpers/http-mocks.ts: createResponseMock() (com status/json/
send/setHeader encadeáveis) e createRequestMock(overrides) (defaults params/query/body/headers
vazios, method: "GET", originalUrl: "/", userId: "user-1", sessionId: "session-1"). Em
tests/helpers/setup.ts: withEnv(vars, fn) para isolar process.env.
Teste de integração
tests/integration/presentation/<recurso>.test.ts. Um arquivo .db por arquivo de teste.
Boilerplate a copiar:
import type { PrismaClient } from "@prisma/client";
import { sqliteTestDatabase } from "../../helpers/sqlite-test-database";
const testDb = sqliteTestDatabase("installment-expense"); // prisma/sqlite/test-installment-expense.db
const PASSWORD = "correct-horse-battery-staple";
beforeAll(async () => {
vi.resetModules();
process.env.DATABASE_PROVIDER = "sqlite";
process.env.DATABASE_URL = testDb.url;
process.env.NODE_ENV = "test";
process.env.PORT = "3000";
process.env.LOG_LEVEL = "error";
// Argon2 mínimo: o cadastro do beforeAll faz hash, e o custo de produção só deixaria o teste lento.
process.env.ARGON2_MEMORY_KIB = "1024";
process.env.ARGON2_TIME_COST = "1";
process.env.ARGON2_PARALLELISM = "1";
process.env.AUTH_RATE_LIMIT_REGISTER_PER_IP = "1000";
testDb.push();
prisma = testDb.client();
const { createHttpServer } = await import("@src/infrastructure/http/server");
app = createHttpServer();
const createUserRes = await request(app).post("/auth/register").send({
name: "Installment User",
email: "installment-user@example.com",
password: PASSWORD,
});
userId = createUserRes.body.user.id;
accessToken = createUserRes.body.accessToken;
});
O usuário nasce por POST /auth/register (senha com 12+ caracteres), que já devolve o access
token: body.user.id e body.accessToken. AUTH_RATE_LIMIT_REGISTER_PER_IP alto evita o 429
quando o arquivo cadastra vários usuários (o limite padrão é 10/h por IP, e todo o Supertest sai do
mesmo IP). Os segredos (JWT_*, PASSWORD_PEPPERS) já vêm do test.env do vitest.config.ts.
sqliteTestDatabase(name) (tests/helpers/sqlite-test-database.ts) devolve url (absoluta: CLI,
client do teste e app abrem o mesmo arquivo), push() (apaga o arquivo e roda db push pelo wrapper
— sem flag de reset, que o CLI do Prisma 7 recusa quando há um agente de IA no ambiente), client()
(PrismaClient com o mesmo driver adapter da aplicação) e remove() (o arquivo e os
-journal/-wal/-shm). O client() vem depois do push(), que apaga o arquivo.
Ordem obrigatória: vi.resetModules() → env → testDb.push() → await import() dinâmico do
app (import estático avaliaria o container contra o ambiente de dev) → seed por HTTP, nunca via
Prisma.
afterAll(async () => {
await prisma.$disconnect();
testDb.remove();
});
beforeEach(async () => {
await prisma.installment.deleteMany(); // tabela filha primeiro
await prisma.installmentExpense.deleteMany();
});
Supertest com Bearer:
const authHeaders = () => ({ Authorization: `Bearer ${accessToken}` });
const res = await request(app).post("/expenses/installment").set(authHeaders()).send({ ... });
expect(res.status).toBe(409);
expect(res.body.error).toBe("NO_FUTURE_INSTALLMENTS");
Rotas de /auth com cookie: o Origin ausente passa pelo requireTrustedOrigin; para testar o
403, mande Origin diferente do CORS_ORIGIN. O refresh vai como Cookie: smaug_refresh=…,
extraído do set-cookie da resposta anterior.
Relógio falso × exp do JWT. O access token tem exp (padrão 600 s) e a sessão tem vida
máxima (SESSION_MAX_LIFETIME_SECONDS). Se o teste emite o token num instante e depois avança o
relógio falso (vi.setSystemTime(APRIL)), o token e a sessão expiram e toda chamada vira 401
UNAUTHORIZED. Duas saídas: emitir o token depois do vi.setSystemTime, ou subir os TTLs no
próprio arquivo antes do import do app, como lancamentos-read-api.test.ts:
// O relógio falso salta de março para abril: token e sessão precisam sobreviver ao salto.
process.env.ACCESS_TOKEN_TTL_SECONDS = String(365 * 24 * 60 * 60);
process.env.SESSION_MAX_LIFETIME_SECONDS = String(365 * 24 * 60 * 60);
Teste que depende de variável ausente (ex.: sem TRUST_PROXY) apaga a variável do
process.env e faz vi.mock("dotenv", () => ({ default: { config: vi.fn() } })) no topo
— senão o dotenv.config() do env.ts a repõe do server/.env local. Ver
auth-rate-limit.test.ts. Esses arquivos guardam o process.env anterior num previousEnv e o
restauram no afterAll.
Arquivos de integração de auth: auth.test.ts (fluxos), auth-refresh-reuse.test.ts (janela de
graça e reuso), auth-rate-limit.test.ts / auth-rate-limit-proxy.test.ts (429 sem e com
TRUST_PROXY=loopback), auth-logs.test.ts (eventos e redação). Os repositórios de auth têm
testes de integração próprios em tests/integration/infrastructure/database/repositories/; o de
concorrência (prisma-auth-session.concurrency.test.ts) roda no SQLite quando o client gerado é
SQLite e no PostgreSQL quando há POSTGRES_TEST_DATABASE_URL (job migrations do CI, com client
postgresql) — com o client errado ele falha alto em vez de pular.
Formato do arquivo: o primeiro teste é um passeio completo pelo ciclo de vida
("should create, list, get, update, terminate, and delete ..."), seguido de um teste por código
de erro. Estado dependente de tempo usa vi.useFakeTimers() + vi.setSystemTime(...) dentro do
it; linha em competência passada é inserida direto pelo Prisma (contornando a guarda da API),
com id explícito e legível ("installment-no-future-1").
Pré-requisito: um client do Prisma já gerado (SQLite). Os arquivos .db ficam em
server/prisma/sqlite/ e são apagados no afterAll.
Checklist
- Arquivo espelhando o caminho do fonte, com
.test.ts. -
describe= nome da classe;it("should …")em inglês. - Imports nomeados do vitest; fonte por
@src. - Fake timers quando houver data.
- Caso de erro assertando também
not.toHaveBeenCalled()no repositório. - Controller coberto por
describeControllerContract, não por testes escritos à mão. - Integração com
.dbpróprio, env de Argon2 mínimo + rate limit alto, import dinâmico do app, usuário e token porPOST /auth/registere seed por HTTP comAuthorization: Bearer. - Relógio falso compatível com o
expdo token (emitir depois, ou TTLs altos no arquivo). -
npm run --prefix server testverde e cobertura 100% (thresholds falham o run).
Não faça
- Não escreva teste de controller manualmente quando
describeControllerContractcobre o caso. - Não use import estático do app no teste de integração.
- Não compartilhe arquivo
.dbentre arquivos de teste. - Não semeie dados de integração direto no Prisma quando a API pode criá-los (a exceção é estado em competência passada, que a API recusa de propósito).
- Não use
it("deve …")no server — português é a convenção doweb/e doe2e/. - Não crie usuário direto no Prisma em teste de rota (a API exige credencial); a exceção são os
testes de repositório (
insertUser). - Não ponha
ARGON2_*notest.envdovitest.config.ts. - Não rode Argon2 com parâmetros de produção em teste.