Skip to content

Commit

Permalink
feat: add #[WithStory] attribute (#728)
Browse files Browse the repository at this point in the history
  • Loading branch information
nikophil authored Dec 4, 2024
1 parent 17dd644 commit 0867ad6
Show file tree
Hide file tree
Showing 13 changed files with 394 additions and 44 deletions.
107 changes: 77 additions & 30 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1608,35 +1608,26 @@ PHPUnit Data Providers

It is possible to use factories in
`PHPUnit data providers <https://phpunit.readthedocs.io/en/9.3/writing-tests-for-phpunit.html#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
<phpunit>
<extensions>
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension"/>
</extensions>
</phpunit>
.. 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;
Expand All @@ -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 <object-proxy>`_, 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:

::

Expand All @@ -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
Expand Down Expand Up @@ -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
---------------

Expand All @@ -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
<phpunit>
<extensions>
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension"/>
</extensions>
</phpunit>
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
--------------------

Expand Down
4 changes: 2 additions & 2 deletions phpunit
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions src/Attribute/WithStory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\Attribute;

use Zenstruck\Foundry\Story;

#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
final class WithStory
{
public function __construct(
/** @var class-string<Story> $story */
public readonly string $story
)
{
if (!\is_subclass_of($story, Story::class)) {
throw new \InvalidArgumentException(\sprintf('"%s" is not a valid story class.', $story));
}
}
}
2 changes: 1 addition & 1 deletion src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
76 changes: 76 additions & 0 deletions src/PHPUnit/BuildStoryOnTestPrepared.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*/
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<WithStory>>
*/
private function collectWithStoryAttributesFromClassAndParents(\ReflectionClass $class): array // @phpstan-ignore missingType.generics
{
return [
...$class->getAttributes(WithStory::class),
...(
$class->getParentClass()
? $this->collectWithStoryAttributesFromClassAndParents($class->getParentClass())
: []
)
];
}
}
19 changes: 9 additions & 10 deletions src/PHPUnit/FoundryExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
36 changes: 36 additions & 0 deletions tests/Fixture/Stories/ServiceStory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*/
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()])
);
}
}
2 changes: 2 additions & 0 deletions tests/Fixture/TestKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\Tests\Integration\Attribute\WithStory;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Attribute\WithStory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
use Zenstruck\Foundry\Tests\Fixture\Stories\EntityStory;
use Zenstruck\Foundry\Tests\Integration\RequiresORM;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
#[WithStory(EntityStory::class)]
abstract class ParentClassWithStoryAttributeTestCase extends KernelTestCase
{
use Factories, ResetDatabase, RequiresORM;
}
Loading

0 comments on commit 0867ad6

Please sign in to comment.