Skip to content

Commit 25e27eb

Browse files
authored
Merge pull request #344 from binaryfire/feat/testbench-testing-features
feat: add Laravel Testbench-style testing features
2 parents 39aa2fe + e1c086f commit 25e27eb

37 files changed

Lines changed: 2154 additions & 49 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing;
6+
7+
use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable;
8+
use Hypervel\Foundation\Testing\Contracts\Attributes\TestingFeature;
9+
use PHPUnit\Framework\TestCase;
10+
use ReflectionAttribute;
11+
use ReflectionClass;
12+
use ReflectionMethod;
13+
14+
/**
15+
* Parses PHPUnit test case attributes for testing features.
16+
*/
17+
class AttributeParser
18+
{
19+
/**
20+
* Parse attributes for a class.
21+
*
22+
* @param class-string $className
23+
* @return array<int, array{key: class-string, instance: object}>
24+
*/
25+
public static function forClass(string $className): array
26+
{
27+
$attributes = [];
28+
$reflection = new ReflectionClass($className);
29+
30+
foreach ($reflection->getAttributes() as $attribute) {
31+
if (! static::validAttribute($attribute->getName())) {
32+
continue;
33+
}
34+
35+
[$name, $instance] = static::resolveAttribute($attribute);
36+
37+
if ($name !== null && $instance !== null) {
38+
$attributes[] = ['key' => $name, 'instance' => $instance];
39+
}
40+
}
41+
42+
$parent = $reflection->getParentClass();
43+
44+
if ($parent !== false && $parent->isSubclassOf(TestCase::class)) {
45+
$attributes = [...static::forClass($parent->getName()), ...$attributes];
46+
}
47+
48+
return $attributes;
49+
}
50+
51+
/**
52+
* Parse attributes for a method.
53+
*
54+
* @param class-string $className
55+
* @return array<int, array{key: class-string, instance: object}>
56+
*/
57+
public static function forMethod(string $className, string $methodName): array
58+
{
59+
$attributes = [];
60+
61+
foreach ((new ReflectionMethod($className, $methodName))->getAttributes() as $attribute) {
62+
if (! static::validAttribute($attribute->getName())) {
63+
continue;
64+
}
65+
66+
[$name, $instance] = static::resolveAttribute($attribute);
67+
68+
if ($name !== null && $instance !== null) {
69+
$attributes[] = ['key' => $name, 'instance' => $instance];
70+
}
71+
}
72+
73+
return $attributes;
74+
}
75+
76+
/**
77+
* Validate if a class is a valid testing attribute.
78+
*
79+
* @param class-string|object $class
80+
*/
81+
public static function validAttribute(object|string $class): bool
82+
{
83+
if (\is_string($class) && ! class_exists($class)) {
84+
return false;
85+
}
86+
87+
$implements = class_implements($class);
88+
89+
return isset($implements[TestingFeature::class])
90+
|| isset($implements[Resolvable::class]);
91+
}
92+
93+
/**
94+
* Resolve the given attribute.
95+
*
96+
* @return array{0: null|class-string, 1: null|object}
97+
*/
98+
protected static function resolveAttribute(ReflectionAttribute $attribute): array
99+
{
100+
/** @var array{0: null|class-string, 1: null|object} */
101+
return rescue(static function () use ($attribute): array { // @phpstan-ignore argument.unresolvableType
102+
$instance = isset(class_implements($attribute->getName())[Resolvable::class])
103+
? transform($attribute->newInstance(), static fn (Resolvable $instance) => $instance->resolve())
104+
: $attribute->newInstance();
105+
106+
if ($instance === null) {
107+
return [null, null];
108+
}
109+
110+
return [$instance::class, $instance];
111+
}, [null, null], false);
112+
}
113+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing\Attributes;
6+
7+
use Attribute;
8+
use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable;
9+
use Hypervel\Foundation\Testing\Contracts\Attributes\TestingFeature;
10+
11+
/**
12+
* Meta-attribute that resolves to actual attribute classes based on group.
13+
*
14+
* Provides a shorthand for common attribute types.
15+
*/
16+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
17+
final class Define implements Resolvable
18+
{
19+
public function __construct(
20+
public readonly string $group,
21+
public readonly string $method
22+
) {
23+
}
24+
25+
/**
26+
* Resolve the actual attribute class.
27+
*/
28+
public function resolve(): ?TestingFeature
29+
{
30+
return match (strtolower($this->group)) {
31+
'env' => new DefineEnvironment($this->method),
32+
'db' => new DefineDatabase($this->method),
33+
'route' => new DefineRoute($this->method),
34+
default => null,
35+
};
36+
}
37+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing\Attributes;
6+
7+
use Attribute;
8+
use Closure;
9+
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
10+
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;
11+
use Hypervel\Foundation\Testing\Contracts\Attributes\AfterEach;
12+
use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeEach;
13+
14+
/**
15+
* Calls a test method for database setup with deferred execution support.
16+
*
17+
* Resets RefreshDatabaseState before and after each test.
18+
*/
19+
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
20+
final class DefineDatabase implements Actionable, AfterEach, BeforeEach
21+
{
22+
public function __construct(
23+
public readonly string $method,
24+
public readonly bool $defer = true
25+
) {
26+
}
27+
28+
/**
29+
* Handle the attribute before each test.
30+
*/
31+
public function beforeEach(ApplicationContract $app): void
32+
{
33+
ResetRefreshDatabaseState::run();
34+
}
35+
36+
/**
37+
* Handle the attribute after each test.
38+
*/
39+
public function afterEach(ApplicationContract $app): void
40+
{
41+
ResetRefreshDatabaseState::run();
42+
}
43+
44+
/**
45+
* Handle the attribute.
46+
*
47+
* @param Closure(string, array<int, mixed>):void $action
48+
*/
49+
public function handle(ApplicationContract $app, Closure $action): ?Closure
50+
{
51+
$resolver = function () use ($app, $action) {
52+
\call_user_func($action, $this->method, [$app]);
53+
};
54+
55+
if ($this->defer === false) {
56+
$resolver();
57+
58+
return null;
59+
}
60+
61+
return $resolver;
62+
}
63+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing\Attributes;
6+
7+
use Attribute;
8+
use Closure;
9+
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
10+
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;
11+
12+
/**
13+
* Calls a test method with the application instance for environment setup.
14+
*/
15+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
16+
final class DefineEnvironment implements Actionable
17+
{
18+
public function __construct(
19+
public readonly string $method
20+
) {
21+
}
22+
23+
/**
24+
* Handle the attribute.
25+
*
26+
* @param Closure(string, array<int, mixed>):void $action
27+
*/
28+
public function handle(ApplicationContract $app, Closure $action): mixed
29+
{
30+
\call_user_func($action, $this->method, [$app]);
31+
32+
return null;
33+
}
34+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing\Attributes;
6+
7+
use Attribute;
8+
use Closure;
9+
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
10+
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;
11+
use Hypervel\Router\Router;
12+
13+
/**
14+
* Calls a test method with the router instance for route definition.
15+
*/
16+
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
17+
final class DefineRoute implements Actionable
18+
{
19+
public function __construct(
20+
public readonly string $method
21+
) {
22+
}
23+
24+
/**
25+
* Handle the attribute.
26+
*
27+
* @param Closure(string, array<int, mixed>):void $action
28+
*/
29+
public function handle(ApplicationContract $app, Closure $action): mixed
30+
{
31+
$router = $app->get(Router::class);
32+
33+
\call_user_func($action, $this->method, [$router]);
34+
35+
return null;
36+
}
37+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing\Attributes;
6+
7+
use Attribute;
8+
use Closure;
9+
use Hypervel\Foundation\Contracts\Application as ApplicationContract;
10+
use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable;
11+
12+
/**
13+
* Skips the test if the required environment variable is missing.
14+
*/
15+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
16+
final class RequiresEnv implements Actionable
17+
{
18+
public function __construct(
19+
public readonly string $key,
20+
public readonly ?string $message = null
21+
) {
22+
}
23+
24+
/**
25+
* Handle the attribute.
26+
*
27+
* @param Closure(string, array<int, mixed>):void $action
28+
*/
29+
public function handle(ApplicationContract $app, Closure $action): mixed
30+
{
31+
$message = $this->message ?? "Missing required environment variable `{$this->key}`";
32+
33+
if (env($this->key) === null) {
34+
\call_user_func($action, 'markTestSkipped', [$message]);
35+
}
36+
37+
return null;
38+
}
39+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Foundation\Testing\Attributes;
6+
7+
use Attribute;
8+
use Hypervel\Foundation\Testing\Contracts\Attributes\AfterAll;
9+
use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeAll;
10+
use Hypervel\Foundation\Testing\RefreshDatabaseState;
11+
12+
/**
13+
* Resets the database state before and after all tests in a class.
14+
*/
15+
#[Attribute(Attribute::TARGET_CLASS)]
16+
final class ResetRefreshDatabaseState implements AfterAll, BeforeAll
17+
{
18+
/**
19+
* Handle the attribute before all tests.
20+
*/
21+
public function beforeAll(): void
22+
{
23+
self::run();
24+
}
25+
26+
/**
27+
* Handle the attribute after all tests.
28+
*/
29+
public function afterAll(): void
30+
{
31+
self::run();
32+
}
33+
34+
/**
35+
* Execute the state reset.
36+
*/
37+
public static function run(): void
38+
{
39+
RefreshDatabaseState::$inMemoryConnections = [];
40+
RefreshDatabaseState::$migrated = false;
41+
RefreshDatabaseState::$lazilyRefreshed = false;
42+
}
43+
}

0 commit comments

Comments
 (0)