Skip to content

Latest commit

 

History

History
841 lines (599 loc) · 28.8 KB

File metadata and controls

841 lines (599 loc) · 28.8 KB

Testes — Guia Completo

Guia de referência do sistema de testes do projeto. Cobre filosofia, padrões, edge cases e instruções para escrever novos testes.


Sumário


1. Filosofia de Testes

Erros como valores, não exceções

O projeto usa o padrão Either para tratamento de erros nos services. Isso muda fundamentalmente o que os testes verificam:

// Ao invés de testar se uma exceção é lançada:
expect(() => service.getUser('invalid')).toThrow()

// Testamos o valor retornado:
const result = await service.getUser('invalid')
expect(result.success).toBe(false)
expect(result.error.code).toBe('NOT_FOUND')

Cada método de service retorna Either<AppError, T> — o teste verifica a forma do retorno, não se algo explodiu. Isso torna os fluxos de erro explícitos e previsíveis.

Pirâmide de testes

        /  E2E  \        ← Poucos: validam o pipeline HTTP completo
       /  Unit   \       ← Muitos: lógica de negócio isolada, rápidos
  • Unitários: testam funções isoladas com mocks nas bordas. Rodam em milissegundos, sem I/O real.
  • E2E: testam rotas reais com Fastify inject(), service mockado. Validam schema Zod, error handler, serialização de resposta.

Por que Vitest

  • Compatível com Bun e Node.js — requisito obrigatório do projeto (dual runtime)
  • ESM nativo, sem configuração extra de transpilação
  • API idêntica ao Jest (describe, it, expect, vi.fn()) — curva de aprendizado zero para quem já conhece Jest
  • Execução rápida com paralelismo por padrão

O que NÃO buscamos

Não buscamos 100% de coverage por linha. O foco é em edge cases e comportamentos críticos de negócio. Um teste que verifica "usuário com email duplicado retorna 409" vale mais que dez testes verificando getters triviais.


2. Estrutura de Diretórios

__tests__/
├── unit/                                    # Testes isolados, sem I/O real
│   ├── core/
│   │   ├── errors/
│   │   │   └── errors.test.ts               # Either, AppError, SchemaError, mapeamento de códigos
│   │   ├── services/
│   │   │   └── user-service.test.ts          # Lógica de negócio com repositório mockado
│   │   └── utils/
│   │       └── uuid.test.ts                  # UUIDv7: formato, unicidade, ordenação temporal
│   └── main/
│       └── infra/
│           └── error-handler.test.ts         # Handler centralizado de erros do Fastify
└── e2e/
    ├── helpers/
    │   └── build-app.ts                      # Factory: createTestApp()
    └── routes/
        ├── status.test.ts                    # GET /api/status (health check)
        ├── check.test.ts                     # POST /api/v1/check/http
        └── user.test.ts                      # CRUD completo /api/v1/user

Por que espelhar a estrutura de src/? Para que qualquer dev encontre o teste de um arquivo fonte sem pensar. Se o service está em src/core/services/user/, o teste está em __tests__/unit/core/services/.


3. Configuração do Vitest

Arquivo: vitest.config.js

Configuração Valor Por quê
globals true describe, it, expect disponíveis globalmente
timeout 30000 Margem para testes E2E que iniciam instância Fastify
include __tests__/**/*.{test,spec}.{ts,js} Apenas arquivos de teste
coverage.exclude migrations, seeds, knexfile, __tests__/ Sem inflar cobertura com código que não é lógica de negócio
alias @ ./src Mesmos imports do código de produção

Mesmo com globals: true, o projeto importa explicitamente describe, it, expect do Vitest. Isso é intencional — torna as dependências visíveis e melhora o suporte da IDE.

Scripts npm

bun run test            # vitest run — execução única (ideal para CI)
bun run test:coverage   # vitest run --coverage — com relatório v8
bun run test:watch      # vitest — modo watch para desenvolvimento

4. Testes Unitários — Camada de Erros

Arquivo: __tests__/unit/core/errors/errors.test.ts Source: src/core/errors/index.ts

4.1 codeToStatus e statusToCode

Mapeamento bidirecional entre códigos semânticos e status HTTP:

it.each([
  ['BAD_REQUEST', 400],
  ['UNAUTHORIZED', 401],
  ['NOT_FOUND', 404],
  ['CONFLICT', 409],
  ['INTERNAL_SERVER_ERROR', 500]
] as const)('should map %s to %d', (code, status) => {
  expect(codeToStatus(code)).toBe(status)
})

