Unit-тест варто починати з доменного обʼєкта, який можна створити через new без бази, контейнера й фасадів. Тоді тест іде мілісекунди і падає лише від зміни логіки в цьому класі. Якщо клас так не створюється, це вже інформація: він сам викликає now(), app() чи модель Eloquent, і це питання дизайну, а не тесту.
Майже кожен хороший тест має три частини: arrange готує обʼєкт і дані, act виконує одну дію, заради якої написано тест, assert перевіряє результат. У BDD те саме звуть given, when, then. Порожні рядки між частинами роблять тест читабельним без коментарів. Якщо в act кілька дій із перевірками між ними, у тесті кілька поведінок, і їх варто розділити: коли перша перевірка впаде, решта навіть не виконається.
Одиниця тесту це поведінка, а не метод і не кількість expect(). Перевірити суму й кількість рядків після однієї дії нормально. Погано, коли в одному тесті три правила й назва «works». Назва має пояснити, що зламалось: «charges delivery only below the free delivery threshold» у звіті CI каже все, а «test order» змушує відкривати код. Список назв тестів класу добре читається як перелік його правил.
Тест бачить обʼєкт ззовні: результат публічних методів, винятки, змінений стан, який можна прочитати публічно. Приватні методи перевіряються опосередковано. Якщо без приватного стану нічого не перевірити, обʼєкту бракує публічного методу або він робить забагато і просить розділення. Reflection у тестах майже завжди означає, що тест прикутий до реалізації.
Решта властивостей хорошого тесту про довіру. Він детермінований: час і випадковість приходять ззовні, дані задані явно. Він незалежний: не спирається на те, що інший тест щось створив. Очікуване значення записане літералом, а не пораховане тією ж формулою, що й код. І він падає від справжньої помилки: якщо замінити >= на > у порозі доставки, тест на межі 2000 грн має почервоніти.
// tests/Unit/OrderTest.php: доменний обʼєкт без бази й фреймворку.
it('adds delivery to the sum of the lines', function () {
// arrange
$order = new Order(delivery: 50);
$order->add('Чашка', price: 450, quantity: 2);
$order->add('Кава', price: 300, quantity: 1);
// act
$total = $order->total();
// assert: очікуване літералом, а не формулою з коду
expect($total)->toBe(1250);
});
it('charges delivery only below the free delivery threshold', function (int $price, int $total) {
$order = new Order(delivery: 50);
$order->add('Кава', price: $price, quantity: 1);
expect($order->total())->toBe($total);
})->with([
'на гривню менше за поріг' => [1999, 2049],
'рівно на порозі' => [2000, 2000], // межу перевіряємо з обох боків
]);
it('rejects a zero quantity', function () {
$order = new Order(delivery: 50);
expect(fn () => $order->add('Чай', price: 100, quantity: 0))
->toThrow(InvalidArgumentException::class);
});
Покажіть тест на межі правила: «доставка безкоштовна від 2000 грн» перевіряю на 1999 і на 2000, бо помилка в `>=` чи `>` ховається саме там. Це одразу видно, що ви думаєте про крайні випадки, а не пишете тест заради покриття.