marwa-framework

Testing Guide

This guide covers writing and running tests for your Marwa Framework application.

Why Test?

Test Framework

The framework uses PHPUnit for testing.

Running Tests

All Tests

composer test

Specific Test File

vendor/bin/phpunit tests/ControllersTest.php

Specific Test Method

vendor/bin/phpunit --filter testUserCanLogin

With Code Coverage

vendor/bin/phpunit --coverage-html coverage/

Test Structure

tests/
├── Fixtures/
│   ├── Controllers/
│   ├── Models/
│   └── Seeders/
├── ControllersTest.php
├── ModelsTest.php
└── SupportsTest.php

Writing Tests

Basic Test

<?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);
    }
}

Testing Controllers

<?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
    }
}

Testing Models

<?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);
    }
}

Testing With Fixtures

<?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'];
}

Testing HTTP Requests

<?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());
    }
}

Testing Events

<?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);
    }
}

Testing Validation

<?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',
            ],
            [],
            [],
            []
        );
    }
}

Testing Exceptions

<?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');
    }
}

Test Best Practices

1. Name Tests Clearly

public function test_user_can_register_with_valid_email(): void
{
    // Test name describes expected behavior
}

2. Arrange-Act-Assert

public function test_example(): void
{
    // Arrange - set up
    $user = new User();
    
    // Act - do something
    $result = $user->save();
    
    // Assert - verify
    $this->assertNotNull($result);
}

3. Test One Thing

// 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 {}

4. Use Data Providers

/**
 * @dataProvider userNameProvider
 */
public function testUserNameValidation(string $name, bool $valid): void
{
    // Test with multiple values
}

public function userNameProvider(): array
{
    return [
        ['John', true],
        ['', false],
        ['A', false],
    ];
}

Mocking

Using PHPUnit Mocks

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);
}

Using Stubs

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);
}

CI Integration

GitHub Actions

# .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

Running Specific Test Suites

# Unit tests only
vendor/bin/phpunit --testsuite unit

# Integration tests
vendor/bin/phpunit --testsuite integration

Code Coverage

# Generate HTML coverage report
vendor/bin/phpunit --coverage-html coverage/

# Minimum coverage
vendor/bin/phpunit --coverage-min=80