Por que it.each? Evita duplicação — cada par código/status vira um teste isolado com uma única linha. Se adicionarmos um novo código de erro, basta adicionar uma linha no array.

Edge case — status desconhecido:

expect(statusToCode(999)).toBe('INTERNAL_SERVER_ERROR')
expect(statusToCode(0)).toBe('INTERNAL_SERVER_ERROR')

Qualquer status não mapeado é tratado como erro interno. Isso previne que status inesperados vazem para o cliente — se não sabemos o que é, é problema do servidor.

4.2 AppError

const error = new AppError('NOT_FOUND', 'User not found')

expect(error).toBeInstanceOf(Error)  // Herda de Error (stack trace funciona)
expect(error.name).toBe('AppError')  // Identificável em logs
expect(error.code).toBe('NOT_FOUND') // Código semântico
expect(error.statusCode).toBe(404)   // Calculado automaticamente

Por que testar instanceof Error? O error handler usa instanceof para identificar tipos de erro. Se a cadeia de protótipos estiver quebrada, o handler não consegue distinguir AppError de um erro genérico.

4.3 SchemaError e ZodError

SchemaError herda de AppError com código fixo BAD_REQUEST. O método estático fromZod() transforma erros do Zod em mensagens legíveis:

Issue único:

const zodError = new ZodError([{
  code: 'too_small',
  origin: 'string',
  minimum: 6,
  message: 'String must contain at least 6 character(s)',
  path: ['password']
} as z.core.$ZodIssue])

const error = SchemaError.fromZod(zodError)
expect(error.message).toBe('password: String must contain at least 6 character(s)')

Múltiplos issues (separados por ;):

// email: Required; username: Too short

Paths aninhados (notação de ponto):

// path: ['address', 'street'] → "address.street: Expected string"

Por que esses três casos? Cobrem as situações reais de validação: campo simples com regra violada, formulário com múltiplos erros simultâneos, e objetos com campos aninhados. O formato campo: mensagem é parseável por frontends para exibir erros inline.

Nota sobre Zod 4: O tipo ZodIssue importado diretamente de 'zod' está deprecated. O projeto usa z.core.$ZodIssue (o tipo atual). Issues do tipo too_small agora exigem a propriedade origin (ex: 'string', 'number', 'array') no lugar do antigo type.

4.4 Either — left e right

left('something went wrong')   { success: false, error: 'something went wrong' }
right(42)                      { success: true, data: 42 }

Type narrowing — o teste mais importante:

const result = Math.random() > 0.5 ? right('ok') : left(new Error('fail'))

if (result.success) {
  expect(result.data).toBe('ok')       // TypeScript sabe que .data existe
} else {
  expect(result.error).toBeInstanceOf(Error) // TypeScript sabe que .error existe
}

Esse teste prova que o discriminated union funciona: o TypeScript força o dev a checar result.success antes de acessar .data ou .error. Se alguém tentar acessar .data sem a verificação, o compilador impede.


5. Testes Unitários — UserService

Arquivo: __tests__/unit/core/services/user-service.test.ts Source: src/core/services/user/user-service.ts

5.1 Por que mockar o Repository

O service contém lógica de negócio. Seus testes devem verificar decisões lógicas (ex: "se email já existe, retorna CONFLICT"), não comportamento do banco de dados.

Mockar o repository traz:

  • Velocidade: testes rodam em milissegundos, sem PostgreSQL
  • Isolamento: falhas no banco não quebram testes de lógica
  • Controle: podemos simular cenários impossíveis em dev (ex: race conditions)
function createMockRepository() {
  return {
    findById: vi.fn(),
    findByEmail: vi.fn(),
    findByUsername: vi.fn(),
    findAll: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    softDelete: vi.fn()
  }
}

A cada teste, o mock é resetado via beforeEach com uma nova instância do service. Isso impede vazamento de estado entre testes.

5.2 Padrão dos testes: Arrange → Act → Assert

Todo teste de service segue o mesmo fluxo:

it('should return user when found', async () => {
  // Arrange: configure o que o mock vai retornar
  vi.mocked(repo.findById).mockResolvedValue(mockUser)

  // Act: chame o método do service
  const result = await service.getUser('some-id')

  // Assert: verifique o resultado
  expect(result).toEqual({ success: true, data: mockUser })
})

5.3 Edge cases por operação

getUser

Cenário Mock Resultado esperado
Usuário encontrado findByIdmockUser right(mockUser)
Usuário não existe findByIdnull left(NOT_FOUND)

