<? phpukraine СТАТТІ
Пошук по платформі
ТЕСТУВАННЯ 4 вересня 2026 · 7 хв читання

Feature-тести HTTP у Pest: перевіряти сторінку, а не реалізацію

Feature-тест має відповідати на питання «що отримає браузер і краулер», а не «які методи викликав контролер». Розбираємо на реальних тестах phpukraine: canonical, meta robots, JSON-LD і sitemap — що перевіряти в HTTP-тесті, що винести в юніт і як не переписувати тести після кожного рефакторингу вʼю.

РP
Редакція phpukraine
Редакція платформи

Найпоширеніша помилка у feature-тестах — писати їх як юніт-тести контролера. Мокати репозиторій, перевіряти, що викликався потрібний метод, потім рефакторити контролер і півдня чинити тести, які нічого не ловили. Feature-тест на HTTP має інший предмет: він перевіряє контракт сторінки. Статус, заголовки відповіді, метадані для краулера, ключові рядки, які бачить користувач. Усе, що всередині, — вільна зона для рефакторингу.

Нижче — як це виглядає на phpukraine (PHP 8.3+, Laravel 13, Pest 5). Найцікавіші приклади саме в SEO-метаданих, бо це той рідкісний випадок, коли «зовнішній вигляд сторінки» — не питання смаку, а формальний контракт із Google.

Контракт сторінки: що взагалі варто стверджувати

Сторінка каталогу вакансій має віддавати чотири речі, які ніхто не побачить очима, але за які платить трафік:

  • HTTP-статус: 200 для курованого лендінга, 404 для вигаданого, 301 для застарілого слага;
  • <link rel="canonical"> з правильною адресою;
  • <meta name="robots"> з index або noindex;
  • JSON-LD зі структурованими даними.

Ось як це записано в tests/Feature/Http/JobsPagesTest.php:

it('serves a curated landing with its own copy and a thin one as noindex pointing at the parent', function () {
    SeedsJobs::laravelJobs(5);

    $this->get('/jobs/laravel')
        ->assertOk()
        ->assertSee('Laravel вакансії в Україні')
        ->assertSee('<meta name="robots" content="index, follow">', false)
        ->assertSee('<link rel="canonical" href="http://localhost:8000/jobs/laravel">', false);

    $this->get('/jobs/symfony')
        ->assertOk()
        ->assertSee('<meta name="robots" content="noindex, follow">', false)
        ->assertSee('<link rel="canonical" href="http://localhost:8000/jobs">', false);

    $this->get('/jobs/laravel/kyiv')->assertNotFound();
});

Два моменти, на яких спотикаються майже всі.

Другий аргумент assertSee. За замовчуванням assertSee($value) екранує значення через e() перед пошуком, тому assertSee('<meta name="robots" ...>') шукатиме в HTML рядок &lt;meta name=&quot;robots&quot;..., ніколи його не знайде і тест впаде на цілком коректній сторінці. assertSee($value, false) вимикає екранування. Для перевірки тегів завжди потрібне false.

Абсолютний URL у canonical. Canonical за специфікацією абсолютний, тому в тесті зʼявляється http://localhost:8000. Це не милиця: значення береться з config('app.url'), а APP_URL у тестовому оточенні фіксований. Побічний ефект приємний — якщо хтось випадково зламає генерацію базового URL, тест на canonical впаде першим, ще до продакшену.

Зверніть увагу, чого в тесті немає. Немає перевірки, який саме клас репозиторію викликано, скільки було запитів до бази і як називається метод контролера. /jobs/laravel можна переписати з контролера на Livewire-компонент — тест не помітить різниці, і це правильно.

Правило — в юніт, проводку — у feature

Спокуса написати feature-тест на кожну комбінацію «скільки вакансій → індексувати чи ні» велика. І це погана ідея: кожен такий тест піднімає HTTP-кернел, малює Blade і йде в базу, а перевіряє одну гілку if.

На phpukraine рішення про індексацію живе в доменному класі PhpUkraine\Seo\Domain\IndexationPolicy — чистий PHP, жодного Laravel. Усі гілки покриті юніт-тестами:

it('sends a thin landing to its parent, never to itself', function () {
    $decision = (new IndexationPolicy)->forListing(
        '/jobs/laravel/remote',
        '/jobs/laravel',
        hasExtraFilters: false,
        resultCount: 3,
        threshold: 5,
    );

    expect($decision->index)->toBeFalse()
        ->and($decision->canonicalPath)->toBe('/jobs/laravel')
        ->and($decision->reason)->toBe(IndexReason::BelowThreshold)
        ->and($decision->robots())->toBe('noindex, follow');
});

Такий тест виконується за мікросекунди, і в ньому видно саме правило: тонкий зріз канонікалізується на батька, а не на себе. Feature-тестів тоді потрібно рівно два — один на індексовану сторінку, один на неіндексовану. Вони перевіряють не політику, а проводку: що рішення справді дійшло до <head>, а не загубилося у вʼю.

Це загальне правило поділу: комбінаторику — в юніт, інтеграцію — у feature. Feature-тест дорогий, тому в ньому має бути по одному представнику кожного класу поведінки, а не декартів добуток параметрів.

Окремий випадок, який варто перевіряти саме на HTTP-рівні, — фільтри поверх лендінга. Тут задіяні і роутинг, і читання query-параметрів, і політика:

it('treats visitor filters as a noindex view canonicalised to the nearest landing', function () {
    SeedsJobs::laravelJobs(6);

    $this->get('/jobs?framework=laravel&english=b2')
        ->assertOk()
        ->assertSee('<meta name="robots" content="noindex, follow">', false)
        ->assertSee('<link rel="canonical" href="http://localhost:8000/jobs/laravel">', false)
        ->assertSee('Англійська B2');
});

Структуровані дані: парсити, а не шукати підрядок

Найпростіший спосіб перевірити JSON-LD — assertSee('"@type":"JobPosting"', false). Він працює, поки не зміниться JSON_UNESCAPED_SLASHES, порядок ключів чи пробіли після двокрапки. Тест впаде на зміні прапорців json_encode, хоча розмітка залишиться валідною.

Надійніше витягнути скрипт і розібрати його. Хелпер живе в tests/Pest.php і доступний усім feature-тестам:

/**
 * @return list<array<string, mixed>>
 */
function jsonLd(Illuminate\Testing\TestResponse $response): array
{
    preg_match_all(
        '~<script type="application/ld\+json">(.*?)</script>~s',
        (string) $response->getContent(),
        $matches,
    );

    return array_map(
        static fn (string $json): array => json_decode($json, true, flags: JSON_THROW_ON_ERROR),
        $matches[1],
    );
}

Далі тест стверджує про дані, а не про форматування:

it('describes a remote job the way Google for Jobs expects', function () {
    SeedsJobs::seed([[
        'title' => 'Senior Laravel Engineer',
        'company' => 'Genesis',
        'id' => 'j1',
        'salary' => [5000, 7000],
        'remote' => true,
        'cities' => [],
    ]]);

    $response = $this->get($this->firstJobPath())->assertOk();

    $posting = collect(jsonLd($response))->firstWhere('@type', 'JobPosting');

    expect($posting)->not->toBeNull()
        ->and($posting['title'])->toBe('Senior Laravel Engineer')
        ->and($posting['hiringOrganization']['name'])->toBe('Genesis')
        ->and($posting['jobLocationType'])->toBe('TELECOMMUTE')
        ->and($posting['baseSalary']['currency'])->toBe('USD')
        ->and($posting['baseSalary']['value']['minValue'])->toBe(5000)
        ->and($posting)->not->toHaveKey('jobLocation');
});

JSON_THROW_ON_ERROR тут не косметика: якщо в опис вакансії потрапить щось, що ламає кодування, тест впаде з осмисленою помилкою замість тихого null. А останній рядок — приклад найкориснішого типу перевірки в структурованих даних: віддалена вакансія не повинна мати jobLocation, інакше Google показує її в пошуку по місту, у якому її немає.

Негативні перевірки коштують дешево і ловлять найдорожче

Sitemap — сторінка, у якій помилка «зайвий рядок» дорожча за помилку «немає рядка»: віддати краулеру тисячу тонких сторінок гірше, ніж не віддати десять хороших. Тому тест на sitemap написаний навпаки — переважно через заперечення:

it('publishes a sitemap of indexable urls only', function () {
    SeedsJobs::laravelJobs(5);
    SeedsJobs::seed([['title' => 'Symfony Developer', 'company' => 'Corefy', 'id' => 'sf1']]);

    $xml = (string) $this->get('/sitemap.xml')
        ->assertOk()
        ->assertHeader('Content-Type', 'application/xml; charset=UTF-8')
        ->getContent();

    expect($xml)->toContain('<loc>http://localhost:8000/jobs/laravel</loc>')
        ->and($xml)->not->toContain('<loc>http://localhost:8000/jobs/symfony</loc>');
});

П'ять Laravel-вакансій — рівно поріг, одна Symfony — нижче порога. Сторінка /jobs/symfony існує і відповідає 200, але в sitemap її бути не повинно. Один тест фіксує узгодженість двох незалежних місць: політики індексації і генератора sitemap. Розсинхрон між ними — типова причина того, що в Search Console зʼявляються сотні «Crawled — currently not indexed».

Так само дешево перевіряються заголовки, які легко втратити при зміні middleware:

$this->get('/')
    ->assertHeader('X-Content-Type-Options', 'nosniff')
    ->assertHeader('X-Frame-Options', 'SAMEORIGIN')
    ->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

Дані заводьте тим самим шляхом, що й продакшен

Остання, але найважливіша для стабільності річ. Спокуса заповнити базу через JobModel::factory() величезна, але тоді тест перевіряє сторінку на даних, які продакшен ніколи не створює: без нормалізованої компанії, без визначеного фреймворку, з полями в станах, недосяжних через реальний імпорт.

На phpukraine хелпер Tests\Support\SeedsJobs заводить вакансії через реальний use case SyncJobsFromSource і фейковий ArraySource. Той самий код, що обробляє вивантаження з джерела, працює і в тесті — просто джерело віддає масив замість HTTP-відповіді. Тому SeedsJobs::laravelJobs(5) дає рівно ті дані, які були б у базі після синхронізації, включно з класифікацією фреймворку, від якої залежить лендінг /jobs/laravel.

Для статей той самий принцип виглядає ще прямолінійніше — у beforeEach викликається справжня консольна команда:

beforeEach(function () {
    $this->artisan('content:import')->assertSuccessful();
});

Ціна — секунда на старті файлу. Вигода — feature-тести бачать реальний контент з реальними тегами, змістом і підсвіченим кодом, а не набір fake()->sentence(). І та сама команда додатково перевіряється на ідемпотентність: другий запуск має звітувати 0 written.


Коротко, чек-лист для HTTP feature-тесту:

  1. Стверджуйте про відповідь: статус, заголовки, canonical, robots, JSON-LD, видимий текст.
  2. Не стверджуйте про реалізацію: класи, методи, кількість викликів. Мокайте лише зовнішній світ (HTTP до третіх сервісів, черги, час).
  3. Комбінаторику правил виносьте в юніт-тести доменного класу, у feature залиште по одному представнику на клас поведінки.
  4. Структуровані дані парсіть, а не шукайте підрядком.
  5. Пишіть негативні перевірки там, де зайве гірше за відсутнє: sitemap, canonical, індексація.
  6. Заводьте дані тим самим шляхом, що й продакшен.

Про те, як тими ж засобами закріпити межі шарів, є окрема стаття: Архітектурні тести в Pest.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті