Skip to content

Testing Winter Boot Applications with PHPUnit

Winter Boot does not ship its own test runner. Use PHPUnit, the standard PHP test framework (the PHP counterpart of JUnit). This page shows two kinds of tests:

  • Unit tests — create a class with new, pass in stubs for its dependencies, and test it in isolation. Fast, and no framework involved.
  • Integration tests — boot the real application context once, using the test profile, and test beans exactly as the application wires them, without starting the HTTP server.
  1. Install PHPUnit

    Terminal window
    composer require --dev phpunit/phpunit

    Register your test namespace for autoloading in composer.json, next to your application namespace, then run composer dump-autoload:

    composer.json
    {
    "autoload": {
    "psr-4": { "com\\example\\myapp\\": "src/" }
    },
    "autoload-dev": {
    "psr-4": { "com\\example\\myapp\\tests\\": "tests/" }
    }
    }
  2. Add a test starter with the test profile

    The test starter points at the same config directory as the real application, but sets profile: 'test'. The framework’s property context then loads config/application-test.yml instead of config/application.yml, and the logger loads config/logger-test.yml (falling back to config/logger.yml when there is no profile-specific file). The profile file must contain the complete configuration — it is not merged with application.yml.

    tests/TestApplication.php
    <?php
    declare(strict_types=1);
    namespace com\example\myapp\tests;
    use dev\winterframework\core\app\WinterWebApplication;
    use dev\winterframework\stereotype\WinterBootApplication;
    #[WinterBootApplication(
    configDirectory: [__DIR__ . '/../config'],
    scanNamespaces: [
    ['com\\example\\myapp', __DIR__ . '/../src'],
    ['com\\example\\myapp\\tests', __DIR__],
    ],
    // Loads config/application-test.yml instead of config/application.yml.
    // Also scanned: the tests namespace, for test-only beans below.
    profile: 'test',
    // Create every bean at startup, so wiring errors surface on boot.
    eager: true
    )]
    class TestApplication
    {
    public static function main(): void
    {
    (new WinterWebApplication())->run(TestApplication::class);
    }
    }

    WinterWebApplication is the plain web runner: under PHPUnit (CLI) it builds the full context — configuration, beans, modules, #[OnApplicationReady] — and returns without serving HTTP, so no server ever starts.

  3. Add the test configuration

    Give tests their own profile file, so they never touch your real database or services. PDBC datasources use SQLite in-memory here:

    config/application-test.yml
    winter:
    application:
    name: My Service (tests)
    id: my-service
    version: 1.0.0
    myApp:
    profile: test
    datasource:
    - name: defaultdb
    isPrimary: true
    url: "sqlite::memory:"
    greeting:
    prefix: Hi

    To silence or redirect test logging, add config/logger-test.yml in the same monolog-cascade schema as logger.yml. Without it, the normal logger.yml applies.

  4. Swap real beans for mocks with #[Bean] methods

    A #[Configuration] class can return a mock when the active profile is test and the real bean otherwise. Read the profile flag from the configuration you defined in both YAML files:

    src/OrderServiceConfig.php
    <?php
    declare(strict_types=1);
    namespace com\example\myapp;
    use dev\winterframework\stereotype\Bean;
    use dev\winterframework\stereotype\Configuration;
    use dev\winterframework\stereotype\Value;
    #[Configuration]
    class OrderServiceConfig
    {
    #[Value('${myApp.profile}')]
    private string $profile = 'prod';
    #[Bean]
    public function orderService(): OrderService
    {
    if ($this->profile === 'test') {
    return new MockOrderService();
    }
    return new RealOrderService();
    }
    }

    Set myApp.profile to the matching value in each file (prod in application.yml, test in application-test.yml). The same pattern works for any external client — payment gateways, mail senders, third-party SDKs — that must not run for real in tests.

  5. Add a context holder and bootstrap file

    The runner returns void, so a small test-support bean captures the booted context into a static holder. ApplicationContext is injectable in any bean, and eager: true on the test starter guarantees the capture runs during boot:

    tests/TestContext.php
    <?php
    declare(strict_types=1);
    namespace com\example\myapp\tests;
    use dev\winterframework\core\context\ApplicationContext;
    use dev\winterframework\stereotype\Autowired;
    use dev\winterframework\stereotype\Configuration;
    use dev\winterframework\stereotype\PostConstruct;
    #[Configuration]
    class TestContext
    {
    public static ?ApplicationContext $context = null;
    #[Autowired]
    private ApplicationContext $ctx;
    #[PostConstruct]
    public function capture(): void
    {
    self::$context = $this->ctx;
    }
    public static function context(): ApplicationContext
    {
    return self::$context ?? throw new \RuntimeException(
    'Test context has not booted — check tests/bootstrap.php.'
    );
    }
    }

    The bootstrap file boots the context once, before any test runs:

    tests/bootstrap.php
    <?php
    declare(strict_types=1);
    require __DIR__ . '/../vendor/autoload.php';
    use com\example\myapp\tests\TestApplication;
    use com\example\myapp\tests\TestContext;
    use dev\winterframework\io\timer\IdleCheckRegistry;
    // The framework parses $_SERVER['argv'] on boot, so hide PHPUnit's own
    // flags (--filter, ...) before booting.
    $argv = $_SERVER['argv'] ?? [];
    $_SERVER['argv'] = [$argv[0] ?? 'phpunit'];
    try {
    TestApplication::main();
    } finally {
    $_SERVER['argv'] = $argv;
    }
    // No server runs here: stop the datasource idle-check timer, or its
    // pending Swoole timer keeps PHPUnit running after the last test.
    TestContext::context()->beanByClass(IdleCheckRegistry::class)->disableTimer();
  6. Configure PHPUnit

    phpunit.xml
    <?xml version="1.0" encoding="UTF-8"?>
    <phpunit bootstrap="tests/bootstrap.php" colors="true">
    <php>
    <!-- Same as the Winter Boot Docker image: hide PHP 8.5 deprecation
    notices raised by third-party dependencies. -->
    <ini name="error_reporting" value="E_ALL &amp; ~E_DEPRECATED &amp; ~E_USER_DEPRECATED"/>
    </php>
    <testsuites>
    <testsuite name="app">
    <directory>tests</directory>
    </testsuite>
    </testsuites>
    </phpunit>

