Lift v1.3.0
Документация
На этой странице

Тестирование

Lift был спроектирован с прицелом на тестируемость с первого дня. Два проектных решения делают тесты тривиальными:

  1. App::handle($request): Response чист — по запросу он возвращает ответ, ни разу не трогая $_SERVER, буферы вывода или заголовки PHP.
  2. Всё внедряется через конструктор — вы можете подменить любой сервис фейком, привязав его до запуска запроса.

Вдобавок 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');
    }
}

Server-Sent Events →