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

Перший unit-тест доменуFirst domain unit test

Перший тест варто писати на доменний обʼєкт: він живе в памʼяті, не знає про базу й Laravel і відповідає за мілісекунди. Підготували обʼєкт, зробили одну дію, перевірили результат.

9 хв читання Junior
01 · ЩО ЦЕ

Обʼєкт, дія, перевірка

Доменний обʼєкт тримає правила: як рахується сума замовлення, коли вакансія застаріває, чи можна скасувати оплачене. Якщо він не викликає базу, фасади й now(), його можна створити через new прямо в тесті. Такий тест не потребує міграцій, контейнера й мережі, тому йде за мілісекунди й падає лише тоді, коли змінилось саме правило.

Майже кожен unit-тест має три частини, і їх звуть . Arrange готує обʼєкт і його дані. Act робить рівно одну дію, заради якої написано тест. Assert перевіряє результат. Порожній рядок між частинами допомагає читачеві побачити, де закінчилась підготовка й що саме перевіряють.

СЛОВНИКArrange. act

Один тест перевіряє . Коли тест падає, його назва вже має пояснити, що зламалось: «adds delivery to the sum of the lines» каже більше за «test order». У Pest назва пишеться як речення після it(), тому читається як специфікація, а набір тестів одного класу стає списком його правил.

СЛОВНИКПоведінка. Одне правило, яке видно ззовні обʼєкта: що повертає метод за певних даних, який виняток кидає, як змінюється стан. Одна поведінка може потребувати кількох перевірок.

Тест перевіряє публічну поведінку обʼєкта, а не його нутрощі. Якщо для перевірки хочеться прочитати приватну властивість чи викликати приватний метод, обʼєкту бракує публічного методу, або ви перевіряєте реалізацію. Тест, привʼязаний до реалізації, падає від кожного рефакторингу, навіть коли поведінка лишилась та сама.

02 · ВАРІАНТИ

Один сценарій, чотири записи

Order рахує суму рядків і додає доставку 50 грн, а від 2000 грн доставка безкоштовна. Перша вкладка показує, з чого часто починають, решта три показують, як записати ту саму перевірку чисто: у Pest, у PHPUnit і з dataset.

php OrderTest.php Усе в одному
1it('works', function () {
2 $order = new Order(delivery: 50);
3 $order->add('Чашка', price: 450, quantity: 2);
4 expect($order->total())->toBe(950);
5
6 $order->add('Кава', price: 1100, quantity: 1);
7 expect($order->total())->toBe(2000);
8
9 expect(fn () => $order->add('Чай', price: 100, quantity: 0))
10 ->toThrow(InvalidArgumentException::class);
11});
ЩО ПЕРЕВІРЯЄ
Три різні правила: суму з доставкою, безкоштовну доставку й заборону нульової кількості. Коли тест упаде, назва «works» нічого не підкаже, а перша невдала перевірка сховає решту.
КОЛИ БРАТИ
Як чернетку, щоб швидко переконатися, що код живий. Перед комітом розділіть на окремі тести, по одному на правило.
php OrderTest.php Pest
1it('adds delivery to the sum of the lines', function () {
2 $order = new Order(delivery: 50);
3 $order->add('Чашка', price: 450, quantity: 2);
4 $order->add('Кава', price: 300, quantity: 1);
5
6 $total = $order->total();
7
8 expect($total)->toBe(1250);
9});
ЩО ПЕРЕВІРЯЄ
Одне правило: сума рядків плюс доставка. Три блоки розділені порожніми рядками: підготовка, одна дія, одна перевірка. toBe() порівнює строго, як ===, тож 1250.0 замість 1250 тест не пропустить.
КОЛИ БРАТИ
За замовчуванням для кожного нового правила. Назва в it() читається як речення про поведінку.
php OrderTest.php PHPUnit
1use PHPUnit\Framework\TestCase;
2
3final class OrderTest extends TestCase
4{
5 public function test_it_adds_delivery_to_the_sum_of_the_lines(): void
6 {
7 $order = new Order(delivery: 50);
8 $order->add('Чашка', price: 450, quantity: 2);
9 $order->add('Кава', price: 300, quantity: 1);
10
11 $total = $order->total();
12
13 $this->assertSame(1250, $total);
14 }
15}
ЩО ПЕРЕВІРЯЄ
Те саме правило в класичному стилі. Метод з префіксом test_ PHPUnit знаходить сам, assertSame() теж порівнює строго. Pest уміє запускати такі класи поруч зі своїми тестами.
КОЛИ БРАТИ
У проєкті на PHPUnit, як-от типовий Symfony, або коли команда звикла до класів. Структура тесту від стилю не залежить.
php OrderTest.php Dataset
1it('charges delivery only below the free delivery threshold', function (int $price, int $total) {
2 $order = new Order(delivery: 50);
3 $order->add('Кава', price: $price, quantity: 1);
4
5 expect($order->total())->toBe($total);
6})->with([
7 'звичайне замовлення' => [1200, 1250],
8 'на гривню менше за поріг' => [1999, 2049],
9 'рівно на порозі' => [2000, 2000],
10]);
ЩО ПЕРЕВІРЯЄ
Одне правило про поріг доставки на трьох наборах даних. Pest запускає тест для кожного рядка окремо й показує його назву у виводі, тож видно, який саме випадок упав.
КОЛИ БРАТИ
Коли правило має межу або кілька однакових за формою випадків. Межу перевіряйте з обох боків: 1999 і 2000.
03 · КОД

