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

Тести, яким можна вірити

Які бувають тести, що чим перевіряти і як не потонути в моках. Pest, PHPStan і архітектурні тести на прикладах з PHP.

ФОРМА ТЕСТІВ
↑ ПОВІЛЬНІШЕ, ДОРОЖЧЕЧАСТКА ТЕСТІВ
↓ ШВИДШЕ, ДЕШЕВШЕ

Піраміда Майка Кона (Mike Cohn): багато швидких unit-тестів і мало повільних. Класична порада для коду з багатою доменною логікою.

Трофей Кента Доддса (Kent C. Dodds): найбільше інтеграційних тестів, а в Laravel це feature-тести. Вони швидкі й ловлять те, що бачить користувач. В основі статичний аналіз.

Частки й час приблизні, це орієнтир, а не норма.

E2E у браузері ≈ 1–10 с
ПЕРЕВІРЯЄ
Справжній браузер клікає по сторінці, як користувач.
ЛАМАЄТЬСЯ ВІД
Верстки, JavaScript, усього разом. Іноді й від повільної мережі.
it('shows the vacancy page', function () {
    visit('/jobs/senior-laravel')
        ->assertSee('Відгукнутися')
        ->click('Відгукнутися')
        ->assertPathIs('/login');
});
Feature / HTTP ≈ 50–500 мс
ПЕРЕВІРЯЄ
HTTP-запит крізь увесь Laravel: маршрут, middleware, валідація, контролер, відповідь.
ЛАМАЄТЬСЯ ВІД
Маршруту, валідації, прав доступу, формату відповіді.
it('lets a candidate withdraw an application', function () {
    $application = Application::factory()->create();
 
    actingAs($application->candidate)
        ->post("/applications/{$application->id}/withdraw")
        ->assertRedirect();
 
    expect($application->fresh()->isWithdrawn())->toBeTrue();
});
Integration ≈ 20–200 мс
ПЕРЕВІРЯЄ
Кілька класів разом зі справжньою залежністю: базою, кешем, файлами.
ЛАМАЄТЬСЯ ВІД
Схеми бази, запиту, конфігу драйвера.
it('finds a saved application', function () {
    $repo = app(ApplicationRepository::class);
    $repo->save($application = ApplicationFactory::new()->make());
 
    expect($repo->find($application->id))->not->toBeNull();
});
Unit ≈ 1–10 мс
ПЕРЕВІРЯЄ
Один клас або функцію без бази, мережі й фреймворку.
ЛАМАЄТЬСЯ ВІД
Зміни логіки саме в цьому класі.
it('calculates the Nova Poshta rate', function () {
    $order = new Order(sum: 1200, weight: 3);
 
    expect((new NovaPoshtaRate)->quote($order))->toBe(96);
});
Статичний аналіз ≈ 5–30 с на проєкт
ПЕРЕВІРЯЄ
Код не запускається: PHPStan читає типи й шукає помилки до виконання.
ЛАМАЄТЬСЯ ВІД
Невідомих методів, неправильних типів, викликів на null.
vendor/bin/phpstan analyse --level=8
 
 [OK] No errors
01 — PHPSTAN І РІВНІ

Рухайте повзунок

Один і той самий контролер на різних рівнях аналізу. Кожен рівень додає перевірки до попередніх, і в коді зʼявляються нові помилки.

ProfileController.php приклад рівень 6: 5 помилок
1final class ProfileController
2{
3 public function __construct(private Users $users) {}
4
5 public function show(int $id): string
6 {
7 $user = $this->users->find($id);
8
9 return $user->name;
L8 Cannot access property $name on User|null.find() повертає ?User: користувача може не бути. Рівень 8 не дає звертатися до nullable-значення без перевірки на null.
10 }
11
12 public function welcome(int $id): void
13 {
14 $this->users->findOrFial($id)->notify(new WelcomMail());
L0 Instantiated class WelcomMail not found.Класу WelcomMail ніде немає, схоже на одруківку. Невідомі класи PHPStan бачить уже на нульовому рівні.
L2 Call to an undefined method Users::findOrFial().У Users є findOrFail, а findOrFial немає. Нульовий рівень перевіряє методи лише на $this, а на будь-якому виразі, як $this->users, з рівня 2.
L10 Cannot call method notify() on mixed.Методу findOrFial немає, тож PHPStan не знає, що він повертає: це неявний mixed. Рівень 10 забороняє викликати методи й на ньому.
15 }
16
17 public function badge($user): string
L6 Method ProfileController::badge() has parameter $user with no type specified.Параметр без типу. Рівень 6 вимагає тип скрізь, де його можна вказати.
18 {
19 return $this->users->rank('42');
L3 Method ProfileController::badge() should return string but returns int.rank() повертає int, а метод обіцяє string. Типи повернення PHPStan звіряє з рівня 3.
L5 Parameter #1 $id of method Users::rank() expects int, string given.rank() чекає int, а сюди передали рядок. Типи аргументів перевіряє рівень 5.
20 }
21
22 public function title(string $raw): string
23 {
24 $data = json_decode($raw, true);
25
26 return $data['title'];
L9 Cannot access offset 'title' on mixed.json_decode() повертає mixed. На рівні 9 з mixed нічого не можна робити, доки тип не звужено, навіть узяти елемент масиву.
L9 Method ProfileController::title() should return string but returns mixed.Метод обіцяє string, а віддає mixed. Рівень 9 більше не вважає mixed сумісним з будь-яким типом.
27 }
28}
Рівень 6 додає: відсутні типи
02 — PEST