O caso "não encontrado" é o erro mais comum em qualquer API. Todo endpoint de busca por ID deve tratar esse cenário.

getAllUsers

Cenário Mock Resultado esperado
Com usuários findAll[mockUser] right([mockUser])
Sem usuários findAll[] right([])

Detalhe importante: lista vazia retorna right([]), não um erro. Uma coleção vazia é um resultado válido — diferente de "recurso não encontrado". Confundir esses dois conceitos é um bug comum em APIs.

createUser

Cenário Mock Resultado esperado
Sucesso findByEmailnull, findByUsernamenull right(newUser)
Email duplicado findByEmailexistingUser left(CONFLICT)
Username duplicado findByEmailnull, findByUsernameexistingUser left(CONFLICT)

Ordem importa: email é verificado primeiro, depois username. O teste de username duplicado mocka findByEmail retornando null para garantir que o service avança até a verificação de username.

Asserção crítica: nos casos de conflito, o teste verifica que repo.create NÃO foi chamado:

expect(repo.create).not.toHaveBeenCalled()

Isso prova que o service interrompe a execução antes de tentar criar o registro.

updateUser

Cenário Mock Resultado esperado
Sucesso update1, findByIdupdatedUser right(updatedUser)
ID não existe update0 left(NOT_FOUND)
Race condition update1, findByIdnull left(NOT_FOUND)

O terceiro caso é uma race condition: entre o UPDATE e o SELECT subsequente, outro request pode ter deletado o usuário. Sem esse teste, o service retornaria right(null) — quebrando o contrato de tipos. Esse edge case só aparece em sistemas com concorrência real.

deleteUser

Cenário Mock Resultado esperado
Sucesso softDelete1 right(undefined)
ID não existe softDelete0 left(NOT_FOUND)

O service usa soft delete (marca deleted_at). O retorno de sucesso é right(undefined) por convenção — operações void não têm dados para retornar.


6. Testes Unitários — UUID

Arquivo: __tests__/unit/core/utils/uuid.test.ts Source: src/core/utils/uuid.ts

O projeto usa UUIDv7 (RFC 9562) gerado internamente, sem dependência externa. Compatível com Bun e Node.js.

O que cada teste verifica

Teste O que valida Por que importa
Formato (8-4-4-4-12) String com 36 caracteres em hex Bancos de dados e validadores aceitam o formato
Version bit = 7 Posição 14 do UUID é 7 Compliance com UUIDv7 (diferencia de v4)
Variant bits = 8/9/a/b Posição 19 do UUID RFC 9562 exige variant 10xx em binário
Regex em 50 UUIDs Formato correto em volume Confiança estatística (não só uma chamada sortuda)
Unicidade em 100 UUIDs Set.size === 100 Detecta bugs de entropia no gerador de random
Comprimento = 36 .length === 36 Guarda contra truncamento

Ordenação temporal — o teste mais importante

const first = uuidv7()
await sleep(5)  // 5ms entre cada
const second = uuidv7()
await sleep(5)
const third = uuidv7()

const sorted = [third, first, second].sort()
expect(sorted).toEqual([first, second, third])

Por que isso importa: UUIDv7 codifica o timestamp nos primeiros 48 bits. Isso significa que ordenação lexicográfica (.sort()) = ordenação cronológica. Em índices de banco de dados, IDs UUIDv7 mantêm ordem de inserção — diferente de UUIDv4 que é completamente aleatório e fragmenta índices B-tree.


7. Testes Unitários — Error Handler

Arquivo: __tests__/unit/main/infra/error-handler.test.ts Source: src/main/infra/error-handler.ts

O error handler é registrado via app.setErrorHandler() e é a última linha de defesa — todo erro não tratado em qualquer rota passa por aqui. Por isso ele precisa cobrir todos os tipos de erro possíveis.

Mock setup

function createMockReply() {
  const reply = {
    status: vi.fn().mockReturnThis(),  // Permite chaining: reply.status(404).send(...)
    send: vi.fn().mockReturnThis()
  }
  return reply as any
}

Logger e env são mockados para evitar output de log nos testes e dependência de variáveis de ambiente.

Cada tipo de erro tratado

