Spiral is designed with a strong emphasis on testing. It comes with built-in support
for PHPUnit, and includes a pre-configured phpunit.xml
file for your application.
The default directory structure of a Spiral application includes a tests
directory, which contains two
subdirectories: Feature
and Unit
.
Unit tests are designed to test small, isolated portions of code, often focusing on a single method. These tests do not boot the entire Spiral application, and therefore cannot access the database or other framework services.
On the other hand, Feature tests are intended to test a larger portion of code, including interactions between multiple objects, or even a full HTTP request to a JSON endpoint. These tests provide more comprehensive coverage of your application and give you greater confidence that it is functioning as intended.
Spiral provides the spiral/testing
package to help developers with the testing of their application. This package
offers a variety of helper methods that can simplify the process of writing tests for Spiral applications.
In order to run tests, you may need to set up certain environment variables. One way to do this is by using
the phpunit.xml
file. This file is used by the PHPUnit testing framework to configure the testing
environment.
Here is an example:
<phpunit>
// ...
<php>
<env name="APP_ENV" value="testing"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MONOLOG_DEFAULT_CHANNEL" value="stderr"/>
<env name="CACHE_STORAGE" value="array"/>
<env name="TOKENIZER_CACHE_TARGETS" value="true" />
<env name="CYCLE_SCHEMA_CACHE" value="true" />
// ...
</php>
</phpunit>
Warning
When you run tests in a Docker container, the settings in Docker are more important than the ones inphpunit.xml
. This might cause trouble if they don't match. Make sure the Docker settings are right for your tests.
Notice It is highly advised to enable
TOKENIZER_CACHE_TARGETS
andCYCLE_SCHEMA_CACHE
for enhanced test performance. By doing so, you allow caching of tokenizer and ORM schema which means they won't be executed with each test iteration. However, please note it's important to clear this cache whenever you make changes to your code or the schema of your entities to ensure tests run with up-to-date configurations.
On the other hand, Unit tests, which focus on small, isolated portions of your code, should extend the
PHPUnit\Framework\TestCase
class. Unit tests do not boot the entire Spiral application, so they are not able to access
the database or other framework services.
Here is an example of a simple unit test:
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class UserTest extends TestCase
{
public function testGetId(): void
{
$user = new User(id: 101);
$this->assertSame(101, $user->getId());
}
}
Feature tests, which test a larger portion of your code, should extend the Tests\TestCase
abstract class. This class
is specifically designed to bootstrap your application, simulating the behavior of a web server, but with testing
environment variables.
Here is an example of a simple feature test:
namespace Tests\Feature\Controller\UserController;
use Tests\TestCase;
final class ShowActionTest extends TestCase
{
public function testShowPageNotFoundIfUserNotExist(): void
{
$http = $this->fakeHttp();
$response = $http->get('/user/1');
$response->assertNotFound();
}
}
The Tests\TestCase
class includes a feature that allows you to set up environment variables for your test cases. This
can be useful if you need to test specific behavior based on environment variables.
use Tests\TestCase;
final class SomeTest extends TestCase
{
public const ENV = [
'DEBUG' => false,
// ...
];
public function testSomeFeature(): void
{
//
}
}
You can also define ENV variables using PHP attributes. This allows for more granular control over the test environment.
use Tests\TestCase;
use Spiral\Testing\Attribute\Env;
final class SomeTest extends TestCase
{
#[Env('DEBUG', false)]
#[Env('APP_ENV', 'production')]
public function testSomeFeature(): void
{
//
}
}
The Tests\TestCase
class also includes a feature that allows you to interact with the container.
$container = $this->getContainer();
$this->assertContainerMissed(\Spiral\Queue\QueueConnectionProviderInterface::class);
$this->assertContainerInstantiable(\Spiral\Queue\QueueConnectionProviderInterface::class);
$this->assertContainerBound(\Spiral\Queue\QueueConnectionProviderInterface::class);
Check if container bound to a specific class
$this->assertContainerBound(
\Spiral\Queue\QueueConnectionProviderInterface::class,
\Spiral\Queue\QueueManager::class
);
Check if container bound to a specific class with parameters
$this->assertContainerBound(
\Spiral\Queue\QueueConnectionProviderInterface::class,
\Spiral\Queue\QueueManager::class,
['foo' => 'bar']
);
You can also use a callback function for additional checks
$this->assertContainerBound(
\Spiral\Queue\QueueConnectionProviderInterface::class,
\Spiral\Queue\QueueManager::class,
['foo' => 'bar'],
function(\Spiral\Queue\QueueManager $manager) {
$this->assertEquals(..., $manager->....)
}
);
$this->assertContainerBoundAsSingleton(
\Spiral\Queue\QueueConnectionProviderInterface::class,
\Spiral\Queue\QueueManager::class
);
$this->assertContainerBoundNotAsSingleton(
\Spiral\Queue\QueueConnectionProviderInterface::class,
\Spiral\Queue\QueueManager::class
);
In some cases you need to mock a service in the container. For example authentication service.
namespace Tests\Feature\Controller\UserController;
use Tests\TestCase;
final class ProfileActionTest extends TestCase
{
public function testShowPageNotFoundIfUserNotExist(): void
{
$auth = $this->mockContainer(\Spiral\Auth\ActorProviderInterface::class);
$auth->shouldReceive('getActor')->with(...)->once()->andReturnNull();
$http = $this->fakeHttp();
$response = $http->get('/user/profile');
$response->assertNotFound();
}
}
Let's imagine that we have the following config:
return [
'basePath' => '/',
'headers' => [
'Content-Type' => 'text/html; charset=UTF-8',
],
'middleware' => [],
];
In some cases you need to define config values for specific tests. You can do it using PHP attributes.
use Tests\TestCase;
use Spiral\Testing\Attribute\Config;
final class SomeTest extends TestCase
{
#[Config('http.basePath', '/custom')]
#[Config('http.headers.Content-Type', 'text/plain')]
public function testSomeFeature(): void
{
//
}
}
$this->assertConfigMatches('http', [
'basePath' => '/',
'headers' => [
'Content-Type' => 'text/html; charset=UTF-8',
],
'middleware' => [],
]);
$this->assertConfigHasFragments('http', [
'basePath' => '/'
])
/** @var array $config */
$config = $this->getConfig('http');
$this-assertDirectoryAliasDefined('root');
$this->assertDirectoryAliasMatches('runtime', __DIR__.'src/runtime');
$this->cleanupDirectories(
__DIR__.'src/runtime/cache',
__DIR__.'src/runtime/tmp'
);
$this->cleanupDirectoriesByAliases(
'runtime', 'cache', 'logs'
);
You can also clean up runtime directory
$this->cleanUpRuntimeDirectory();
$this->assertCommandRegistered('ping');
You can run a console command in your test case and check the result.
$output = $this->runCommand('ping', ['site' => 'https://google.com']);
$this->assertStringContainsString('Pong', $output);
You can also check strings in the output using
$this->assertConsoleCommandOutputContainsStrings(
'ping',
['site' => 'https://google.com'],
['Site found', 'Starting ping ...', 'Success!']
);
The Tests\TestCase
class also includes a feature that allows you to interact with bootloaders.
$this->assertBootloaderLoaded(\MyPackage\Bootloaders\PackageBootloader::class);
$this->assertBootloaderMissed(\MyPackage\Bootloaders\PackageBootloader::class);
$this->assertDispatcherRegistered(HttpDispatcher::class);
$this->assertDispatcherMissed(HttpDispatcher::class);
You can run a dispatcher with passing some bindings as second argument. It will be run inside scope with bindings.
$this->serveDispatcher(HttpDispatcher::class, [
\Spiral\Boot\EnvironmentInterface::class => new \Spiral\Boot\Environment([
'foo' => 'bar'
]),
]);
Check if a dispatcher can be served with current environment.
$this->assertDispatcherCanBeServed(HttpDispatcher::class);
Check if a dispatcher cannot be served with current environment.
$this->assertDispatcherCannotBeServed(HttpDispatcher::class);
/** @var class-string<\Spiral\Boot\DispatcherInterface>[] $dispatchers */
$dispatchers = $this->getRegisteredDispatchers();
We can provide the ability to test scaffolder commands.
$this->assertScaffolderCommandSame(
'create:command',
[
'name' => 'TestCommand',
],
expected: <<<'PHP'
<?php
declare(strict_types=1);
namespace Spiral\Testing\Command;
use Spiral\Console\Attribute\Argument;
use Spiral\Console\Attribute\AsCommand;
use Spiral\Console\Attribute\Option;
use Spiral\Console\Attribute\Question;
use Spiral\Console\Command;
#[AsCommand(name: 'test:command')]
final class TestCommand extends Command
{
public function __invoke(): int
{
// Put your command logic here
$this->info('Command logic is not implemented yet');
return self::SUCCESS;
}
}
PHP,
expectedFilename: 'app/src/Command/TestCommand.php',
expectedOutputStrings: [
"Declaration of 'TestCommand' has been successfully written into 'app/src/Command/TestCommand.php",
],
);
$this->assertScaffolderCommandContains(
'create:command',
[
'name' => 'TestCommand',
'--namespace' => 'App\Command',
],
expectedStrings: [
'namespace App\Command;',
],
expectedFilename: 'app/src/TestCommand.php',
);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Not enough arguments (missing: "name").');
$this->assertScaffolderCommandSame(
'create:command',
[],
'',
);
$this->assertScaffolderCommandContains(
'create:command',
[
'name' => 'TestCommand',
'-o' => 'foo',
],
expectedStrings: [
"#[Option(description: 'Argument description')]",
'private bool $foo;'
],
);
When you run the vendor/bin/phpunit
command, it will automatically look for test files in the tests
directory of
your application, and run them using the configuration specified in the phpunit.xml
file.
To run the tests in your Spiral application, you can simply execute the command:
./vendor/bin/phpunit
Enjoy testing!
Now, dive deeper into the fundamentals by reading some articles: