Найпоширеніша помилка у 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 рядок <meta name="robots"..., ніколи його не знайде і тест впаде на цілком коректній сторінці. 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-тесту:
- Стверджуйте про відповідь: статус, заголовки, canonical, robots, JSON-LD, видимий текст.
- Не стверджуйте про реалізацію: класи, методи, кількість викликів. Мокайте лише зовнішній світ (HTTP до третіх сервісів, черги, час).
- Комбінаторику правил виносьте в юніт-тести доменного класу, у feature залиште по одному представнику на клас поведінки.
- Структуровані дані парсіть, а не шукайте підрядком.
- Пишіть негативні перевірки там, де зайве гірше за відсутнє: sitemap, canonical, індексація.
- Заводьте дані тим самим шляхом, що й продакшен.
Про те, як тими ж засобами закріпити межі шарів, є окрема стаття: Архітектурні тести в Pest.