Тестування
Lift був спроєктований із прицілом на тестовність із першого дня. Два проєктні рішення роблять тести тривіальними:
App::handle($request): Responseчистий — за запитом він повертає відповідь, жодного разу не торкаючись$_SERVER, буферів виводу чи заголовків PHP.- Усе впроваджується через конструктор — ви можете підмінити будь-який сервіс фейком, прив’язавши його до запуску запиту.
На додачу Lift постачає крихітний базовий клас PHPUnit — Lift\Testing\TestCase — із плавним API тверджень. Ви писатимете інтеграційні тести цілих HTTP-маршрутів у 5 рядків.
Налаштування
У composer.json:
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.12"
},
"autoload-dev": {
"psr-4": { "Tests\\": "tests/" }
}
phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
Запуск:
vendor/bin/phpunit
Запуск тестів і статичного аналізу через Composer scripts:
composer test
composer analyse
Ваш перший feature-тест
<?php
namespace Tests\Feature;
use Lift\App;
use Lift\Http\Response;
use Lift\Testing\TestCase;
final class HelloTest extends TestCase
{
protected function createApp(): App
{
$app = new App();
$app->get('/hello/{name}', fn($req) => Response::json([
'message' => 'Hello, ' . $req->param('name'),
]));
return $app;
}
public function testItGreets(): void
{
$this->get('/hello/Alice')
->assertOk()
->assertJson(['message' => 'Hello, Alice']);
}
}
createApp() викликається з setUp() і зберігається в $this->app. Перевизначте його один раз на клас тесту.
HTTP-помічники
$this->get ('/users');
$this->post ('/users', ['name' => 'Alice']);
$this->put ('/users/1', ['name' => 'Bobby']);
$this->patch ('/users/1', ['name' => 'Carol']);
$this->delete('/users/1');
// Завжди-JSON варіанти:
$this->getJson ('/users'); // надсилає Accept: application/json, стверджує 200
$this->postJson('/users', ['name' => 'A']);
// Власні заголовки:
$this->get('/users', ['Authorization' => 'Bearer ' . $token]);
$this->post('/orders', ['sku' => 'ABC'], ['X-Idempotency-Key' => 'k1']);
Масиви тіла кодуються в JSON автоматично. Щоб надіслати щось інше, побудуйте запит вручну:
$req = new \Lift\Http\Request('POST', new \Lift\Http\Uri('/upload'),
headers: ['Content-Type' => 'multipart/form-data'],
body: \Lift\Http\Stream::fromString($rawMultipart),
);
$response = $this->app->handle($req);
API тверджень
Кожен помічник повертає TestResponse, усі методи якого зчіплюються:
$this->post('/api/users', ['name' => 'Alice', 'email' => '[email protected]'])
->assertCreated()
->assertContentType('application/json')
->assertHeader('Location', '/api/users/1')
->assertJson(['name' => 'Alice'])
->assertJsonHas('id')
->assertJsonPath('email', '[email protected]');
Твердження статусу
| Метод | Що перевіряє |
|---|---|
assertStatus(int $code) |
Точний код стану |
assertOk() |
200 |
assertCreated() |
201 |
assertNoContent() |
204 |
assertRedirect($url?) |
3xx, опційно з Location |
assertUnauthorized() |
401 |
assertForbidden() |
403 |
assertNotFound() |
404 |
assertUnprocessable() |
422 |
Твердження заголовків
| Метод | Що перевіряє |
|---|---|
assertHeader(string $name, ?string $value) |
Заголовок існує (і дорівнює значенню, якщо задано) |
assertContentType(string $type) |
Content-Type містить заданий media-тип |
Твердження тіла
| Метод | Що перевіряє |
|---|---|
assertSee(string $text) |
Сире тіло містить рядок |
assertDontSee(string $text) |
Сире тіло не містить рядок |
assertJson(array $expected, bool $exact = false) |
JSON-тіло збігається з очікуваними парами (частково за замовчуванням) |
assertJsonHas(string $key) |
JSON-тіло має ключ із крапковою нотацією ('user.email') |
assertJsonPath(string $path, mixed $expected) |
Шлях із крапковою нотацією дорівнює очікуваному значенню |
assertJsonCount(int $count, ?string $key = null) |
Тіло (або $key) — масив із рівно $count елементів |
Сирі аксесори (аварійні виходи)
$response = $this->get('/x');
$response->status(); // int
$response->body(); // string
$response->json(); // array (викидає виняток, якщо не JSON)
$response->header('X-Foo'); // ?string (перше значення)
$response->getResponse(); // нижчележний Lift\Http\Response
Підміна сервісів фейками
Увесь DI-контейнер у вас під рукою всередині createApp():
protected function createApp(): App
{
$app = new App();
// Замінити справжній mailer на фейк у пам’яті
$this->mailer = new InMemoryMailer();
$app->instance(Mailer::class, $this->mailer);
// Заглушити клієнт стороннього API
$app->instance(GithubClient::class, new FakeGithubClient([
'octocat' => ['name' => 'The Cat'],
]));
require __DIR__ . '/../../routes/web.php'; // ваша звичайна реєстрація маршрутів
return $app;
}
public function testSignupSendsEmail(): void
{
$this->postJson('/signup', ['email' => '[email protected]', 'password' => 'secret'])
->assertCreated();
self::assertCount(1, $this->mailer->sent);
self::assertSame('[email protected]', $this->mailer->sent[0]->to);
}
$app->instance(...) поміщає уже побудований об’єкт у контейнер; більше нічого не змінюється. Обробник розв’язує ваш фейк автоматично.
Тести бази даних із SQLite
Справжня база даних без справжньої бази даних:
protected function createApp(): App
{
$app = new App();
$app->singleton(Connection::class, fn() => Connection::fromConfig([
'driver' => 'sqlite',
'database' => ':memory:',
]));
// Побудувати схему один раз на тест:
$db = $app->make(Connection::class);
$db->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
require __DIR__ . '/../../routes/web.php';
return $app;
}
public function testCreateUser(): void
{
$this->postJson('/users', ['name' => 'Alice'])
->assertCreated()
->assertJson(['id' => 1, 'name' => 'Alice']);
}
Для більших схем запускайте свої міграції проти БД у пам’яті:
(new \Lift\Database\Migrator($db, __DIR__ . '/../../database/migrations'))->migrate();
Кожен тест отримує свіжий :memory: SQLite — ідеально ізольований, блискавично швидкий.
Сесії та автентифікація в тестах
Використовуйте сховище в пам’яті й засійте сесію до диспетчеризації:
use Lift\Http\Session\ArraySessionStore;
use Lift\Http\Session\Session;
use Lift\Http\Session\SessionMiddleware;
protected function createApp(): App
{
$app = new App();
$this->session = new Session(new ArraySessionStore());
$app->use(new SessionMiddleware($this->session));
require __DIR__ . '/../../routes/web.php';
return $app;
}
public function testProtectedRoute(): void
{
// «Залогінити користувача», записавши user_id напряму
$this->session->set('user_id', 42);
$this->getJson('/dashboard')->assertOk();
}
Для маршрутів, захищених JWT, викарбуйте токен напряму:
$token = $this->app->make(\Lift\Jwt\Jwt::class)->encode(['sub' => 42]);
$this->get('/me', ['Authorization' => "Bearer $token"])->assertOk();
Чисті юніт-тести
Для класів без HTTP-контексту — сервісів, валідаторів, кодувальників — звичайний TestCase PHPUnit (PHPUnit\Framework\TestCase) — правильна база. Жодної участі Lift узагалі:
final class PriceCalculatorTest extends \PHPUnit\Framework\TestCase
{
public function testApplyDiscount(): void
{
$calc = new PriceCalculator();
self::assertSame(80.0, $calc->apply(100.0, discount: 20));
}
}
Огляд запитів у тестах middleware
Middleware — це звичайний клас — створіть його, передайте йому запит і фейковий обробник:
public function testAuthMiddlewareRejectsMissingToken(): void
{
$mw = new AuthMiddleware(new Jwt(secret: 'k'));
$req = new Request('GET', new Uri('/x'));
$next = new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $r): ResponseInterface { return new Response(200); }
};
$response = $mw->process($req, $next);
self::assertSame(401, $response->getStatusCode());
}
Поради для швидких і коректних тестів
- Скидайте стан у
setUp(), не в методах тесту. Інакше тести заважають один одному під час запуску окремо. - Використовуйте
setUp()лише для речей, прив’язаних до$this— для налаштування рівня застосунку віддавайте перевагуcreateApp(). - Уникайте мережі. Заглушайте HTTP-клієнти, платіжні SDK тощо, прив’язуючи фейки.
- Не розділяйте стан між тестами. Жодних статичних синглтонів, жодних глобалей. Кожен тест перебудовує застосунок.
- Тестуйте HTTP-контракт (код стану, форма тіла, заголовки), а не внутрішні класи — контракт — це те, що бачать ваші користувачі.
- Швидкість: ~5 000 HTTP-рівневих тестів за хвилину досяжні на типовому ноутбуці, бо
App::handle()не робить вводу-виводу.
Часті підводні камені
| Симптом | Причина | Виправлення |
|---|---|---|
| Тести проходять окремо, але падають під час групування | Розділюваний глобальний стан (статичний кеш, env-змінна) | Скидайте в setUp() або робіть тести самодостатніми. |
Response body is not valid JSON |
Ендпоінт повернув HTML / порожнечу (наприклад, 422 валідації з власним шаблоном) | Використовуйте $response->body() або спершу перевірте assertContentType('application/json'). |
| Заголовки не встановлені в тесті | Ви викликали withHeader і відкинули результат |
Завжди присвоюйте назад. (Та сама пастка, що в Response.) |
| Автентифікація працює в браузері, але не в тесті | Браузер автоматично несе cookie / CSRF-токен; тест ні | Засійте сесію/JWT у setUp() або спершу надішліть запит входу. |
Cannot resolve parameter $cfg під час завантаження |
Тестовий застосунок пропустив прив’язку, яка є в public/index.php |
Перенесіть прив’язку в bootstrap.php, який ви викликаєте з обох. |
Шпаргалка
final class FooTest extends \Lift\Testing\TestCase
{
protected function createApp(): App
{
$app = new App();
$app->instance(Mailer::class, $this->mailer = new InMemoryMailer());
// …реєстрація маршрутів…
return $app;
}
public function test_it_works(): void
{
$this->postJson('/users', ['name' => 'Alice'])
->assertCreated()
->assertJson(['name' => 'Alice'])
->assertJsonHas('id')
->assertHeader('Location');
}
}