Testing
Lift was designed for testability from day one. Two design choices make tests trivial:
App::handle($request): Responseis pure — given a request, it returns a response, without ever touching$_SERVER, output buffers, or PHP headers.- Everything is constructor-injected — you can swap any service for a fake by binding it before the request fires.
On top of that, Lift ships a tiny PHPUnit base class — Lift\Testing\TestCase — with a fluent assertion API. You'll write integration tests for entire HTTP routes in 5 lines.
Setup
In 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>
Run with:
vendor/bin/phpunit
Run tests and static analysis with Composer scripts:
composer test
composer analyse
Your first feature test
<?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() is called by setUp() and stored in $this->app. Override it once per test class.
The HTTP helpers
$this->get ('/users');
$this->post ('/users', ['name' => 'Alice']);
$this->put ('/users/1', ['name' => 'Bobby']);
$this->patch ('/users/1', ['name' => 'Carol']);
$this->delete('/users/1');
// Always JSON variants:
$this->getJson ('/users'); // sends Accept: application/json, asserts 200
$this->postJson('/users', ['name' => 'A']);
// Custom headers:
$this->get('/users', ['Authorization' => 'Bearer ' . $token]);
$this->post('/orders', ['sku' => 'ABC'], ['X-Idempotency-Key' => 'k1']);
Body arrays are JSON-encoded automatically. To send something else, build the request manually:
$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);
The assertion API
Every helper returns a TestResponse whose methods all chain:
$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]');
Status assertions
| Method | What it checks |
|---|---|
assertStatus(int $code) |
Exact status code |
assertOk() |
200 |
assertCreated() |
201 |
assertNoContent() |
204 |
assertRedirect($url?) |
3xx, optionally with the Location |
assertUnauthorized() |
401 |
assertForbidden() |
403 |
assertNotFound() |
404 |
assertUnprocessable() |
422 |
Header assertions
| Method | What it checks |
|---|---|
assertHeader(string $name, ?string $value) |
Header exists (and equals value, if given) |
assertContentType(string $type) |
Content-Type contains the given media type |
Body assertions
| Method | What it checks |
|---|---|
assertSee(string $text) |
Raw body contains the string |
assertDontSee(string $text) |
Raw body does not contain the string |
assertJson(array $expected, bool $exact = false) |
JSON body matches the expected pairs (partial by default) |
assertJsonHas(string $key) |
JSON body has the dot-notated key ('user.email') |
assertJsonPath(string $path, mixed $expected) |
Dot-notated path equals the expected value |
assertJsonCount(int $count, ?string $key = null) |
Body (or $key) is an array of exactly $count items |
Raw accessors (escape hatches)
$response = $this->get('/x');
$response->status(); // int
$response->body(); // string
$response->json(); // array (throws if non-JSON)
$response->header('X-Foo'); // ?string (first value)
$response->getResponse(); // underlying Lift\Http\Response
Swapping services for fakes
The whole DI container is at your fingertips inside createApp():
protected function createApp(): App
{
$app = new App();
// Replace the real mailer with an in-memory fake
$this->mailer = new InMemoryMailer();
$app->instance(Mailer::class, $this->mailer);
// Stub a third-party API client
$app->instance(GithubClient::class, new FakeGithubClient([
'octocat' => ['name' => 'The Cat'],
]));
require __DIR__ . '/../../routes/web.php'; // your normal route registration
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(...) puts a pre-built object in the container; nothing else changes. The handler resolves your fake automatically.
Database tests with SQLite
A real database without a real database:
protected function createApp(): App
{
$app = new App();
$app->singleton(Connection::class, fn() => Connection::fromConfig([
'driver' => 'sqlite',
'database' => ':memory:',
]));
// Build the schema once per test:
$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']);
}
For larger schemas, run your migrations against the in-memory DB:
(new \Lift\Database\Migrator($db, __DIR__ . '/../../database/migrations'))->migrate();
Each test gets a fresh :memory: SQLite — perfectly isolated, blazingly fast.
Sessions and authentication in tests
Use the in-memory store and seed the session before dispatching:
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
{
// "Log the user in" by writing the user_id directly
$this->session->set('user_id', 42);
$this->getJson('/dashboard')->assertOk();
}
For JWT-protected routes, mint a token directly:
$token = $this->app->make(\Lift\Jwt\Jwt::class)->encode(['sub' => 42]);
$this->get('/me', ['Authorization' => "Bearer $token"])->assertOk();
Pure unit tests
For classes that have no HTTP context — services, validators, encoders — the regular PHPUnit TestCase (PHPUnit\Framework\TestCase) is the right base. No Lift involvement at all:
final class PriceCalculatorTest extends \PHPUnit\Framework\TestCase
{
public function testApplyDiscount(): void
{
$calc = new PriceCalculator();
self::assertSame(80.0, $calc->apply(100.0, discount: 20));
}
}
Inspecting requests in middleware tests
Middleware is a normal class — instantiate it, hand it a request and a fake handler:
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());
}
Tips for fast & correct tests
- Reset state in
setUp(), not in test methods. Otherwise tests interfere when run individually. - Use
setUp()only for things tied to$this— for application-wide setup, prefercreateApp(). - Avoid the network. Stub HTTP clients, payment SDKs, etc. by binding fakes.
- Don't share state between tests. No static singletons, no globals. Each test rebuilds the app.
- Test the HTTP contract (status code, body shape, headers) rather than internal classes — the contract is what your users see.
- Speed: ~5 000 HTTP-level tests per minute is achievable on a typical laptop because
App::handle()does no I/O.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Tests pass alone but fail when grouped | Shared global state (static cache, env var) | Reset in setUp(), or make tests self-contained. |
Response body is not valid JSON |
Endpoint returned HTML / empty (e.g. validation 422 with custom view) | Use $response->body() instead, or check assertContentType('application/json') first. |
| Headers not set in test | You called withHeader and discarded the return |
Always assign back. (Same trap as in Response.) |
| Auth works in browser but not in test | Browser carries cookies / CSRF token automatically; the test doesn't | Seed the session/JWT in setUp(), or fire a login request first. |
Cannot resolve parameter $cfg at boot |
Test app missed a binding you have in public/index.php |
Move the binding into a bootstrap.php you call from both. |
Cheat sheet
final class FooTest extends \Lift\Testing\TestCase
{
protected function createApp(): App
{
$app = new App();
$app->instance(Mailer::class, $this->mailer = new InMemoryMailer());
// …register routes…
return $app;
}
public function test_it_works(): void
{
$this->postJson('/users', ['name' => 'Alice'])
->assertCreated()
->assertJson(['name' => 'Alice'])
->assertJsonHas('id')
->assertHeader('Location');
}
}