StackPractices
intermediate Por Mathias Paulenko

Vitest Snapshot Testing para React

Cómo usar Vitest snapshot testing para detectar cambios no intencionados en la UI de componentes React, incluyendo inline snapshots y flujos de actualización.

Resumen

El snapshot testing captura la salida renderizada de un componente en un momento dado. En ejecuciones posteriores, Vitest compara la salida actual contra el snapshot almacenado y falla si difieren — alertándote sobre cambios no intencionados en la UI. Vitest es un corredor de tests compatible con Jest para proyectos Vite, con soporte nativo de snapshots. Si venís de Jest, la API es casi idéntica; mirá nuestra recipe de Jest snapshot testing para las diferencias, y el Patrón Snapshot Testing para el patrón general detrás de ambas. Para un tratamiento más profundo del setup completo de Vitest, mirá la guía completa de Vitest + React testing.

Cuándo Usar

  • Para detectar regresiones accidentales de CSS o markup en componentes presentacionales.
  • Para verificar que un componente renderice la misma estructura luego de un refactor.
  • Para testear componentes con salida estable y determinística (sin IDs aleatorios ni timestamps).
  • Para documentar la salida esperada de funciones utilitarias que retornan objetos complejos.

Cuándo NO Usar

  • Componentes con contenido dinámico (fechas, valores aleatorios, UUIDs) — los snapshots fallarán siempre.
  • Para testear lógica de negocio — usá unit tests con aserciones explícitas.
  • Componentes que cambian frecuentemente durante desarrollo activo — el churn de snapshots genera ruido.
  • Para testear accesibilidad o interacción — los snapshots solo chequean HTML renderizado, no comportamiento.

Solución

Configuración

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./test/setup.ts"],
  },
});
// test/setup.ts
import "@testing-library/jest-dom";

Test de snapshot básico

import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Button } from "./Button";

