Testing
Guía completa para configurar y escribir tests en tu SaaS
Testing
Esta guía te enseña a configurar y escribir tests para tu SaaS usando Vitest y React Testing Library.
Stack de testing
| Herramienta | Propósito |
|---|---|
| Vitest | Test runner rápido compatible con Vite |
| React Testing Library | Tests de componentes React |
| MSW | Mock de APIs y requests |
| Playwright | Tests end-to-end (E2E) |
Paso 1: Instalar dependencias
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event mswPaso 2: Configurar Vitest
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
include: ["**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"tests/setup.ts",
"**/*.d.ts",
"**/*.config.*",
],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
});Paso 3: Configurar setup de tests
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
// Limpiar después de cada test
afterEach(() => {
cleanup();
});
// Mock de next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
// Mock de next-intl
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "es",
}));
// Mock de auth client
vi.mock("@/lib/auth-client", () => ({
useSession: () => ({
data: {
user: {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
},
},
isPending: false,
}),
signIn: vi.fn(),
signOut: vi.fn(),
}));Paso 4: Añadir scripts a package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}Tests de componentes
Test básico de un componente
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./button";
describe("Button", () => {
it("renders correctly", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
});
it("handles click events", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("applies variant styles", () => {
render(<Button variant="destructive">Delete</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-destructive");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Test de formulario
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignInForm } from "./sign-in-form";
import { signIn } from "@/lib/auth-client";
vi.mock("@/lib/auth-client");
describe("SignInForm", () => {
it("renders email and password fields", () => {
render(<SignInForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/contraseña/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /iniciar sesión/i })).toBeInTheDocument();
});
it("submits form with credentials", async () => {
const user = userEvent.setup();
const mockSignIn = vi.mocked(signIn.email);
mockSignIn.mockResolvedValue({ data: null, error: null });
render(<SignInForm />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/contraseña/i), "password123");
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
await waitFor(() => {
expect(mockSignIn).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
callbackURL: "/dashboard",
});
});
});
it("shows error message on failed login", async () => {
const user = userEvent.setup();
const mockSignIn = vi.mocked(signIn.email);
mockSignIn.mockResolvedValue({
data: null,
error: { message: "Credenciales inválidas" },
});
render(<SignInForm />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/contraseña/i), "wrongpassword");
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
// Verificar que se muestra el toast de error
// (depende de cómo esté implementado en tu app)
});
it("validates email format", async () => {
const user = userEvent.setup();
render(<SignInForm />);
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, "invalid-email");
expect(emailInput).toBeInvalid();
});
});Test de hook personalizado
import { describe, it, expect, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useSubscription } from "./use-subscription";
// Mock del fetch
global.fetch = vi.fn();
describe("useSubscription", () => {
it("fetches subscription data", async () => {
const mockSubscription = {
plan: "pro",
status: "active",
currentPeriodEnd: "2025-12-31",
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => mockSubscription,
} as Response);
const { result } = renderHook(() => useSubscription());
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.subscription).toEqual(mockSubscription);
});
it("handles error state", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useSubscription());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeTruthy();
});
});Tests de API routes
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PATCH, GET } from "./route";
import { NextRequest } from "next/server";
// Mock de dependencias
vi.mock("@/lib/auth", () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}));
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn(),
findUnique: vi.fn(),
},
},
}));
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
describe("Profile API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("PATCH /api/user/profile", () => {
it("returns 401 if not authenticated", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null);
const request = new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
body: JSON.stringify({ name: "New Name" }),
});
const response = await PATCH(request);
expect(response.status).toBe(401);
});
it("updates user name", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: "user-123", email: "test@example.com" },
} as any);
vi.mocked(db.user.update).mockResolvedValue({
id: "user-123",
name: "Updated Name",
email: "test@example.com",
image: null,
} as any);
const request = new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
body: JSON.stringify({ name: "Updated Name" }),
});
const response = await PATCH(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.user.name).toBe("Updated Name");
});
it("validates name is not empty", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: "user-123" },
} as any);
const request = new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
body: JSON.stringify({ name: "" }),
});
const response = await PATCH(request);
expect(response.status).toBe(400);
});
});
});Mock de APIs con MSW
import { http, HttpResponse } from "msw";
export const handlers = [
// Mock de API de usuario
http.get("/api/user/profile", () => {
return HttpResponse.json({
user: {
id: "test-id",
name: "Test User",
email: "test@example.com",
},
});
}),
// Mock de API de suscripción
http.get("/api/subscription", () => {
return HttpResponse.json({
plan: "pro",
status: "active",
currentPeriodEnd: "2025-12-31",
});
}),
// Mock de Stripe checkout
http.post("/api/stripe/checkout", () => {
return HttpResponse.json({
url: "https://checkout.stripe.com/test-session",
});
}),
];import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);import { server } from "./mocks/server";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Tests E2E con Playwright
Instalar Playwright:
npm install -D @playwright/test
npx playwright installConfigurar Playwright:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});Escribir test E2E:
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("user can sign up", async ({ page }) => {
await page.goto("/sign-up");
await page.fill('input[name="name"]', "Test User");
await page.fill('input[name="email"]', `test-${Date.now()}@example.com`);
await page.fill('input[name="password"]', "password123");
await page.fill('input[name="confirmPassword"]', "password123");
await page.click('button[type="submit"]');
// Debería redirigir al dashboard
await expect(page).toHaveURL(/\/dashboard/);
});
test("user can sign in", async ({ page }) => {
await page.goto("/sign-in");
await page.fill('input[name="email"]', "existing@example.com");
await page.fill('input[name="password"]', "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
});
test("shows error for invalid credentials", async ({ page }) => {
await page.goto("/sign-in");
await page.fill('input[name="email"]', "wrong@example.com");
await page.fill('input[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
// Verificar mensaje de error
await expect(page.locator('[role="alert"]')).toBeVisible();
});
});Añadir scripts:
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}Ejecutar tests
# Tests unitarios
npm test
# Con UI interactiva
npm run test:ui
# Ejecutar una vez
npm run test:run
# Con cobertura
npm run test:coverage
# Tests E2E
npm run test:e2e
# E2E con UI
npm run test:e2e:uiCI/CD con GitHub Actions
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:run
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/Mejores prácticas
Recomendaciones
- Escribe tests que prueben comportamiento, no implementación
- Usa
data-testidpara selectores estables - Mantén los tests independientes entre sí
- Mockea dependencias externas (APIs, base de datos)
- Ejecuta tests en CI antes de merge
Resumen
Has configurado:
- ✅ Vitest para tests unitarios
- ✅ React Testing Library para componentes
- ✅ MSW para mocking de APIs
- ✅ Playwright para tests E2E
- ✅ GitHub Actions para CI/CD