Tipo de Erro Status HTTP Código Por que esse teste existe
ZodError 400 BAD_REQUEST Validação Zod falha na rota → handler converte via SchemaError.fromZod()
AppError(NOT_FOUND) 404 NOT_FOUND Erro de negócio com status mapeado automaticamente
AppError(CONFLICT) 409 CONFLICT Prova que o mapeamento funciona para todos os códigos, não só 404
Fastify validation error 400 BAD_REQUEST Validação nativa do Fastify (separada do Zod)
Erro com statusCode: 404 404 NOT_FOUND Rota não encontrada pelo router do Fastify
SyntaxError (JSON) 400 BAD_REQUEST Body JSON malformado → mensagem amigável sobre Content-Type
Erro genérico 500 INTERNAL_SERVER_ERROR Fallback: mensagem genérica, nunca expõe detalhes internos
Erro com statusCode < 500 Preservado (ex: 429) ERROR Rate limiting e erros de middleware mantêm seu status original

requestId sempre presente:

it('should always include requestId in response', () => {
  const sent = reply.send.mock.calls[0][0]
  expect(sent.requestId).toBe('test-request-id')
})

Todo response de erro inclui requestId — essencial para correlacionar erros nos logs em produção. Sem isso, debugar em produção seria impossível.

Por que o erro genérico esconde a mensagem: para status >= 500, o handler retorna "Internal Server Error" ao invés da mensagem real. Isso previne vazamento de stack traces, queries SQL, ou detalhes internos para o cliente.


8. Testes E2E — Estratégia

app.inject() vs Supertest

O projeto usa app.inject() do Fastify para simular requests HTTP sem abrir porta TCP:

const response = await app.inject({
  method: 'GET',
  url: '/api/v1/user'
})

Vantagens sobre Supertest:

  • Mais rápido: sem overhead de rede TCP
  • Sem conflito de portas: múltiplos testes em paralelo sem problema
  • Nativo do Fastify: response tipada, sem dependência extra

Supertest está disponível como dependência para casos que precisem de semântica HTTP real.

createTestApp() — o helper E2E

// __tests__/e2e/helpers/build-app.ts
import { buildApp } from '@/main/app'

export function createTestApp() {
  const app = buildApp()
  return app
}

Usa o mesmo buildApp() do código de produção. Isso garante que os testes E2E exercitem a configuração real: plugins (Helmet, CORS, Rate Limit), middleware (error handler), e rotas. Todo teste E2E deve chamar app.close() no afterAll.

Onde mockar nos testes E2E

Rota (real) → Zod (real) → Handler (real) → Service (MOCKADO) → Repository (não chega aqui)

O mock fica no service, não no repository. Isso significa:

  • O que roda de verdade: routes, validação Zod, error handler, serialização de resposta
  • O que é controlado: retornos do service (Either com left/right)

Assim testamos o pipeline HTTP completo sem precisar de banco de dados.


9. Testes E2E — Status e Check

Status (Health Check)

Arquivo: __tests__/e2e/routes/status.test.ts

it('should return 200 with status ok', async () => {
  const response = await app.inject({ method: 'GET', url: '/api/status' })
  expect(response.statusCode).toBe(200)
  expect(response.json()).toEqual({ status: 'ok' })
})

Parece trivial, mas esse teste valida que toda a inicialização do Fastify funciona: plugins carregaram, rotas registraram, server responde.

HTTP Check

Arquivo: __tests__/e2e/routes/check.test.ts

O endpoint faz requests HTTP externos. Para testar sem rede real:

const fetchSpy = vi.spyOn(globalThis, 'fetch')
Cenário Mock do fetch Resultado
URL acessível mockResolvedValue({ status: 200 }) status: 'success', responseTime numérico
URL inacessível mockRejectedValue(new Error('Network error')) status: 'failed' (HTTP 200 — o check em si não falhou)
Sem método especificado - fetch chamado com method: 'GET' (default)
Método POST - fetch chamado com method: 'POST'
URL inválida Não chega ao fetch HTTP 400 (Zod valida formato com z.url())

Detalhe: URL inacessível retorna HTTP 200, não 500. O endpoint de check reporta o resultado da verificação — se o alvo está fora, o check em si foi executado com sucesso, o resultado é 'failed'.


10. Testes E2E — User CRUD

Arquivo: __tests__/e2e/routes/user.test.ts

Mock do service

const mockUserService = {
  getUser: vi.fn(),
  getAllUsers: vi.fn(),
  createUser: vi.fn(),
  updateUser: vi.fn(),
  deleteUser: vi.fn()
}

vi.mock('@/core/services', () => ({
  makeUserService: () => mockUserService
}))

O vi.mock substitui a factory makeUserService() para retornar o mock. O mock é registrado antes do app ser construído.

GET /api/v1/user — Listar

