| name | testing-kit |
| description | Unit + E2E testing + quality gate para Next.js con Claude Code. Combina TDD workflow, Vitest patterns, Playwright E2E, validacion de build, env vars, y security check en una sola skill. Se activa al crear/modificar route.ts, page.tsx, o archivos de test. Para cualquier proyecto Next.js con App Router. |
| version | 1.1.1 |
| author | Fernando Montero (Fersora Solutions) |
| license | MIT |
testing-kit — TDD + Unit + E2E + Quality Gate para Next.js
Parte 1: TDD — Test First, Code Second
3 Reglas de Hierro
1. TEST FIRST, CODE SECOND — sin excepciones
2. NUNCA codigo de produccion sin un test que falle primero
3. CADA route.ts tiene route.test.ts, CADA page.tsx tiene .spec.ts
Ciclo RED → GREEN → REFACTOR
RED: Escribir UN test que describe UN comportamiento → ejecutar → DEBE FALLAR
GREEN: Escribir el codigo MINIMO para que pase → ejecutar → DEBE PASAR
REFACTOR: Limpiar sin romper tests → ejecutar → SIGUE PASANDO
Repetir. Un test a la vez. SIEMPRE vertical (test → codigo → green), NUNCA horizontal (todos los tests → luego todo el codigo).
Test List (antes de escribir codigo)
Listar todos los comportamientos a testear. Ejemplo para POST /api/users:
1. 201 — crea usuario con datos validos
2. 401 — rechaza sin autenticacion
3. 400 — rechaza campos requeridos vacios
4. 400 — rechaza email invalido
5. 404 — recurso padre no existe
6. 500 — maneja error de base de datos
Cada item = un ciclo RED → GREEN.
Parte 2: Unit Tests con Vitest
Template de 6 Casos (minimo obligatorio)
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGetUser = vi.fn()
const mockFrom = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(() => Promise.resolve({
auth: { getUser: mockGetUser },
from: mockFrom,
})),
}))
import { POST } from './route'
const MOCK_USER = { id: 'user-123', email: 'test@example.com' }
function createRequest(body?: unknown): Request {
return new Request('http://localhost/api/resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
})
}
beforeEach(() => { vi.clearAllMocks() })
describe('POST /api/resource', () => {
it('returns 201 with created resource', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } })
mockFrom.mockReturnValue({
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: { id: '1', name: 'Test' }, error: null }),
})
const res = await POST(createRequest({ name: 'Test' }))
expect(res.status).toBe(201)
})
it('returns 401 when not authenticated', async () => {
mockGetUser.mockResolvedValue({ data: { user: null } })
const res = await POST(createRequest({ name: 'Test' }))
expect(res.status).toBe(401)
})
it('returns 400 on invalid input', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } })
const res = await POST(createRequest({}))
expect(res.status).toBe(400)
})
it('returns 404 when resource not found', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } })
mockFrom.mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: null, error: null }),
})
const res = await POST(createRequest({ parentId: 'not-found' }))
expect(res.status).toBe(404)
})
it('returns 500 on database error', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } })
mockFrom.mockReturnValue({
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: null, error: { message: 'DB error' } }),
})
const res = await POST(createRequest({ name: 'Test' }))
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error).not.toContain('DB error')
})
it('returns 409 when duplicate name exists', async () => {
mockGetUser.mockResolvedValue({ data: { user: MOCK_USER } })
mockFrom.mockReturnValue({
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: null,
error: { message: 'unique_violation', code: '23505' },
}),
})
const res = await POST(createRequest({ name: 'Duplicado' }))
expect(res.status).toBe(409)
})
})
Multiples metodos HTTP en un endpoint
Si un route.ts exporta GET y POST (o mas), crear un describe() separado por cada metodo:
import { GET, POST } from './route'
describe('GET /api/posts', () => {
it('returns 200 with posts list', async () => {
})
})
describe('POST /api/posts', () => {
it('returns 201 with created post', async () => {
})
})
Mock Patterns para Supabase
Nota: Estos patrones son para proyectos con Supabase. Si usas otra BD (Prisma, Drizzle, MongoDB), adapta los mocks a tu cliente de base de datos. La estructura del test (6 casos, assertions de status) aplica igual.
mockFrom.mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: [item1, item2], error: null }),
})
mockFrom.mockReturnValue({
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: newItem, error: null }),
})
mockFrom.mockReturnValue({
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockResolvedValue({ data: updated, error: null }),
})
mockFrom.mockReturnValue({
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockResolvedValue({ error: null }),
})
Mock Patterns para Prisma
Si usas Prisma en vez de Supabase, usa estos patrones. La estructura del test (6 casos, assertions de status) aplica igual.
const mockPrisma = {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}
vi.mock('@/lib/prisma', () => ({
default: mockPrisma,
}))
mockPrisma.user.findMany.mockResolvedValue([{ id: '1', name: 'Test' }])
mockPrisma.user.findUnique.mockResolvedValue({ id: '1', name: 'Test' })
mockPrisma.user.create.mockResolvedValue({ id: '1', name: 'Test' })
mockPrisma.user.update.mockResolvedValue({ id: '1', name: 'Updated' })
mockPrisma.user.delete.mockResolvedValue({ id: '1' })
mockPrisma.user.findMany.mockRejectedValue(new Error('DB connection failed'))
Construir Requests
new Request('http://localhost/api/posts?status=draft&page=1')
new Request('http://localhost/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Test' }),
})
const res = await GET(req, { params: Promise.resolve({ id: 'abc-123' }) })
Vitest Config
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
clearMocks: true,
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
})
Parte 3: E2E con Playwright
5 Reglas Clave
- Selector priority —
getByRole > getByText > getByTestId > CSS
- Web-first assertions —
await expect(locator).toBeVisible(), NUNCA locator.isVisible()
- Auth reuse — login una vez, guardar
storageState, reusar en todos los tests
- Un comportamiento por test — independientes, sin estado compartido
- SIEMPRE limpiar datos de test — no dejar residuos en BD
Playwright Config
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: '**/auth.setup.ts' },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
},
})
Auth Setup
import { test as setup, expect } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel(/email/i).fill(process.env.E2E_USER_EMAIL!)
await page.getByLabel(/password/i).fill(process.env.E2E_USER_PASSWORD!)
await page.getByRole('button', { name: /login|iniciar/i }).click()
await page.waitForURL('**/dashboard**', { timeout: 15_000 })
await page.context().storageState({ path: 'tests/.auth/user.json' })
})
Nota: Este auth setup es un template para login con formulario HTML tradicional.
Si el proyecto usa Clerk, Auth0, NextAuth, o Supabase Auth UI, adaptar el setup:
verificar que componentes renderiza la pagina de login y usar los selectores apropiados.
Si usa redirect-based auth sin formulario, usar storageState con cookies/tokens
inyectados directamente via page.context().addCookies().
Template E2E
import { test, expect } from '@playwright/test'
test.describe('Posts page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/posts')
})
test('shows page heading', async ({ page }) => {
await expect(page.getByRole('heading', { name: /posts/i })).toBeVisible()
})
test('shows at least one post', async ({ page }) => {
const articles = page.getByRole('article')
await expect(articles.first()).toBeVisible()
})
test('redirects to login without auth', async ({ browser }) => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto('/posts')
await expect(page).toHaveURL(/\/login/)
await ctx.close()
})
})
3 Gotchas Imprescindibles
1. Race condition en carga:
await page.waitForLoadState('networkidle')
await expect(page.getByRole('heading', { name: 'Posts' })).toBeVisible({ timeout: 15_000 })
2. Multiples matches:
page.getByText('Guardar')
page.getByRole('button', { name: 'Guardar' })
3. URL parcial:
await expect(page).toHaveURL(/\/posts\//)
await expect(page).toHaveURL(/\/posts\/[0-9a-f]{8}/)
Estructura de Archivos
src/app/api/posts/
route.ts ← codigo
route.test.ts ← test unitario (adyacente)
[id]/
route.ts
route.test.ts
tests/
auth.setup.ts ← login para E2E
posts.spec.ts ← E2E de /posts
settings.spec.ts ← E2E de /settings
.auth/user.json ← session guardada (gitignored)
Tests unitarios: JUNTO al codigo.
Tests E2E: en carpeta tests/.
Paginas que NO necesitan E2E
No crear .spec.ts para:
layout.tsx — no son paginas, son wrappers
error.tsx, not-found.tsx, loading.tsx — paginas del sistema
- Paginas que solo hacen
redirect() sin UI propia
Reportar como excluidas:
⊘ src/app/not-found.tsx — pagina del sistema, omitido
Parte 4: Quality Gate (v1.1)
Score 0-100
/check-tests ahora ejecuta 5 fases de validacion antes de la generacion de tests:
| Fase | Puntos | Que valida |
|---|
| Build | 30 | npm run build — TypeScript errors, imports rotos |
| Env vars | 20 | Variables referenciadas vs definidas en .env* |
| Seguridad | 15 | API keys hardcodeadas, endpoints sin auth |
| Tests | 25 | Cobertura de unit tests + E2E |
| Lint | 10 | next lint o eslint |
Semaforo
- 🟢 90-100: Listo para produccion
- 🟡 75-89: Funciona pero hay cosas que mejorar
- 🟠 50-74: Riesgoso, arregla lo rojo
- 🔴 0-49: NO subas esto
Env vars — que detectar
grep -roEh 'process\.env\.[A-Za-z_][A-Za-z0-9_]*' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.mjs' --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=dist . 2>/dev/null | sort -u
Clasificar como:
- Definida: existe en
.env.local o .env con valor real
- Faltante: referenciada en codigo pero no existe en ningun
.env*
- Placeholder: existe pero con valor de ejemplo (vacio,
TODO, your-key-here)
Security — que detectar
- Strings largos hardcodeados que parecen tokens (32+ chars)
- Prefijos conocidos:
sk_live_, sk_test_, eyJ (JWT)
SUPABASE_SERVICE_ROLE_KEY en archivos con 'use client'
- API routes sin importaciones de auth (excepto publicos: health, webhook, callback, cron, revalidate, og, login, register, signup, verify, reset-password, stripe/webhook)
Pre-push hook
El hook .husky/pre-push ahora ejecuta npm run build ANTES de los E2E tests.
Si el build falla, el push se bloquea. Esto garantiza que nunca se suba codigo que no compila.