AppKit projects use PHPUnit for tests, PHPStan for static analysis, and PHP-CS-Fixer for code style. All three are available as Composer scripts.
composer test
# or directly:
vendor/bin/phpunitRun a single test suite:
vendor/bin/phpunit --testsuite ClassesRun a single test file:
vendor/bin/phpunit tests/Unit/Entity/UserTest.phpphpunit.xml.dist defines two suites: Classes covers ./tests minus the
database tests, and Database holds the platform-sensitive Doctrine tests.
The default run executes both; composer test:db runs the Database suite
alone — against SQLite in memory by default, or any real engine via
DB_DRIVER (see Testing against real databases).
The framework ships its test harness under Modufolio\Appkit\Testing\ — the
same classes AppKit's own suite runs on. PHPUnit stays in your require-dev;
the classes simply aren't loadable without it, which only test code minds.
Your application's base test case fills exactly one seam — how your app is
built — and inherits the rest: in-process request dispatch with SAPI-faithful
server params (get/post/form/json/request), session and CSRF
continuity across requests, engine-agnostic refreshDatabase(), and
actingAs()/logout() against the framework's form-login conventions.
// tests/Case/AppTestCase.php
namespace App\Tests\Case;
use App\App;
use App\AppFactory;
use Modufolio\Appkit\Testing\AppTestCase as BaseAppTestCase;
abstract class AppTestCase extends BaseAppTestCase
{
private static ?App $app = null;
// The one required seam. Declaring your concrete App as the return
// type gives every test typed access to your accessors.
protected function app(): App
{
if (self::$app === null) {
self::$app = AppFactory::create(dirname(__DIR__, 2), 'test');
self::$app->initializeConsoleState();
}
return self::$app;
}
}Optional hooks, all no-ops by default:
| Hook | When it runs | Override it to |
|---|---|---|
loadFixtures() |
on demand from your tests | seed Doctrine fixtures |
resetAppConfiguration() |
in tearDown(), after reset() |
undo per-test config changes (e.g. restore firewalls) |
afterSchemaCreate() |
at the end of refreshDatabase() |
apply DDL SchemaTool doesn't know — triggers, views |
actingAs() / logout() |
when your tests call them | match your login route and field names |
Responses come back wrapped in Modufolio\Appkit\Testing\TestResponse
(status, header, JSON and Inertia assertions — see below). Database-level
tests use Modufolio\Appkit\Testing\DatabaseTestingCapabilities; for query
tracking, fixtures, snapshots and schema management against a DBAL connection.
Both the harness trait and a config/test/doctrine.php built on the same
convention read the connection from the environment — SQLite in memory when
nothing is set, so a fresh checkout tests with zero setup:
docker compose up -d mysql postgres
DB_DRIVER=pdo_mysql DB_PORT=3308 DB_USER=root DB_PASSWORD=secret composer test:db
DB_DRIVER=pdo_pgsql DB_PORT=5434 DB_USER=postgres DB_PASSWORD=secret composer test:dbDB_DRIVER, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD are
recognised; SQL Server additionally gets TrustServerCertificate set for the
self-signed certificate a containerised server presents. The harness keeps
teardown portable — referential checks are suspended per platform, and
schema changes between test classes are detected and rebuilt. For tests that
must skip or assert something engine-specific, the trait exposes
self::driver() and self::isDriverOneOf('pdo_sqlsrv', ...).
CI runs the Database suite against MySQL 8.4, PostgreSQL 16 and SQL Server 2022 on every push.
// tests/Unit/Entity/UserTest.php
namespace App\Tests\Unit\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
final class UserTest extends TestCase
{
public function testRolesAlwaysContainRoleUser(): void
{
$user = new User();
$user->setRoles([]);
$this->assertContains('ROLE_USER', $user->getRoles());
}
public function testEnabledByDefault(): void
{
$user = new User();
$this->assertTrue($user->isEnabled());
}
}Set APP_ENV=test to activate the test environment. The Kernel will use ArrayAdapter for Doctrine's metadata and query caches instead of FilesystemAdapter, keeping tests fast.
If you create config/test/doctrine.php, the console will use it when you pass --env=test. This is useful for running migrations against an in-memory SQLite database.
// config/test/doctrine.php
use Modufolio\Appkit\Doctrine\OrmConfigurator;
return function (OrmConfigurator $orm) use ($projectDir): void {
$orm->connection([
'driver' => 'pdo_sqlite',
'memory' => true,
])->entities($projectDir . '/src/Entity');
};php bin/console orm:schema-tool:create --env=test
vendor/bin/phpunitModufolio\Appkit\Doctrine\EntityFactory creates and persists test fixtures.
use Modufolio\Appkit\Doctrine\EntityFactory;
use App\Entity\User;
$factory = new EntityFactory(
entityManager: $em,
serializer: $serializer,
validator: $validator,
);
// Create and persist one entity
$factory->create(User::class, [
'email' => 'test@example.com',
'password' => 'hashed-password',
'roles' => ['ROLE_USER'],
])->store();
// Create many entities
$factory->createMany(User::class, 10, function (int $i): array {
return [
'email' => "user{$i}@example.com",
];
})->store();Pass a config file to predefine factory defaults:
$factory->loadConfig([
User::class => [
'roles' => ['ROLE_USER'],
'enabled' => true,
],
]);Override specific fields per instance:
$factory->create(User::class, ['email' => 'admin@example.com'])
->withResolverArgs(['roles' => ['ROLE_ADMIN']])
->store();Modufolio\Appkit\Tests\Response\TestResponse wraps a PSR-7 ResponseInterface and provides a fluent assertion API inspired by Laravel's TestResponse. Use it in feature tests to assert HTTP responses without parsing raw headers or body strings.
The recommended shape for feature tests is: boot the real app once via your AppFactory, build PSR-7 requests, pass them to $app->handle(), and assert on the wrapped response — no mocking of framework internals. AppKit's own suite does exactly this; its tests/Case/AppTestCase.php (with get()/post() helpers, automatic session cookies and CSRF headers, and an actingAs() login helper) is the reference implementation to copy into your project.
use Modufolio\Appkit\Tests\Response\TestResponse;
$response = new TestResponse($this->app->handle($request));
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/json');$response->assertStatus(200);
$response->assertStatus(422);
$response->assertRedirect('/login');assertRedirect() checks that the status is a 3xx code and that the Location header matches the given URL.
$response->assertHeader('X-Custom-Header', 'value');When the response is an Inertia JSON response, chain into Inertia-specific assertions:
$response
->assertStatus(200)
->assertInertia()
->component('Dashboard')
->hasProp('user')
->whereProp('user.email', 'test@example.com')
->whereProp('stats.count', 42);| Method | Description |
|---|---|
assertInertia() |
Assert the response is an Inertia response; returns $this for chaining |
component(string $name) |
Assert the rendered component name |
hasProp(string $key) |
Assert a prop key exists (dot notation supported) |
whereProp(string $key, mixed $value) |
Assert a prop value (dot notation supported) |
$response->dump(); // print response body and continue
$response->dd(); // print and exitModufolio\Appkit\Tests\Traits\DatabaseTestingCapabilities is a PHPUnit trait that adds query tracking, database assertions, fixture seeding, and performance monitoring to any test class. It registers its hooks with #[Before] and #[After] so no setUp()/tearDown() wiring is needed.
use Modufolio\Appkit\Tests\Traits\DatabaseTestingCapabilities;
use PHPUnit\Framework\TestCase;
final class UserFeatureTest extends TestCase
{
use DatabaseTestingCapabilities;
// ...
}Assign rows to $this->fixtures before the test runs, or call seed() inside the test body:
// Declarative — set before the test
$this->fixtures = [
'users' => [
['email' => 'alice@example.com', 'roles' => '["ROLE_USER"]'],
['email' => 'bob@example.com', 'roles' => '["ROLE_ADMIN"]'],
],
];
// Imperative — call inside the test
$this->seed('users', [
['email' => 'charlie@example.com'],
]);$this->assertDatabaseHas('users', ['email' => 'alice@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'deleted@example.com']);
$this->assertDatabaseCount('users', 2);$this->assertQueryCount(3); // total queries executed
$this->assertQueryCount(1, 'SELECT'); // only SELECT queriesUse a regex pattern to assert that a specific query ran — or did not run:
$this->assertQueryExecuted('/SELECT.*FROM users/', 2);
$this->assertQueryNotExecuted('/DELETE/');$this->assertTableQueried('users', 'SELECT'); // table was SELECTed
$this->assertTableNotQueried('sessions'); // table was never touched$this->assertNoSlowQueries(); // no query exceeded the threshold
$this->assertQueryPerformance('/SELECT.*users/', 0.05); // pattern must complete in < 50 msSet the slow query threshold (default 1.0 s):
$this->setSlowQueryThreshold(0.5); // queries over 500 ms are "slow"$report = $this->getPerformanceReport();
// ['total_queries' => 4, 'slow_queries' => 0, 'total_time' => 0.012, ...]withAutoSnapshot() saves the database state before the test and restores it after, giving you full isolation without rebuilding the schema:
$this->withAutoSnapshot();$this->dumpQueryLog(); // print all recorded queries
$log = $this->getQueryLog('SELECT', 'users'); // filter by type and tablecomposer stanPHPStan runs at level 8 — the framework and the skeleton both hold that level. The config file is phpstan.php in the project root:
return [
'parameters' => [
'level' => 8,
'paths' => ['src'],
],
];If level 8 is too strict for a legacy codebase you are migrating, lower level and raise it back incrementally.
Fix errors before committing. PHPStan catches type mismatches, undefined variables, and unreachable code that tests might miss.
composer fixReformats all PHP files in src/ to match the configured coding standard. Run this before every commit to keep the diff clean.
Check what would change without modifying files:
vendor/bin/php-cs-fixer fix --dry-run --diffA reliable CI pipeline runs these steps in order. Note that CI installs with dev dependencies — PHPUnit and PHPStan live in require-dev; --no-dev belongs to the deploy build, not the test run:
# 1. Install dependencies (including require-dev, for phpunit + phpstan)
composer install --optimize-autoloader
# 2. Install Node.js dependencies and build assets
npm ci
npm run build
# 3. Create the database schema (or run migrations)
php bin/console migrations:migrate --no-interaction
# 4. Run the test suite
vendor/bin/phpunit
# 5. Run static analysis
vendor/bin/phpstan analyseFor the artifact you actually deploy, build separately with composer install --no-dev --optimize-autoloader.
Each test that touches the database should use a transaction rollback or rebuild the schema from scratch between test runs. A simple approach with in-memory SQLite:
protected function setUp(): void
{
// Rebuild schema before each test
$schemaTool = new \Doctrine\ORM\Tools\SchemaTool($this->em);
$schemaTool->createSchema($this->em->getMetadataFactory()->getAllMetadata());
}
protected function tearDown(): void
{
$schemaTool = new \Doctrine\ORM\Tools\SchemaTool($this->em);
$schemaTool->dropDatabase();
}