Cenário Service retorna HTTP Status Body
Com usuários right([mockUser]) 200 [{ username: 'johndoe', ... }]
Sem usuários right([]) 200 []

GET /api/v1/user/:id — Buscar

Cenário Service retorna HTTP Status
Encontrado right(mockUser) 200
Não encontrado left(AppError('NOT_FOUND')) 404
UUID inválido Não chega ao service 400

O teste de UUID inválido é chave: o schema UserParamsSchema usa z.uuidv7(), que rejeita strings fora do formato UUIDv7. A validação acontece antes do handler executar — o mock do service não é nem chamado. Isso prova que a validação está na borda.

POST /api/v1/user — Criar

Cenário Input Service retorna HTTP Status
Sucesso { username, email, password } right(newUser) 201
Email duplicado { username, email, password } left(CONFLICT) 409
Campos faltando { username } (sem email/password) Não chega 400
Senha curta { ..., password: '123' } Não chega 400
Email inválido { ..., email: 'not-email' } Não chega 400
Username curto { ..., username: 'ab' } Não chega 400

Todos os casos 400 são validação Zod pura — o mock do service nunca é chamado. Isso prova a separação: validação na borda, lógica no service.

PUT /api/v1/user/:id — Atualizar

Cenário Service retorna HTTP Status
Sucesso right(updatedUser) 200
Não encontrado left(NOT_FOUND) 404

DELETE /api/v1/user/:id — Deletar

Cenário Service retorna HTTP Status Body
Sucesso right(undefined) 200 { message: 'User deleted successfully' }
Não encontrado left(NOT_FOUND) 404 { code: 'NOT_FOUND', ... }

unwrap() — a ponte entre Either e Fastify

Os handlers usam unwrap() para converter Either em exceções:

function unwrap<T>(result: Either<Error, T>): T {
  if (!result.success) throw result.error  // Lança AppError
  return result.data
}

// No handler:
const user = unwrap(await userService.getUser(id))
return user  // Só chega aqui se result.success === true

Quando unwrap lança, o error handler centralizado captura e formata a resposta. Assim os services mantêm o padrão Either puro, e as rotas não precisam de if/else em cada handler.


11. Padrões de Mock — Referência Rápida

vi.fn() — criar mock de função

const mockFn = vi.fn()
mockFn.mockReturnValue('sync value')
mockFn.mockResolvedValue('async value')  // Para funções async

vi.mocked() — tipagem do mock

vi.mocked(repo.findById).mockResolvedValue(mockUser)
// TypeScript sabe que mockResolvedValue aceita User | null

vi.mock() — mock de módulo inteiro

vi.mock('@/core/services', () => ({
  makeUserService: () => mockUserService
}))

Substitui o módulo inteiro. Útil para trocar factories de DI nos testes E2E.

vi.spyOn() — espionar sem substituir o módulo

const fetchSpy = vi.spyOn(globalThis, 'fetch')
fetchSpy.mockResolvedValue(new Response('ok'))

// Depois do teste:
fetchSpy.mockRestore()  // Restaura implementação original

Ideal para interceptar globais como fetch sem afetar outros testes.

mockReturnThis() — method chaining

const reply = {
  status: vi.fn().mockReturnThis(),  // reply.status(404) retorna reply
  send: vi.fn().mockReturnThis()     // reply.send({}) retorna reply
}
// Permite: reply.status(404).send({ code: 'NOT_FOUND' })

mockResolvedValue vs mockReturnValue

Método Quando usar
mockReturnValue(x) Função síncrona que retorna x
mockResolvedValue(x) Função async que retorna Promise.resolve(x)
mockRejectedValue(e) Função async que retorna Promise.reject(e)

12. Como Rodar os Testes

# Todos os testes (modo CI)
bun run test

# Com relatório de cobertura
bun run test:coverage

# Modo watch (desenvolvimento — re-executa ao salvar)
bun run test:watch

# Apenas testes unitários
bunx vitest run __tests__/unit

# Apenas testes E2E
bunx vitest run __tests__/e2e

# Um arquivo específico
bunx vitest run __tests__/unit/core/errors/errors.test.ts

# Testes que correspondem a um padrão
bunx vitest run -t "UserService"

# Com Node.js (ao invés de Bun)
npx vitest run

13. Como Escrever Novos Testes

13.1 Teste unitário para um novo Service

Passo 1 — Crie o arquivo espelhando a estrutura de src/:

src/core/services/order/order-service.ts
→ __tests__/unit/core/services/order-service.test.ts

Passo 2 — Monte o mock do repository:

