У сучасних веб-застосунках адресний рядок браузера (URL) виступає в ролі глобального, серіалізовного та спільного джерела істини (Single Source of Truth). Стан фільтрів каталогу, пошукової видачі, сортування таблиць та пагінації повинен синхронізуватися з параметрами рядка запиту (URLSearchParams). Це гарантує, що користувач може скопіювати посилання, надіслати його колезі або перезавантажити сторінку з повним збереженням контексту.
У Next.js 15 взаємодія з адресним рядком на клієнті здійснюється через пакет next/navigation:
useRouter(): забезпечує методи програмної навігації (push, replace, back, refresh, prefetch);useSearchParams(): повертає доступний лише для читання екземпляр ReadonlyURLSearchParams;usePathname(): надає поточний сегмент шляху маршрутизатора;useParams(): повертає параметри динамічного сегмента.На стороні сервера генерація SEO-тегів та протоколів соцмереж (OpenGraph, Twitter Cards) делегована функції generateMetadata(). У Next.js 15 аргументи 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')]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 15#У Next.js 15 функція 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 15
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 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('Тестування React Server Components | Fullstack Agentic Lab');
expect(metadata.description).toBe('Глибокий гайд із тестування асинхронних RSC та DAL у Next.js 15');
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 });
});
});У клієнтських компонентах 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 15 сигнатура generateMetadata({ params, searchParams }) передає властивості у вигляді промісів. Спроба деструктурувати { slug } напряму з першого аргументу призведе до того, що змінна отримає значення undefined.
Хук useRouter() призначений виключно для клієнтських компонентів ('use client'). У серверних компонентах перенаправлення виконуються викликом серверної утиліти redirect('/target') з next/navigation.
Задати початковий стан рядка запиту (searchParams) та шляху (pathname) через vi.mock.
Відрендерити форму пошуку або панель фільтрів за допомогою React Testing Library.
Симулювати зміну сортування, введення пошукового запиту або клік пагінації через userEvent.
Упевнитися, що цільовий URL містить очікувані query-параметри зі збереженням існуючих ключів.
Перевірити, що параметр сторінки page був коректно видалений при зміні фільтрів.
Викликати серверну функцію генерації метаданих з асинхронними params та перевірити теги SEO.