Тестове завдання читає жива людина, у якої на цей тиждень свої тікети. Вона відкриває репозиторій, шукає інструкцію запуску, виконує одну команду і чекає. Якщо через пʼять хвилин застосунок не піднявся, код далі читають уже з упередженням, і причина тут не в характері рецензента: непіднятий проєкт означає, що автор жодного разу не перевірив свою ж інструкцію на чистій машині.
Далі про те, що відбувається з вашим кодом після відправки, і як зробити так, щоб його дочитали до кінця.
Що насправді оцінюють
Питання, на яке рецензент шукає відповідь, приблизно таке: чи хочу я супроводжувати цей код через три місяці разом з автором. Звідси всі критерії.
Почнемо з меж. Чи HTTP-шар займається HTTP (валідація входу, формат відповіді, коди статусів), а логіка живе в класі, який можна викликати з консолі, з черги і з тесту без жодного Request. Якщо вся задача розміщена в методі контролера на двісті рядків, читати далі нічого: рівень уже видно.
Наступне питання: як код поводиться, коли все погано. Зовнішнє API відповіло 500, CSV прийшов з BOM і зайвою колонкою, у полі суми пробіл замість цифри. Більшість тестових завдань саме про це, навіть коли в умові про це не сказано ні слова.
І тести: чи перевіряють вони поведінку. Десять тестів на гетери й сетери гірші за два тести на межові умови, бо вони показують, що автор писав тести для галочки.
Ось типовий фрагмент, з якого видно все одразу. Умова на кшталт «забери курси з публічного API і покажи список» зустрічається часто, і рецензент дивиться рівно на три речі: таймаут, поведінку при відмові джерела і те, чи масив з відповіді розповзається далі по застосунку.
final readonly class RateClient
{
public function __construct(private PendingRequest $http) {}
/**
* @return list<Rate>
*
* @throws RateSourceUnavailable
*/
public function ratesFor(DateTimeImmutable $day): array
{
$response = $this->http
->timeout(5)
->retry(2, 200, throw: false)
->get('/exchange', ['date' => $day->format('Ymd'), 'json' => '']);
if ($response->failed()) {
throw new RateSourceUnavailable($response->status());
}
return array_map(
static fn (array $row): Rate => Rate::fromSource($row),
$response->json(),
);
}
}
П'ять секунд таймауту замість дефолтного очікування назавжди, дві повторні спроби, явний виняток замість null, і сирий масив перетворюється на Rate на межі, а не через три шари. Тут немає нічого складного, але саме цього в присланих рішеннях зазвичай і бракує.
Чого не оцінюють: власного DI-контейнера, абстрактної фабрики над єдиною реалізацією, шести шарів і чотирьох інтерфейсів на задачу «список з фільтром». Over-engineering у тестовому читається так само погано, як його відсутність, бо це теж помилка оцінки задачі.
Скільки часу витрачати і як про це сказати
Бюджет треба зафіксувати до того, як ви відкрили редактор. Написано «приблизно чотири години» - плануйте чотири-шість і на цьому зупиняйтесь. Тиждень полірування дає зворотний ефект: людина, яка робила два дні те, що закладали на вечір, на робочих оцінках буде помилятися так само.
Найкорисніше, що можна зробити з цим бюджетом, це написати про нього в README окремим розділом. Формулювання просте:
## Обсяг і компроміси
Витрачено близько 5 годин. Що свідомо не зроблено:
- кешування відповідей джерела: у ТЗ немає вимог до частоти запитів,
місце під кеш винесено в `RateRepository`;
- авторизація: усі ендпоінти публічні, бо в умові немає користувачів;
- черга для імпорту: синхронний виклик, запуск через `rates:import`.
Такий розділ знімає половину питань на дзвінку і перетворює «не зробив» у «вирішив не робити». Рецензент майже завжди читає його першим після команди запуску.
Перед стартом варто ще й уточнити дві речі листом у два рядки: який приблизний обсяг очікують і хто саме дивитиме результат. Якщо на це немає відповіді, ви робите завдання наосліп.
Структура: README, запуск однією командою, тести
README має відповідати на чотири питання і не більше: що це, як підняти, як запустити тести, що не зроблено. Порядок саме такий.
Запуск робиться однією командою. .env.example лежить у репозиторії з робочими значеннями під docker-мережу, порти або не пробиті назовні, або пробиті нестандартні, щоб не конфліктувати з тим, що вже крутиться на машині рецензента.
"scripts": {
"setup": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate --ansi",
"@php artisan migrate --seed --force"
],
"test": "vendor/bin/pest --parallel",
"check": [
"vendor/bin/pint --test",
"vendor/bin/phpstan analyse",
"@test"
]
}
З docker compose up -d --wait і composer setup у README ви отримуєте два рядки для копіювання. Якщо стек простіший, php -S localhost:8000 -t public плюс SQLite-файл теж повністю нормальний вибір: ніхто не додає балів за Kubernetes у тестовому.
Перевірте інструкцію на чистій копії. git clone у новий каталог, без vendor, без .env, без вашого локального Redis. Це п'ять хвилин, і саме тут відсіюється найбільше рішень.
Тести. Три-чотири штуки, які перевіряють правила з умови, дають більше, ніж двадцять на автогенерованих фабриках. Один з них обовʼязково має бути на сценарій відмови:
it('fails loudly when the rate source is down', function () {
Http::fake(['*' => Http::response('', 500)]);
expect(fn () => app(RateClient::class)->ratesFor(new DateTimeImmutable('2026-09-01')))
->toThrow(RateSourceUnavailable::class);
});
Зовнішня мережа в тестах підмінена, тест зелений на машині без інтернету, назва тесту описує правило. Pest 5 і PHPUnit тут рівноцінні, вибирайте те, що краще знаєте: рецензент дивиться на зміст перевірок, а не на синтаксис.
Історію комітів теж читають. Десять осмислених комітів по кроках задачі читаються як хід думки; один коміт init на дві тисячі рядків не читається ніяк.
Типові причини відмови
Найчастіше рішення закривають з таких причин, приблизно в порядку частоти.
Проєкт не піднімається за інструкцією: у .env.example хост 127.0.0.1 замість імені сервісу, міграції падають на порожній базі, у README забута команда встановлення залежностей.
Зроблено не те, що просили. В умові був фільтр за трьома полями, у рішенні два, бо третій «здався неважливим». Це читається як невміння працювати з вимогами, і виправити на дзвінку вже нічого не можна.
Проглочені винятки. Конструкція, яку я бачу в тестових регулярно:
try {
$this->importer->run($file);
} catch (\Throwable) {
// ignore
}
Тут одразу два висновки: автор не думав про сценарії відмови і в продакшені такий імпорт «працюватиме» назавжди, тихо втрачаючи дані.
Гроші у float. 0.1 + 0.2 !== 0.3 у PHP, як і всюди, тому суми тримають у мінімальних одиницях цілим числом або в decimal на рівні бази. Це одна з тих дрібниць, яку senior помічає за секунду.
N+1 у головному ендпоінті задачі. Якщо завдання про список замовлень з клієнтами, запит у циклі шаблону побачать одразу, бо саме це в тому завданні і перевіряли.
Секрети в репозиторії. Реальний .env з ключами, дамп бази з чиїмись даними, токен у коді. Після цього вже неважливо, як написана логіка.
Відсутність типів у 2026 році. PHP 8.4, declare(strict_types=1), типізовані властивості й повернення давно стали базовою гігієною, а array як тип для всього означає, що PHPStan на проєкті ніколи не запускали.
Коли від тестового варто відмовитись
Тестове завдання це витрата вашого вечора, тож право не погоджуватись у вас є, і користуватись ним нормально.
Відмовляйтесь, коли завдання виглядає як робоча задача: інтеграція з їхнім конкретним платіжним провайдером, парсер фіду їхнього конкретного постачальника, виправлення бага в їхньому приватному репозиторії. Безкоштовна робота під виглядом перевірки навичок трапляється, і виглядає вона саме так.
Відмовляйтесь, коли обсяг не обмежений: «зробіть застосунок з авторизацією, адмінкою, тестами й деплоєм» без жодної згадки про час. Спитайте, у який бюджет годин це закладали. Немає відповіді - немає завдання.
Відмовляйтесь і тоді, коли тестове прислали до будь-якої розмови та без вилки. Ви не знаєте ні про гроші, ні про стек, ні про проєкт, а вкласти маєте вечір.
Майте що запропонувати замість. Зазвичай працює одне з двох: жива сесія на годину, де ви пишете код разом з їхнім розробником, або розбір вашого публічного репозиторію з поясненням рішень. Обидва варіанти дають рецензенту більше, ніж домашній код, бо там видно, як ви думаєте, а не тільки що ви здали.
Останні пів години перед відправкою
Клонуйте власний репозиторій у чистий каталог і пройдіть README буквально. Запустіть composer check. Перечитайте умову рядок за рядком і поставте галочку напроти кожного пункту. Додайте до README розділ про компроміси. Перевірте git log на сміття і git grep на випадкові ключі.
Це приблизно двадцять хвилин, і вони впливають на результат сильніше, ніж ще одна абстракція в коді.