EmpiezaTuSaaS

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

HerramientaPropósito
VitestTest runner rápido compatible con Vite
React Testing LibraryTests de componentes React
MSWMock de APIs y requests
PlaywrightTests 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 msw

Paso 2: Configurar Vitest

vitest.config.ts
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

tests/setup.ts
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

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

components/ui/button.test.tsx
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

components/auth/sign-in-form.test.tsx
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

hooks/use-subscription.test.ts
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

app/api/user/profile/route.test.ts
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

tests/mocks/handlers.ts
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",
    });
  }),
];
tests/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
tests/setup.ts
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 install

Configurar Playwright:

playwright.config.ts
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:

tests/e2e/auth.spec.ts
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:

package.json
{
  "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:ui

CI/CD con GitHub Actions

.github/workflows/test.yml
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-testid para 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

On this page