Тест і те, що він каже

Три тести для перевізників зі сторінки Стратегії. Перемкніть на «Вивід», а потім зламайте тариф Укрпошти й подивіться, що каже Pest.

Що зламали в UkrposhtaRate
- return 35 + $order->weight * 8;
+ return 38 + $order->weight * 8;

Базовий тариф Укрпошти виріс з 35 до 38 грн, і посилка вагою 3 кг тепер коштує 38 + 3 × 8 = 62 замість 59.

PHPUnit, на якому стоїть Pest, спершу називає фактичне значення: «62 is identical to 59» означає «отримали 62, очікували 59».

Рядок зі стрілкою ➜ показує, де саме впала перевірка: восьмий рядок файлу з тестом. Два інші тести лишились зеленими, бо Нову пошту й Checkout зміна не зачепила.

Pest 5
<?php
 
it('calculates the Nova Poshta rate', function () {
    expect((new NovaPoshtaRate)->quote(order(weight: 3)))->toBe(96);
});
 
it('calculates the Ukrposhta rate', function () {
    expect((new UkrposhtaRate)->quote(order(weight: 3)))->toBe(59);
});
 
it('adds the rate to the order sum', function () {
    $checkout = new Checkout(new FakeRate(price: 50));
 
    expect($checkout->total(order(sum: 1200)))->toBe(1250);
});
PASS Tests\Unit\ShippingRateTest
✓ it calculates the Nova Poshta rate 0.01s
✓ it calculates the Ukrposhta rate
✓ it adds the rate to the order sum
Tests: 3 passed (3 assertions)
Duration: 0.10s
FAIL Tests\Unit\ShippingRateTest
✓ it calculates the Nova Poshta rate
⨯ it calculates the Ukrposhta rate
✓ it adds the rate to the order sum
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Unit\ShippingRateTest > it calculates the Ukrposhta rate
Failed asserting that 62 is identical to 59.
at tests/Unit/ShippingRateTest.php:8
4▕ expect((new NovaPoshtaRate)->quote(order(weight: 3)))->toBe(96);
5▕ });
6▕
7▕ it('calculates the Ukrposhta rate', function () {
➜ 8▕ expect((new UkrposhtaRate)->quote(order(weight: 3)))->toBe(59);
9▕ });
10▕
11▕ it('adds the rate to the order sum', function () {
12▕ $checkout = new Checkout(new FakeRate(price: 50));
1 tests/Unit/ShippingRateTest.php:8
Tests: 1 failed, 2 passed (3 assertions)
Duration: 0.08s
$ vendor/bin/pest tests/Unit/ShippingRateTest.php
03 — ТЕМИ

Каталог тем

11 розібрано

Основи

Basics · 5 тем

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

Інструменти

Tools · 3 теми

Pest, вбудовані фейки Laravel і тести в Symfony.

Якість коду

Code quality · 3 теми

Перевірки, які ловлять помилки без жодного написаного тесту, і тести для самих тестів.

04 — СПІЛЬНИЙ МАРШРУТ

Архітектура й тести разом

Щойно зʼявляється нова ідея, ви одразу вчитеся її перевіряти. Цей самий маршрут є й на хабі архітектури.

АрхітектураТестування
9 кроків · 9 вже є
  1. 1
    АРХІТЕКТУРАШари і де живе логікаінтерфейс, застосунок, домен, інфраструктура
  2. 2
    ТЕСТУВАННЯПерший unit-тест доменутест без бази й фреймворку
  3. 3
    АРХІТЕКТУРАValue Objectгроші, email, період як значення
  4. 4
    ТЕСТУВАННЯTest doublesdummy, stub, spy, mock, fake
  5. 5
    АРХІТЕКТУРАHexagonalпорти й адаптери
  6. 6
    ТЕСТУВАННЯФейки адаптерівin-memory замість бази й API
  7. 7
    ТЕСТУВАННЯАрхітектурні тестидомен не імпортує Laravel
  8. 8
    ТЕСТУВАННЯPHPStan і рівнівід нуля до десяти
  9. 9
    АРХІТЕКТУРАМодульний монолітмодулі з чіткими межами в одному репо
// ЯК У PHP UKRAINE

Що перевіряють перед кожним деплоєм цього сайту

deploy/deploy.sh Перед кожним деплоєм запускає PHPStan і весь набір тестів Pest. Якщо хоч щось червоне, деплой зупиняється.
tests/Architecture/LayersTest.php Не пускає Illuminate й Livewire у доменний і прикладний шари, домену забороняє ще й App, а в усьому src/ вимагає strict_types.
src/Shared/Clock/FrozenClock.php Підміняє годинник у тестах: advance('+30 days') замість місяця очікування.
tests/Support/InMemoryJobRepository.php In-memory JobRepository для unit-тестів домену, без жодної бази.
phpstan.neon PHPStan рівня 8 з Larastan перевіряє app/ і src/.