Testing

Canvas's reliance on constructor injection and plain PHP classes means every component can be tested directly with PHPUnit — no special bootstrapping, test doubles, or framework test helpers required.

Unit Testing

Because Canvas relies on constructor injection and standard PHP classes, every component can be instantiated and tested in isolation without involving the framework at all. The test namespace is registered in composer.json under autoload-dev:

"autoload-dev": {
    "psr-4": {
        "App\\Tests\\": "tests/"
    }
}

Validation rules

Each validation rule is a standalone class implementing ValidationRuleInterface. Rules can be instantiated and exercised directly — no container or request object involved:

use PHPUnit\Framework\TestCase;
use Quellabs\Canvas\Validation\Rules\Email;
use Quellabs\Canvas\Validation\Rules\Length;
use Quellabs\Canvas\Validation\Rules\NotBlank;

class EmailRuleTest extends TestCase {

    public function testPassesValidAddress(): void {
        $this->assertTrue((new Email())->validate('user@example.com'));
    }

    public function testFailsMissingAtSign(): void {
        $this->assertFalse((new Email())->validate('userexample.com'));
    }

    public function testEmptyStringPassesByDefault(): void {
        // Empty is allowed — combine with NotBlank for mandatory fields
        $this->assertTrue((new Email())->validate(''));
    }
}

Rules that carry configuration are tested the same way:

public function testLengthFailsStringTooShort(): void {
    $this->assertFalse((new Length(min: 5))->validate('hi'));
}

public function testLengthErrorMessageContainsMinValue(): void {
    $rule = new Length(min: 5, max: 20);
    $rule->validate('hi');
    $this->assertStringContainsString('5', $rule->getError());
}

Sanitization rules

Sanitization rules implement SanitizationRuleInterface and follow the same pattern. All rules pass non-string values through unchanged:

use Quellabs\Canvas\Sanitization\Rules\ScriptSafe;
use Quellabs\Canvas\Sanitization\Rules\Trim;

public function testScriptSafeRemovesOnClickHandler(): void {
    $rule   = new ScriptSafe();
    $result = $rule->sanitize('');
    $this->assertStringNotContainsString('onclick', $result);
}

public function testTrimPassesThroughNonString(): void {
    $this->assertSame(42, (new Trim())->sanitize(42));
}

The Validator orchestrator

Validator accepts any class implementing ValidationInterface and returns an array of errors keyed by field name. Nested fields use dot notation:

use Quellabs\Canvas\Validation\Validator;
use Quellabs\Canvas\Validation\Contracts\ValidationInterface;
use Quellabs\Canvas\Validation\Rules\NotBlank;
use Quellabs\Canvas\Validation\Rules\Email;

class OrderFormRules implements ValidationInterface {
    public function getRules(): array {
        return [
            'email' => [new NotBlank(), new Email()],
            'address' => [
                'city' => [new NotBlank()],
            ],
        ];
    }
}

class ValidatorTest extends TestCase {

    public function testReturnsErrorsKeyedByField(): void {
        $errors = (new Validator())->validate(
            ['email' => 'not-an-email', 'address' => ['city' => '']],
            new OrderFormRules()
        );

        $this->assertArrayHasKey('email', $errors);
        $this->assertArrayHasKey('address.city', $errors);
    }

    public function testReturnsEmptyArrayWhenAllFieldsValid(): void {
        $errors = (new Validator())->validate(
            ['email' => 'user@example.com', 'address' => ['city' => 'Haarlem']],
            new OrderFormRules()
        );

        $this->assertEmpty($errors);
    }
}

Controllers and services

Controllers are plain PHP classes. Dependencies can be mocked using PHPUnit's built-in mock builder or any compatible mock library:

class UserControllerTest extends TestCase {

    public function testReturns404WhenUserNotFound(): void {
        $em = $this->createMock(EntityManager::class);
        $em->method('find')->willReturn(null);

        $container = $this->createMock(Container::class);
        $container->method('get')->willReturn($em);

        $controller = new UserController($container);
        $response   = $controller->show(999);

        $this->assertSame(404, $response->getStatusCode());
    }
}

Configuration

The Configuration class is a pure value object and can be tested directly. The getAs() method supports type casting with common string representations for booleans and comma-separated strings for arrays:

use Quellabs\Canvas\Configuration\Configuration;

public function testGetAsBoolRecognisesStringTrue(): void {
    $config = new Configuration(['debug' => 'true']);
    $this->assertTrue($config->getAs('debug', 'bool'));
}

public function testGetAsArraySplitsCommaSeparatedString(): void {
    $config = new Configuration(['tags' => 'php,canvas,testing']);
    $this->assertSame(['php', 'canvas', 'testing'], $config->getAs('tags', 'array'));
}

Integration Testing

Unit tests cover individual components in isolation. Integration tests verify that those components work correctly together — for example, that a controller annotated with @InterceptWith(ValidateAspect::class) actually has its request data validated before the method body runs, or that @WithContext causes the DI container to resolve the correct implementation.

Canvas uses Symfony HttpFoundation for requests and responses, so Symfony's testing utilities work with Canvas out of the box for HTTP-level tests. Alternatively, Codeception maps cleanly onto Canvas's three testable layers — unit, integration, and full HTTP functional tests — with Kernel::handle(Request) serving as the natural entry point for functional coverage.