DEV Community

IagoLast
IagoLast

Posted on

Testing de SPAs con Vitest Browser Mode: Velocidad de Unit Tests con Confianza E2E

La Revolución del Testing

Kent C. Dodds revolucionó el testing frontend con una idea simple pero poderosa:

"Cuanto más se parezcan tus tests a la forma en que se usa tu software, más confianza pueden darte."

Antes de Testing Library, los tests tenían este aspecto:

// Probando detalles de implementación ❌
const input = wrapper.find(".primary-focus-default-foo");
input.simulate("change", { target: { value: "test@test.com" } });
expect(wrapper.state().email).toBe("test@test.com");
Enter fullscreen mode Exit fullscreen mode

Este enfoque tenía un problema fundamental: estábamos probando cómo funcionaba el código, no lo que experimentaba el usuario. Si renombrábamos una clase CSS o refactorizábamos el estado interno, el test se rompía—incluso si la aplicación seguía funcionando perfectamente.

Testing Library lo cambió todo:

// Probando comportamiento ✅
const input = screen.getByLabelText("Email");
await userEvent.type(input, "test@test.com");
expect(input).toHaveValue("test@test.com");
Enter fullscreen mode Exit fullscreen mode

Ahora nuestros tests se leen como historias de usuario: "buscar el input de email, escribir un email, verificar que aparece". Si cambia la clase CSS, el test sigue pasando. Si cambiamos el estado de React por una librería de formularios, el test sigue pasando. Estamos probando comportamiento, no implementación.

La Era de jsdom: Velocidad a un Coste

Los tests con Testing Library son rápidos—milisegundos, no segundos. Esta velocidad viene de jsdom, una implementación de JavaScript del DOM que se ejecuta en Node.js.

En lugar de lanzar un navegador real, jsdom simula uno en memoria. Nuestros tests se ejecutan al instante porque no hay arranque del navegador, no hay motor de renderizado, no hay pila de red real.

Pero jsdom es una simulación. No implementa todo:

  • No hay renderizado real de CSS ni cálculos de layout
  • No hay peticiones de red reales
  • Diferencias sutiles en el manejo de eventos
  • APIs web que faltan

Para la mayoría de tests, esto no importa. Pero de vez en cuando, un test pasa en jsdom y falla en un navegador real. O peor: un test pasa en todas partes, pero la funcionalidad está rota en producción.

El Problema de los Efectos Secundarios: MSW y server-stubs

Incluso con una simulación perfecta del DOM, nuestras aplicaciones no existen aisladas. Hacen peticiones HTTP. Leen cookies. Interactúan con servidores.

Nuestros tests necesitan manejar estos efectos secundarios. La comunidad se decantó por MSW (Mock Service Worker) como solución estándar. MSW intercepta las peticiones HTTP a nivel de red—nuestra aplicación no sabe que está siendo testeada.

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/browser";

const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "John" },
      { id: 2, name: "Jane" },
    ]);
  })
);
Enter fullscreen mode Exit fullscreen mode

MSW es potente, pero configurar los handlers puede volverse verboso. Por eso construimos @frontend-testing/server-stubs—una capa fina sobre MSW que simplifica el stubbing a una sola línea:

import { stubJsonResponse } from "@frontend-testing/server-stubs";

const { spy } = stubJsonResponse({
  path: "*/api/users",
  method: "GET",
  response: [{ id: 1, name: "John" }],
});

// Después de la petición:
expect(spy).toHaveBeenCalledTimes(1);
Enter fullscreen mode Exit fullscreen mode

Sin boilerplate de handlers. Spies de peticiones integrados. Respuestas secuenciales para lógica de reintentos. Todos los patrones comunes, simplificados.

2025: Vitest Browser Mode lo Cambia Todo

Luego llegó Vitest 2.0 con Browser Mode, y el panorama cambió.

Browser Mode ejecuta tus tests en un navegador real—Chromium, Firefox o WebKit—mientras mantiene la velocidad de Vitest. No más jsdom. No más simulaciones. DOM real, eventos reales, APIs reales del navegador.

// vitest.config.ts
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Tus tests se ejecutan en milisegundos—igual que con jsdom—pero en un navegador real. La brecha de confianza se cierra.

Mi Enfoque: Casi E2E, a Velocidad de Unit Test

Con estas piezas en su lugar, propongo una estrategia de testing que te da la confianza de E2E a velocidad de unit test:

  1. Usar Vitest Browser Mode — Navegador real, confianza real
  2. Renderizar la aplicación completa — No componentes aislados, la app real
  3. Navegar a la ruta bajo prueba — Usando @frontend-testing/vitest-browser-navigate
  4. Hacer stub de respuestas del servidor — Usando server-stubs sobre MSW
  5. Opcionalmente sembrar estado — Cookies, localStorage, tokens de autenticación

Todo lo demás es real. Routing real. Componentes reales. Interacciones de usuario reales.

El Problema de la Navegación

Hay un detalle: en Browser Mode, tu app se renderiza dentro de un iframe. No puedes simplemente cambiar window.location—necesitas simular la navegación de una SPA.

Por eso construimos @frontend-testing/vitest-browser-navigate:

npm install -D @frontend-testing/vitest-browser-navigate
Enter fullscreen mode Exit fullscreen mode
// vitest.config.ts
import { navigate } from "@frontend-testing/vitest-browser-navigate";

