spiral/testing
Testing SDK for Spiral Framework packages. Provides a custom TestCase with a TestApp so you can test packages without a full application setup. Configure root directory and bootloaders, and keep test app config under tests/app. PHP 8.1+, Spiral 3.15+.
## Getting Started
### Minimal Setup
1. **Installation**:
```bash
composer require spiral/testing --dev
Ensure your project meets the requirements: PHP 8.1+ and Spiral Framework 3.15+.
Basic TestCase Structure:
Create a base test class extending Spiral\Testing\TestCase in your package's tests/src directory:
namespace MyPackage\Tests;
abstract class TestCase extends \Spiral\Testing\TestCase
{
public function rootDirectory(): string
{
return __DIR__.'/../../'; // Adjust path to your package root
}
public function defineBootloaders(): array
{
return [
\MyPackage\Bootloaders\PackageBootloader::class,
];
}
}
First Test:
Use the fakeHttp() helper to test HTTP endpoints:
use MyPackage\Tests\TestCase;
final class MyFirstTest extends TestCase
{
public function test_index_route()
{
$response = $this->fakeHttp()->get('/');
$response->assertOk();
}
}
Run Tests:
vendor/bin/phpunit
fakeHttp(): Simulate HTTP requests with middleware and routes.fakeEventDispatcher(): Mock event listeners and assertions.fakeQueue(): Test job processing and assertions.assertConfigHasFragments(): Validate configuration values.$response = $this->fakeHttp()->get('/api/users');
$response->assertStatus(200);
$response->assertJson(['count' => 1]);
$response = $this->fakeHttp()->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com'
]);
$response->assertCreated();
// Disable middleware for a request
$response = $this->fakeHttp()->withoutMiddleware()->get('/');
// Add custom middleware
$response = $this->fakeHttp()->withMiddleware(\App\Middleware\AuthMiddleware::class)->get('/admin');
$response = $this->fakeHttp()->post('/upload', [
'file' => new \Psr\Http\Message\UploadedFile(
fopen(__DIR__.'/test.txt', 'r'),
10,
UPLOAD_ERR_OK,
'test.txt',
'text/plain'
)
]);
protected function setUp(): void
{
parent::setUp();
$this->eventDispatcher = $this->fakeEventDispatcher();
}
public function test_event_listener()
{
$this->eventDispatcher->assertListening(
\MyPackage\Events\UserCreated::class,
\MyPackage\Listeners\SendWelcomeEmail::class
);
}
public function test_event_dispatched()
{
// Trigger an event (e.g., via a service)
$this->app->make(\MyPackage\Services\UserService::class)->createUser('john');
$this->eventDispatcher->assertDispatched(\MyPackage\Events\UserCreated::class);
$this->eventDispatcher->assertDispatchedTimes(\MyPackage\Events\UserCreated::class, 1);
}
public function test_queue_job()
{
$this->fakeQueue()->assertPushed(
\MyPackage\Jobs\SendEmailJob::class,
static function (\MyPackage\Jobs\SendEmailJob $job) {
return $job->email === 'john@example.com';
}
);
}
public function test_job_processing()
{
$this->fakeQueue()->push(new \MyPackage\Jobs\SendEmailJob('john@example.com'));
$this->fakeQueue()->run();
$this->fakeQueue()->assertProcessed(\MyPackage\Jobs\SendEmailJob::class);
}
public function test_config_values()
{
$this->assertConfigHasFragments('http', [
'basePath' => '/',
'headers' => [
'Content-Type' => 'application/json',
],
]);
}
public function test_config_override()
{
$this->app->config(['my-package' => ['debug' => true]]);
$this->assertTrue($this->app->config('my-package.debug'));
}
public function test_command_registered()
{
$this->assertCommandRegistered(\MyPackage\Console\GenerateCommand::class);
}
public function test_command_output()
{
$output = $this->fakeConsole()->run(\MyPackage\Console\GenerateCommand::class, ['name' => 'test']);
$this->assertStringContainsString('Generated test', $output);
}
http):use Spiral\Testing\Attribute\TestScope;
#[TestScope('http')]
public function test_http_scope()
{
$response = $this->fakeHttp()->get('/');
$response->assertOk();
}
Container State Leakage:
fakeHttp(), fakeQueue(), etc., to isolate scopes.@TestScope attributes to limit scope leakage.Middleware Scope Issues:
withMiddleware() may not persist across requests if not scoped correctly.withoutMiddleware() sparingly and ensure middleware is properly bound in your bootloader.Configuration Overrides:
setUp() and tearDown() to reset config if needed.setUp() and restore defaults in tearDown():
protected function tearDown(): void
{
$this->app->config(['my-package' => []]); // Reset to defaults
parent::tearDown();
}
Event Dispatcher Conflicts:
EventDispatcher, fakeEventDispatcher() may throw errors.if ($this->app->hasBinding(\Spiral\Event\EventDispatcherInterface::class)) {
$this->eventDispatcher = $this->fakeEventDispatcher();
}
Queue Job Assertions:
assertPushed() and assertProcessed() are strict about job class names. Use anonymous functions for complex assertions:
$this->fakeQueue()->assertPushed(
\MyPackage\Jobs\SendEmailJob::class,
static fn (\MyPackage\Jobs\SendEmailJob $job) => $job->priority > 0
);
Inspect HTTP Requests:
Use FakeHttp::createRequest() to manually craft requests for debugging:
$request = $this->fakeHttp()->createRequest('GET', '/api/users');
$request->getUri()->withQuery('page=1');
$response = $this->fakeHttp()->handleRequest($request);
Log Container Bindings: Dump container bindings to debug scope issues:
$this->app->bindings()->each(function ($binding, $abstract) {
\Log::debug("Binding: {$abstract} => {$binding}");
});
Test Response Body:
When assertions fail, assertStatus() now displays the response body for debugging:
$response->assertStatus(200); // Shows body if status fails
Interactive Commands: Non-interactive mode is enabled by default for tests. To simulate user input:
$this->fakeConsole()->run(\MyPackage\Console\InteractiveCommand::class, [], [
'input' => ['y'], // Simulate user input
]);
Custom Assertions:
Extend TestCase to add reusable assertions:
abstract class TestCase extends \Spiral\Testing\TestCase
{
protected function assertResponseHasData(array $expectedData)
{
$response = $this->fakeHttp()->get('/api/data');
$response->assertJson($expectedData);
}
}
Mocking External Services:
Use Spiral’s bind() to mock dependencies:
protected function setUp(): void
{
parent
How can I help you explore Laravel packages today?