<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ТЕСТУВАННЯ · ІНСТРУМЕНТИ

Pest

Тестовий фреймворк, у якому тест пишеться функцією, а перевірка читається як речення. Під капотом той самий PHPUnit, тож старі класові тести нікуди не діваються.

11 хв читання Junior → Middle
01 · ЩО ЦЕ

PHPUnit, яким зручно писати

Pest не має власного рушія. Кожен файл з викликами it() і test() він перетворює на клас, що успадковує , і віддає його : у цьому проєкті Pest 5 працює на PHPUnit 13. Тому конфіг лишається в phpunit.xml, а звичайний клас з методами test_* запуститься поруч з тестами Pest без жодної зміни. Переходити можна по одному файлу.

СЛОВНИКTestCase. Базовий клас PHPUnit, від якого успадковується кожен тест. Pest генерує такий клас для кожного файлу через eval(), а ваші замикання стають його методами.
СЛОВНИКPHPUnit. Стандартний тестовий фреймворк PHP Себастіана Бергмана (Sebastian Bergmann). Pest використовує його раннер, конфіг phpunit.xml, асерти й звіти про покриття.

Перевірка починається з expect($value), за яким іде ланцюжок : toBe(), toHaveCount(), toThrow(). Метод ->and($other) переводить ланцюжок на нове значення, а ->not заперечує лише наступну перевірку. Так кілька повʼязаних перевірок читаються одним реченням.

СЛОВНИКExpectation. Обʼєкт, який повертає expect(). Кожен його метод перевіряє значення й повертає той самий обʼєкт, тож перевірки йдуть ланцюжком.

Коли той самий тест треба прогнати на десятку входів, його не копіюють, а дають йому : масив прямо в ->with(), іменований dataset() у тому самому файлі або спільний з теки tests/Datasets, який бачать усі тести. Спільну підготовку виносять у beforeEach() і afterEach(), а describe() збирає тести одного класу в групу зі своїми хуками.

СЛОВНИКDataset. Набір вхідних даних для одного тесту. Pest запускає тест окремо для кожного рядка й показує ключ рядка у виводі.
СЛОВНИКХук. Функція, яку Pest викликає навколо тестів: beforeEach() перед кожним тестом файлу чи групи, afterEach() після нього, beforeAll() і afterAll() раз на файл.

Те, що в PHPUnit писали в базовому класі, у Pest живе у tests/Pest.php: pest()->extend(TestCase::class) каже, від якого класу успадковуються тести з теки, ->use() додає трейти на кшталт RefreshDatabase, а ->in('Feature') обмежує це однією текою. Звідси ж беруться $this->get() і $this->actingAs() у feature-тестах Laravel.

Крім unit- і feature-тестів, Pest уміє ще два види. перевіряє не поведінку, а залежності коду: arch()->expect('App\Domain')->not->toUse('Illuminate'). Браузерні тести живуть в окремому плагіні pestphp/pest-plugin-browser на Playwright: visit('/jobs')->click('Відгукнутися') відкриває справжній браузер. А з прапорців варто знати чотири: --parallel ділить тести між процесами, --profile показує десять найповільніших, --mutate вмикає мутаційне тестування для класів, названих у covers() чи mutates(), а --dirty запускає лише тести з незакоміченими змінами.

СЛОВНИКarch(). Архітектурний тест Pest. Він не запускає код, а читає, які класи й функції використовує простір імен, і падає, якщо домен тягне фреймворк чи хтось лишив dd().
02 · ВАРІАНТИ

Один тест, шість записів

SalaryParser з цього сайту дістає зарплату з назви вакансії: «PHP Developer, 3000-4500$» дає від 3000 до 4500 доларів. Перемикайте вкладки: сценарій той самий, міняється лише те, як його записано. Усі шість варіантів перевірено в Pest 5 на цьому проєкті.

