Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c797914
feat(testing): add testbench-style testing infrastructure (phases 1-4.2)
binaryfire Jan 16, 2026
7e42a9f
feat(testing): add InteractsWithTestCase trait
binaryfire Jan 16, 2026
9edebb2
feat(testing): add TestingFeature orchestrator
binaryfire Jan 16, 2026
5a41b4b
feat(testing): add testing attributes
binaryfire Jan 16, 2026
fbd99ee
feat(testbench): add package provider, route, and database traits
binaryfire Jan 16, 2026
c0b27a5
feat(testbench): integrate traits and attributes into TestCase
binaryfire Jan 16, 2026
7442f0a
test(foundation): add AttributesTest for testing attributes
binaryfire Jan 16, 2026
d1bd785
test(foundation): add HandlesAttributesTest
binaryfire Jan 16, 2026
87b8d6d
test(foundation): add InteractsWithTestCaseTest
binaryfire Jan 16, 2026
89be1bb
test(testbench): add CreatesApplicationTest
binaryfire Jan 16, 2026
8c975e5
test(testbench): add HandlesRoutesTest
binaryfire Jan 16, 2026
682f152
test(testbench): add TestCaseTest
binaryfire Jan 16, 2026
9163bb8
fix(testing): fix return types and attribute execution in testing inf…
binaryfire Jan 16, 2026
406d743
style: apply php-cs-fixer formatting
binaryfire Jan 16, 2026
9d96389
feat(testbench): integrate route registration into TestCase lifecycle
binaryfire Jan 16, 2026
15c8dfd
style: apply php-cs-fixer formatting to testbench routes
binaryfire Jan 16, 2026
e91a8da
test(testing): add tests for Define meta-attribute and attribute inhe…
binaryfire Jan 16, 2026
78f73d3
refactor(testing): add ApplicationContract and Router type hints
binaryfire Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/foundation/src/Testing/AttributeParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing;

use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable;
use Hypervel\Foundation\Testing\Contracts\Attributes\TestingFeature;
use PHPUnit\Framework\TestCase;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionMethod;

/**
* Parses PHPUnit test case attributes for testing features.
*/
class AttributeParser
{
/**
* Parse attributes for a class.
*
* @param class-string $className
* @return array<int, array{key: class-string, instance: object}>
*/
public static function forClass(string $className): array
{
$attributes = [];
$reflection = new ReflectionClass($className);

foreach ($reflection->getAttributes() as $attribute) {
if (! static::validAttribute($attribute->getName())) {
continue;
}

[$name, $instance] = static::resolveAttribute($attribute);

if ($name !== null && $instance !== null) {
$attributes[] = ['key' => $name, 'instance' => $instance];
}
}

$parent = $reflection->getParentClass();

if ($parent !== false && $parent->isSubclassOf(TestCase::class)) {
$attributes = [...static::forClass($parent->getName()), ...$attributes];
}

return $attributes;
}

/**
* Parse attributes for a method.
*
* @param class-string $className
* @return array<int, array{key: class-string, instance: object}>
*/
public static function forMethod(string $className, string $methodName): array
{
$attributes = [];

foreach ((new ReflectionMethod($className, $methodName))->getAttributes() as $attribute) {
if (! static::validAttribute($attribute->getName())) {
continue;
}

[$name, $instance] = static::resolveAttribute($attribute);

if ($name !== null && $instance !== null) {
$attributes[] = ['key' => $name, 'instance' => $instance];
}
}

return $attributes;
}

/**
* Validate if a class is a valid testing attribute.
*
* @param class-string|object $class
*/
public static function validAttribute(object|string $class): bool
{
if (\is_string($class) && ! class_exists($class)) {
return false;
}

$implements = class_implements($class);

return isset($implements[TestingFeature::class])
|| isset($implements[Resolvable::class]);
}

/**
* Resolve the given attribute.
*
* @return array{0: null|class-string, 1: null|object}
*/
protected static function resolveAttribute(ReflectionAttribute $attribute): array
{
/** @var array{0: null|class-string, 1: null|object} */
return rescue(static function () use ($attribute): array { // @phpstan-ignore argument.unresolvableType
$instance = isset(class_implements($attribute->getName())[Resolvable::class])
? transform($attribute->newInstance(), static fn (Resolvable $instance) => $instance->resolve())
: $attribute->newInstance();

if ($instance === null) {
return [null, null];
}

return [$instance::class, $instance];
}, [null, null], false);
}
}
37 changes: 37 additions & 0 deletions src/foundation/src/Testing/Attributes/Define.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing\Attributes;