Запустіть і подивіться

PHP 8.5
1<?php
2
31final class Order
4{
5 private const FREE_DELIVERY_FROM = 2000;
6
7 /** @var list<array{string, int, int}> */
8 private array $lines = [];
9
10 public function __construct(private int $delivery) {}
11
12 public function add(string $name, int $price, int $quantity): void
13 {
14 if ($quantity < 1) {
15 throw new InvalidArgumentException("Кількість для «{$name}» має бути більшою за нуль");
16 }
17
18 $this->lines[] = [$name, $price, $quantity];
19 }
20
21 public function total(): int
22 {
23 $sum = array_sum(array_map(fn (array $line) => $line[1] * $line[2], $this->lines));
24
25 return $sum >= self::FREE_DELIVERY_FROM ? $sum : $sum + $this->delivery;
26 }
27}
28
292// Мінімальний «фреймворк»: назва тесту, тіло й одна перевірка.
30function test(string $name, Closure $body): void
31{
32 try {
33 $body();
34 echo "✓ {$name}\n";
35 } catch (Throwable $e) {
36 echo "✗ {$name}\n {$e->getMessage()}\n";
37 }
38}
39
40function assertSame(mixed $expected, mixed $actual): void
41{
42 if ($expected !== $actual) {
43 throw new AssertionError('очікували '.var_export($expected, true).', отримали '.var_export($actual, true));
44 }
45}
46
473test('додає доставку до суми рядків', function () {
48 $order = new Order(delivery: 50);
49 $order->add('Чашка', price: 450, quantity: 2);
50 $order->add('Кава', price: 300, quantity: 1);
51
52 $total = $order->total();
53
54 assertSame(1250, $total);
55});
56
574test('не бере за доставку від 2000 грн', function () {
58 $order = new Order(delivery: 50);
59 $order->add('Кава', price: 2000, quantity: 1);
60
61 assertSame(2000, $order->total());
62});
63
645test('відхиляє нульову кількість', function () {
65 $order = new Order(delivery: 50);
66
67 try {
68 $order->add('Чай', price: 100, quantity: 0);
69 } catch (InvalidArgumentException) {
70 return;
71 }
72
73 throw new AssertionError('очікували InvalidArgumentException');
74});
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–27

    Доменний обʼєкт без бази й фреймворку. Його створюють через new, і тесту більше нічого не треба.

  2. 2 рядки 29–45

    Те, що роблять it() і expect() у Pest, у півтора десятка рядків: виконати тіло, зловити невдалу перевірку й надрукувати ✓ або ✗.

  3. 3 рядки 47–55

    Arrange, act, assert, розділені порожніми рядками. Змініть 1250 на 1200 і запустіть ще раз: побачите, як читається падіння.

  4. 4 рядки 57–62

    Окремий тест на окреме правило. Поріг перевірено рівно на межі, де помилка в >= чи > ховається найчастіше.

  5. 5 рядки 64–74

    Виняток теж поведінка. У Pest його перевіряє один рядок з toThrow(), як у вкладці «Усе в одному» вище.

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

SalaryTest у наших тестах

Вилка зарплати в PHP Ukraine це value object Salary з src/Hiring/Domain/Job: межі від і до, валюта, форматування й середина вилки. Тест лежить у tests/Unit і створює обʼєкт через new, без бази й Laravel. Кожен тест перевіряє одне правило, а кілька expect() в одному ланцюжку перебирають випадки цього правила.