export default defineConfig({
  test: {
    browser: {
      commands: { navigate },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Ahora puedes navegar como un usuario:

import { commands } from "vitest/browser";

await commands.navigate("/dashboard");
Enter fullscreen mode Exit fullscreen mode

Bajo el capó, llama a history.pushState() y dispara un evento popstate. Tu router—React Router, Vue Router, el que uses—reacciona exactamente igual que con navegación real.

Ejemplo Completo: Testeando un Flujo de Login

// login-page.spec.tsx
import "@frontend-testing/vitest-browser-navigate";
import App from "@/App";
import { stubJsonResponse } from "@frontend-testing/server-stubs";
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { commands, userEvent } from "vitest/browser";

describe("LoginPage", () => {
  test("should login and redirect to dashboard", async () => {
    // 1. Hacer stub de la respuesta del servidor
    const { spy } = stubJsonResponse({
      path: "*/auth/login",
      method: "POST",
      response: { token: "mock-token", user: { id: 1 } },
    });

    // 2. Navegar a la página de login
    await commands.navigate("/login");

    // 3. Renderizar la aplicación COMPLETA
    await render(<App />);

    // 4. Interactuar como un usuario real
    await userEvent.type(
      screen.getByLabelText("Email"),
      "test@test.com"
    );
    await userEvent.type(
      screen.getByLabelText("Password"),
      "password123"
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Login" })
    );

    // 5. Verificar que la petición se hizo correctamente
    expect(spy).toHaveBeenCalledTimes(1);
    expect(spy.mock.calls[0][0].body).toEqual({
      email: "test@test.com",
      password: "password123",
    });

    // 6. Verificar que la navegación ocurrió
    await waitFor(() => {
      expect(window.location.pathname).toBe("/dashboard");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Fíjate en lo que estamos probando:

  • ✅ Renderizado en navegador real
  • ✅ Routing completo de la aplicación
  • ✅ Interacciones de usuario reales
  • ✅ Verificación de peticiones HTTP
  • ✅ Resultados de navegación

El único mock es la respuesta del servidor. Todo lo demás es la aplicación real.

Por Qué Funciona Este Enfoque

Este enfoque funciona porque combina lo mejor de ambos mundos: la velocidad de los unit tests con la confianza de los tests E2E. Mis tests se ejecutan en un navegador real, así que si pasan, la funcionalidad realmente funciona—no más sorpresas de "funciona en jsdom". Los tests se completan en milisegundos, permitiéndome ejecutar cientos en segundos y mantener mi flujo de desarrollo ágil. Pruebo comportamiento, no implementación, lo que significa que los refactors no rompen tests y los cambios de CSS no rompen tests—solo los cambios de funcionalidad real lo hacen. Y es simple: un patrón para todo. Navegar, interactuar, verificar. Sin infraestructura compleja de E2E, sin servidores separados de Cypress, sin mantener dos suites de tests diferentes.

Empezando

Configurar este stack de testing lleva unos 10 minutos. Aquí tienes una guía paso a paso.

1. Instalar Dependencias

Primero, instala Vitest con soporte para browser mode y nuestras librerías auxiliares:

npm install -D vitest @vitest/browser playwright
npm install -D @frontend-testing/vitest-browser-navigate
npm install -D @frontend-testing/server-stubs msw
Enter fullscreen mode Exit fullscreen mode

Si estás usando React, también querrás las utilidades de render compatibles con el navegador:

npm install -D vitest-browser-react @testing-library/react
Enter fullscreen mode Exit fullscreen mode

2. Configurar Vitest

Crea o actualiza tu vitest.config.ts para habilitar browser mode y registrar el comando navigate:

// vitest.config.ts
import { playwright } from "@vitest/browser-playwright";
import { navigate } from "@frontend-testing/vitest-browser-navigate";
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
      commands: { navigate },
    },
    setupFiles: ["./test/setup.ts"],
  },
});
Enter fullscreen mode Exit fullscreen mode

3. Configurar MSW y server-stubs

Crea un archivo de setup de tests que inicialice MSW. El serverManager de server-stubs desacopla el servidor MSW de tus archivos de test, así que lo configuras una vez y usas stubs en cualquier lugar:

// test/setup.ts
import { setupWorker } from "msw/browser";
import { serverManager } from "@frontend-testing/server-stubs";
import { beforeAll, afterEach, afterAll } from "vitest";

const server = setupWorker();
serverManager.setDefaultServerLoader(() => server);

beforeAll(() => server.start({ onUnhandledRequest: "warn" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.stop());
Enter fullscreen mode Exit fullscreen mode

La opción onUnhandledRequest: "warn" es útil durante el desarrollo—registra una advertencia cuando tu app hace una petición que no está stubbeada, ayudándote a detectar mocks faltantes.

4. Escribir Tu Primer Test

Ahora estás listo para escribir tests. Crea un archivo spec e importa todo lo que necesites:

// src/pages/home.spec.tsx
import { stubJsonResponse } from "@frontend-testing/server-stubs";
import { screen } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { commands, userEvent } from "vitest/browser";
import App from "./App";

describe("HomePage", () => {
  test("displays user data after loading", async () => {
    // Hacer stub de la API
    stubJsonResponse({
      path: "*/api/user",
      response: { name: "John Doe", email: "john@example.com" },
    });

    // Navegar y renderizar
    await commands.navigate("/");
    await render(<App />);

    // Verificar
    await expect.element(screen.getByText("John Doe")).toBeVisible();
  });
});
Enter fullscreen mode Exit fullscreen mode

5. Ejecutar Tus Tests

npx vitest
Enter fullscreen mode Exit fullscreen mode

Vitest lanzará un navegador, ejecutará tus tests y mostrará los resultados en la terminal. Los tests se ejecutan en modo watch por defecto, re-ejecutándose cuando guardas cambios.

Para entornos de CI, usa:

npx vitest run
Enter fullscreen mode Exit fullscreen mode

Eso es todo. Estás testeando en un navegador real, a velocidad de unit test.


¿Preguntas? Abre un issue en GitHub.


Y si, este post esta escrito por IA, pero revisado por mi :)

Top comments (0)