php SalaryParserTest.php PHPUnit
1use PhpUkraine\Hiring\Domain\Classification\SalaryParser;
2use PHPUnit\Framework\TestCase;
3
4final class SalaryParserTest extends TestCase
5{
6 public function test_it_parses_a_range(): void
7 {
8 $salary = (new SalaryParser)->parse('PHP Developer, 3000-4500$');
9
10 $this->assertNotNull($salary);
11 $this->assertSame(3000, $salary->from);
12 $this->assertSame(4500, $salary->to);
13 }
14}
ЩО ПЕРЕВІРЯЄ
Те саме, що й решта вкладок, але класом PHPUnit. vendor/bin/pest знаходить і запускає його так само, як тести на функціях.
КОЛИ БРАТИ
Старий код, який ще не переписали, або команда, що звикла до класів. Переписувати такі тести заради Pest не обовʼязково.
php SalaryParserTest.php expect()
1it('parses a range', function () {
2 $salary = (new SalaryParser)->parse('PHP Developer, 3000-4500$');
3
4 expect($salary)->not->toBeNull()
5 ->and($salary->from)->toBe(3000)
6 ->and($salary->to)->toBe(4500);
7});
ЩО ПЕРЕВІРЯЄ
Три перевірки одним ланцюжком. ->not заперечує лише toBeNull(), а ->and() перемикає ланцюжок спершу на from, потім на to.
КОЛИ БРАТИ
Типовий тест на Pest: один сценарій, кілька повʼязаних перевірок результату.
php SalaryParserTest.php Dataset
1it('parses a range', function (string $text, int $from, int $to) {
2 $salary = (new SalaryParser)->parse($text);
3
4 expect($salary->from)->toBe($from)
5 ->and($salary->to)->toBe($to);
6})->with([
7 'через дефіс' => ['PHP Developer, 3000-4500$', 3000, 4500],
8 'тисячі через k' => ['Symfony Developer (3.5k–5k USD)', 3500, 5000],
9 'гривні' => ['WordPress Developer, 40 000 – 60 000 грн', 40000, 60000],
10]);
ЩО ПЕРЕВІРЯЄ
Той самий тест тричі, по разу на рядок. Ключ рядка потрапляє у вивід: it parses a range with dataset "гривні", тож одразу видно, який випадок упав.
КОЛИ БРАТИ
Одна логіка й багато входів: парсери, валідація, правила округлення. Новий випадок додається рядком, а не новим тестом.
php SalaryParserTest.php Спільний dataset
1// tests/Datasets/Salaries.php
2dataset('ranges', [
3 'через дефіс' => ['PHP Developer, 3000-4500$', 3000, 4500],
4 'гривні' => ['WordPress Developer, 40 000 – 60 000 грн', 40000, 60000],
5]);
6
7// tests/Unit/SalaryParserTest.php
8it('parses a range', function (string $text, int $from, int $to) {
9 expect((new SalaryParser)->parse($text))
10 ->from->toBe($from)
11 ->to->toBe($to);
12})->with('ranges');
ЩО ПЕРЕВІРЯЄ
Дані винесено в окремий файл під імʼям ranges, і будь-який тест бере їх через ->with('ranges'). Заодно видно ще один прийом: ->from і ->to переводять expect() на властивості обʼєкта.
КОЛИ БРАТИ
Той самий набір прикладів потрібен кільком тестам або файлам. Якщо набір потрібен лише одному файлу, dataset() можна оголосити прямо в ньому.
php SalaryParserTest.php describe()
1describe('SalaryParser', function () {
2 beforeEach(function () {
3 $this->parser = new SalaryParser;
4 });
5
6 it('parses a range', function () {
7 expect($this->parser->parse('PHP Developer, 3000-4500$')->from)->toBe(3000);
8 });
9
10 it('ignores text without a salary', function () {
11 expect($this->parser->parse('Senior PHP Developer (Kyiv, remote)'))->toBeNull();
12 });
13});
ЩО ПЕРЕВІРЯЄ
Два тести в групі. beforeEach() створює парсер перед кожним із них заново, а у виводі назви стають «SalaryParser → it parses a range».
КОЛИ БРАТИ
Тести одного класу чи методу ділять підготовку. Хук усередині describe() діє лише на цю групу, а не на весь файл.
php SalaryParserTest.php Higher-order
1it('ignores text without a salary')
2 ->expect(fn () => (new SalaryParser)->parse('Senior PHP Developer (Kyiv, remote)'))
3 ->toBeNull();
ЩО ПЕРЕВІРЯЄ
Тест без замикання-тіла: expect() з функцією й перевірка викликаються прямо на it(). Функцію Pest викличе вже під час тесту.
КОЛИ БРАТИ
Тест в один рядок, де тіло лише загортає expect(). Щойно зʼявляється підготовка, повертайтеся до звичайного замикання.
03 · КОД

Маленький Pest своїми руками