use Attribute;
use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable;
use Hypervel\Foundation\Testing\Contracts\Attributes\TestingFeature;

/**
* Meta-attribute that resolves to actual attribute classes based on group.
*
* Provides a shorthand for common attribute types.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Define implements Resolvable
{
public function __construct(
public readonly string $group,
public readonly string $method
) {
}

/**
* Resolve the actual attribute class.
*/
public function resolve(): ?TestingFeature
{
return match (strtolower($this->group)) {
'env' => new DefineEnvironment($this->method),
'db' => new DefineDatabase($this->method),
'route' => new DefineRoute($this->method),
default => null,
};
}
}
63 changes: 63 additions & 0 deletions src/foundation/src/Testing/Attributes/DefineDatabase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing\Attributes;

use Attribute;
use Closure;
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;
use Hypervel\Foundation\Testing\Contracts\Attributes\AfterEach;
use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeEach;

/**
* Calls a test method for database setup with deferred execution support.
*
* Resets RefreshDatabaseState before and after each test.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class DefineDatabase implements Actionable, AfterEach, BeforeEach
{
public function __construct(
public readonly string $method,
public readonly bool $defer = true
) {
}

/**
* Handle the attribute before each test.
*/
public function beforeEach(ApplicationContract $app): void
{
ResetRefreshDatabaseState::run();
}

/**
* Handle the attribute after each test.
*/
public function afterEach(ApplicationContract $app): void
{
ResetRefreshDatabaseState::run();
}

/**
* Handle the attribute.
*
* @param Closure(string, array<int, mixed>):void $action
*/
public function handle(ApplicationContract $app, Closure $action): ?Closure
{
$resolver = function () use ($app, $action) {
\call_user_func($action, $this->method, [$app]);
};

if ($this->defer === false) {
$resolver();

return null;
}

return $resolver;
}
}
34 changes: 34 additions & 0 deletions src/foundation/src/Testing/Attributes/DefineEnvironment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing\Attributes;

use Attribute;
use Closure;
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;

/**
* Calls a test method with the application instance for environment setup.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class DefineEnvironment implements Actionable
{
public function __construct(
public readonly string $method
) {
}

/**
* Handle the attribute.
*
* @param Closure(string, array<int, mixed>):void $action
*/
public function handle(ApplicationContract $app, Closure $action): mixed
{
\call_user_func($action, $this->method, [$app]);

return null;
}
}
37 changes: 37 additions & 0 deletions src/foundation/src/Testing/Attributes/DefineRoute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing\Attributes;

use Attribute;
use Closure;
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;
use Hypervel\Router\Router;

/**
* Calls a test method with the router instance for route definition.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class DefineRoute implements Actionable
{
public function __construct(
public readonly string $method
) {
}

/**
* Handle the attribute.
*
* @param Closure(string, array<int, mixed>):void $action
*/
public function handle(ApplicationContract $app, Closure $action): mixed
{
$router = $app->get(Router::class);

\call_user_func($action, $this->method, [$router]);

return null;
}
}
39 changes: 39 additions & 0 deletions src/foundation/src/Testing/Attributes/RequiresEnv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing\Attributes;

use Attribute;
use Closure;
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;

/**
* Skips the test if the required environment variable is missing.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class RequiresEnv implements Actionable
{
public function __construct(
public readonly string $key,
public readonly ?string $message = null
) {
}

/**
* Handle the attribute.
*
* @param Closure(string, array<int, mixed>):void $action
*/
public function handle(ApplicationContract $app, Closure $action): mixed
{
$message = $this->message ?? "Missing required environment variable `{$this->key}`";

if (env($this->key) === null) {
\call_user_func($action, 'markTestSkipped', [$message]);
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Hypervel\Foundation\Testing\Attributes;

use Attribute;
use Hypervel\Foundation\Testing\Contracts\Attributes\AfterAll;
use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeAll;
use Hypervel\Foundation\Testing\RefreshDatabaseState;

/**
* Resets the database state before and after all tests in a class.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class ResetRefreshDatabaseState implements AfterAll, BeforeAll
{
/**
* Handle the attribute before all tests.
*/
public function beforeAll(): void
{
self::run();
}

/**
* Handle the attribute after all tests.
*/
public function afterAll(): void
{
self::run();
}

/**
* Execute the state reset.
*/
public static function run(): void
{
RefreshDatabaseState::$inMemoryConnections = [];
RefreshDatabaseState::$migrated = false;
RefreshDatabaseState::$lazilyRefreshed = false;
}
}
Loading