Beans that receive their dependencies through the constructor can be created with new in a test, with PHPUnit stubs standing in for the dependencies. No container is needed:

src/WelcomeService.php
#[Service]
class WelcomeService
{
public function __construct(private GreetingService $greetings) {}
public function welcome(string $name): string
{
return strtoupper($this->greetings->greet($name));
}
}
tests/WelcomeServiceTest.php
<?php
declare(strict_types=1);
namespace com\example\myapp\tests;
use com\example\myapp\GreetingService;
use com\example\myapp\WelcomeService;
use PHPUnit\Framework\TestCase;
final class WelcomeServiceTest extends TestCase
{
public function testWelcomeUppercasesTheGreeting(): void
{
$greetings = $this->createStub(GreetingService::class);
$greetings->method('greet')->willReturn('Hey, Bob!');
$service = new WelcomeService($greetings);
$this->assertSame('HEY, BOB!', $service->welcome('Bob'));
}
}

Fetch beans from the booted test context with beanByClass() (or beanByName()). They are the real, fully wired beans, using application-test.yml — mocks where your #[Bean] methods return them:

tests/GreetingServiceTest.php
<?php
declare(strict_types=1);
namespace com\example\myapp\tests;
use com\example\myapp\GreetingService;
use PHPUnit\Framework\TestCase;
final class GreetingServiceTest extends TestCase
{
private GreetingService $service;
protected function setUp(): void
{
$this->service = TestContext::context()->beanByClass(GreetingService::class);
}
public function testGreetUsesConfiguredPrefix(): void
{
// "Hi" comes from config/application-test.yml
$this->assertSame('Hi, Alice!', $this->service->greet('Alice'));
}
}

The context is built once and shared by every test in the run, just as beans are shared in the real application. Tests that change shared state (a database row, a cache entry) should clean it up, or rely on the fresh in-memory SQLite database each run starts with.

Terminal window
vendor/bin/phpunit
vendor/bin/phpunit --filter testGreetUsesConfiguredPrefix

The Winter Boot startup banner is printed once, when the context boots.

The test bootstrap builds the full context but does not start the server. Everything that lives inside beans works — dependency injection, #[Value], #[PostConstruct], #[OnApplicationReady], datasources and PdbcTemplate, #[Transactional], #[Cacheable] and other AOP attributes, and RestTemplate.

The parts that need the running server are not started:

Feature In tests
HTTP routing, controllers via HTTP, interceptors Not served. Test controller methods by calling them directly, and test the logic in services.
#[Async] Calling an #[Async] method on a bean from the context throws BeansException (no async worker pool). Put the work in a plain method or service and test that.
#[Scheduled], #[DaemonThread] Not started. Call the method directly to test its logic.
Local KV and queue stores Not started.