describe("Button", () => {
  it("matches snapshot for default variant", () => {
    const { container } = render(<Button>Click me</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });

  it("matches snapshot for primary variant", () => {
    const { container } = render(<Button variant="primary">Save</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });

  it("matches snapshot for disabled state", () => {
    const { container } = render(<Button disabled>Disabled</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });
});

Inline snapshot

import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Badge } from "./Badge";

describe("Badge", () => {
  it("renders inline snapshot", () => {
    const { container } = render(<Badge count={5} />);
    expect(container.firstChild).toMatchInlineSnapshot(`
      <span
        class="badge ri"
      >
        5
      </span>
    `);
  });
});

Snapshot con property matchers

import { describe, it, expect } from "vitest";
import { buildApiResponse } from "./api";

describe("buildApiResponse", () => {
  it("matches snapshot ignoring dynamic date", () => {
    const response = buildApiResponse({ data: [1, 2, 3], status: 200 });
    expect(response).toMatchSnapshot({
      timestamp: expect.any(String),
      reportId: expect.any(String),
    });
  });
});

Snapshot de salida de función

import { describe, it, expect } from "vitest";
import { formatCurrency } from "./format";

describe("formatCurrency", () => {
  it("matches snapshot for USD", () => {
    expect(formatCurrency(1234.56, "USD")).toMatchInlineSnapshot(
      `"$1,234.56"`,
    );
  });

  it("matches snapshot for EUR", () => {
    expect(formatCurrency(1234.56, "EUR")).toMatchInlineSnapshot(
      `"€1,234.56"`,
    );
  });
});

Snapshot de componente async

import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { UserProfile } from "./UserProfile";

describe("UserProfile", () => {
  it("matches snapshot after data loads", async () => {
    render(<UserProfile userId={42} />);
    await screen.findByRole("heading", { name: /alice/i });
    expect(document.body).toMatchSnapshot();
  });
});

Explicación

flowchart diagram: Renderizar componente

toMatchSnapshot() serializa el valor la primera vez que se ejecuta y lo escribe en un archivo .snap. En ejecuciones posteriores, Vitest compara el valor serializado con la copia almacenada. Usá toMatchInlineSnapshot() para mantener el valor esperado en el archivo de test, lo que facilita las revisiones. Los property matchers permiten saltear campos dinámicos — chequeás el tipo, no el valor exacto.

Yo uso inline snapshots cuando la salida es lo suficientemente chica para leerla en un diff de PR — un string formateado, un objeto de config chico, el HTML de un componente simple. Para cosas más grandes, los archivos .snap mantienen el test legible, pero perdés la revisión a simple vista. El trade-off es real: los inline snapshots hinchan el archivo de test, pero los .snap son fáciles de saltear en la revisión de código porque son auto-generados.

Algo que me mordió: los snapshots no atrapan todo. Un componente puede renderizar el mismo HTML pero comportarse distinto — un botón que dejó de disparar onClick, un form que submittea al endpoint equivocado, un focus trap que rompe la navegación por teclado. Los snapshots verifican estructura, no comportamiento. Combinálos con tests de interacción de @testing-library/react para cobertura real.

Variantes

Snapshot con mock calls

import { render, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { SubmitForm } from "./SubmitForm";

describe("SubmitForm", () => {
  it("calls onSubmit with form data", () => {
    const onSubmit = vi.fn();
    const { getByRole } = render(<SubmitForm onSubmit={onSubmit} />);

    fireEvent.click(getByRole("button", { name: /submit/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "",
      password: "",
    });
    expect(onSubmit.mock.calls).toMatchSnapshot();
  });
});

Snapshot de RSC

Para React Server Components, renderizá la salida a string antes de hacer snapshot:

import { renderToString } from "react-dom/server";
import { describe, it, expect } from "vitest";
import { StaticPage } from "./StaticPage";

describe("StaticPage", () => {
  it("matches server snapshot", () => {
    const html = renderToString(<StaticPage />);
    expect(html).toMatchSnapshot();
  });
});

Buenas Prácticas

  • Mantené los snapshots pequeños — hacé snapshot de un componente, no de todo el árbol de una página. Me mordió tener snapshots a nivel de página que se rompían en cada tweak de CSS.
  • Usá inline snapshots para salidas pequeñas y estables, así son revisables en PRs.
  • Revisá los diffs de snapshots en PRs — un test verde con un snapshot modificado significa que alguien aprobó el cambio. Bloqueo PRs donde los .snap cambian sin un comentario que explique por qué.
  • Usá property matchers para campos dinámicos — expect.any(String) o expect.any(Date) te salva del flakiness de timestamps.
  • Ejecutá vitest -u para actualizar snapshots solo después de verificar que el cambio es intencional.
  • No hagas snapshot de componentes con IDs aleatorios, timestamps o clases generadas.

Errores Comunes

  • Actualizar snapshots a ciegas — ejecutar vitest -u sin revisar el diff oculta regresiones reales.
  • Hacer snapshot de todo — un snapshot de 200 líneas es ilegible y falla ante cualquier cambio menor de CSS. Dividilo en snapshots de componentes más pequeños.
  • No usar property matchers para datos dinámicos — sin ellos, un timestamp en la salida rompe el snapshot en cada ejecución.
  • Guardar snapshots lejos de los tests — mantené los archivos .snap junto a los tests para facilitar su descubrimiento.
  • Usar snapshots como único test — los snapshots verifican estructura, no comportamiento. Agregá tests de interacción con @testing-library/react.

See Also

Preguntas frecuentes

¿Cómo actualizo los snapshots después de un cambio intencional?

Ejecutá npx vitest -u (o --update). Esto regenera todos los archivos .snap. Revisá el diff en Git antes de commitear.

¿Qué diferencia hay entre toMatchSnapshot y toMatchInlineSnapshot?

toMatchSnapshot escribe en un archivo .snap separado. toMatchInlineSnapshot escribe el snapshot directamente en el archivo de test como un string literal. Los inline snapshots son más revisables en PRs, pero pueden hinchar el archivo de test para salidas grandes.

¿Cómo ignoro valores dinámicos en los snapshots?

Usá property matchers:

expect(result).toMatchSnapshot({
  id: expect.any(String),
  createdAt: expect.any(String),
});

Vitest va a matchear la estructura pero ignorar los valores reales de esos campos.

¿Debería commitear archivos .snap en Git?

Sí. Los archivos de snapshot deben commitearse y revisarse en PRs. Sirven como contrato de la salida esperada.

¿Puedo usar snapshot testing con React Server Components?

Sí. Usá renderToString de react-dom/server y hacé snapshot de la salida HTML. Los tests de renderizado del lado del cliente usan @testing-library/react como siempre.

¿Por qué ocurre el drift de snapshots en suites grandes?

Usá toMatchInlineSnapshot para salidas pequeñas así el valor esperado se ve en la revisión de código. Para archivos .snap, habilitá --ci en CI para fallar ante snapshots desactualizados en lugar de escribir nuevos silenciosamente. Ejecutá vitest -u solo de forma local después de verificar que el cambio es intencional.