В современных веб-приложениях адресная строка браузера (URL) выступает в роли глобального, сериализуемого и разделяемого источника истины (Single Source of Truth). Состояние фильтров каталога, поисковой выдачи, сортировки таблиц и пагинации должно синхронизироваться с параметрами строки запроса (URLSearchParams). Это гарантирует, что пользователь может скопировать ссылку, отправить ее коллеге или перезагрузить страницу с полным сохранением контекста.
В Next.js 16 взаимодействие с адресной строкой на клиенте осуществляется через пакет next/navigation:
useRouter(): обеспечивает методы программной навигации (push, replace, back, refresh, prefetch);useSearchParams(): возвращает доступный только для чтения экземпляр ReadonlyURLSearchParams;usePathname(): предоставляет текущий сегмент пути маршрутизатора;useParams(): возвращает параметры динамического сегмента.На стороне сервера генерация SEO-тегов и протоколов соцсетей (OpenGraph, Twitter Cards) делегирована функции generateMetadata(). В Next.js 16 аргументы params и searchParams функции generateMetadata передаются в виде Promise, что требует обновления стратегий автоматизированного тестирования.
В ментальной модели тестирования клиентская навигация разделяется на два встречных потока: чтение состояния URL в компонент и отправка мутаций URL обратно в роутер.
[Тестовый контекст: Vitest + React Testing Library]
│
├─► Мокирование хуков next/navigation:
│ - useSearchParams() ──► new URLSearchParams('category=dev&page=2')
│ - usePathname() ──► '/catalog'
│ - useRouter() ──► { push: vi.fn(), replace: vi.fn() }
│
▼
+-------------------------------------------------------------------------------+
| Клиентский компонент: <CatalogFilters /> |
+-------------------------------------------------------------------------------+
│
├─► Чтение начального состояния: category = 'dev', page = 2
│
▼ (Пользователь меняет сортировку: userEvent.selectOptions)
+-------------------------------------------------------------------------------+
| Мутация URLSearchParams |
| const params = new URLSearchParams(searchParams) |
| params.set('sort', 'price_asc') |
| params.delete('page') // Сброс пагинации на 1 |
+-------------------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------------------+
| Вызов router.push('/catalog?' + params.toString()) |
+-------------------------------------------------------------------------------+
│
▼
[Ассерт в тесте: expect(router.push).toHaveBeenCalledWith('/catalog?category=dev&sort=price_asc')]Задать начальное состояние строки запроса (searchParams) и пути (pathname) через vi.mock.
Отрендерить форму поиска или панель фильтров с помощью React Testing Library.
Симулировать смену сортировки, ввод поискового запроса или клик пагинации через userEvent.
Удостовериться, что целевой URL содержит ожидаемые query-параметры с сохранением существующих ключей.
Проверить, что параметр страницы page был корректно удален при смене фильтров.
Вызвать серверную функцию генерации метаданных с асинхронными params и проверить теги SEO.
Почему хук useSearchParams() возвращает неизменяемый объект ReadonlyURLSearchParams, и каков корректный алгоритм обновления query-параметров в тестах и приложении?
router.replace() с обновленным параметром ?q=query и сохранением всех остальных активных фильтров.page=1 при смене категории товаров во избежание показа пустых страниц.router.push('/login?returnUrl=/profile') при сбросе сессии на клиенте.next/navigation#Для изоляции тестов навигации создается вспомогательная фабрика, позволяющая настраивать параметры текущего URL для каждого теста индивидуально.
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,
};
}Компонент обновляет параметр сортировки и сбрасывает пагинацию, сохраняя все остальные параметры запроса.
'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;
// Создаем изменяемую копию текущих параметров URL
const nextParams = new URLSearchParams(searchParams.toString());
if (nextSort) {
nextParams.set('sort', nextSort);
} else {
nextParams.delete('sort');
}
// При смене сортировки обязательно сбрасываем номер страницы
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">Сортировка:</label>
<select
id="sort-select"
value={currentSort}
onChange={handleSortChange}
data-testid="sort-select"
>
<option value="">По умолчанию</option>
<option value="price_asc">Сначала дешевле</option>
<option value="price_desc">Сначала дороже</option>
<option value="popular">По популярности</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('обновляет параметр sort и удаляет page при выборе новой сортировки', async () => {
const user = userEvent.setup();
// Начальный URL: /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(/сортировка/i);
expect(select).toHaveValue('');
// Пользователь выбирает сортировку "Сначала дешевле"
await user.selectOptions(select, 'price_asc');
// Проверяем, что router.push вызван с сохраненным brand, обновленным sort и удаленным page
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(
'/catalog/phones?brand=apple&sort=price_asc',
{ scroll: false }
);
});
it('удаляет параметр sort из URL, если выбрано значение по умолчанию', async () => {
const user = userEvent.setup();
// Начальный URL содержит sort
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams('brand=samsung&sort=popular') as unknown as ReturnType<typeof useSearchParams>
);
render(<CatalogSort />);
const select = screen.getByLabelText(/сортировка/i);
expect(select).toHaveValue('popular');
// Сбрасываем на "По умолчанию"
await user.selectOptions(select, '');
expect(mockPush).toHaveBeenCalledWith(
'/catalog/phones?brand=samsung',
{ scroll: false }
);
});
});generateMetadata() в Next.js 16#В Next.js 16 функция generateMetadata() принимает параметры маршрута и строки запроса в виде промисов: props: { params: Promise<T>, searchParams: Promise<U> }. Тестируем ее напрямую как серверную функцию.
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> {
// Асинхронное получение параметров в Next.js 16
const { slug } = await params;
const { preview } = await searchParams;
const article = await getArticleBySlug(slug, preview === 'true');
if (!article) {
return {
title: 'Статья не найдена | Fullstack Agentic Lab',
description: 'Запрашиваемый материал отсутствует в базе знаний.',
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>Статья {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('формирует корректные OpenGraph и Canonical метатеги для найденной статьи', async () => {
vi.mocked(articlesDal.getArticleBySlug).mockResolvedValue({
id: 'art-1',
slug: 'testing-nextjs-rsc',
title: 'Тестирование React Server Components',
summary: 'Глубокий гайд по тестированию асинхронных RSC и DAL в Next.js 16',
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('Тестирование React Server Components | Fullstack Agentic Lab');
expect(metadata.description).toBe('Глубокий гайд по тестированию асинхронных RSC и DAL в Next.js 16');
expect(metadata.alternates?.canonical).toBe('https://agenticlab.dev/articles/testing-nextjs-rsc');
// Проверяем OpenGraph протокол
expect(metadata.openGraph).toMatchObject({
title: 'Тестирование 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('возвращает noindex метатеги, если сущность не найдена', async () => {
vi.mocked(articlesDal.getArticleBySlug).mockResolvedValue(null);
const metadata = await generateMetadata({
params: Promise.resolve({ slug: 'missing-article' }),
searchParams: Promise.resolve({}),
});
expect(metadata.title).toBe('Статья не найдена | Fullstack Agentic Lab');
expect(metadata.robots).toEqual({ index: false, follow: false });
});
});Какое поведение необходимо протестировать при изменении фильтров поиска или категорий каталога относительно параметра пагинации (page)?
В клиентских компонентах Next.js навигация управляется внутренним контекстом роутера. Изменение window.location.search = '...' или window.history.pushState в тестах не активирует механизмы RSC-переходов Next.js, а в тестах без виртуального окна приведет к ошибкам. Всегда мокируйте модуль next/navigation.
Типичная ошибка при формировании ссылок фильтрации — создание нового объекта new URLSearchParams() только с одним измененным полем, что приводит к случайному удалению категорий, языковых настроек или параметров пагинации.
// ❌ ОШИБКА: Все остальные query-параметры будут стерты
const nextParams = new URLSearchParams({ sort: 'asc' });
// ✅ ПРАВИЛЬНО: Клонирование текущих параметров searchParams
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set('sort', 'asc');В Next.js 16 сигнатура generateMetadata({ params, searchParams }) передает свойства в виде промисов. Попытка деструктурировать { slug } напрямую из первого аргумента приведет к тому, что переменная получит значение undefined.
Хук useRouter() предназначен исключительно для клиентских компонентов ('use client'). В серверных компонентах перенаправления выполняются вызовом серверной утилиты redirect('/target') из next/navigation.