This guide covers writing and running tests for your Marwa Framework application.
The framework uses PHPUnit for testing.
composer test
vendor/bin/phpunit tests/ControllersTest.php
vendor/bin/phpunit --filter testUserCanLogin
vendor/bin/phpunit --coverage-html coverage/
tests/
├── Fixtures/
│ ├── Controllers/
│ ├── Models/
│ └── Seeders/
├── ControllersTest.php
├── ModelsTest.php
└── SupportsTest.php
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
final class ExampleTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testExpectedValue(): void
{
$value = 1 + 1;
$this->assertSame(2, $value);
}
}
<?php
declare(strict_types=1);
namespace Tests;
use Tests\Fixtures\Controllers\InspectableController;
use PHPUnit\Framework\TestCase;
use Marwa\Framework\HttpKernel;
use Psr\Http\Message\ServerRequestInterface;
final class ControllerTest extends TestCase
{
public function testControllerReturnsResponse(): void
{
// Arrange
$app = $this->createApplication();
// Act
$response = $app->handle($this->createRequest('GET', '/users'));
// Assert
$this->assertSame(200, $response->getStatusCode());
}
private function createApplication(): HttpKernel
{
// Create test application
}
private function createRequest(string $method, string $path): ServerRequestInterface
{
// Create test request
}
}
<?php
declare(strict_types=1);
namespace Tests;
use Tests\Fixtures\Models\CrudUser;
use PHPUnit\Framework\TestCase;
final class ModelTest extends TestCase
{
protected function setUp(): void
{
// Set up test database
}
public function testCanCreateUser(): void
{
// Arrange
$user = new CrudUser();
$user->name = 'John Doe';
$user->email = 'john@example.com';
// Act
$id = $user->save();
// Assert
$this->assertNotNull($id);
}
public function testCanFindUser(): void
{
// Arrange - create user
$user = new CrudUser();
$user->name = 'John Doe';
$user->email = 'john@example.com';
$user->save();
// Act
$found = CrudUser::find($user->id);
// Assert
$this->assertNotNull($found);
$this->assertSame('John Doe', $found->name);
}
public function testCanUpdateUser(): void
{
// Arrange
$user = new CrudUser();
$user->name = 'John Doe';
$user->save();
// Act
$user->name = 'Jane Doe';
$user->save();
// Assert
$updated = CrudUser::find($user->id);
$this->assertSame('Jane Doe', $updated->name);
}
public function testCanDeleteUser(): void
{
// Arrange
$user = new CrudUser();
$user->name = 'John Doe';
$user->save();
$id = $user->id;
// Act
$user->delete();
// Assert
$deleted = CrudUser::find($id);
$this->assertNull($deleted);
}
}
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Models;
use Marwa\DB\ORM\Model;
final class CrudUser extends Model
{
protected string $table = 'users';
protected array $fillable = ['name', 'email'];
protected array $hidden = ['password'];
}
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use Marwa\Framework\Facades\Http;
final class HttpClientTest extends TestCase
{
public function testCanMakeGetRequest(): void
{
// Arrange
$client = http();
// Act
$response = $client->get('https://api.example.com/users');
// Assert
$this->assertSame(200, $response->getStatusCode());
}
public function testCanMakePostRequest(): void
{
// Arrange
$client = http();
// Act
$response = $client->post('https://api.example.com/users', [
'json' => ['name' => 'John']
]);
// Assert
$this->assertSame(201, $response->getStatusCode());
}
}
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use Marwa\Framework\Adapters\Event\RequestHandled;
use Marwa\Framework\Facades\Event;
final class EventTest extends TestCase
{
public function testEventListenerIsCalled(): void
{
// Arrange
$called = false;
Event::listen(RequestHandled::class, function (RequestHandled $event) use (&$called) {
$called = true;
});
// Act
$event = new RequestHandled('GET', '/users', 200);
app()->dispatch($event);
// Assert
$this->assertTrue($called);
}
}
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use Marwa\Framework\Adapters\Validation\RequestValidatorAdapter;
use Marwa\Support\Validation\ValidationException;
final class StrongPasswordRule extends \Marwa\Support\Validation\AbstractRule
{
public function name(): string
{
return 'strong_password';
}
public function validate(mixed $value, array $context): bool
{
return is_string($value)
&& strlen($value) >= 12
&& preg_match('/[A-Z]/', $value)
&& preg_match('/[a-z]/', $value)
&& preg_match('/[0-9]/', $value)
&& preg_match('/[^A-Za-z0-9]/', $value);
}
public function message(string $field, array $attributes): string
{
return $this->formatMessage(
'The :attribute must be at least 12 characters and include upper, lower, number, and symbol.',
$field,
$attributes
);
}
}
final class ValidationTest extends TestCase
{
public function testValidDataPasses(): void
{
// Arrange
$validator = app(RequestValidatorAdapter::class);
// Act
$result = $validator->validateInputWithCustomRules(
[
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Secret123!@#',
'password_confirmation' => 'Secret123!@#',
],
[
'name' => 'required|string|max:255',
'email' => 'required|email',
'password' => 'required|strong_password|confirmed',
],
[],
[],
[
'strong_password' => StrongPasswordRule::class,
]
);
// Assert
$this->assertSame('John Doe', $result['name']);
$this->assertSame('john@example.com', $result['email']);
}
public function testInvalidDataFails(): void
{
// Arrange
$validator = app(RequestValidatorAdapter::class);
// Act & Assert
$this->expectException(ValidationException::class);
$validator->validateInputWithCustomRules(
[
'name' => 'John',
],
[
'name' => 'required|string|max:255',
'email' => 'required|email',
],
[],
[],
[]
);
}
}
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
final class ExceptionTest extends TestCase
{
public function testThrowsException(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Error message');
throw new \RuntimeException('Error message');
}
}
public function test_user_can_register_with_valid_email(): void
{
// Test name describes expected behavior
}
public function test_example(): void
{
// Arrange - set up
$user = new User();
// Act - do something
$result = $user->save();
// Assert - verify
$this->assertNotNull($result);
}
// Good - one assertion per test
public function test_user_has_name(): void
{
$this->assertSame('John', $user->name);
}
// Good - separate tests for different behaviors
public function test_user_can_have_null_email(): void {}
public function test_user_email_must_be_unique(): void {}
/**
* @dataProvider userNameProvider
*/
public function testUserNameValidation(string $name, bool $valid): void
{
// Test with multiple values
}
public function userNameProvider(): array
{
return [
['John', true],
['', false],
['A', false],
];
}
public function testServiceIsCalled(): void
{
// Create mock
$mock = $this->createMock(Service::class);
// Expect method call
$mock->expects($this->once())
->method('doSomething')
->willReturn('result');
// Use mock
$result = $mock->doSomething();
$this->assertSame('result', $result);
}
public function testReturnsCachedData(): void
{
// Create stub
$stub = $this->createStub(CacheInterface::class);
$stub->method('get')
->willReturn(['cached' => 'data']);
// Use stub
$result = $stub->get('key');
$this->assertSame(['cached' => 'data'], $result);
}
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install dependencies
run: composer install --no-interaction
- name: Run tests
run: composer test
- name: Run static analysis
run: composer stan
# Unit tests only
vendor/bin/phpunit --testsuite unit
# Integration tests
vendor/bin/phpunit --testsuite integration
# Generate HTML coverage report
vendor/bin/phpunit --coverage-html coverage/
# Minimum coverage
vendor/bin/phpunit --coverage-min=80