Testing
Write comprehensive tests to ensure your application works correctly.
Quick Start
Glueful uses PHPUnit 10. The framework ships a base test case, Glueful\Testing\TestCase, for tests that need a booted application and container; plain PHPUnit\Framework\TestCase is the right choice for focused unit tests that don't.
# Run all tests
vendor/bin/phpunit
# Run one suite, or one test
vendor/bin/phpunit --testsuite Unit
vendor/bin/phpunit --filter UserServiceTest
# With coverage (needs Xdebug or PCOV)
vendor/bin/phpunit --coverage-html build/coverage
Scaffold a test class with the CLI:
php glueful scaffold:test UserServiceTest # unit test (default)
php glueful scaffold:test UserServiceTest --class="App\Services\UserService" --methods=create,update
php glueful scaffold:test CheckoutTest --feature # feature/integration test
Test Structure
The api-skeleton ships this layout, with a phpunit.xml that defines the Feature and Unit suites:
tests/
├── Feature/ # Tests that boot the app
├── Unit/ # Isolated class tests
├── Support/ # Test doubles (e.g. InMemoryUserProvider)
└── TestCase.php # App\Tests\TestCase extends Glueful\Testing\TestCase
The Base TestCase
Glueful\Testing\TestCase boots a fresh application in setUp() for every test: Framework::create($basePath)->withEnvironment('testing')->boot(). In tearDown() it clears the permission provider installed by the acting* helpers, clears the context's config cache and request state, and drops the application.
| Method | Purpose |
|---|---|
$this->get(Service::class) | Resolve a service from the booted container |
$this->getContainer() | The PSR-11 container |
$this->has($id) | Whether a service is registered |
$this->app() | The Glueful\Application (->handle($request), ->getContext()) |
$this->actingWithPermissions([...], $uuid = 'test-user') | Grant those permission slugs to $uuid only; returns the uuid |
$this->actingWithRoles([...], $uuid = 'test-user') | Grant the permissions of declared roles; returns the uuid |
$this->refreshApplication() | Reset state and boot a new application mid-test |
createApplication() / getBasePath() | Override to change how or where the app boots |
getBasePath() defaults to dirname(__DIR__, 3) of the framework's TestCase file, which only points at your project when you override it. The skeleton's App\Tests\TestCase overrides createApplication() with its own root:
namespace App\Tests;
use Glueful\Framework;
use Glueful\Testing\TestCase as FrameworkTestCase;
abstract class TestCase extends FrameworkTestCase
{
protected function createApplication(): \Glueful\Application
{
return Framework::create(__DIR__ . '/..')
->withEnvironment('testing')
->boot();
}
}
Inside a test, resolve services with
$this->get(...); there is no$contextvariable in a test method. When an API needs the context, use$this->app()->getContext().
Permission helpers
actingWithPermissions() installs Glueful\Testing\InMemoryPermissionProvider in replace mode, so only the returned uuid is granted the given slugs. It does not authenticate a request: it returns the uuid to use as the acting user. actingWithRoles() resolves each role's permissions from the PermissionRegistry and throws InvalidArgumentException for a role that isn't declared, so a typo fails loudly instead of denying silently.
public function test_editor_can_publish(): void
{
$uuid = $this->actingWithPermissions(['posts.publish']);
$permissions = $this->get('permission.manager');
$this->assertTrue($permissions->can($uuid, 'posts.publish', 'posts'));
}
Unit Tests
Test individual classes in isolation with plain PHPUnit; there is nothing to boot:
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\PriceCalculator;
final class PriceCalculatorTest extends TestCase
{
public function test_applies_discount(): void
{
$calculator = new PriceCalculator();
$this->assertSame(90.0, $calculator->discounted(100.0, 0.10));
}
public function test_rejects_negative_discount(): void
{
$this->expectException(\InvalidArgumentException::class);
(new PriceCalculator())->discounted(100.0, -0.5);
}
}
Integration Tests
Extend your app's TestCase and resolve real services. Services that need the context take it from $this->app()->getContext():
namespace App\Tests\Feature;
use App\Tests\TestCase;
use Glueful\Database\Connection;
final class ArticleQueryTest extends TestCase
{
public function test_lists_published_articles(): void
{
$db = $this->get(Connection::class);
$db->table('articles')->insert(['title' => 'Hello', 'status' => 'published']);
$rows = $db->table('articles')->where('status', 'published')->get();
$this->assertCount(1, $rows);
}
}
The skeleton's own feature test needs no database: it swaps in a test double (tests/Support/InMemoryUserProvider) for the user store. Prefer that for seams that have an interface.
Feature Tests (HTTP)
$this->app()->handle() runs a Symfony Request through the full pipeline: the router with your loaded routes and middleware, the exception handler, CORS and the baseline security headers.
namespace App\Tests\Feature;
use App\Tests\TestCase;
use Symfony\Component\HttpFoundation\Request;
final class HealthTest extends TestCase
{
public function test_liveness_probe_answers(): void
{
$response = $this->app()->handle(Request::create('/healthz', 'GET'));
$this->assertSame(200, $response->getStatusCode());
}
}
Framework API routes (auth, blobs, ...) are mounted under the API prefix (/api/v1/... by default); public routes such as /healthz are not; app route files under routes/ control their own prefix.
To test a route in isolation, build a router and register it directly:
use Glueful\Http\Response;
use Glueful\Routing\Router;
$router = new Router($this->getContainer());
$router->get('/ping', fn() => Response::success(['pong' => true]));
$response = $router->dispatch(Request::create('/ping', 'GET'));
Testing Database
Use SQLite
Point the test run at SQLite in phpunit.xml. Use DB_DRIVER and DB_SQLITE_DATABASE; DB_CONNECTION is not read:
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_DRIVER" value="sqlite"/>
<env name="DB_SQLITE_DATABASE" value="storage/database/testing.sqlite"/>
<env name="LOG_TO_FILE" value="false"/>
<env name="LOG_TO_DB" value="false"/>
<env name="JWT_KEY" value="testing-secret-key-not-for-production-use-only"/>
</php>
A relative DB_SQLITE_DATABASE is resolved from the project root; add the test file to .gitignore.
DB_SQLITE_DATABASE=:memory: (as the skeleton's phpunit.xml ships) is a real in-memory database since 1.91; before, it was resolved to a file named :memory: in the project root. An in-memory database lives only as long as its connection and each SQLite Connection opens its own, so it starts empty: migrate:run in another process can't populate it, and tables must be created in the test (for example in setUp()). Use a file path when tests need a migrated schema. If your app has its own config/database.php copy, check that its sqlite.primary closure passes :memory: through unchanged (see Configuration).
Run your migrations against that database before the suite. Real environment variables win over .env, so you can target the test file directly:
DB_DRIVER=sqlite DB_SQLITE_DATABASE=storage/database/testing.sqlite php glueful migrate:run
Or create the tables a test needs in setUp().
Isolating tests
Connection has no beginTransaction() / rollback(). Its transaction API is transaction(callable), which commits on return and rolls back on an exception, plus afterCommit() and withinTransaction(). A per-test "wrap and roll back" therefore doesn't map onto it. Instead:
- Delete the rows you created in
tearDown(), or use unique values per test. - Or recreate the SQLite file between suites.
To assert rollback behaviour, throw inside transaction():
$db = $this->get(Connection::class);
try {
$db->transaction(function () use ($db) {
$db->table('orders')->insert(['reference' => 'A-1']);
throw new \RuntimeException('boom');
});
} catch (\RuntimeException) {
}
$this->assertSame(0, $db->table('orders')->where('reference', 'A-1')->count());
Testing Jobs
Jobs extend Glueful\Queue\Job: the constructor takes (array $data = [], ?ApplicationContext $context = null) and handle() returns void. Inside the job, data is $this->getData() and the context is $this->context.
public function test_welcome_email_job_runs(): void
{
$job = new \App\Jobs\SendWelcomeEmailJob(
['user_uuid' => 'u-123'],
$this->app()->getContext()
);
$job->handle();
// Assert side effects: a mailer test double, a row written, a log entry...
}
Testing Events
public function test_order_shipped_listener_runs(): void
{
$this->get(\Glueful\Events\EventService::class)
->dispatch(new \App\Events\OrderShippedEvent('order-1'));
// Assert side effects triggered by your listener(s)
}
Test-State Gotchas
- Don't combine
Framework::boot()with#[RunInSeparateProcess]. Booting in a PHPUnit child process breaks the child's result serialization ("child process ended unexpectedly"). - Routes load once per process. See the
RouteManifest::reset()note under Feature Tests. Separately,ServiceProvider::loadRoutesFrom()loads each file once per process: a second boot in the same process won't re-register an extension's routes from that file unless you call\Glueful\Extensions\ServiceProvider::resetLoadedRoutes()first. Alternatively,requirethe route file into a freshRouterdirectly. SoftDeleteHandlercaches "does this table havedeleted_at?" perConnectioninstance and table, for the process. If two tests reuse oneConnectionand the second rebuildsuserswithoutdeleted_at, its queries still addWHERE deleted_at IS NULLagainst a missing column. Give each test schema its ownConnection, or keep hand-built test schemas identical to the real migrations.- Run the suite with
--order-by=randomoccasionally to catch order-dependent state.
Mocking
Mock Dependencies
public function test_sends_notification()
{
$mockMailer = $this->createMock(MailerInterface::class);
$mockMailer->expects($this->once())
->method('send')
->with($this->equalTo('[email protected]'));
$service = new NotificationService($mockMailer);
$service->sendWelcome('[email protected]');
}
Mock External APIs
public function test_fetches_weather()
{
$mockClient = $this->createMock(HttpClientInterface::class);
$mockClient->method('get')
->willReturn(['temp' => 72, 'condition' => 'sunny']);
$weather = new WeatherService($mockClient);
$result = $weather->getWeather('London');
$this->assertEquals(72, $result['temp']);
}
Assertions
Common Assertions
// Equality
$this->assertEquals($expected, $actual);
$this->assertNotEquals($expected, $actual);
// Identity
$this->assertSame($expected, $actual);
$this->assertNotSame($expected, $actual);
// Truthiness
$this->assertTrue($value);
$this->assertFalse($value);
$this->assertNull($value);
$this->assertNotNull($value);
// Arrays
$this->assertCount(3, $array);
$this->assertContains('value', $array);
$this->assertArrayHasKey('key', $array);
// Strings
$this->assertStringContainsString('needle', $haystack);
$this->assertStringStartsWith('prefix', $string);
$this->assertStringEndsWith('suffix', $string);
// Exceptions
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Error message');
Custom Assertions
protected function assertValidUser(array $user): void
{
$this->assertNotEmpty($user['uuid'] ?? null);
$this->assertNotFalse(filter_var($user['email'] ?? '', FILTER_VALIDATE_EMAIL));
}
Data Providers
PHPUnit 10 uses attributes; data provider methods must be public static:
use PHPUnit\Framework\Attributes\DataProvider;
#[DataProvider('validEmails')]
public function test_accepts_valid_email(string $email): void
{
$this->assertNotFalse(filter_var($email, FILTER_VALIDATE_EMAIL));
}
public static function validEmails(): array
{
return [
['[email protected]'],
['[email protected]'],
['[email protected]'],
];
}
Test Organization
Group Tests
use PHPUnit\Framework\Attributes\Group;
#[Group('slow')]
public function test_complex_calculation(): void
{
// Slow test
}
Run specific group:
vendor/bin/phpunit --group slow
vendor/bin/phpunit --exclude-group slow
Test Suites
phpunit.xml:
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
Run specific suite:
vendor/bin/phpunit --testsuite Unit
vendor/bin/phpunit --testsuite Feature
Best Practices
AAA Pattern
public function test_creates_post()
{
// Arrange
$data = ['title' => 'Test Post', 'content' => 'Content'];
// Act
$post = $this->service->create($data);
// Assert
$this->assertSame('Test Post', $post['title']);
}
One Assertion Per Test
// ✅ Good
public function test_post_has_title()
{
$post = $this->createPost();
$this->assertEquals('Test', $post->title);
}
public function test_post_has_content()
{
$post = $this->createPost();
$this->assertNotEmpty($post->content);
}
// ❌ Bad - multiple concerns
public function test_post()
{
$post = $this->createPost();
$this->assertEquals('Test', $post->title);
$this->assertNotEmpty($post->content);
$this->assertNotNull($post->uuid);
}
Descriptive Test Names
// ✅ Good
public function test_user_cannot_delete_other_users_posts()
// ❌ Bad
public function test_delete()
Continuous Integration
GitHub Actions
.github/workflows/tests.yml:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_sqlite, sodium
- name: Install dependencies
run: composer install
- name: Run tests
run: vendor/bin/phpunit
Next Steps
- Configuration - Environment setup
- Middleware - Test middleware
- Performance - Performance testing