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
testprofile, and test beans exactly as the application wires them, without starting the HTTP server.
-
Install PHPUnit
Terminal window composer require --dev phpunit/phpunitRegister your test namespace for autoloading in
composer.json, next to your application namespace, then runcomposer dump-autoload:composer.json {"autoload": {"psr-4": { "com\\example\\myapp\\": "src/" }},"autoload-dev": {"psr-4": { "com\\example\\myapp\\tests\\": "tests/" }}} -
Add a test starter with the
testprofileThe test starter points at the same config directory as the real application, but sets
profile: 'test'. The framework’s property context then loadsconfig/application-test.ymlinstead ofconfig/application.yml, and the logger loadsconfig/logger-test.yml(falling back toconfig/logger.ymlwhen there is no profile-specific file). The profile file must contain the complete configuration — it is not merged withapplication.yml.tests/TestApplication.php <?phpdeclare(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);}}WinterWebApplicationis 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. -
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-serviceversion: 1.0.0myApp:profile: testdatasource:- name: defaultdbisPrimary: trueurl: "sqlite::memory:"greeting:prefix: HiTo silence or redirect test logging, add
config/logger-test.ymlin the same monolog-cascade schema aslogger.yml. Without it, the normallogger.ymlapplies. -
Swap real beans for mocks with
#[Bean]methodsA
#[Configuration]class can return a mock when the active profile istestand the real bean otherwise. Read the profile flag from the configuration you defined in both YAML files:src/OrderServiceConfig.php <?phpdeclare(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.profileto the matching value in each file (prodinapplication.yml,testinapplication-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. -
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.ApplicationContextis injectable in any bean, andeager: trueon the test starter guarantees the capture runs during boot:tests/TestContext.php <?phpdeclare(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 <?phpdeclare(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(); -
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 deprecationnotices raised by third-party dependencies. --><ini name="error_reporting" value="E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED"/></php><testsuites><testsuite name="app"><directory>tests</directory></testsuite></testsuites></phpunit>
Unit tests
Section titled “Unit tests”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:
#[Service]class WelcomeService{ public function __construct(private GreetingService $greetings) {}
public function welcome(string $name): string { return strtoupper($this->greetings->greet($name)); }}<?phpdeclare(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')); }}Integration tests
Section titled “Integration tests”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:
<?phpdeclare(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.
Run the tests
Section titled “Run the tests”vendor/bin/phpunitvendor/bin/phpunit --filter testGreetUsesConfiguredPrefixThe Winter Boot startup banner is printed once, when the context boots.
What runs in tests, and what does not
Section titled “What runs in tests, and what does not”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. |
Related pages
Section titled “Related pages”- Dependency Injection — constructor and property injection.
- Configuration — how
application.yml, profiles, and#[Value]work. - Profiles — how
profile: 'test'selectsapplication-test.ymlandlogger-test.yml. - Application Lifecycle — what happens during startup.