import { describe, expect, it, beforeEach, vi } from 'vitest'
import { OrderService } from '@/core/services/order/order-service'

function createMockRepository() {
  return {
    findById: vi.fn(),
    create: vi.fn(),
    // ... cada método da interface do repository
  }
}

Passo 3 — Instancie o service com o mock em beforeEach:

let service: OrderService
let repo: ReturnType<typeof createMockRepository>

beforeEach(() => {
  repo = createMockRepository()
  service = new OrderService(repo as any)
})

Passo 4 — Escreva o happy path primeiro:

it('should return order when found', async () => {
  vi.mocked(repo.findById).mockResolvedValue(mockOrder)

  const result = await service.getOrder('order-id')

  expect(result).toEqual({ success: true, data: mockOrder })
})

Passo 5 — Adicione os caminhos de erro:

it('should return NOT_FOUND when order does not exist', async () => {
  vi.mocked(repo.findById).mockResolvedValue(null)

  const result = await service.getOrder('non-existent-id')

  expect(result.success).toBe(false)
  if (!result.success) {
    expect(result.error.code).toBe('NOT_FOUND')
  }
})

Passo 6 — Verifique que o repository foi chamado corretamente:

expect(repo.findById).toHaveBeenCalledWith('order-id')

13.2 Teste E2E para uma nova rota

Passo 1 — Mock do service (se a rota usa um):

const mockOrderService = {
  getOrder: vi.fn(),
  createOrder: vi.fn()
}

vi.mock('@/core/services', () => ({
  makeOrderService: () => mockOrderService
}))

Passo 2 — Crie a instância do app:

import { createTestApp } from '../helpers/build-app'

const app = createTestApp()

afterAll(async () => {
  await app.close()
})

Passo 3 — Teste com app.inject():

it('should return 200 with order data', async () => {
  mockOrderService.getOrder.mockResolvedValue(right(mockOrder))

  const response = await app.inject({
    method: 'GET',
    url: `/api/v1/order/${mockOrder.id}`
  })

  expect(response.statusCode).toBe(200)
  expect(response.json().id).toBe(mockOrder.id)
})

Passo 4 — Teste validação na borda:

it('should return 400 for invalid id format', async () => {
  const response = await app.inject({
    method: 'GET',
    url: '/api/v1/order/not-a-uuid'
  })

  expect(response.statusCode).toBe(400)
  // Mock do service NÃO deve ter sido chamado
})

13.3 Checklist antes de abrir PR

  • Todos os testes passam: bun run test
  • Cobertura não diminuiu: bun run test:coverage
  • Edge cases cobertos: not found, conflict, input inválido
  • Mocks tipados corretamente
  • Sem console.log nos testes
  • app.close() chamado no afterAll (testes E2E)
  • Mocks restaurados quando usar vi.spyOn (mockRestore())

14. Decisões Técnicas e Trade-offs

Por que não testar repositories em unit tests?

Repositories são wrappers finos sobre Knex. Seus testes dependem de SQL real executando contra PostgreSQL. Mockar o query builder do Knex criaria testes frágeis e de baixo valor — qualquer mudança na query quebraria o mock sem provar que a query funciona.

A estratégia correta para repositories é testes de integração contra um banco real (futuro).

Por que mockar no service (e não no repository) nos testes E2E?

Rota → Zod → Handler → Service (mock aqui) → Repository → DB

Se mockássemos no repository, estaríamos testando o service de novo (já coberto nos unit tests). Mockando no service, testamos o que é exclusivo dos E2E: pipeline HTTP, validação Zod, error handler, serialização.

Por que imports explícitos com globals: true?

// O projeto faz:
import { describe, expect, it } from 'vitest'

// Ao invés de usar os globais diretamente:
describe(...)

Imports explícitos tornam dependências visíveis, melhoram autocomplete da IDE, e deixam claro de onde cada função vem. Em projetos grandes com múltiplos frameworks, isso evita ambiguidade.

Por que app.inject() ao invés de Supertest?

inject() é nativo do Fastify, não abre porta TCP, e não causa conflito de portas em testes paralelos. Supertest existe como dependência para casos que precisem de semântica HTTP real (headers, cookies, redirects), mas para a maioria dos testes inject() é suficiente e mais rápido.

Por que 50/100 UUIDs nos testes de unicidade?

Números pragmáticos: suficientes para detectar bugs óbvios de entropia (seed fixo, overflow de timestamp), sem tornar os testes lentos. Não é um teste de carga — é uma verificação de sanidade.