PHP 8.5
1<?php
2
3final class ExpectationFailed extends Exception {}
4
51final class Expectation
6{
7 private bool $negate = false;
8
9 public function __construct(private mixed $value) {}
10
11 // ->not без дужок: властивість, яка перемикає наступну перевірку.
12 public function __get(string $name): self
13 {
14 if ($name !== 'not') {
15 throw new LogicException("Невідома властивість {$name}");
16 }
17 $this->negate = true;
18
19 return $this;
20 }
21
22 public function toBe(mixed $expected): self
23 {
24 return $this->check($this->value === $expected, 'identical to '.json_encode($expected));
25 }
26
27 public function toBeNull(): self
28 {
29 return $this->check($this->value === null, 'null');
30 }
31
322 // ->and() починає новий ланцюжок для іншого значення.
33 public function and(mixed $value): self
34 {
35 return new self($value);
36 }
37
383 private function check(bool $passed, string $what): self
39 {
40 if ($passed === $this->negate) {
41 $is = $this->negate ? 'is not' : 'is';
42 throw new ExpectationFailed('Failed asserting that '.json_encode($this->value)." {$is} {$what}.");
43 }
44 $this->negate = false;
45
46 return $this;
47 }
48}
49
50function expect(mixed $value): Expectation
51{
52 return new Expectation($value);
53}
54
554// Кожен рядок датасету: окремий запуск того самого тіла тесту.
56function it(string $name, Closure $test, array $dataset = [[]]): void
57{
58 foreach ($dataset as $key => $arguments) {
59 $label = is_string($key) ? "{$name} with dataset \"{$key}\"" : $name;
60 try {
61 $test(...$arguments);
62 echo " ✓ it {$label}\n";
63 } catch (ExpectationFailed $e) {
64 echo " ⨯ it {$label}\n {$e->getMessage()}\n";
65 }
66 }
67}
68
69function parseSalary(string $text): ?array
70{
71 if (preg_match('/(\d+)\s*-\s*(\d+)/', $text, $m) !== 1) {
72 return null;
73 }
74
75 return ['from' => (int) $m[1], 'to' => (int) $m[2]];
76}
77
785it('parses a range', function (string $text, int $from, int $to) {
79 $salary = parseSalary($text);
80
81 expect($salary)->not->toBeNull()
82 ->and($salary['from'])->toBe($from)
83 ->and($salary['to'])->toBe($to);
84}, [
85 'через дефіс' => ['PHP Developer, 3000-4500$', 3000, 4500],
86 'з пробілами' => ['Laravel Developer, 2500 - 3500 USD', 2500, 3500],
87 'тисячі через k' => ['Symfony Developer (3.5k-5k USD)', 3500, 5000],
88]);
89
90it('ignores text without a salary', function () {
91 expect(parseSalary('Senior PHP Developer (Kyiv, remote)'))->toBeNull();
92});
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 5–20

    expect() повертає обʼєкт, а ->not це магічна властивість через __get(): вона лише ставить прапорець для наступної перевірки.

  2. 2 рядки 32–36

    ->and() не перевіряє нічого, а повертає новий обʼєкт для іншого значення. Тому ланцюжок і читається як одне речення.

  3. 3 рядки 38–47

    Перевірка кидає виняток, а прапорець not після неї скидається. У Pest так само: ->not діє лише на один крок.

  4. 4 рядки 55–67

    Датасет це цикл: тіло тесту викликається з кожним рядком як з аргументами, а ключ рядка йде в назву.

  5. 5 рядки 78–88

    Третій рядок падає: наївний парсер не розуміє «3.5k». Справжній SalaryParser цей випадок знає, а датасет одразу показує, який саме вхід зламався.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ЯК У PHP UKRAINE

tests/Pest.php і датасет для SalaryParser

Увесь наш tests/Pest.php займає кілька рядків: feature-тести успадковують Tests\TestCase і отримують RefreshDatabase, а unit-тести лишаються голими й не чіпають бази. Нижче тест SalaryParser з тим самим прийомом, що й у вкладці Dataset: тринадцять назв вакансій, записаних так, як їх пишуть на джоб-бордах, проганяють один тест, і кожен рядок у виводі звітує окремо.

php SalaryParserTest.php фрагмент, скорочено
1it('parses salary ranges written the way job boards write them', function (string $text, ?int $from, ?int $to, Currency $currency) {← аргументи з рядка датасету
2 $salary = (new SalaryParser)->parse($text);
3
4 expect($salary)->not->toBeNull()← not лише для toBeNull()
5 ->and($salary->from)->toBe($from)
6 ->and($salary->to)->toBe($to)
7 ->and($salary->currency)->toBe($currency);
8})->with([
9 ['Senior Laravel Engineer ($5 000 – 7 000)', 5000, 7000, Currency::Usd],← один рядок, один запуск
10 ['Backend Developer, від $4000', 4000, null, Currency::Usd],
11 ['Junior PHP до $1 200', null, 1200, Currency::Usd],
12 ['Symfony Developer (3.5k–5k USD)', 3500, 5000, Currency::Usd],
13 ['WordPress Developer, 40 000 – 60 000 грн', 40000, 60000, Currency::Uah],
14 ['Laravel Developer, €3 000 – 4 200', 3000, 4200, Currency::Eur],
15]);
16
17it('returns null when there is no believable salary', function (string $text) {← негативні випадки окремим тестом
18 expect((new SalaryParser)->parse($text))->toBeNull();
19})->with([
20 'Senior PHP Developer (Kyiv, remote)',
21 'Бюджет на навчання $3 на рік',
22 '$999 999 per month',
23]);
05 · ТИПОВІ ПОМИЛКИ

