The template ships with Codeception pre-configured as the test runner for your application. It is not tied to the framework in any way — if you prefer PHPUnit, Pest, or no test framework at all, you can remove it. Jest and k6 are also available in the template for JavaScript unit tests and performance/load testing respectively.
Suite selection at a glance:
tests/
├── Acceptance/
│ ├── ExampleAcceptanceCest.php
│ └── .gitignore
├── Functional/
│ ├── ExampleFunctionalCest.php
│ └── .gitignore
├── Unit/
│ └── ExampleUnitCest.php
├── Support/
│ ├── AcceptanceTester.php
│ ├── FunctionalTester.php
│ ├── UnitTester.php
│ ├── Uni2Tester.php
│ ├── Helper/
│ │ ├── Acceptance.php
│ │ ├── Functional.php
│ │ └── Unit.php
│ └── _generated/ ← auto-generated, not committed
├── _data/ ← test fixtures data
├── _output/ ← test output, not committed
├── Acceptance.suite.yml
├── Functional.suite.yml
├── Unit.suite.yml
└── bootstrap.php
src/
└── Tests/
├── Performance/
│ └── load.test.js ← k6 performance tests
└── Unit/
└── example.js ← Jest unit tests
The test bootstrap wires the framework kernel the same way public/index.php does, with a few additions for the test context:
// tests/bootstrap.php
require_once __DIR__ . '/../Platform/vendor/autoload.php';
require_once __DIR__ . '/../functions/functions.php';
require_once __DIR__ . '/../config/constants.php';
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../config/eventListeners.php';
// PHPUnit sets APP_ENV=test via phpunit.xml.dist <server> before this runs.
// Codeception does not, so we default it here so bootEnv('.env') picks up .env.test.
$_SERVER['APP_ENV'] ??= 'test';
$_ENV['APP_ENV'] ??= 'test';
( new Dotenv() )->bootEnv( __DIR__ . '/../.env' );
$kernel = ( new PHP_SF\Kernel() )
->addTranslationFiles( __DIR__ . '/../lang' )
->addControllers( __DIR__ . '/../App/Http/Controller' )
->setHeaderTemplateClassName( header::class )
->setFooterTemplateClassName( footer::class )
->setApplicationUserClassName( User::class )
->addTemplatesDirectory( 'templates', 'App\View' );
Router::loadRoutesOnly( $kernel );
auth::logInUser();
$GLOBALS['kernel'] = $kernel;
Router::loadRoutesOnly( $kernel ) pre-populates the framework's route list from the registered controller directories without dispatching any request. This is required so that PhpSfRouteLoader (used by Codeception's Symfony module) can register all PHP_SF routes into Symfony's compiled router cache.
$GLOBALS['kernel'] stores the kernel instance for test classes that need direct access — the MiddlewaresExecutorTest uses it to construct middleware with the correct kernel instance.
auth::logInUser() with no arguments attempts to restore an authenticated user from the session. In the test environment this typically finds nothing and sets auth::$user = false, but it's called to match the production boot sequence.
codeception.yml at the project root configures the runner:
namespace: Tests
support_namespace: Support
paths:
tests: tests
output: tests/_output
data: tests/_data
support: tests/Support
envs: tests/_envs
actor_suffix: Tester
extensions:
enabled:
- Codeception\Extension\RunFailed
coverage:
enabled: true
include:
- App/*
- Platform/*
- templates/*
- functions/*
low_limit: 30
high_limit: 60
show_uncovered: true
env:
.env.test
Coverage includes App/, Platform/, templates/, and functions/. Low watermark is 30%, high is 60% — below 30% the build fails, above 60% coverage is considered acceptable. Adjust these thresholds as the test suite matures.
.env.test is loaded for all test runs — keep test-specific environment overrides there (test database URL, etc.).
# tests/Unit.suite.yml
actor: Uni2Tester
suite_namespace: Tests\Uni2
modules:
enabled:
- Asserts
- \Tests\Support\Helper\Unit
bootstrap: ../bootstrap.php
Unit tests use Uni2Tester (note the naming — a second unit tester actor, the first UnitTester exists for compatibility). The Asserts module provides assertion methods directly on the tester.
Unit test files use the Cest suffix:
// tests/Unit/ExampleUnitCest.php
namespace Tests\Unit;
use PHP_SF\System\Core\Cache\RedisCacheAdapter;
use Tests\Support\Uni2Tester;
class ExampleUnitCest
{
public function testSomething( Uni2Tester $I ): void
{
$I->assertInstanceOf( RedisCacheAdapter::class, ca() );
}
}
Since the framework kernel is booted in bootstrap.php, all framework helpers are available in unit tests — ca(), em(), rca(), entity static finders, everything. This makes it straightforward to test against real Redis and real database connections rather than mocking everything.
# tests/Functional.suite.yml
actor: FunctionalTester
modules:
enabled:
- Symfony:
app_path: 'App'
environment: 'test'
- Asserts
- \Tests\Support\Helper\Functional
bootstrap: ../bootstrap.php
Functional tests use Symfony's Codeception module — they emulate HTTP requests through the Symfony kernel without a real browser. This is the right suite for testing API endpoints: it's fast, has full access to the Symfony container, and supports JSON response assertions without needing a running server. Both Symfony controllers and PHP_SF framework controllers are supported:
// tests/Functional/ExampleFunctionalCest.php
namespace Tests\Functional;
use Tests\Support\FunctionalTester;
class ExampleFunctionalCest
{
// Tests a native Symfony controller (App/Http/SymfonyControllers/)
public function testSymfonyControllerReturnsJson( FunctionalTester $I ): void
{
$I->amOnPage( '/example/symfony' );
$I->seeResponseCodeIs( 200 );
$I->seeInSource( '{"key":"value"}' );
}
// Tests a PHP_SF framework controller (App/Http/Controller/)
public function testPhpSfFrameworkControllerReturnsJSON( FunctionalTester $I ): void
{
$I->amOnPage( '/example/page/json_response' );
$I->seeResponseCodeIs( 200 );
$I->seeInSource( '{"status":"ok"}' );
}
}
PHP_SF framework routes are available in functional tests because PHP_SF\Framework\Routing\PhpSfRouteLoader is registered as a Symfony routing.loader service (active only in the test environment via when@test in config/routes.yaml). This loader runs during Symfony's cache warming and includes all PHP_SF routes in the compiled router matcher, so Codeception's kernel reboots between tests do not lose the routes.
# tests/Acceptance.suite.yml
actor: AcceptanceTester
modules:
enabled:
- WebDriver:
browser: firefox
host: '127.0.0.1'
port: 4444
url: 'https://host.docker.internal:7000/'
window_size: 1280x1280
capabilities:
acceptInsecureCerts: true
Acceptance tests use WebDriver (Selenium) to drive a real browser against a running application. These are full end-to-end tests — JavaScript executes normally, so this is the correct suite for pages that require JS to function:
// tests/Acceptance/ExampleAcceptanceCest.php
namespace Tests\Acceptance;
use Tests\Support\AcceptanceTester;
class ExampleAcceptanceCest
{
public function testSomething( AcceptanceTester $I ): void
{
$I->amOnPage( '/' );
$I->wait( 1 );
$I->see( 'Haven\'t registered yet' );
}
}
Acceptance tests require:
https://host.docker.internal:7000/docker-compose.yml, start with docker-compose up -dacceptInsecureCerts: true to handle self-signed certificates from Symfony CLI's local HTTPS# Run all suites
php vendor/bin/codecept run
# Run a specific suite
php vendor/bin/codecept run Unit
php vendor/bin/codecept run Functional
php vendor/bin/codecept run Acceptance
# Run a specific test file
php vendor/bin/codecept run Unit ExampleUnitCest
# Run a specific test method
php vendor/bin/codecept run Unit ExampleUnitCest:testSomething
# Run with coverage report
php vendor/bin/codecept run --coverage --coverage-html
# Run only previously failed tests
php vendor/bin/codecept run -g failed
The RunFailed extension enabled in codeception.yml automatically tags failed tests with the failed group, making it easy to re-run only failures after fixing them.
PHPUnit is available alongside Codeception for tests that are more naturally expressed as PHPUnit test cases — particularly the framework's own internal tests for cache adapters and middleware:
# Run all PHPUnit tests
php bin/phpunit
# Run a specific test file
php bin/phpunit tests/System/Core/Cache/RedisCacheAdapterTest.php
# Run with coverage
php bin/phpunit --coverage-html var/coverage
phpunit.xml.dist configures the test run:
<phpunit bootstrap="tests/bootstrap.php">
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">App</directory>
</include>
</coverage>
</phpunit>
PHPUnit uses the same tests/bootstrap.php as Codeception — the framework kernel is booted and all helpers are available. Copy phpunit.xml.dist to phpunit.xml (which is gitignored) to customise your local test run without affecting the committed configuration.
PHPUnit test classes can sit anywhere in the tests/ directory. The cache adapter tests are good examples of what framework-aware PHPUnit tests look like:
// tests/System/Core/Cache/RedisCacheAdapterTest.php
namespace PHP_SF\Tests\System\Core\Cache;
use PHP_SF\System\Core\Cache\RedisCacheAdapter;
use PHPUnit\Framework\TestCase;
final class RedisCacheAdapterTest extends TestCase
{
protected function tearDown(): void
{
// Clean up after each test
rca()->clear();
}
public function testGetInstance(): void
{
$instance1 = RedisCacheAdapter::getInstance();
$instance2 = RedisCacheAdapter::getInstance();
$this->assertInstanceOf( RedisCacheAdapter::class, $instance1 );
$this->assertSame( $instance1, $instance2 );
}
public function testSetAndGet(): void
{
rca()->set( 'test_key', 'test_value' );
$this->assertSame( 'test_value', rca()->get( 'test_key' ) );
}
public function testDelete(): void
{
rca()->set( 'test_key', 'test_value' );
rca()->delete( 'test_key' );
$this->assertNull( rca()->get( 'test_key' ) );
}
}
Because bootstrap.php boots the full framework kernel, tests run against real Redis, real database connections, and real cache adapters. There's no mocking infrastructure built into the framework — tests work against actual services. Use the tearDown() method to clean up after each test.
Load and performance tests use k6 — a JavaScript-based load testing tool. Test files live in src/Tests/Performance/:
// src/Tests/Performance/load.test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export default function () {
let res = http.get( 'https://127.0.0.1:7000' );
check( res, {
'status was 200': ( r ) => r.status === 200
} );
sleep( 1 );
}
Run k6 tests (requires k6 installed separately):
# Basic run
k6 run src/Tests/Performance/load.test.js
# With virtual users and duration
k6 run --vus 10 --duration 30s src/Tests/Performance/load.test.js
# With ramp-up
k6 run --stage 5s:10,30s:50,10s:0 src/Tests/Performance/load.test.js
k6 is not part of the Composer or npm dependency tree — install it via your system package manager or download from the k6 website. The test files are committed, the runner is not.
Use performance tests to:
JavaScript unit tests use Jest, configured in jest.config.js:
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: [ '<rootDir>/src/Tests/Unit' ],
testMatch: [
'**/?(*.)+(spec|test).[tj]s?(x)'
],
};
// src/Tests/Unit/example.js
import expect from 'expect';
describe( 'exampleTest', () => {
it( 'should pass', () => {
expect( true ).toBe( true );
} );
} );
Run Jest tests:
# Run all Jest tests
npm run test:unit
# Watch mode
npm run test:unit -- --watch
Jest tests live in src/Tests/Unit/ and test frontend JavaScript logic — game engine calculations, UI state management, WebSocket message handling, anything that runs in the browser. They don't have access to PHP or the framework — these are pure JavaScript tests.
.env.test holds test-specific environment overrides:
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=disabled
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
For a separate test database, add an override to .env.test or set it per connection:
DATABASE_MAIN_DBNAME_TEST=nations-original-app_test
The test database name is configured in config/packages/doctrine.yaml. Each connection has an explicit when@test override that uses the default: env processor to fall back to the base name with a _test suffix if no explicit test name is provided:
when@test:
doctrine:
dbal:
connections:
main:
dbname: '%env(default:app.db_main_test_dbname:DATABASE_MAIN_DBNAME_TEST)%'
parameters:
app.db_main_test_dbname: '%env(DATABASE_MAIN_DBNAME)%_test'
Create the test database schema (database must be created before schema):
bin/console doctrine:database:create --if-not-exists --em=main --env=test
bin/console doctrine:schema:create --em=main --env=test
Codeception and PHPUnit both generate coverage reports. Coverage is configured to include App/, Platform/, templates/, and functions/:
# Codeception HTML coverage report
php vendor/bin/codecept run --coverage --coverage-html
# PHPUnit HTML coverage report
php bin/phpunit --coverage-html var/coverage
Reports are written to tests/_output/ (Codeception) or var/coverage/ (PHPUnit). Both directories are gitignored.
The 30%/60% thresholds in codeception.yml are starting points. For a game backend with complex business logic, I aim higher — 70%+ coverage on entity validation, middleware, and core game mechanics significantly reduces regression risk.
Not cleaning up after tests — framework tests run against real Redis and real database connections. Without tearDown() cleanup, test data bleeds between test methods and causes unpredictable failures depending on test execution order. Always clean up in tearDown().
Running acceptance tests without the app running — acceptance tests require the application to be running at https://127.0.0.1:7000/. Running them without starting the server first produces WebDriver connection errors, not useful test failures. Always start the dev server before running the acceptance suite.
Forgetting to create the test database — if DATABASE_URL in .env.test points to a separate test database, it must be created and migrated before tests run. A missing test database causes all database-touching tests to fail with connection errors.
Using DEV_MODE = true in test environment — APCu is cleared on every request boot in DEV_MODE, which adds overhead to every test. Set DEV_MODE = false in config/constants.php when running the test suite for accurate performance and to test production cache behaviour. Or maintain a separate config/constants.test.php and load it in bootstrap.php.