From 0867ad6173343427046ba2275116bef5167b4f08 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Wed, 4 Dec 2024 14:25:28 +0100 Subject: [PATCH] feat: add `#[WithStory]` attribute (#728) --- docs/index.rst | 107 +++++++++++++----- phpunit | 4 +- src/Attribute/WithStory.php | 21 ++++ .../BootFoundryOnDataProviderMethodCalled.php | 2 +- src/PHPUnit/BuildStoryOnTestPrepared.php | 76 +++++++++++++ src/PHPUnit/FoundryExtension.php | 19 ++-- ...ownFoundryOnDataProviderMethodFinished.php | 2 +- tests/Fixture/Stories/ServiceStory.php | 36 ++++++ tests/Fixture/TestKernel.php | 2 + .../ParentClassWithStoryAttributeTestCase.php | 21 ++++ .../WithStory/WithStoryOnClassTest.php | 58 ++++++++++ .../WithStory/WithStoryOnMethodTest.php | 60 ++++++++++ .../WithStory/WithStoryOnParentClassTest.php | 30 +++++ 13 files changed, 394 insertions(+), 44 deletions(-) create mode 100644 src/Attribute/WithStory.php create mode 100644 src/PHPUnit/BuildStoryOnTestPrepared.php create mode 100644 tests/Fixture/Stories/ServiceStory.php create mode 100644 tests/Integration/Attribute/WithStory/ParentClassWithStoryAttributeTestCase.php create mode 100644 tests/Integration/Attribute/WithStory/WithStoryOnClassTest.php create mode 100644 tests/Integration/Attribute/WithStory/WithStoryOnMethodTest.php create mode 100644 tests/Integration/Attribute/WithStory/WithStoryOnParentClassTest.php diff --git a/docs/index.rst b/docs/index.rst index 26b7ec23..60612f84 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1608,35 +1608,26 @@ PHPUnit Data Providers It is possible to use factories in `PHPUnit data providers `_. -Their usage depends on which Foundry version you are running: +Their usage depends on whether you're using Foundry's `PHPUnit Extension`_ or not.: -Data Providers with Foundry ^2.2 -................................ +With PHPUnit Extension +...................... -From version 2.2, Foundry provides an extension for PHPUnit. -You can install it by modifying you ``phpunit.xml.dist``: +.. versionadded:: 2.2 -.. configuration-block:: + The ability to call ``Factory::create()`` in data providers was introduced in Foundry 2.2. - .. code-block:: xml - - - - - - - .. warning:: - This PHPUnit extension requires at least PHPUnit 11.4. + You will need at least PHPUnit 11.4 to call ``Factory::create()`` in your data providers. -Using this extension will allow to use your factories in your data providers the same way you're using them in tests. -Thanks to it, you can: +Thanks to Foundry's `PHPUnit Extension`_, you'll be able to use your factories in your data providers the same way +you're using them in tests. Thanks to it, you can: * Call ``->create()`` or ``::createOne()`` or any other method which creates objects in unit tests (using ``PHPUnit\Framework\TestCase``) and functional tests (``Symfony\Bundle\FrameworkBundle\Test\KernelTestCase``) * Use `Factories as Services`_ in functional tests * Use `faker()` normally, without wrapping its call in a callable - + :: use App\Factory\PostFactory; @@ -1654,21 +1645,22 @@ Thanks to it, you can: yield [PostWithServiceFactory::createOne()]; yield [PostFactory::createOne(['body' => faker()->sentence()]; } - - + .. warning:: Because Foundry is relying on its `Proxy mechanism `_, when using persistence, your factories must extend ``Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory`` to work in your data providers. - + .. warning:: - For the same reason, you should not call methods from `Proxy` class in your data providers, - not even ``->_real()``. + For the same reason, you should not call methods from `Proxy` class in your data providers, not even ``->_real()``. + +Without PHPUnit Extension +......................... -Data Providers before Foundry v2.2 -.................................. +Data providers are computed early in the phpunit process before Foundry is booted. +Be sure your data provider returns only instances of ``Factory`` and you do not try to call ``->create()`` on them: :: @@ -1690,11 +1682,6 @@ Data Providers before Foundry v2.2 yield [PostFactory::new()->published()]; } -.. note:: - - Be sure your data provider returns only instances of ``Factory`` and you do not try to call ``->create()`` on them. - Data providers are computed early in the phpunit process before Foundry is booted. - .. note:: For the same reason as above, it is not possible to use `Factories as Services`_ with required @@ -2168,6 +2155,39 @@ Objects can be fetched from pools in your tests, fixtures or other stories: ProvinceStory::getRandomRange('be', 1, 4); // between 1 and 4 random Province|Proxy's from "be" pool ProvinceStory::getPool('be'); // all Province|Proxy's from "be" pool +#[WithStory] Attribute +~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.3 + + The `#[WithStory]` attribute was added in Foundry 2.3. + +.. warning:: + + The `PHPUnit Extension`_ for Foundry is needed to use ``#[WithStory]`` attribute. + +You can use the ``#[WithStory]`` attribute to load stories in your tests: + +:: + + use App\Story\CategoryStory; + use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + use Zenstruck\Foundry\Attribute\WithStory; + + // You can use the attribute on the class... + #[WithStory(CategoryStory::class)] + final class NeedsCategoriesTest extends KernelTestCase + { + // ... or on the method + #[WithStory(CategoryStory::class)] + public function testThatNeedStories(): void + { + // ... + } + } + +If used on the class, the story will be loaded before each test method. + Static Analysis --------------- @@ -2181,6 +2201,33 @@ Please, enable it with: $ vendor/bin/psalm-plugin enable zenstruck/foundry +PHPUnit Extension +----------------- + +Foundry is shipped with an extension for PHPUnit. You can install it by modifying the file ``phpunit.xml.dist``: + +.. configuration-block:: + + .. code-block:: xml + + + + + + + +This extension provides the following features: +- support for the `#[WithStory] Attribute`_ +- ability to use ``Factory::create()`` in `PHPUnit Data Providers`_ (along with PHPUnit ^11.4) + +.. versionadded:: 2.2 + + The PHPUnit extension was introduced in Foundry 2.2. + +.. warning:: + + The PHPUnit extension is only compatible with PHPUnit 10+. + Bundle Configuration -------------------- diff --git a/phpunit b/phpunit index c291e79a..d14150c0 100755 --- a/phpunit +++ b/phpunit @@ -43,8 +43,8 @@ fi DAMA_EXTENSION="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" FOUNDRY_EXTENSION="Zenstruck\Foundry\PHPUnit\FoundryExtension" -if [ "${USE_FOUNDRY_PHPUNIT_EXTENSION:-0}" = "1" ] && [ "${PHPUNIT_VERSION}" != "11" ]; then - echo "❌ USE_FOUNDRY_PHPUNIT_EXTENSION could only be used with PHPUNIT_VERSION=11"; +if [ "${USE_FOUNDRY_PHPUNIT_EXTENSION:-0}" = "1" ] && [ "${PHPUNIT_VERSION}" = "9" ]; then + echo "❌ USE_FOUNDRY_PHPUNIT_EXTENSION cannot be used with PHPUNIT_VERSION=10"; exit 1; fi diff --git a/src/Attribute/WithStory.php b/src/Attribute/WithStory.php new file mode 100644 index 00000000..8d2331b3 --- /dev/null +++ b/src/Attribute/WithStory.php @@ -0,0 +1,21 @@ + $story */ + public readonly string $story + ) + { + if (!\is_subclass_of($story, Story::class)) { + throw new \InvalidArgumentException(\sprintf('"%s" is not a valid story class.', $story)); + } + } +} diff --git a/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php b/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php index 2eccf412..4564efb6 100644 --- a/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php +++ b/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php @@ -24,7 +24,7 @@ final class BootFoundryOnDataProviderMethodCalled implements Event\Test\DataProv public function notify(Event\Test\DataProviderMethodCalled $event): void { if (\method_exists($event->testMethod()->className(), '_bootForDataProvider')) { - \call_user_func([$event->testMethod()->className(), '_bootForDataProvider']); + $event->testMethod()->className()::_bootForDataProvider(); } } } diff --git a/src/PHPUnit/BuildStoryOnTestPrepared.php b/src/PHPUnit/BuildStoryOnTestPrepared.php new file mode 100644 index 00000000..67bf0e47 --- /dev/null +++ b/src/PHPUnit/BuildStoryOnTestPrepared.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\PHPUnit; + +use PHPUnit\Event; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Attribute\WithStory; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class BuildStoryOnTestPrepared implements Event\Test\PreparedSubscriber +{ + public function notify(Event\Test\Prepared $event): void + { + $test = $event->test(); + + if (!$test->isTestMethod()) { + return; + } + + /** @var Event\Code\TestMethod $test */ + + $reflectionClass = new \ReflectionClass($test->className()); + $withStoryAttributes = [ + ...$this->collectWithStoryAttributesFromClassAndParents($reflectionClass), + ...$reflectionClass->getMethod($test->methodName())->getAttributes(WithStory::class), + ]; + + if (!$withStoryAttributes) { + return; + } + + if (!is_subclass_of($test->className(), KernelTestCase::class)) { + throw new \InvalidArgumentException( + \sprintf( + 'The test class "%s" must extend "%s" to use the "%s" attribute.', + $test->className(), + KernelTestCase::class, + WithStory::class + ) + ); + } + + foreach ($withStoryAttributes as $withStoryAttribute) { + $withStoryAttribute->newInstance()->story::load(); + } + } + + /** + * @return list<\ReflectionAttribute> + */ + private function collectWithStoryAttributesFromClassAndParents(\ReflectionClass $class): array // @phpstan-ignore missingType.generics + { + return [ + ...$class->getAttributes(WithStory::class), + ...( + $class->getParentClass() + ? $this->collectWithStoryAttributesFromClassAndParents($class->getParentClass()) + : [] + ) + ]; + } +} diff --git a/src/PHPUnit/FoundryExtension.php b/src/PHPUnit/FoundryExtension.php index 738a1a16..20cbb5a2 100644 --- a/src/PHPUnit/FoundryExtension.php +++ b/src/PHPUnit/FoundryExtension.php @@ -24,25 +24,24 @@ */ final class FoundryExtension implements Runner\Extension\Extension { - public const MIN_PHPUNIT_VERSION = '11.4'; - public function bootstrap( TextUI\Configuration\Configuration $configuration, Runner\Extension\Facade $facade, Runner\Extension\ParameterCollection $parameters, ): void { - if (!ConstraintRequirement::from(self::MIN_PHPUNIT_VERSION)->isSatisfiedBy(Runner\Version::id())) { - throw new \LogicException(\sprintf('Your PHPUnit version (%s) is not compatible with the minimum version (%s) needed to use this extension.', Runner\Version::id(), self::MIN_PHPUNIT_VERSION)); - } - // shutdown Foundry if for some reason it has been booted before if (Configuration::isBooted()) { Configuration::shutdown(); } - $facade->registerSubscribers( - new BootFoundryOnDataProviderMethodCalled(), - new ShutdownFoundryOnDataProviderMethodFinished(), - ); + $subscribers = [new BuildStoryOnTestPrepared()]; + + if (ConstraintRequirement::from('11.4')->isSatisfiedBy(Runner\Version::id())) { + // those deal with data provider events which can be useful only if PHPUnit 11.4 is used + $subscribers[] = new BootFoundryOnDataProviderMethodCalled(); + $subscribers[] = new ShutdownFoundryOnDataProviderMethodFinished(); + } + + $facade->registerSubscribers(...$subscribers); } } diff --git a/src/PHPUnit/ShutdownFoundryOnDataProviderMethodFinished.php b/src/PHPUnit/ShutdownFoundryOnDataProviderMethodFinished.php index 6fbe1394..b028394b 100644 --- a/src/PHPUnit/ShutdownFoundryOnDataProviderMethodFinished.php +++ b/src/PHPUnit/ShutdownFoundryOnDataProviderMethodFinished.php @@ -24,7 +24,7 @@ final class ShutdownFoundryOnDataProviderMethodFinished implements Event\Test\Da public function notify(Event\Test\DataProviderMethodFinished $event): void { if (\method_exists($event->testMethod()->className(), '_shutdownAfterDataProvider')) { - \call_user_func([$event->testMethod()->className(), '_shutdownAfterDataProvider']); + $event->testMethod()->className()::_shutdownAfterDataProvider(); } } } diff --git a/tests/Fixture/Stories/ServiceStory.php b/tests/Fixture/Stories/ServiceStory.php new file mode 100644 index 00000000..943652f8 --- /dev/null +++ b/tests/Fixture/Stories/ServiceStory.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Stories; + +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Routing\RouterInterface; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory; + +/** + * @author Nicolas PHILIPPE + */ + final class ServiceStory extends Story +{ + public function __construct( + private readonly RouterInterface $router + ) { + } + + public function build(): void + { + $this->addState( + 'foo', + GenericEntityFactory::createOne(['prop1' => $this->router->getContext()->getHost()]) + ); + } +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index 63e61850..589e903e 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -28,6 +28,7 @@ use Zenstruck\Foundry\Tests\Fixture\Factories\Object1Factory; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalInvokableService; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalStory; +use Zenstruck\Foundry\Tests\Fixture\Stories\ServiceStory; use Zenstruck\Foundry\ZenstruckFoundryBundle; /** @@ -162,6 +163,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(GlobalInvokableService::class); $c->register(ArrayFactory::class)->setAutowired(true)->setAutoconfigured(true); $c->register(Object1Factory::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(ServiceStory::class)->setAutowired(true)->setAutoconfigured(true); } protected function configureRoutes(RoutingConfigurator $routes): void diff --git a/tests/Integration/Attribute/WithStory/ParentClassWithStoryAttributeTestCase.php b/tests/Integration/Attribute/WithStory/ParentClassWithStoryAttributeTestCase.php new file mode 100644 index 00000000..730f1e4f --- /dev/null +++ b/tests/Integration/Attribute/WithStory/ParentClassWithStoryAttributeTestCase.php @@ -0,0 +1,21 @@ + + */ +#[WithStory(EntityStory::class)] +abstract class ParentClassWithStoryAttributeTestCase extends KernelTestCase +{ + use Factories, ResetDatabase, RequiresORM; +} diff --git a/tests/Integration/Attribute/WithStory/WithStoryOnClassTest.php b/tests/Integration/Attribute/WithStory/WithStoryOnClassTest.php new file mode 100644 index 00000000..edb317c9 --- /dev/null +++ b/tests/Integration/Attribute/WithStory/WithStoryOnClassTest.php @@ -0,0 +1,58 @@ + + * @requires PHPUnit 11 + */ +#[RequiresPhpunit('11')] +#[RequiresPhpunitExtension(FoundryExtension::class)] +#[WithStory(EntityStory::class)] +final class WithStoryOnClassTest extends KernelTestCase +{ + use Factories, ResetDatabase, RequiresORM; + + /** + * @test + */ + public function can_use_story_in_attribute(): void + { + GenericEntityFactory::assert()->count(2); + + // ensure state is accessible + $this->assertSame('foo', EntityStory::get('foo')->getProp1()); + } + + /** + * @test + */ + #[WithStory(EntityStory::class)] + public function can_use_story_in_attribute_multiple_times(): void + { + GenericEntityFactory::assert()->count(2); + } + + /** + * @test + */ + #[WithStory(EntityPoolStory::class)] + public function can_use_another_story_at_level_class(): void + { + GenericEntityFactory::assert()->count(5); + } +} diff --git a/tests/Integration/Attribute/WithStory/WithStoryOnMethodTest.php b/tests/Integration/Attribute/WithStory/WithStoryOnMethodTest.php new file mode 100644 index 00000000..bc3b8193 --- /dev/null +++ b/tests/Integration/Attribute/WithStory/WithStoryOnMethodTest.php @@ -0,0 +1,60 @@ + + * @requires PHPUnit 11 + */ +#[RequiresPhpunit('11')] +#[RequiresPhpunitExtension(FoundryExtension::class)] +final class WithStoryOnMethodTest extends KernelTestCase +{ + use Factories, ResetDatabase, RequiresORM; + + /** + * @test + */ + #[WithStory(EntityStory::class)] + public function can_use_story_in_attribute(): void + { + GenericEntityFactory::assert()->count(2); + + // ensure state is accessible + $this->assertSame('foo', EntityStory::get('foo')->getProp1()); + } + + /** + * @test + */ + #[WithStory(EntityStory::class)] + #[WithStory(EntityPoolStory::class)] + public function can_use_multiple_story_in_attribute(): void + { + GenericEntityFactory::assert()->count(5); + } + + /** + * @test + */ + #[WithStory(ServiceStory::class)] + public function can_use_service_story(): void + { + $this->assertSame('localhost', ServiceStory::get('foo')->getProp1()); + } +} diff --git a/tests/Integration/Attribute/WithStory/WithStoryOnParentClassTest.php b/tests/Integration/Attribute/WithStory/WithStoryOnParentClassTest.php new file mode 100644 index 00000000..fdf337a6 --- /dev/null +++ b/tests/Integration/Attribute/WithStory/WithStoryOnParentClassTest.php @@ -0,0 +1,30 @@ + + * @requires PHPUnit 11 + */ +#[RequiresPhpunit('11')] +#[RequiresPhpunitExtension(FoundryExtension::class)] +#[WithStory(EntityPoolStory::class)] +final class WithStoryOnParentClassTest extends ParentClassWithStoryAttributeTestCase +{ + /** + * @test + */ + public function can_use_story_in_attribute_from_parent_class(): void + { + GenericEntityFactory::assert()->count(5); + } +}