Де Pest підставляє ногу

✕ 1

Модель прямо в масиві датасету

->with([User::factory()->create()]) падає ще до запуску, і то з невиразною помилкою DatasetMissing. Вираз у масиві PHP виконує, щойно читає файл тестів, а застосунку й бази в цей момент ще немає.

Натомість: загорніть значення в замикання: ->with([fn () => User::factory()->create()]). Pest викличе його вже всередині тесту, після setUp() і RefreshDatabase.

✕ 2

Думати, що ->not діє на весь ланцюжок

expect($ids)->not->toBeEmpty()->toContain(42) не означає «не порожній і не містить 42». ->not стосується лише першої перевірки, друга знову звичайна.

Натомість: ставте ->not перед кожною перевіркою, яку заперечуєте, а довгий ланцюжок з кількома ->not краще розбийте на окремі expect().

✕ 3

RefreshDatabase для всіх тестів

pest()->extend(TestCase::class)->use(RefreshDatabase::class) без ->in() вмикає Laravel і міграції навіть для unit-тестів домену. Вони стають помітно повільнішими, а залежність від бази ховається.

Натомість: обмежте це текою: ->in('Feature'), як у tests/Pest.php цього сайту. Unit-тести тоді не піднімають фреймворк і йдуть за мілісекунди.

✕ 4

skip() назавжди

Тест вимкнули через ->skip(), щоб злити гілку, і він місяцями висить жовтим. Набір зелений, а сценарій ніхто не перевіряє.

Натомість: для запланованого пишіть todo('...'), а для вимкненого вказуйте причину: ->skip('чекає на API Нової пошти'). vendor/bin/pest --todos покаже весь список.

06 · СЛОВНИК
TestCase
Базовий клас PHPUnit, від якого успадковується кожен тест. Pest генерує такий клас для кожного файлу через eval(), а ваші замикання стають його методами.
PHPUnit
Стандартний тестовий фреймворк PHP Себастіана Бергмана (Sebastian Bergmann). Pest використовує його раннер, конфіг phpunit.xml, асерти й звіти про покриття.
Expectation
Обʼєкт, який повертає expect(). Кожен його метод перевіряє значення й повертає той самий обʼєкт, тож перевірки йдуть ланцюжком.
Dataset
Набір вхідних даних для одного тесту. Pest запускає тест окремо для кожного рядка й показує ключ рядка у виводі.
arch()
Архітектурний тест Pest. Він не запускає код, а читає, які класи й функції використовує простір імен, і падає, якщо домен тягне фреймворк чи хтось лишив dd().
Хук
Функція, яку Pest викликає навколо тестів: beforeEach() перед кожним тестом файлу чи групи, afterEach() після нього, beforeAll() і afterAll() раз на файл.
07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з тестування →

->and($value) не перевіряє нічого сам, а переводить ланцюжок на нове значення, щоб наступні перевірки стосувалися його. ->not заперечує лише одну наступну перевірку: expect($x)->not->toBeNull()->toBe(5) означає «не null і дорівнює 5».

Pest це надбудова: з кожного файлу з it() він генерує клас, що успадковує TestCase, і запускає його раннером PHPUnit з конфігом phpunit.xml. Тому асерти PHPUnit працюють і в Pest, а клас з методами test_* у тій самій теці запускається без змін. На Pest можна переходити по одному файлу.

Масив датасету PHP обчислює, коли Pest завантажує файл і реєструє тести, тобто до setUp(): застосунок ще не піднято, а транзакції RefreshDatabase немає. Замикання Pest викликає вже всередині тесту, тож модель створюється у свіжій базі й відкочується разом з тестом.

Тести починають іти в кількох процесах одночасно, і все спільне стає гонкою: одна база, ті самі файли, кеш, порти, статичні змінні, порядок тестів. Laravel частину цього бере на себе: для кожного процесу створює окрему тестову базу з суфіксом _test_N і окрему теку для Storage::fake(). Решта на вас: тест не має залежати від іншого тесту чи від порядку запуску. А --profile покаже десять найповільніших тестів, з яких варто почати пришвидшення.