diff --git a/src/foundation/src/Testing/AttributeParser.php b/src/foundation/src/Testing/AttributeParser.php new file mode 100644 index 000000000..5664bc305 --- /dev/null +++ b/src/foundation/src/Testing/AttributeParser.php @@ -0,0 +1,113 @@ + + */ + 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 + */ + 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); + } +} diff --git a/src/foundation/src/Testing/Attributes/Define.php b/src/foundation/src/Testing/Attributes/Define.php new file mode 100644 index 000000000..1d55570b2 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/Define.php @@ -0,0 +1,37 @@ +group)) { + 'env' => new DefineEnvironment($this->method), + 'db' => new DefineDatabase($this->method), + 'route' => new DefineRoute($this->method), + default => null, + }; + } +} diff --git a/src/foundation/src/Testing/Attributes/DefineDatabase.php b/src/foundation/src/Testing/Attributes/DefineDatabase.php new file mode 100644 index 000000000..d8feec7e5 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/DefineDatabase.php @@ -0,0 +1,63 @@ +):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; + } +} diff --git a/src/foundation/src/Testing/Attributes/DefineEnvironment.php b/src/foundation/src/Testing/Attributes/DefineEnvironment.php new file mode 100644 index 000000000..1ca822be4 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/DefineEnvironment.php @@ -0,0 +1,34 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed + { + \call_user_func($action, $this->method, [$app]); + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/DefineRoute.php b/src/foundation/src/Testing/Attributes/DefineRoute.php new file mode 100644 index 000000000..fd238b0d7 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/DefineRoute.php @@ -0,0 +1,37 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed + { + $router = $app->get(Router::class); + + \call_user_func($action, $this->method, [$router]); + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/RequiresEnv.php b/src/foundation/src/Testing/Attributes/RequiresEnv.php new file mode 100644 index 000000000..603a43044 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/RequiresEnv.php @@ -0,0 +1,39 @@ +):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; + } +} diff --git a/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php b/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php new file mode 100644 index 000000000..957370091 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php @@ -0,0 +1,43 @@ +get('config')->set($this->key, $this->value); + + return null; + } +} diff --git a/src/foundation/src/Testing/Attributes/WithMigration.php b/src/foundation/src/Testing/Attributes/WithMigration.php new file mode 100644 index 000000000..31dbb3a03 --- /dev/null +++ b/src/foundation/src/Testing/Attributes/WithMigration.php @@ -0,0 +1,44 @@ + + */ + public readonly array $paths; + + /** + * @param string ...$paths Migration paths to load + */ + public function __construct(string ...$paths) + { + $this->paths = $paths; + } + + /** + * Handle the attribute. + */ + public function __invoke(ApplicationContract $app): mixed + { + $app->afterResolving(Migrator::class, function (Migrator $migrator) { + foreach ($this->paths as $path) { + $migrator->path($path); + } + }); + + return null; + } +} diff --git a/src/foundation/src/Testing/Concerns/HandlesAttributes.php b/src/foundation/src/Testing/Concerns/HandlesAttributes.php new file mode 100644 index 000000000..eb9a05fbd --- /dev/null +++ b/src/foundation/src/Testing/Concerns/HandlesAttributes.php @@ -0,0 +1,56 @@ +resolvePhpUnitAttributes() + ->filter(static fn ($attributes, string $key) => $key === $attribute && ! empty($attributes)) + ->flatten() + ->map(function ($instance) use ($app) { + if ($instance instanceof Invokable) { + return $instance($app); + } + + if ($instance instanceof Actionable) { + return $instance->handle($app, fn ($method, $parameters) => $this->{$method}(...$parameters)); + } + + return null; + }) + ->filter() + ->values(); + + return new FeaturesCollection($attributes); + } + + /** + * Resolve PHPUnit method attributes. + * + * @return \Hypervel\Support\Collection> + */ + abstract protected function resolvePhpUnitAttributes(): Collection; +} diff --git a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php index 79e7c4842..50a05aeed 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php @@ -90,9 +90,20 @@ protected function refreshApplication(): void /* @phpstan-ignore-next-line */ $this->app->bind(HttpDispatcher::class, TestingHttpDispatcher::class); $this->app->bind(ConnectionResolverInterface::class, DatabaseConnectionResolver::class); + + $this->defineEnvironment($this->app); + $this->app->get(ApplicationInterface::class); } + /** + * Define environment setup. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + // Override in subclass. + } + protected function createApplication(): ApplicationContract { return require BASE_PATH . '/bootstrap/app.php'; diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php new file mode 100644 index 000000000..b9def808a --- /dev/null +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php @@ -0,0 +1,245 @@ +> + */ + protected static array $cachedTestCaseClassAttributes = []; + + /** + * Cached method attributes by "class:method" key. + * + * @var array> + */ + protected static array $cachedTestCaseMethodAttributes = []; + + /** + * Programmatically added class-level testing features. + * + * @var array + */ + protected static array $testCaseTestingFeatures = []; + + /** + * Programmatically added method-level testing features. + * + * @var array + */ + protected static array $testCaseMethodTestingFeatures = []; + + /** + * Cached traits used by test case. + * + * @var null|array + */ + protected static ?array $cachedTestCaseUses = null; + + /** + * Check if the test case uses a specific trait. + * + * @param class-string $trait + */ + public static function usesTestingConcern(string $trait): bool + { + return isset(static::cachedUsesForTestCase()[$trait]); + } + + /** + * Cache and return traits used by test case. + * + * @return array + */ + public static function cachedUsesForTestCase(): array + { + if (static::$cachedTestCaseUses === null) { + /** @var array $uses */ + $uses = array_flip(class_uses_recursive(static::class)); + static::$cachedTestCaseUses = $uses; + } + + return static::$cachedTestCaseUses; + } + + /** + * Programmatically add a testing feature attribute. + */ + public static function usesTestingFeature(object $attribute, int $flag = Attribute::TARGET_CLASS): void + { + if (! AttributeParser::validAttribute($attribute)) { + return; + } + + $attribute = $attribute instanceof Resolvable ? $attribute->resolve() : $attribute; + + if ($attribute === null) { + return; + } + + if ($flag & Attribute::TARGET_CLASS) { + static::$testCaseTestingFeatures[] = [ + 'key' => $attribute::class, + 'instance' => $attribute, + ]; + } elseif ($flag & Attribute::TARGET_METHOD) { + static::$testCaseMethodTestingFeatures[] = [ + 'key' => $attribute::class, + 'instance' => $attribute, + ]; + } + } + + /** + * Resolve and cache PHPUnit attributes for current test. + * + * @return \Hypervel\Support\Collection> + */ + protected function resolvePhpUnitAttributes(): Collection + { + $className = static::class; + $methodName = $this->name(); + + // Cache class attributes + if (! isset(static::$cachedTestCaseClassAttributes[$className])) { + static::$cachedTestCaseClassAttributes[$className] = AttributeParser::forClass($className); + } + + // Cache method attributes + $cacheKey = "{$className}:{$methodName}"; + if (! isset(static::$cachedTestCaseMethodAttributes[$cacheKey])) { + static::$cachedTestCaseMethodAttributes[$cacheKey] = AttributeParser::forMethod($className, $methodName); + } + + // Merge all sources and group by attribute class + return (new Collection(array_merge( + static::$testCaseTestingFeatures, + static::$cachedTestCaseClassAttributes[$className], + static::$testCaseMethodTestingFeatures, + static::$cachedTestCaseMethodAttributes[$cacheKey], + )))->groupBy('key') + ->map(static fn ($attrs) => $attrs->pluck('instance')); + } + + /** + * Resolve attributes for class (and optionally method) - used by static lifecycle methods. + * + * @param class-string $className + * @return \Hypervel\Support\Collection> + */ + protected static function resolvePhpUnitAttributesForMethod(string $className, ?string $methodName = null): Collection + { + $attributes = array_merge( + static::$testCaseTestingFeatures, + AttributeParser::forClass($className), + ); + + if ($methodName !== null) { + $attributes = array_merge( + $attributes, + static::$testCaseMethodTestingFeatures, + AttributeParser::forMethod($className, $methodName), + ); + } + + return (new Collection($attributes)) + ->groupBy('key') + ->map(static fn ($attrs) => $attrs->pluck('instance')); + } + + /** + * Execute setup lifecycle attributes (Invokable, Actionable, BeforeEach). + */ + protected function setUpTheTestEnvironmentUsingTestCase(): void + { + $attributes = $this->resolvePhpUnitAttributes()->flatten(); + + // Execute Invokable attributes (like WithConfig) + $attributes + ->filter(static fn ($instance) => $instance instanceof Invokable) + ->each(fn ($instance) => $instance($this->app)); + + // Execute Actionable attributes (like DefineEnvironment, DefineRoute, DefineDatabase) + // Some attributes (like DefineDatabase with defer: true) return a Closure + // that must be executed to complete the setup + $attributes + ->filter(static fn ($instance) => $instance instanceof Actionable) + ->each(function ($instance): void { + $result = $instance->handle( + $this->app, + fn ($method, $parameters) => $this->{$method}(...$parameters) + ); + + if ($result instanceof Closure) { + $result(); + } + }); + + // Execute BeforeEach attributes + $attributes + ->filter(static fn ($instance) => $instance instanceof BeforeEach) + ->each(fn ($instance) => $instance->beforeEach($this->app)); + } + + /** + * Execute AfterEach lifecycle attributes. + */ + protected function tearDownTheTestEnvironmentUsingTestCase(): void + { + $this->resolvePhpUnitAttributes() + ->flatten() + ->filter(static fn ($instance) => $instance instanceof AfterEach) + ->each(fn ($instance) => $instance->afterEach($this->app)); + + static::$testCaseMethodTestingFeatures = []; + } + + /** + * Execute BeforeAll lifecycle attributes. + */ + public static function setUpBeforeClassUsingTestCase(): void + { + static::resolvePhpUnitAttributesForMethod(static::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof BeforeAll) + ->each(static fn ($instance) => $instance->beforeAll()); + } + + /** + * Execute AfterAll lifecycle attributes and clear caches. + */ + public static function tearDownAfterClassUsingTestCase(): void + { + static::resolvePhpUnitAttributesForMethod(static::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof AfterAll) + ->each(static fn ($instance) => $instance->afterAll()); + + static::$testCaseTestingFeatures = []; + static::$cachedTestCaseClassAttributes = []; + static::$cachedTestCaseMethodAttributes = []; + static::$cachedTestCaseUses = null; + } +} diff --git a/src/foundation/src/Testing/Contracts/Attributes/Actionable.php b/src/foundation/src/Testing/Contracts/Attributes/Actionable.php new file mode 100644 index 000000000..3f22166fa --- /dev/null +++ b/src/foundation/src/Testing/Contracts/Attributes/Actionable.php @@ -0,0 +1,21 @@ +):void $action + */ + public function handle(ApplicationContract $app, Closure $action): mixed; +} diff --git a/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php b/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php new file mode 100644 index 000000000..f82834655 --- /dev/null +++ b/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php @@ -0,0 +1,16 @@ +isEmpty()) { + return; + } + + $this->each($callback ?? static fn ($attribute) => value($attribute)); + } +} diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php new file mode 100644 index 000000000..f9853e891 --- /dev/null +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -0,0 +1,59 @@ + + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return []; + } + + /** + * Get package aliases. + * + * @return array + */ + protected function getPackageAliases(ApplicationContract $app): array + { + return []; + } + + /** + * Register package providers. + */ + protected function registerPackageProviders(ApplicationContract $app): void + { + foreach ($this->getPackageProviders($app) as $provider) { + $app->register($provider); + } + } + + /** + * Register package aliases. + */ + protected function registerPackageAliases(ApplicationContract $app): void + { + $aliases = $this->getPackageAliases($app); + + if (empty($aliases)) { + return; + } + + $config = $app->get('config'); + $existing = $config->get('app.aliases', []); + $config->set('app.aliases', array_merge($existing, $aliases)); + } +} diff --git a/src/testbench/src/Concerns/HandlesDatabases.php b/src/testbench/src/Concerns/HandlesDatabases.php new file mode 100644 index 000000000..ed6d918e0 --- /dev/null +++ b/src/testbench/src/Concerns/HandlesDatabases.php @@ -0,0 +1,56 @@ +defineDatabaseMigrations(); + $this->beforeApplicationDestroyed(fn () => $this->destroyDatabaseMigrations()); + + $callback(); + + $this->defineDatabaseSeeders(); + } +} diff --git a/src/testbench/src/Concerns/HandlesRoutes.php b/src/testbench/src/Concerns/HandlesRoutes.php new file mode 100644 index 000000000..3cea8604f --- /dev/null +++ b/src/testbench/src/Concerns/HandlesRoutes.php @@ -0,0 +1,48 @@ +get(Router::class); + + $this->defineRoutes($router); + + // Only set up web routes group if the method is overridden + // This prevents empty group registration from interfering with other routes + $refMethod = new ReflectionMethod($this, 'defineWebRoutes'); + if ($refMethod->getDeclaringClass()->getName() !== self::class) { + $router->group('/', fn () => $this->defineWebRoutes($router), ['middleware' => ['web']]); + } + } +} diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 8adc46495..df85d6213 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -12,18 +12,28 @@ use Hypervel\Foundation\Console\Kernel as ConsoleKernel; use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Foundation\Testing\Concerns\HandlesAttributes; +use Hypervel\Foundation\Testing\Concerns\InteractsWithTestCase; use Hypervel\Foundation\Testing\TestCase as BaseTestCase; use Hypervel\Queue\Queue; use Swoole\Timer; use Workbench\App\Exceptions\ExceptionHandler; /** + * Base test case for package testing with testbench features. + * * @internal * @coversNothing */ class TestCase extends BaseTestCase { - protected static $hasBootstrappedTestbench = false; + use Concerns\CreatesApplication; + use Concerns\HandlesDatabases; + use Concerns\HandlesRoutes; + use HandlesAttributes; + use InteractsWithTestCase; + + protected static bool $hasBootstrappedTestbench = false; protected function setUp(): void { @@ -35,9 +45,25 @@ protected function setUp(): void $this->afterApplicationCreated(function () { Timer::clearAll(); CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + + // Setup routes after application is created (providers are booted) + $this->setUpApplicationRoutes($this->app); }); parent::setUp(); + + // Execute BeforeEach attributes INSIDE coroutine context + // (matches where setUpTraits runs in Foundation TestCase) + $this->runInCoroutine(fn () => $this->setUpTheTestEnvironmentUsingTestCase()); + } + + /** + * Define environment setup. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + $this->registerPackageProviders($app); + $this->registerPackageAliases($app); } protected function createApplication(): ApplicationContract @@ -53,8 +79,32 @@ protected function createApplication(): ApplicationContract protected function tearDown(): void { + // Execute AfterEach attributes INSIDE coroutine context + $this->runInCoroutine(fn () => $this->tearDownTheTestEnvironmentUsingTestCase()); + parent::tearDown(); Queue::createPayloadUsing(null); } + + /** + * Reload the application instance. + */ + protected function reloadApplication(): void + { + $this->tearDown(); + $this->setUp(); + } + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::setUpBeforeClassUsingTestCase(); + } + + public static function tearDownAfterClass(): void + { + static::tearDownAfterClassUsingTestCase(); + parent::tearDownAfterClass(); + } } diff --git a/tests/Foundation/Testing/Attributes/AttributesTest.php b/tests/Foundation/Testing/Attributes/AttributesTest.php new file mode 100644 index 000000000..5b1d9369e --- /dev/null +++ b/tests/Foundation/Testing/Attributes/AttributesTest.php @@ -0,0 +1,257 @@ +assertInstanceOf(Actionable::class, $attribute); + $this->assertSame('someMethod', $attribute->method); + } + + public function testDefineEnvironmentCallsMethod(): void + { + $attribute = new DefineEnvironment('testMethod'); + $called = false; + $receivedArgs = null; + + $action = function (string $method, array $params) use (&$called, &$receivedArgs) { + $called = true; + $receivedArgs = [$method, $params]; + }; + + $attribute->handle($this->app, $action); + + $this->assertTrue($called); + $this->assertSame('testMethod', $receivedArgs[0]); + $this->assertSame([$this->app], $receivedArgs[1]); + } + + public function testWithConfigImplementsInvokable(): void + { + $attribute = new WithConfig('app.name', 'TestApp'); + + $this->assertInstanceOf(Invokable::class, $attribute); + $this->assertSame('app.name', $attribute->key); + $this->assertSame('TestApp', $attribute->value); + } + + public function testWithConfigSetsConfigValue(): void + { + $attribute = new WithConfig('testing.attributes.key', 'test_value'); + + $attribute($this->app); + + $this->assertSame('test_value', $this->app->get('config')->get('testing.attributes.key')); + } + + public function testDefineRouteImplementsActionable(): void + { + $attribute = new DefineRoute('defineTestRoutes'); + + $this->assertInstanceOf(Actionable::class, $attribute); + $this->assertSame('defineTestRoutes', $attribute->method); + } + + public function testDefineDatabaseImplementsRequiredInterfaces(): void + { + $attribute = new DefineDatabase('defineMigrations'); + + $this->assertInstanceOf(Actionable::class, $attribute); + $this->assertInstanceOf(BeforeEach::class, $attribute); + $this->assertInstanceOf(AfterEach::class, $attribute); + } + + public function testDefineDatabaseDeferredExecution(): void + { + $attribute = new DefineDatabase('defineMigrations', defer: true); + $called = false; + + $action = function () use (&$called) { + $called = true; + }; + + $result = $attribute->handle($this->app, $action); + + $this->assertFalse($called); + $this->assertInstanceOf(Closure::class, $result); + + // Execute the deferred callback + $result(); + $this->assertTrue($called); + } + + public function testDefineDatabaseImmediateExecution(): void + { + $attribute = new DefineDatabase('defineMigrations', defer: false); + $called = false; + + $action = function () use (&$called) { + $called = true; + }; + + $result = $attribute->handle($this->app, $action); + + $this->assertTrue($called); + $this->assertNull($result); + } + + public function testResetRefreshDatabaseStateImplementsLifecycleInterfaces(): void + { + $attribute = new ResetRefreshDatabaseState(); + + $this->assertInstanceOf(BeforeAll::class, $attribute); + $this->assertInstanceOf(AfterAll::class, $attribute); + } + + public function testResetRefreshDatabaseStateResetsState(): void + { + // Set some state + RefreshDatabaseState::$migrated = true; + RefreshDatabaseState::$lazilyRefreshed = true; + RefreshDatabaseState::$inMemoryConnections = ['test']; + + ResetRefreshDatabaseState::run(); + + $this->assertFalse(RefreshDatabaseState::$migrated); + $this->assertFalse(RefreshDatabaseState::$lazilyRefreshed); + $this->assertEmpty(RefreshDatabaseState::$inMemoryConnections); + } + + public function testWithMigrationImplementsInvokable(): void + { + $attribute = new WithMigration('/path/to/migrations'); + + $this->assertInstanceOf(Invokable::class, $attribute); + $this->assertSame(['/path/to/migrations'], $attribute->paths); + } + + public function testWithMigrationMultiplePaths(): void + { + $attribute = new WithMigration('/path/one', '/path/two'); + + $this->assertSame(['/path/one', '/path/two'], $attribute->paths); + } + + public function testRequiresEnvImplementsActionable(): void + { + $attribute = new RequiresEnv('SOME_VAR'); + + $this->assertInstanceOf(Actionable::class, $attribute); + $this->assertSame('SOME_VAR', $attribute->key); + } + + public function testDefineImplementsResolvable(): void + { + $attribute = new Define('env', 'setupEnv'); + + $this->assertInstanceOf(Resolvable::class, $attribute); + $this->assertSame('env', $attribute->group); + $this->assertSame('setupEnv', $attribute->method); + } + + public function testDefineResolvesToDefineEnvironment(): void + { + $attribute = new Define('env', 'setupEnv'); + $resolved = $attribute->resolve(); + + $this->assertInstanceOf(DefineEnvironment::class, $resolved); + $this->assertSame('setupEnv', $resolved->method); + } + + public function testDefineResolvesToDefineDatabase(): void + { + $attribute = new Define('db', 'setupDb'); + $resolved = $attribute->resolve(); + + $this->assertInstanceOf(DefineDatabase::class, $resolved); + $this->assertSame('setupDb', $resolved->method); + } + + public function testDefineResolvesToDefineRoute(): void + { + $attribute = new Define('route', 'setupRoutes'); + $resolved = $attribute->resolve(); + + $this->assertInstanceOf(DefineRoute::class, $resolved); + $this->assertSame('setupRoutes', $resolved->method); + } + + public function testDefineReturnsNullForUnknownGroup(): void + { + $attribute = new Define('unknown', 'someMethod'); + $resolved = $attribute->resolve(); + + $this->assertNull($resolved); + } + + public function testDefineGroupIsCaseInsensitive(): void + { + $envUpper = new Define('ENV', 'method'); + $envMixed = new Define('Env', 'method'); + + $this->assertInstanceOf(DefineEnvironment::class, $envUpper->resolve()); + $this->assertInstanceOf(DefineEnvironment::class, $envMixed->resolve()); + } + + public function testAttributesHaveCorrectTargets(): void + { + $this->assertAttributeHasTargets(DefineEnvironment::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(WithConfig::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(DefineRoute::class, ['TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(DefineDatabase::class, ['TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(ResetRefreshDatabaseState::class, ['TARGET_CLASS']); + $this->assertAttributeHasTargets(WithMigration::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(RequiresEnv::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + $this->assertAttributeHasTargets(Define::class, ['TARGET_CLASS', 'TARGET_METHOD', 'IS_REPEATABLE']); + } + + private function assertAttributeHasTargets(string $class, array $expectedTargets): void + { + $reflection = new ReflectionClass($class); + $attributes = $reflection->getAttributes(Attribute::class); + + $this->assertNotEmpty($attributes, "Class {$class} should have #[Attribute]"); + + $attributeInstance = $attributes[0]->newInstance(); + $flags = $attributeInstance->flags; + + foreach ($expectedTargets as $target) { + $constant = constant("Attribute::{$target}"); + $this->assertTrue( + ($flags & $constant) !== 0, + "Class {$class} should have {$target} flag" + ); + } + } +} diff --git a/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php b/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php new file mode 100644 index 000000000..0e90c411a --- /dev/null +++ b/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php @@ -0,0 +1,85 @@ + $attr['key'] === WithConfig::class + ); + + $this->assertCount(2, $withConfigAttributes); + + // Extract the config keys to verify both are present + $configKeys = array_map( + fn ($attr) => $attr['instance']->key, + $withConfigAttributes + ); + + $this->assertContains('testing.parent_class', $configKeys); + $this->assertContains('testing.child_class', $configKeys); + } + + public function testParentAttributeIsExecutedThroughLifecycle(): void + { + // The parent's #[WithConfig('testing.parent_class', 'parent_value')] should be applied + $this->assertSame( + 'parent_value', + $this->app->get('config')->get('testing.parent_class') + ); + } + + public function testChildAttributeIsExecutedThroughLifecycle(): void + { + // The child's #[WithConfig('testing.child_class', 'child_value')] should be applied + $this->assertSame( + 'child_value', + $this->app->get('config')->get('testing.child_class') + ); + } + + public function testParentAttributesAreAppliedBeforeChildAttributes(): void + { + // Parent attributes come first in the array (prepended during recursion) + $attributes = AttributeParser::forClass(static::class); + + $withConfigAttributes = array_values(array_filter( + $attributes, + fn ($attr) => $attr['key'] === WithConfig::class + )); + + // Parent should be first + $this->assertSame('testing.parent_class', $withConfigAttributes[0]['instance']->key); + // Child should be second + $this->assertSame('testing.child_class', $withConfigAttributes[1]['instance']->key); + } +} + +/** + * Abstract parent test case with class-level attributes for inheritance testing. + * + * @internal + */ +#[WithConfig('testing.parent_class', 'parent_value')] +abstract class AbstractParentTestCase extends TestCase +{ +} diff --git a/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php b/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php new file mode 100644 index 000000000..24742d730 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php @@ -0,0 +1,60 @@ +get('config')->set('testing.deferred_executed', true); + } + + #[DefineDatabase('defineDatabaseSetup', defer: true)] + public function testDeferredDefineDatabaseAttributeIsExecuted(): void + { + // The DefineDatabase attribute with defer: true should have its method called + // during the setUp lifecycle, even though execution is deferred + $this->assertTrue( + static::$deferredMethodWasCalled, + 'Deferred DefineDatabase method should be called during setUp' + ); + $this->assertTrue( + $this->app->get('config')->get('testing.deferred_executed', false), + 'Deferred DefineDatabase should have set config value' + ); + } + + #[DefineDatabase('defineDatabaseSetup', defer: false)] + public function testImmediateDefineDatabaseAttributeIsExecuted(): void + { + // The DefineDatabase attribute with defer: false should execute immediately + $this->assertTrue( + static::$deferredMethodWasCalled, + 'Immediate DefineDatabase method should be called during setUp' + ); + $this->assertTrue( + $this->app->get('config')->get('testing.deferred_executed', false), + 'Immediate DefineDatabase should have set config value' + ); + } +} diff --git a/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php b/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php new file mode 100644 index 000000000..d8fc7549f --- /dev/null +++ b/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php @@ -0,0 +1,48 @@ +defineEnvironmentCalled = true; + $this->passedApp = $app; + + // Set a config value to verify it takes effect before providers boot + $app->get('config')->set('testing.define_environment_test', 'configured'); + } + + public function testDefineEnvironmentIsCalledDuringSetUp(): void + { + $this->assertTrue($this->defineEnvironmentCalled); + } + + public function testAppInstanceIsPassed(): void + { + $this->assertNotNull($this->passedApp); + $this->assertInstanceOf(ApplicationContract::class, $this->passedApp); + $this->assertSame($this->app, $this->passedApp); + } + + public function testConfigChangesAreApplied(): void + { + $this->assertSame( + 'configured', + $this->app->get('config')->get('testing.define_environment_test') + ); + } +} diff --git a/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php b/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php new file mode 100644 index 000000000..a13d21588 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php @@ -0,0 +1,68 @@ +get('config')->set('testing.method_env', 'method_value'); + } + + public function testParseTestMethodAttributesReturnsCollection(): void + { + $result = $this->parseTestMethodAttributes($this->app, WithConfig::class); + + $this->assertInstanceOf(FeaturesCollection::class, $result); + } + + #[WithConfig('testing.method_attribute', 'test_value')] + public function testParseTestMethodAttributesHandlesInvokable(): void + { + // Parse WithConfig attribute which is Invokable + $this->parseTestMethodAttributes($this->app, WithConfig::class); + + // The attribute should have set the config value + $this->assertSame('test_value', $this->app->get('config')->get('testing.method_attribute')); + } + + #[DefineEnvironment('defineConfigEnv')] + public function testParseTestMethodAttributesHandlesActionable(): void + { + // Parse DefineEnvironment attribute which is Actionable + $this->parseTestMethodAttributes($this->app, DefineEnvironment::class); + + // The attribute should have called the method which set the config value + $this->assertSame('method_value', $this->app->get('config')->get('testing.method_env')); + } + + public function testParseTestMethodAttributesReturnsEmptyCollectionForNoMatch(): void + { + $result = $this->parseTestMethodAttributes($this->app, DefineEnvironment::class); + + $this->assertInstanceOf(FeaturesCollection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + #[WithConfig('testing.multi_one', 'one')] + #[WithConfig('testing.multi_two', 'two')] + public function testParseTestMethodAttributesHandlesMultipleAttributes(): void + { + $this->parseTestMethodAttributes($this->app, WithConfig::class); + + $this->assertSame('one', $this->app->get('config')->get('testing.multi_one')); + $this->assertSame('two', $this->app->get('config')->get('testing.multi_two')); + } +} diff --git a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php new file mode 100644 index 000000000..460862c41 --- /dev/null +++ b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php @@ -0,0 +1,150 @@ +assertTrue(static::usesTestingConcern(HandlesAttributes::class)); + $this->assertTrue(static::usesTestingConcern(InteractsWithTestCase::class)); + } + + public function testUsesTestingConcernReturnsFalseForUnusedTrait(): void + { + $this->assertFalse(static::usesTestingConcern('NonExistentTrait')); + } + + public function testCachedUsesForTestCaseReturnsTraits(): void + { + $uses = static::cachedUsesForTestCase(); + + $this->assertIsArray($uses); + $this->assertArrayHasKey(HandlesAttributes::class, $uses); + $this->assertArrayHasKey(InteractsWithTestCase::class, $uses); + } + + public function testResolvePhpUnitAttributesReturnsCollection(): void + { + $attributes = $this->resolvePhpUnitAttributes(); + + $this->assertInstanceOf(Collection::class, $attributes); + } + + #[WithConfig('testing.method_level', 'method_value')] + public function testResolvePhpUnitAttributesMergesClassAndMethodAttributes(): void + { + $attributes = $this->resolvePhpUnitAttributes(); + + // Should have WithConfig from both class and method level + $this->assertTrue($attributes->has(WithConfig::class)); + + $withConfigInstances = $attributes->get(WithConfig::class); + $this->assertCount(2, $withConfigInstances); + } + + public function testClassLevelAttributeIsApplied(): void + { + // The WithConfig attribute at class level should be applied + $this->assertSame('class_value', $this->app->get('config')->get('testing.class_level')); + } + + public function testUsesTestingFeatureAddsAttribute(): void + { + // Add a testing feature programmatically at method level so it doesn't + // persist to other tests in this class + static::usesTestingFeature( + new WithConfig('testing.programmatic', 'added'), + Attribute::TARGET_METHOD + ); + + // Re-resolve attributes to include the programmatically added one + $attributes = $this->resolvePhpUnitAttributes(); + + $this->assertTrue($attributes->has(WithConfig::class)); + } + + public function testDefineMetaAttributeIsResolvedByAttributeParser(): void + { + // Test that AttributeParser resolves #[Define('env', 'method')] to DefineEnvironment + $attributes = AttributeParser::forMethod( + DefineMetaAttributeTestCase::class, + 'testWithDefineAttribute' + ); + + // Should have one attribute, resolved from Define to DefineEnvironment + $this->assertCount(1, $attributes); + $this->assertSame(DefineEnvironment::class, $attributes[0]['key']); + $this->assertInstanceOf(DefineEnvironment::class, $attributes[0]['instance']); + $this->assertSame('setupDefineEnv', $attributes[0]['instance']->method); + } + + #[Define('env', 'setupDefineEnvForExecution')] + public function testDefineMetaAttributeIsExecutedThroughLifecycle(): void + { + // The #[Define('env', 'setupDefineEnvForExecution')] attribute should have been + // resolved to DefineEnvironment and executed during setUp, calling our method + $this->assertSame( + 'define_env_executed', + $this->app->get('config')->get('testing.define_meta_attribute') + ); + } + + protected function setupDefineEnvForExecution($app): void + { + $app->get('config')->set('testing.define_meta_attribute', 'define_env_executed'); + } + + public function testResolvePhpUnitAttributesReturnsCollectionOfCollections(): void + { + $attributes = $this->resolvePhpUnitAttributes(); + + $this->assertInstanceOf(Collection::class, $attributes); + + // Each value should be a Collection, not an array + $attributes->each(function ($value, $key) { + $this->assertInstanceOf( + Collection::class, + $value, + "Value for key {$key} should be a Collection, not " . gettype($value) + ); + }); + } +} + +/** + * Test fixture for Define meta-attribute parsing. + * + * @internal + * @coversNothing + */ +class DefineMetaAttributeTestCase extends TestCase +{ + #[Define('env', 'setupDefineEnv')] + public function testWithDefineAttribute(): void + { + // This method exists just to have the attribute parsed + } + + protected function setupDefineEnv($app): void + { + // Method that would be called + } +} diff --git a/tests/Sanctum/AuthenticateRequestsTest.php b/tests/Sanctum/AuthenticateRequestsTest.php index af83035b0..dc67361a5 100644 --- a/tests/Sanctum/AuthenticateRequestsTest.php +++ b/tests/Sanctum/AuthenticateRequestsTest.php @@ -4,14 +4,14 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\Context; +use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Router\Router; use Hypervel\Sanctum\PersonalAccessToken; use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\SanctumServiceProvider; -use Hypervel\Support\Facades\Route; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Sanctum\Stub\TestUser; @@ -30,31 +30,60 @@ protected function setUp(): void { parent::setUp(); - $this->app->register(SanctumServiceProvider::class); - - // Configure test environment - $this->app->get(ConfigInterface::class) - ->set([ - 'app.key' => 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF', - 'auth.guards.sanctum' => [ - 'driver' => 'sanctum', - 'provider' => 'users', - ], - 'auth.guards.web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - 'auth.providers.users.model' => TestUser::class, - 'auth.providers.users.driver' => 'eloquent', - 'database.default' => 'testing', - 'sanctum.stateful' => ['localhost', '127.0.0.1'], - 'sanctum.guard' => ['web'], - ]); - - $this->defineRoutes(); $this->createUsersTable(); } + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + SanctumServiceProvider::class, + ]; + } + + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $app->get('config')->set([ + 'app.key' => 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF', + 'auth.guards.sanctum' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], + 'auth.guards.web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + 'auth.providers.users.model' => TestUser::class, + 'auth.providers.users.driver' => 'eloquent', + 'sanctum.stateful' => ['localhost', '127.0.0.1'], + 'sanctum.guard' => ['web'], + ]); + } + + protected function defineRoutes(Router $router): void + { + $router->get('/sanctum/api/user', function () { + $user = auth('sanctum')->user(); + + if (! $user) { + abort(401); + } + + return response()->json(['email' => $user->email]); + }); + + $router->get('/sanctum/web/user', function () { + $user = auth('sanctum')->user(); + + if (! $user) { + abort(401); + } + + return response()->json(['email' => $user->email]); + }); + } + protected function tearDown(): void { parent::tearDown(); @@ -89,29 +118,6 @@ protected function createUsersTable(): void }); } - protected function defineRoutes(): void - { - Route::get('/sanctum/api/user', function () { - $user = auth('sanctum')->user(); - - if (! $user) { - abort(401); - } - - return response()->json(['email' => $user->email]); - }); - - Route::get('/sanctum/web/user', function () { - $user = auth('sanctum')->user(); - - if (! $user) { - abort(401); - } - - return response()->json(['email' => $user->email]); - }); - } - public function testCanAuthorizeValidUserUsingAuthorizationHeader(): void { // Create a user in the database diff --git a/tests/Sentry/Features/LogFeatureTest.php b/tests/Sentry/Features/LogFeatureTest.php index 91e69eaa8..982898318 100644 --- a/tests/Sentry/Features/LogFeatureTest.php +++ b/tests/Sentry/Features/LogFeatureTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Sentry\Features; use Hyperf\Contract\ConfigInterface; +use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Sentry\Features\LogFeature; use Hypervel\Support\Facades\Log; @@ -26,7 +27,7 @@ class LogFeatureTest extends SentryTestCase ], ]; - protected function defineEnvironment($app): void + protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); diff --git a/tests/Testbench/Concerns/CreatesApplicationTest.php b/tests/Testbench/Concerns/CreatesApplicationTest.php new file mode 100644 index 000000000..40dd7896e --- /dev/null +++ b/tests/Testbench/Concerns/CreatesApplicationTest.php @@ -0,0 +1,83 @@ + TestFacade::class, + ]; + } + + public function testGetPackageProvidersReturnsProviders(): void + { + $providers = $this->getPackageProviders($this->app); + + $this->assertContains(TestServiceProvider::class, $providers); + } + + public function testGetPackageAliasesReturnsAliases(): void + { + $aliases = $this->getPackageAliases($this->app); + + $this->assertArrayHasKey('TestAlias', $aliases); + $this->assertSame(TestFacade::class, $aliases['TestAlias']); + } + + public function testRegisterPackageProvidersRegistersProviders(): void + { + // The provider should be registered via defineEnvironment + // which calls registerPackageProviders + $this->assertTrue( + $this->app->providerIsLoaded(TestServiceProvider::class), + 'TestServiceProvider should be registered' + ); + } + + public function testRegisterPackageAliasesAddsToConfig(): void + { + $aliases = $this->app->get('config')->get('app.aliases', []); + + $this->assertArrayHasKey('TestAlias', $aliases); + $this->assertSame(TestFacade::class, $aliases['TestAlias']); + } +} + +/** + * Test service provider for testing. + */ +class TestServiceProvider extends \Hypervel\Support\ServiceProvider +{ + public function register(): void + { + $this->app->bind('test.service', fn () => 'test_value'); + } +} + +/** + * Test facade for testing. + */ +class TestFacade +{ + // Empty facade class for testing +} diff --git a/tests/Testbench/Concerns/HandlesRoutesTest.php b/tests/Testbench/Concerns/HandlesRoutesTest.php new file mode 100644 index 000000000..e8b98070a --- /dev/null +++ b/tests/Testbench/Concerns/HandlesRoutesTest.php @@ -0,0 +1,82 @@ +defineRoutesCalled = true; + + $router->get('/api/test', fn () => 'api_response'); + } + + protected function defineWebRoutes(Router $router): void + { + $this->defineWebRoutesCalled = true; + + // Note: Web routes are wrapped in 'web' middleware group by setUpApplicationRoutes + // We register a simple route here just to verify the method is called + $router->get('/web/test', fn () => 'web_response'); + } + + public function testDefineRoutesMethodExists(): void + { + $this->assertTrue(method_exists($this, 'defineRoutes')); + } + + public function testDefineWebRoutesMethodExists(): void + { + $this->assertTrue(method_exists($this, 'defineWebRoutes')); + } + + public function testSetUpApplicationRoutesMethodExists(): void + { + $this->assertTrue(method_exists($this, 'setUpApplicationRoutes')); + } + + public function testSetUpApplicationRoutesCallsDefineRoutes(): void + { + // setUpApplicationRoutes is called automatically in setUp via afterApplicationCreated + // so defineRoutesCalled should already be true + $this->assertTrue($this->defineRoutesCalled); + } + + public function testSetUpApplicationRoutesCallsDefineWebRoutes(): void + { + // setUpApplicationRoutes is called automatically in setUp via afterApplicationCreated + // so defineWebRoutesCalled should already be true + $this->assertTrue($this->defineWebRoutesCalled); + } + + public function testRouterIsPassedToDefineRoutes(): void + { + $router = $this->app->get(Router::class); + + $this->assertInstanceOf(Router::class, $router); + } + + public function testDefinedRoutesAreAccessibleViaHttp(): void + { + $response = $this->get('/api/test'); + + $response->assertSuccessful(); + $response->assertContent('api_response'); + } +} diff --git a/tests/Testbench/Concerns/HandlesRoutesWithoutWebRoutesTest.php b/tests/Testbench/Concerns/HandlesRoutesWithoutWebRoutesTest.php new file mode 100644 index 000000000..bd86e9002 --- /dev/null +++ b/tests/Testbench/Concerns/HandlesRoutesWithoutWebRoutesTest.php @@ -0,0 +1,30 @@ +get('/only-api', fn () => 'only_api_response'); + } + + public function testRoutesWorkWithoutDefineWebRoutes(): void + { + $this->get('/only-api')->assertSuccessful()->assertContent('only_api_response'); + } +} diff --git a/tests/Testbench/TestCaseTest.php b/tests/Testbench/TestCaseTest.php new file mode 100644 index 000000000..1d3ea02a8 --- /dev/null +++ b/tests/Testbench/TestCaseTest.php @@ -0,0 +1,107 @@ +defineEnvironmentCalled = true; + $app->get('config')->set('testing.define_environment', 'called'); + } + + public function testTestCaseUsesCreatesApplicationTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(CreatesApplication::class, $uses); + } + + public function testTestCaseUsesHandlesRoutesTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(HandlesRoutes::class, $uses); + } + + public function testTestCaseUsesHandlesDatabasesTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(HandlesDatabases::class, $uses); + } + + public function testTestCaseUsesHandlesAttributesTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(HandlesAttributes::class, $uses); + } + + public function testTestCaseUsesInteractsWithTestCaseTrait(): void + { + $uses = class_uses_recursive(static::class); + + $this->assertArrayHasKey(InteractsWithTestCase::class, $uses); + } + + public function testDefineEnvironmentIsCalled(): void + { + $this->assertTrue($this->defineEnvironmentCalled); + $this->assertSame('called', $this->app->get('config')->get('testing.define_environment')); + } + + public function testClassLevelAttributeIsApplied(): void + { + // The WithConfig attribute at class level should be applied + $this->assertSame('class_level', $this->app->get('config')->get('testing.testcase_class')); + } + + #[WithConfig('testing.method_attribute', 'method_level')] + public function testMethodLevelAttributeIsApplied(): void + { + // The WithConfig attribute at method level should be applied + $this->assertSame('method_level', $this->app->get('config')->get('testing.method_attribute')); + } + + public function testReloadApplicationMethodExists(): void + { + $this->assertTrue(method_exists($this, 'reloadApplication')); + } + + public function testStaticLifecycleMethodsExist(): void + { + $this->assertTrue(method_exists(static::class, 'setUpBeforeClass')); + $this->assertTrue(method_exists(static::class, 'tearDownAfterClass')); + } + + public function testUsesTestingConcernIsAvailable(): void + { + $this->assertTrue(static::usesTestingConcern(HandlesAttributes::class)); + } + + public function testAppIsAvailable(): void + { + $this->assertNotNull($this->app); + } +}