En las aplicaciones web modernas, la barra de direcciones del navegador (URL) actúa como una fuente única de verdad (Single Source of Truth) global, serializable y compartible. El estado de los filtros del catálogo, los criterios de búsqueda, la ordenación de tablas y la paginación deben sincronizarse con los parámetros de la cadena de consulta (URLSearchParams). Esto garantiza que el usuario pueda copiar el enlace, compartirlo con colegas o recargar la página preservando con exactitud el estado visual.
En Next.js 15, la interacción con la barra de direcciones en el cliente se gestiona mediante el paquete next/navigation:
useRouter(): Proporciona métodos imperativos de navegación programática (push, replace, back, refresh, prefetch);useSearchParams(): Devuelve una instancia inmutable de solo lectura ReadonlyURLSearchParams;usePathname(): Expone el segmento de ruta actual del enrutador;useParams(): Proporciona el mapa de parámetros de segmentos dinámicos.En el servidor, la generación de etiquetas SEO y metadatos para redes sociales (OpenGraph, Twitter Cards) se delega a la función generateMetadata(). En Next.js 15, los argumentos params y searchParams suministrados a generateMetadata se reciben como Promise, lo que exige actualizar las estrategias de testing automatizado.
En el modelo mental de testing, la navegación en el cliente se descompone en dos flujos complementarios: la lectura del estado de la URL hacia el componente y el envío de mutaciones de URL de vuelta al enrutador.
[Contexto de Pruebas: Vitest + React Testing Library]
│
├─► Mockeo de hooks de next/navigation:
│ - useSearchParams() ──► new URLSearchParams('category=dev&page=2')
│ - usePathname() ──► '/catalog'
│ - useRouter() ──► { push: vi.fn(), replace: vi.fn() }
│
▼
+-------------------------------------------------------------------------------+
| Componente de Cliente: <CatalogFilters /> |
+-------------------------------------------------------------------------------+
│
├─► Lectura del estado inicial: category = 'dev', page = 2
│
▼ (El usuario cambia la ordenación: userEvent.selectOptions)
+-------------------------------------------------------------------------------+
| Mutación de URLSearchParams |
| const params = new URLSearchParams(searchParams) |
| params.set('sort', 'price_asc') |
| params.delete('page') // Reinicio de paginación a pág 1 |
+-------------------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------------------+
| Llamada a router.push('/catalog?' + params.toString()) |
+-------------------------------------------------------------------------------+
│
▼
[Aserción de prueba: expect(router.push).toHaveBeenCalledWith('/catalog?category=dev&sort=price_asc')]router.replace() con el parámetro ?q=query actualizado, reteniendo los demás filtros activos.page=1 o restablezca el índice para evitar vistas vacías.router.push('/login?returnUrl=/profile') al expirar la sesión en el cliente.next/navigation#Para aislar las pruebas de navegación de forma limpia, creamos una fábrica reutilizable que permite configurar el estado de la URL para cada prueba individualmente.
import { vi } from 'vitest';
export interface NavigationMockState {
pathname?: string;
searchParams?: Record<string, string>;
params?: Record<string, string>;
}
export function setupNavigationMock(initialState: NavigationMockState = {}) {
const push = vi.fn();
const replace = vi.fn();
const back = vi.fn();
const forward = vi.fn();
const refresh = vi.fn();
const prefetch = vi.fn();
const pathname = initialState.pathname ?? '/';
const urlSearchParams = new URLSearchParams(initialState.searchParams ?? {});
const routeParams = initialState.params ?? {};
const useRouterMock = vi.fn(() => ({
push,
replace,
back,
forward,
refresh,
prefetch,
}));
const useSearchParamsMock = vi.fn(() => urlSearchParams);
const usePathnameMock = vi.fn(() => pathname);
const useParamsMock = vi.fn(() => routeParams);
vi.mock('next/navigation', () => ({
useRouter: useRouterMock,
useSearchParams: useSearchParamsMock,
usePathname: usePathnameMock,
useParams: useParamsMock,
}));
return {
push,
replace,
back,
refresh,
pathname,
urlSearchParams,
useRouterMock,
useSearchParamsMock,
};
}Este componente actualiza el parámetro de ordenación y restablece la paginación preservando todos los parámetros de consulta existentes.
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { ChangeEvent } from 'react';
export function CatalogSort() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
const nextSort = event.target.value;
// Creamos una copia mutable de los parámetros actuales
const nextParams = new URLSearchParams(searchParams.toString());
if (nextSort) {
nextParams.set('sort', nextSort);
} else {
nextParams.delete('sort');
}
// Al cambiar la ordenación se debe reiniciar la paginación
nextParams.delete('page');
const queryString = nextParams.toString();
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
router.push(targetUrl, { scroll: false });
};
const currentSort = searchParams.get('sort') || '';
return (
<div className="sort-wrapper">
<label htmlFor="sort-select">Ordenar por:</label>
<select
id="sort-select"
value={currentSort}
onChange={handleSortChange}
data-testid="sort-select"
>
<option value="">Por defecto</option>
<option value="price_asc">Precio: de menor a mayor</option>
<option value="price_desc">Precio: de mayor a menor</option>
<option value="popular">Más populares</option>
</select>
</div>
);
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CatalogSort } from './CatalogSort';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
usePathname: vi.fn(),
}));
describe('CatalogSort (Client Navigation)', () => {
const mockPush = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
});
vi.mocked(usePathname).mockReturnValue('/catalog/phones');
});
it('actualiza el parámetro sort y elimina page al cambiar la selección', async () => {
const user = userEvent.setup();
// URL inicial: /catalog/phones?brand=apple&page=3
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams('brand=apple&page=3') as unknown as ReturnType<typeof useSearchParams>
);
render(<CatalogSort />);
const select = screen.getByLabelText(/ordenar por/i);
expect(select).toHaveValue('');
// El usuario selecciona "Precio: de menor a mayor"
await user.selectOptions(select, 'price_asc');
// Comprobamos que router.push se invoque preservando brand, actualizando sort y eliminando page
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(
'/catalog/phones?brand=apple&sort=price_asc',
{ scroll: false }
);
});
it('elimina el parámetro sort cuando se selecciona la opción por defecto', async () => {
const user = userEvent.setup();
// URL inicial con sort
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams('brand=samsung&sort=popular') as unknown as ReturnType<typeof useSearchParams>
);
render(<CatalogSort />);
const select = screen.getByLabelText(/ordenar por/i);
expect(select).toHaveValue('popular');
// Restablecemos a por defecto
await user.selectOptions(select, '');
expect(mockPush).toHaveBeenCalledWith(
'/catalog/phones?brand=samsung',
{ scroll: false }
);
});
});generateMetadata() en Next.js 15#En Next.js 15, generateMetadata() recibe los parámetros de ruta y de búsqueda como promesas: props: { params: Promise<T>, searchParams: Promise<U> }. La probamos directamente como función asíncrona de servidor.
import { Metadata } from 'next';
import { getArticleBySlug } from '@/dal/articles';
export interface ArticlePageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ preview?: string }>;
}
export async function generateMetadata({
params,
searchParams,
}: ArticlePageProps): Promise<Metadata> {
// Resolución asíncrona de parámetros en Next.js 15
const { slug } = await params;
const { preview } = await searchParams;
const article = await getArticleBySlug(slug, preview === 'true');
if (!article) {
return {
title: 'Artículo no encontrado | Fullstack Agentic Lab',
description: 'El material solicitado no se encuentra en la base de conocimientos.',
robots: { index: false, follow: false },
};
}
return {
title: `${article.title} | Fullstack Agentic Lab`,
description: article.summary,
alternates: {
canonical: `https://agenticlab.dev/articles/${article.slug}`,
},
openGraph: {
title: article.title,
description: article.summary,
url: `https://agenticlab.dev/articles/${article.slug}`,
type: 'article',
images: [
{
url: article.coverImage,
width: 1200,
height: 630,
alt: article.title,
},
],
},
};
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
return <article>Artículo {slug}</article>;
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateMetadata } from './page';
import * as articlesDal from '@/dal/articles';
vi.mock('@/dal/articles', () => ({
getArticleBySlug: vi.fn(),
}));
describe('generateMetadata (Dynamic SEO Tags)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('genera metadatos OpenGraph y Canonical correctos para un artículo existente', async () => {
vi.mocked(articlesDal.getArticleBySlug).mockResolvedValue({
id: 'art-1',
slug: 'testing-nextjs-rsc',
title: 'Pruebas de React Server Components',
summary: 'Guía exhaustiva sobre pruebas de RSC y DAL asíncronos en Next.js 15',
coverImage: 'https://agenticlab.dev/images/og-rsc.png',
});
const metadata = await generateMetadata({
params: Promise.resolve({ slug: 'testing-nextjs-rsc' }),
searchParams: Promise.resolve({}),
});
expect(metadata.title).toBe('Pruebas de React Server Components | Fullstack Agentic Lab');
expect(metadata.description).toBe('Guía exhaustiva sobre pruebas de RSC y DAL asíncronos en Next.js 15');
expect(metadata.alternates?.canonical).toBe('https://agenticlab.dev/articles/testing-nextjs-rsc');
// Verificamos el protocolo OpenGraph
expect(metadata.openGraph).toMatchObject({
title: 'Pruebas de React Server Components',
type: 'article',
url: 'https://agenticlab.dev/articles/testing-nextjs-rsc',
images: [
{
url: 'https://agenticlab.dev/images/og-rsc.png',
width: 1200,
height: 630,
},
],
});
});
it('retorna metadatos con noindex si el artículo no existe', async () => {
vi.mocked(articlesDal.getArticleBySlug).mockResolvedValue(null);
const metadata = await generateMetadata({
params: Promise.resolve({ slug: 'missing-article' }),
searchParams: Promise.resolve({}),
});
expect(metadata.title).toBe('Artículo no encontrado | Fullstack Agentic Lab');
expect(metadata.robots).toEqual({ index: false, follow: false });
});
});En componentes cliente de Next.js, la navegación es orquestada por el contexto del enrutador. Modificar window.location.search = '...' o invocar window.history.pushState en pruebas no activa los ciclos de transición de Next.js, y en entornos de pruebas sin ventanas virtuales provocará fallos. Mockee siempre next/navigation.
Un fallo común al construir enlaces de filtrado consiste en crear un nuevo objeto new URLSearchParams() conteniendo únicamente la clave modificada, eliminando inadvertidamente categorías, idiomas o parámetros de paginación.
// ❌ INCORRECTO: Se borran todos los demás parámetros de búsqueda
const nextParams = new URLSearchParams({ sort: 'asc' });
// ✅ CORRECTO: Clonar los searchParams actuales antes de mutar
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('sort', 'asc');En Next.js 15, la firma generateMetadata({ params, searchParams }) suministra las propiedades como Promesas. Desestructurar { slug } directamente desde params asignará a la variable el valor undefined.
El hook useRouter() pertenece exclusivamente a los Componentes de Cliente ('use client'). En componentes de servidor, las redirecciones deben ejecutarse mediante la función de servidor redirect('/target') importada de next/navigation.
Establecer el estado inicial de parámetros de consulta (searchParams) y rutas (pathname) con vi.mock.
Montar el formulario de búsqueda o panel de filtros mediante React Testing Library.
Simular cambios de ordenación, introducción de texto o clics de paginación con userEvent.
Validar que la URL de destino contenga los parámetros esperados preservando las claves existentes.
Comprobar que el parámetro de paginación page se elimine al alterar los filtros principales.
Invocar generadores de metadatos con params asíncronos y verificar las etiquetas SEO resultantes.