php SalaryTest.php read-only
1use PhpUkraine\Hiring\Domain\Job\Currency;← лише домен
2use PhpUkraine\Hiring\Domain\Job\Salary;
3
4it('formats ranges and open bounds', function () {← одне правило, кілька випадків
5 expect(Salary::range(5000, 7000, Currency::Usd)->format())->toBe("\$5\u{00A0}000\u{00A0}– 7\u{00A0}000")
6 ->and((new Salary(4000, null, Currency::Usd))->format())->toBe("від\u{00A0}\$4\u{00A0}000")
7 ->and((new Salary(null, 3000, Currency::Eur))->format())->toBe("до\u{00A0}€3\u{00A0}000")
8 ->and(Salary::range(4500, 4500, Currency::Uah)->format())->toBe("₴4\u{00A0}500");
9});
10
11it('swaps inverted bounds and computes the midpoint', function () {
12 $salary = Salary::range(7000, 5000, Currency::Usd);← arrange і act в одному рядку
13
14 expect($salary->from)->toBe(5000)
15 ->and($salary->to)->toBe(7000)
16 ->and($salary->midpoint())->toBe(6000)
17 ->and((new Salary(4000, null, Currency::Usd))->midpoint())->toBe(4000);
18});
19
20it('rejects an empty or negative salary', function () {← виняток теж поведінка
21 expect(fn () => new Salary(null, null, Currency::Usd))->toThrow(InvalidArgumentException::class)
22 ->and(fn () => new Salary(-1, 10, Currency::Usd))->toThrow(InvalidArgumentException::class)
23 ->and(fn () => new Salary(10, 5, Currency::Usd))->toThrow(InvalidArgumentException::class);
24});
05 · ТИПОВІ ПОМИЛКИ

Що заважає першому тесту

✕ 1

Тест на геттери й сетери

Перший тест перевіряє, що setName() зберігає імʼя, а getName() його повертає. Такий тест ніколи не впаде від справжньої помилки, зате створює враження, що код покрито.

Натомість: починайте з правила, яке можна зламати: розрахунок, межа, заборонений перехід статусу. Геттери перевіряться самі, коли ними скористаються такі тести.

✕ 2

Доменний обʼєкт сам питає час і базу

Метод isExpired() викликає now() або DB::table(). Тест на нього вже не unit: треба піднімати Laravel, а результат залежить від дня запуску.

Натомість: передавайте час параметром, isExpiredAt($now), або через порт Clock, а дані завантажуйте до виклику доменного методу. Тоді обʼєкт створюється через new у будь-якому тесті.

✕ 3

Перевірка приватного стану

Тест читає приватну властивість через Reflection, щоб перевірити масив рядків замовлення. Після рефакторингу на колекцію тест падає, хоча total() рахує так само.

Натомість: перевіряйте те, що видно ззовні: результат публічних методів і винятки. Якщо без приватного стану нічого не перевірити, обʼєкту бракує публічного методу.

✕ 4

Назва, яка нічого не каже

test1, it works, test order. Коли такий тест падає в CI, треба відкрити код, щоб зрозуміти, яке правило зламалось.

Натомість: називайте тест реченням про поведінку: «charges delivery only below the free delivery threshold». Список назв має читатися як перелік правил класу.

06 · ДЕРЕВО РІШЕНЬ

Чи готовий клас до unit-тесту

1
Клас можна створити через new без контейнера й бази?
Конструктор приймає значення й інтерфейси, а не викликає app() чи модель Eloquent.
НІ →Спершу винесіть правило в окремий клас без фреймворку або тестуйте feature-тестом.
2
Клас не викликає now(), rand() чи фасади всередині методів?
Час, випадкові значення, конфіг і фасади роблять результат залежним від оточення.
НІ →Передайте час і випадковість ззовні: параметром, через Clock чи через інтерфейс генератора.
3
У класі є правило, яке можна зламати?
Розрахунок, межа, заборонений перехід статусу, валідація значення.
НІ →Окремий тест не потрібен: клас, що лише зберігає дані, перевіриться тестами тих, хто ним користується.
ТАК НА ВСІ →Пишіть unit-тест: new, одна дія, одна перевірка, по тесту на кожне правило.
07 · СЛОВНИК
Arrange
act
Поведінка
Одне правило, яке видно ззовні обʼєкта: що повертає метод за певних даних, який виняток кидає, як змінюється стан. Одна поведінка може потребувати кількох перевірок.
08 · ПЕРЕВІР СЕБЕ

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

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

Три частини тесту. Arrange готує обʼєкт і дані, act виконує одну дію, заради якої написано тест, assert перевіряє результат. Чітка межа між ними показує, що саме тест перевіряє, а якщо act розростається на кілька дій, тест, найімовірніше, перевіряє кілька поведінок.

toBe() порівнює строго, як ===: той самий тип і значення, а для обʼєктів той самий екземпляр. toEqual() порівнює як ==, тож два різні обʼєкти з однаковими властивостями рівні. Для чисел і рядків беріть toBe(), щоб 1250.0 не пройшло за 1250, для value objects зазвичай toEqual().

Так, якщо всі вони перевіряють одну поведінку: результат і стан після однієї дії або кілька випадків одного правила. Погано, коли в одному тесті кілька дій і кілька правил: назва вже не описує, що зламалось, а перша невдала перевірка ховає решту.

Приватні методи це реалізація, і тест на них падає від кожного рефакторингу, хоча поведінка лишилась та сама. Їх перевіряють опосередковано, через публічні методи, що ними користуються. Якщо приватна логіка така складна, що її хочеться тестувати окремо, це сигнал винести її в окремий клас з власним публічним API.