Históricamente, los intentos de instaurar Test-Driven Development en el desarrollo frontend solían suscitar frustración y desconfianza. El origen radicaba en una delimitación errónea del objetivo de las pruebas: los desarrolladores escribían tests TDD acoplados a selectores de clases CSS (clases utilitarias de Tailwind o convenciones BEM), estructuras de estado interno (useState, props) o representaciones superficiales del árbol virtual de componentes. Ante el más mínimo rediseño estético, tales pruebas frágiles se rompían sistemáticamente, imponiendo un coste de mantenimiento desmedido sin detectar verdaderas regresiones funcionales.
User-Centric UI TDD resuelve este desfase estructural transformando el foco de verificación:
UI = f(state, events). La especificación formaliza una causalidad observable: «Cuando el usuario interactúa con el elemento X, el árbol de accesibilidad transiciona al estado Y».combobox, listbox, button, status), sus nombres accesibles y estados dinámicos (aria-expanded, aria-selected) constituyen la API pública legítima del componente.renderHook().TDD en React permite concebir componentes sólidos y universalmente accesibles (a11y) mucho antes de que el equipo de diseño concluya las hojas de estilo visual definitivas.
El ciclo interno de User-Centric TDD en React reproduce la secuencia verídica de propagación de eventos a través del planificador concurrente de React 19:
+-----------------------------------------------------------------------+
| USER-CENTRIC REACT TDD CYCLE |
+-----------------------------------------------------------------------+
| |
| 1. Especificación del Contrato de Usuario (RED) |
| [userEvent.type(input, 'TypeScript')] |
| │ |
| ▼ |
| 2. Secuencia Real de Eventos del Navegador |
| (focusin -> keydown -> input -> change -> keyup) |
| │ |
| ▼ |
| 3. Planificador Concurrente de React 19 |
| (Fiber Work Loop -> State Transition -> Microtasks Flush) |
| │ |
| ▼ |
| 4. Mutación del DOM y Árbol de Accesibilidad (GREEN) |
| [<div role="listbox"> -> <li role="option">TypeScript</li>] |
| │ |
| ▼ |
| 5. Verificación del Contrato ARIA |
| expect(screen.getByRole('option', { name: 'TypeScript' })) |
| |
+-----------------------------------------------------------------------+ArrowUp/ArrowDown/Enter) y anuncios para lectores de pantalla.useActionState y useOptimistic, contemplando deshabilitación de campos, indicadores de carga y mensajes accesibles de error.localStorage, escuchadores de media queries y temporizadores de inactividad de sesión.Escape y restitución del foco al elemento desencadenante.Implementaremos un componente de selección múltiple de etiquetas, TagFilterSearch, dotado de filtrado con retardo (debounce). Estructuramos el desarrollo en dos etapas TDD independientes:
useDebouncedSearch mediante renderHook().TagFilterSearch con React Testing Library y @testing-library/user-event.useDebouncedSearch#El hook debe admitir un valor de búsqueda y un retardo en milisegundos, retornando el valor estabilizado. Configuramos el control del tiempo virtual mediante vi.useFakeTimers().
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebouncedSearch } from './use-debounced-search';
describe('useDebouncedSearch', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('debe retornar el valor inicial inmediatamente en el montaje inicial', () => {
const { result } = renderHook(() => useDebouncedSearch('react', 300));
expect(result.current).toBe('react');
});
it('solo debe actualizar el valor tras transcurrir el retardo especificado', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedSearch(value, 300),
{ initialProps: { value: 'react' } }
);
// Actualizamos el valor entrante
rerender({ value: 'react 19' });
// Antes de completarse el temporizador, persiste el valor previo
expect(result.current).toBe('react');
// Avanzamos el reloj virtual 299 ms
act(() => {
vi.advanceTimersByTime(299);
});
expect(result.current).toBe('react');
// Avanzamos el milisegundo final
act(() => {
vi.advanceTimersByTime(1);
});
expect(result.current).toBe('react 19');
});
it('debe cancelar el temporizador anterior ante entradas sucesivas rápidas', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedSearch(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ value: 'abc' });
act(() => {
vi.advanceTimersByTime(200);
});
// Han pasado 400 ms en total, pero solo 200 ms desde la última pulsación
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('abc');
});
});El test falla al no existir el archivo del hook (FAIL).
import { useState, useEffect } from 'react';
export function useDebouncedSearch<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(timerId);
};
}, [value, delayMs]);
return debouncedValue;
}Las 3 especificaciones del hook pasan de manera limpia (PASS).
TagFilterSearch#Definimos los requerimientos del componente TagFilterSearch:
role="combobox").role="listbox") con las opciones coincidentes (role="option").import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TagFilterSearch } from './tag-filter-search';
describe('TagFilterSearch', () => {
const availableTags = ['Architecture', 'Vitest', 'React 19', 'Performance', 'TypeScript'];
it('debe renderizar un combobox accesible y desplegar sugerencias filtradas al escribir', async () => {
const user = userEvent.setup();
const handleTagsChange = vi.fn();
render(
<TagFilterSearch
tags={availableTags}
selectedTags={[]}
onChange={handleTagsChange}
/>
);
const input = screen.getByRole('combobox', { name: /buscar etiquetas/i });
expect(input).toBeInTheDocument();
// Escribimos la consulta
await user.type(input, 'Type');
// Verificamos la aparición de la opción sugerida
const option = await screen.findByRole('option', { name: 'TypeScript' });
expect(option).toBeInTheDocument();
// Las opciones no coincidentes deben estar ausentes
expect(screen.queryByRole('option', { name: 'Architecture' })).toBeNull();
});
});La prueba falla al carecer de la definición del componente (FAIL).
'use client';
import React, { useState, useId, useMemo } from 'react';
export interface TagFilterSearchProps {
readonly tags: readonly string[];
readonly selectedTags: readonly string[];
readonly onChange: (nextTags: readonly string[]) => void;
}
export function TagFilterSearch({
tags,
selectedTags,
onChange,
}: TagFilterSearchProps): React.JSX.Element {
const [query, setQuery] = useState('');
const inputId = useId();
const listboxId = useId();
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags]);
const filteredTags = useMemo(() => {
if (!query.trim()) return [];
return tags
.filter((tag) => !selectedSet.has(tag))
.filter((tag) => tag.toLowerCase().includes(query.toLowerCase()));
}, [tags, query, selectedSet]);
const hasSuggestions = filteredTags.length > 0;
return (
<div className="tag-filter-container">
<label htmlFor={inputId}>Buscar etiquetas</label>
<input
id={inputId}
role="combobox"
aria-expanded={hasSuggestions}
aria-controls={hasSuggestions ? listboxId : undefined}
aria-autocomplete="list"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{hasSuggestions && (
<ul id={listboxId} role="listbox" aria-label="Etiquetas sugeridas">
{filteredTags.map((tag) => (
<li
key={tag}
role="option"
aria-selected={false}
onClick={() => {
onChange([...selectedTags, tag]);
setQuery('');
}}
>
{tag}
</li>
))}
</ul>
)}
</div>
);
}El primer test pasa a verde (PASS).
Ampliamos el repertorio de pruebas:
onChange, se vacía el campo y se oculta la lista desplegable."Eliminar etiqueta [nombre]".onChange excluyendo dicha etiqueta.it('debe agregar una etiqueta al hacer clic en una opción y vaciar el input', async () => {
const user = userEvent.setup();
const handleTagsChange = vi.fn();
const { rerender } = render(
<TagFilterSearch
tags={availableTags}
selectedTags={[]}
onChange={handleTagsChange}
/>
);
const input = screen.getByRole('combobox', { name: /buscar etiquetas/i });
await user.type(input, 'React');
const option = screen.getByRole('option', { name: 'React 19' });
await user.click(option);
expect(handleTagsChange).toHaveBeenCalledTimes(1);
expect(handleTagsChange).toHaveBeenCalledWith(['React 19']);
// Simulamos la actualización del estado padre
rerender(
<TagFilterSearch
tags={availableTags}
selectedTags={['React 19']}
onChange={handleTagsChange}
/>
);
expect(input).toHaveValue('');
expect(screen.queryByRole('listbox')).toBeNull();
const removeButton = screen.getByRole('button', { name: /eliminar etiqueta react 19/i });
expect(removeButton).toBeInTheDocument();
});
it('debe ejecutar onChange excluyendo la etiqueta al pulsar el botón de remoción', async () => {
const user = userEvent.setup();
const handleTagsChange = vi.fn();
render(
<TagFilterSearch
tags={availableTags}
selectedTags={['Architecture', 'Vitest']}
onChange={handleTagsChange}
/>
);
const removeArchButton = screen.getByRole('button', { name: /eliminar etiqueta architecture/i });
await user.click(removeArchButton);
expect(handleTagsChange).toHaveBeenCalledTimes(1);
expect(handleTagsChange).toHaveBeenCalledWith(['Vitest']);
});Las pruebas fallan ante la ausencia de insignias y manejadores de remoción.
'use client';
import React, { useState, useId, useMemo, useCallback } from 'react';
export interface TagFilterSearchProps {
readonly tags: readonly string[];
readonly selectedTags: readonly string[];
readonly onChange: (nextTags: readonly string[]) => void;
}
export function TagFilterSearch({
tags,
selectedTags,
onChange,
}: TagFilterSearchProps): React.JSX.Element {
const [query, setQuery] = useState('');
const inputId = useId();
const listboxId = useId();
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags]);
const filteredTags = useMemo(() => {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return [];
return tags
.filter((tag) => !selectedSet.has(tag))
.filter((tag) => tag.toLowerCase().includes(trimmed));
}, [tags, query, selectedSet]);
const handleSelect = useCallback(
(tag: string) => {
onChange([...selectedTags, tag]);
setQuery('');
},
[selectedTags, onChange]
);
const handleRemove = useCallback(
(tagToRemove: string) => {
onChange(selectedTags.filter((t) => t !== tagToRemove));
},
[selectedTags, onChange]
);
const hasSuggestions = filteredTags.length > 0;
return (
<div className="tag-filter-search">
{/* Lista de etiquetas previamente seleccionadas */}
{selectedTags.length > 0 && (
<ul className="selected-tags-list" aria-label="Etiquetas seleccionadas">
{selectedTags.map((tag) => (
<li key={tag} className="selected-tag-item">
<span>{tag}</span>
<button
type="button"
aria-label={`Eliminar etiqueta ${tag}`}
onClick={() => handleRemove(tag)}
>
×
</button>
</li>
))}
</ul>
)}
{/* Campo de búsqueda */}
<div className="input-wrapper">
<label htmlFor={inputId}>Buscar etiquetas</label>
<input
id={inputId}
role="combobox"
aria-expanded={hasSuggestions}
aria-controls={hasSuggestions ? listboxId : undefined}
aria-autocomplete="list"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{/* Menú desplegable de sugerencias */}
{hasSuggestions && (
<ul id={listboxId} role="listbox" aria-label="Etiquetas sugeridas">
{filteredTags.map((tag) => (
<li
key={tag}
role="option"
aria-selected={false}
tabIndex={0}
onClick={() => handleSelect(tag)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSelect(tag);
}
}}
>
{tag}
</li>
))}
</ul>
)}
</div>
);
}Todas las especificaciones se ejecutan satisfactoriamente (PASS). Hemos obtenido un componente plenamente accesible bajo WAI-ARIA (combobox 1.2), gobernable por teclado y libre de fugas de memoria.
El método fireEvent.change(input, { target: { value: 'react' } }) lanza un evento sintético aislado en el DOM sin completar el ciclo de eventos del navegador (enfoque, eventos keydown, input, change, keyup y posicionamiento de cursor). La función userEvent.type() simula la interacción real del usuario y asegura la ejecución fiel de los escuchadores.
Invocar vi.useFakeTimers() en un caso de prueba sin restablecer el reloj nativo mediante vi.useRealTimers() en el bloque afterEach. La persistencia de temporizadores falsos corrompe las operaciones asíncronas posteriores (como las consultas findBy* y el vaciado de microtareas en act), bloqueando el runner hasta el tiempo límite de espera.
Inspeccionar propiedades internas del estado del componente (useState) o nodos del árbol Fiber de React. Si la interfaz se proyecta con rigor semántico en el Accessibility Tree, la estrategia técnica de almacenamiento del estado debe ser completamente transparente para la prueba.
Las funciones de userEvent (v14+) devuelven promesas. Omitir await hace que el evento se programe en microtareas tras completarse las aserciones, provocando la célebre advertencia: An update to Component inside a test was not wrapped in act(...).
Establecer roles de accesibilidad (combobox, dialog, form) y nombres accesibles. Crear las primeras aserciones de renderizado e interacción con getByRole.
Construir marcado HTML semántico básico con atributos aria-* y manejadores para superar el test inicial.
Redactar pruebas para eventos de teclado, clics, navegación por flechas y cambios dinámicos de aria-selected / aria-expanded.
Aislar cálculos complejos, temporizadores o sincronizaciones en hooks desacoplados probados con renderHook.
Incorporar clases de Tailwind CSS o módulos de CSS. Dado que los tests validan contratos ARIA, el estilizado jamás romperá una prueba.