From dcde2b986f1eb97a22e553d999ee8539fbb23f20 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Wed, 4 Feb 2026 20:40:02 +0100 Subject: [PATCH 01/10] feat/behat context (#1083) * feature: Behat extension * refactor(behat): don't use subtree split approach * feat(behat): create FoundryContext * feat(behat): create object with properties * feat(behat): create multiple objects * feat(behat): handle -ToOne relation ships * feat(behat): transformer for last ids * feat(behat): handle fixtures as stories wip * feat(behat): can name fixtures from story based on story state * feat(behat): reset database by scenario * feat(behat): reset database by feature * feat(behat): introduce @resetDB tag * feat(behat): provide support for Dama * feat(behat): introduce @noResetDB tag * feat(behat): short syntax for referencing objets * feat(behat): handle correctly built in types * feat(behat): handle correctly enums * feat(behat): use main-dama as main testsuite * feat(behat): tests scenario with errors * refactor(behat): extract all normalization logic into FoundryCallFilter * minor(behat): only load behat services when needed * minor(behat): can load several fixtures on the same scenario * minor(behat): rename exceptions without suffix * tests(behat): test few behaviors with PHPUnit * fix(behat): manual strategy should not reset the DB before the first test * minor(behat): add behat file+line in exceptions * fix(behat): make the CI green --- .github/workflows/behat.yml | 86 +++ .github/workflows/phpunit.yml | 2 +- .github/workflows/static-analysis.yml | 6 +- behat.yml | 116 ++++ bin/tools/behat/.gitignore | 1 + bin/tools/behat/composer.json | 17 + bin/tools/behat/symfony.lock | 49 ++ composer.json | 4 +- config/behat.php | 44 ++ config/persistence.php | 11 +- docs/index.rst | 199 +++++++ phpstan.neon | 1 + phpunit-paratest.xml.dist | 2 + src/Attribute/FactoryShortName.php | 27 + src/Command/LoadFixturesCommand.php | 66 ++- .../AsFixtureStoryCompilerPass.php | 6 +- src/Persistence/PersistedObjectsTracker.php | 2 +- src/Persistence/PersistenceManager.php | 8 +- src/Story.php | 5 + src/Story/Event/StateAddedToStory.php | 28 + src/Story/FixtureStoryNotFound.php | 52 ++ src/Story/FixtureStoryResolver.php | 101 ++++ src/Test/Behat/DatabaseResetMode.php | 25 + .../DamaNativeExtensionIncompatibility.php | 30 ++ .../Behat/Exception/FactoryNotResolvable.php | 38 ++ .../Exception/InvalidObjectParameter.php | 36 ++ .../Behat/Exception/InvalidResetDbTag.php | 71 +++ .../Exception/ObjectAlreadyRegistered.php | 27 + src/Test/Behat/Exception/ObjectNotFound.php | 34 ++ src/Test/Behat/FactoryShortNameResolver.php | 153 ++++++ src/Test/Behat/FoundryCallFilter.php | 211 ++++++++ src/Test/Behat/FoundryContext.php | 178 ++++++ src/Test/Behat/FoundryExtension.php | 108 ++++ src/Test/Behat/FoundryTableNode.php | 88 +++ .../Listener/BootConfigurationListener.php | 70 +++ .../Behat/Listener/DatabaseResetListener.php | 218 ++++++++ .../Behat/Listener/LoadFixturesListener.php | 89 +++ src/Test/Behat/ObjectRegistry.php | 164 ++++++ src/ZenstruckFoundryBundle.php | 20 + .../App/Controller/HelloWorldController.php | 17 + tests/Fixture/Behat/BehatTestKernel.php | 71 +++ .../Behat/Factories/Tag/TagFactory.php | 37 ++ tests/Fixture/Behat/Factories/TagFactory.php | 35 ++ .../Behat/ResetDisabledTestContext.php | 25 + tests/Fixture/Behat/Stories/CategoryStory.php | 28 + .../Fixture/Behat/Stories/ConflictStory1.php | 28 + .../Fixture/Behat/Stories/ConflictStory2.php | 28 + tests/Fixture/Behat/Stories/ContactStory.php | 28 + tests/Fixture/Behat/TestFoundryContext.php | 11 + .../Entity/Contact/ChildContactFactory.php | 2 + tests/Fixture/FoundryTestKernel.php | 9 +- .../{SomeEnum.php => IntBackedEnum.php} | 5 +- .../can_create_factory_with_all_fields.php | 7 + .../can_create_factory_with_default_enum.php | 4 +- tests/Fixture/Model/GenericModel.php | 22 + tests/Fixture/ObjectWithEnum.php | 2 +- .../ResetDatabase/ResetDatabaseTestKernel.php | 5 +- tests/Fixture/StringBackedEnum.php | 18 + tests/Fixture/TestKernel.php | 11 +- .../BootConfigurationListenerTest.php | 88 +++ .../Listener/DatabaseResetListenerTest.php | 367 +++++++++++++ .../Listener/LoadFixturesListenerTest.php | 175 ++++++ .../Command/LoadFixturesCommandTest.php | 15 +- tests/Unit/Test/Behat/FactoryResolverTest.php | 217 ++++++++ .../Unit/Test/Behat/FoundryCallFilterTest.php | 507 ++++++++++++++++++ .../Unit/Test/Behat/FoundryTableNodeTest.php | 247 +++++++++ tests/Unit/Test/Behat/ObjectRegistryTest.php | 331 ++++++++++++ .../behatFeatures/main/create-objects.feature | 189 +++++++ tests/behatFeatures/main/no-reset-db.feature | 15 + .../main/persist-entities.feature | 65 +++ .../main/with-fixture-on-feature.feature | 9 + tests/behatFeatures/main/with-fixture.feature | 47 ++ .../reset-disabled/manual-isolation-1.feature | 16 + .../reset-disabled/manual-isolation-2.feature | 13 + .../reset-feature/isolation.feature | 24 + .../reset-feature/isolation2.feature | 18 + .../reset-feature/reset-db.feature | 22 + .../with-fixture-on-feature.feature | 17 + .../with-fixture-on-scenario.feature | 20 + .../reset-manual/manual-isolation-1.feature | 17 + .../reset-manual/manual-isolation-2.feature | 15 + .../reset-manual/manual-isolation-3.feature | 12 + .../with-fixture-on-feature.feature | 18 + .../with-fixture-on-scenario.feature | 23 + 84 files changed, 5205 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/behat.yml create mode 100644 behat.yml create mode 100644 bin/tools/behat/.gitignore create mode 100644 bin/tools/behat/composer.json create mode 100644 bin/tools/behat/symfony.lock create mode 100644 config/behat.php create mode 100644 src/Attribute/FactoryShortName.php create mode 100644 src/Story/Event/StateAddedToStory.php create mode 100644 src/Story/FixtureStoryNotFound.php create mode 100644 src/Story/FixtureStoryResolver.php create mode 100644 src/Test/Behat/DatabaseResetMode.php create mode 100644 src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php create mode 100644 src/Test/Behat/Exception/FactoryNotResolvable.php create mode 100644 src/Test/Behat/Exception/InvalidObjectParameter.php create mode 100644 src/Test/Behat/Exception/InvalidResetDbTag.php create mode 100644 src/Test/Behat/Exception/ObjectAlreadyRegistered.php create mode 100644 src/Test/Behat/Exception/ObjectNotFound.php create mode 100644 src/Test/Behat/FactoryShortNameResolver.php create mode 100644 src/Test/Behat/FoundryCallFilter.php create mode 100644 src/Test/Behat/FoundryContext.php create mode 100644 src/Test/Behat/FoundryExtension.php create mode 100644 src/Test/Behat/FoundryTableNode.php create mode 100644 src/Test/Behat/Listener/BootConfigurationListener.php create mode 100644 src/Test/Behat/Listener/DatabaseResetListener.php create mode 100644 src/Test/Behat/Listener/LoadFixturesListener.php create mode 100644 src/Test/Behat/ObjectRegistry.php create mode 100644 tests/Fixture/App/Controller/HelloWorldController.php create mode 100644 tests/Fixture/Behat/BehatTestKernel.php create mode 100644 tests/Fixture/Behat/Factories/Tag/TagFactory.php create mode 100644 tests/Fixture/Behat/Factories/TagFactory.php create mode 100644 tests/Fixture/Behat/ResetDisabledTestContext.php create mode 100644 tests/Fixture/Behat/Stories/CategoryStory.php create mode 100644 tests/Fixture/Behat/Stories/ConflictStory1.php create mode 100644 tests/Fixture/Behat/Stories/ConflictStory2.php create mode 100644 tests/Fixture/Behat/Stories/ContactStory.php create mode 100644 tests/Fixture/Behat/TestFoundryContext.php rename tests/Fixture/{SomeEnum.php => IntBackedEnum.php} (78%) create mode 100644 tests/Fixture/StringBackedEnum.php create mode 100644 tests/Integration/Behat/Listener/BootConfigurationListenerTest.php create mode 100644 tests/Integration/Behat/Listener/DatabaseResetListenerTest.php create mode 100644 tests/Integration/Behat/Listener/LoadFixturesListenerTest.php create mode 100644 tests/Unit/Test/Behat/FactoryResolverTest.php create mode 100644 tests/Unit/Test/Behat/FoundryCallFilterTest.php create mode 100644 tests/Unit/Test/Behat/FoundryTableNodeTest.php create mode 100644 tests/Unit/Test/Behat/ObjectRegistryTest.php create mode 100644 tests/behatFeatures/main/create-objects.feature create mode 100644 tests/behatFeatures/main/no-reset-db.feature create mode 100644 tests/behatFeatures/main/persist-entities.feature create mode 100644 tests/behatFeatures/main/with-fixture-on-feature.feature create mode 100644 tests/behatFeatures/main/with-fixture.feature create mode 100644 tests/behatFeatures/reset-disabled/manual-isolation-1.feature create mode 100644 tests/behatFeatures/reset-disabled/manual-isolation-2.feature create mode 100644 tests/behatFeatures/reset-feature/isolation.feature create mode 100644 tests/behatFeatures/reset-feature/isolation2.feature create mode 100644 tests/behatFeatures/reset-feature/reset-db.feature create mode 100644 tests/behatFeatures/reset-feature/with-fixture-on-feature.feature create mode 100644 tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature create mode 100644 tests/behatFeatures/reset-manual/manual-isolation-1.feature create mode 100644 tests/behatFeatures/reset-manual/manual-isolation-2.feature create mode 100644 tests/behatFeatures/reset-manual/manual-isolation-3.feature create mode 100644 tests/behatFeatures/reset-manual/with-fixture-on-feature.feature create mode 100644 tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml new file mode 100644 index 000000000..d04cdd72e --- /dev/null +++ b/.github/workflows/behat.yml @@ -0,0 +1,86 @@ +name: Behat + +on: + push: + paths: &paths + - .github/workflows/behat.yml + - bin/tools/behat + - src/Behat/** + - tests/Behat/** + - behat.yml + - composer.json + pull_request: + paths: *paths + schedule: + - cron: '0 0 1,16 * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + behat: + name: P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.3, 8.5 ] + symfony: [ 6.4.*, 7.4.* ] + deps: [ highest, lowest ] + env: + DATABASE_URL: 'sqlite:///%kernel.project_dir%/var/data.db' + USE_DAMA_DOCTRINE_TEST_BUNDLE: 1 + USE_PHP_84_LAZY_OBJECTS: 1 + MONGO_URL: '' + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: flex + + - name: Add the polyfill compatible with Doctrine ^2.16 + if: ${{ matrix.deps == 'lowest' }} + run: composer require --dev symfony/polyfill-php80:^1.16 --no-update + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + dependency-versions: ${{ matrix.deps }} + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: Install Behat + run: composer bin behat update ${{ matrix.deps == 'lowest' && '--prefer-lowest' || '' }} --prefer-dist + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: "Main test suite: reset DB at scenario level, with Dama support" + run: vendor/bin/behat --colors -vvv + + - name: "Main test suite: reset DB at scenario level, without Dama support" + run: vendor/bin/behat --colors -vvv --profile=main-no-dama + + - name: "Main test suite: reset DB at scenario level, with native Dama extension" + run: vendor/bin/behat --colors -vvv --profile=main-native-dama --tags='~@skip-with-native-dama' + + - name: Manual reset DB + run: vendor/bin/behat --colors -vvv --profile=reset-manual + + - name: Manual reset DB and Dama support + run: vendor/bin/behat --colors -vvv --profile=reset-manual-dama + + - name: Reset DB at feature level + run: vendor/bin/behat --colors -vvv --profile=reset-feature + + - name: Reset DB at feature level with Dama support + run: vendor/bin/behat --colors -vvv --profile=reset-feature-dama + + - name: Reset DB disabled + run: vendor/bin/behat --colors -vvv --profile=reset-disabled diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index fd89b765d..b5edd507b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -249,7 +249,7 @@ jobs: - name: Test run: | - vendor/bin/phpunit tests/Unit + vendor/bin/phpunit -c phpunit-10.xml.dist tests/Unit test-with-paratest: name: Test with paratest diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 0957a6c90..09a83e6d5 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -47,8 +47,10 @@ jobs: - name: Install PHPStan run: composer bin phpstan install - - name: Install PHPBench - run: composer bin phpbench install + - name: Install PHPBench & Behat + run: | + composer bin phpbench install + composer bin behat install - name: Run PHPStan run: bin/tools/phpstan/vendor/phpstan/phpstan/phpstan analyse diff --git a/behat.yml b/behat.yml new file mode 100644 index 000000000..952679851 --- /dev/null +++ b/behat.yml @@ -0,0 +1,116 @@ +default: + testers: + stop_on_failure: true + + gherkin: + cache: var/cache/gherkin + + extensions: + Yceruto\BehatExtension\Extension\ExceptionExtension: ~ + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: true + Behat\MinkExtension: + sessions: + symfony: + symfony: ~ + FriendsOfBehat\SymfonyExtension: + bootstrap: tests/bootstrap.php + kernel: + class: Zenstruck\Foundry\Tests\Fixture\Behat\BehatTestKernel + + suites: + main: + paths: [tests/behatFeatures/main] + contexts: &common_contexts + - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Tests\Fixture\Behat\TestFoundryContext + +main-no-dama: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: false + + suites: + main: false + main-no-dama: + paths: [tests/behatFeatures/main] + contexts: *common_contexts + +main-native-dama: + extensions: + DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension: ~ + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: false + + suites: + main: false + main-native-dama: + paths: [tests/behatFeatures/main] + contexts: *common_contexts + +reset-manual: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: manual + enable_dama_support: false + + suites: + main: false + reset-manual: + paths: [tests/behatFeatures/reset-manual] + contexts: *common_contexts + +reset-manual-dama: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: manual + enable_dama_support: true + + suites: + main: false + reset-manual-dama: + paths: [tests/behatFeatures/reset-manual] + contexts: *common_contexts + +reset-feature: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: feature + enable_dama_support: false + + suites: + main: false + reset-feature: + paths: [tests/behatFeatures/reset-feature] + contexts: *common_contexts + + +reset-feature-dama: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: feature + enable_dama_support: true + + suites: + main: false + reset-feature-dama: + paths: [tests/behatFeatures/reset-feature] + contexts: *common_contexts + +reset-disabled: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: disabled + enable_dama_support: false + + suites: + main: false + reset-disabled: + paths: [tests/behatFeatures/reset-disabled] + contexts: + - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Tests\Fixture\Behat\TestFoundryContext + - Zenstruck\Foundry\Tests\Fixture\Behat\ResetDisabledTestContext diff --git a/bin/tools/behat/.gitignore b/bin/tools/behat/.gitignore new file mode 100644 index 000000000..17fb14310 --- /dev/null +++ b/bin/tools/behat/.gitignore @@ -0,0 +1 @@ +/composer.lock diff --git a/bin/tools/behat/composer.json b/bin/tools/behat/composer.json new file mode 100644 index 000000000..86c281381 --- /dev/null +++ b/bin/tools/behat/composer.json @@ -0,0 +1,17 @@ +{ + "require": { + "behat/behat": "^3.22", + "behat/mink-browserkit-driver": "^2.0", + "friends-of-behat/mink-extension": "^2.0", + "friends-of-behat/symfony-extension": "^2.0", + "symfony/flex": "^2.10", + "yceruto/behat-extension": "^1.0.2", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" + }, + "config": { + "allow-plugins": { + "symfony/flex": true + } + } +} diff --git a/bin/tools/behat/symfony.lock b/bin/tools/behat/symfony.lock new file mode 100644 index 000000000..dade0c32b --- /dev/null +++ b/bin/tools/behat/symfony.lock @@ -0,0 +1,49 @@ +{ + "friends-of-behat/symfony-extension": { + "version": "2.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "2.0", + "ref": "1e012e04f573524ca83795cd19df9ea690adb604" + } + }, + "symfony/console": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" + }, + "files": [ + "bin/console" + ] + }, + "symfony/flex": { + "version": "2.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" + }, + "files": [ + ".env", + ".env.dev" + ] + }, + "symfony/translation": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + } +} diff --git a/composer.json b/composer.json index e414a099a..143cd65f0 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "symfony/polyfill-php85": "^1.33", "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2|^8.0", "zenstruck/assert": "^1.4" }, @@ -46,6 +47,7 @@ "symfony/framework-bundle": "^6.4|^7.0|^8.0", "symfony/maker-bundle": "^1.55", "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", + "symfony/polyfill-php80": "^1.16", "symfony/routing": "^6.4|^7.0|^8.0", "symfony/runtime": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^3.4", @@ -102,7 +104,7 @@ } }, "scripts": { - "post-install-cmd": ["@composer bin phpstan install", "@composer bin phpbench install"] + "post-install-cmd": ["@composer bin phpstan install", "@composer bin phpbench install", "@composer bin behat install"] }, "minimum-stability": "dev", "prefer-stable": true diff --git a/config/behat.php b/config/behat.php new file mode 100644 index 000000000..384f481fc --- /dev/null +++ b/config/behat.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\FoundryContext; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('.zenstruck_foundry.behat.factory_resolver', FactoryShortNameResolver::class) + ->args([ + tagged_iterator('foundry.factory'), + ]) + ->public() + + ->set('.zenstruck_foundry.behat.object_registry', ObjectRegistry::class) + ->args([ + service('.zenstruck_foundry.behat.factory_resolver'), + service('.zenstruck_foundry.persistence_manager'), + ]) + ->tag('kernel.event_listener', ['method' => 'storeLastId', 'event' => AfterPersist::class]) + ->tag('kernel.event_listener', ['method' => 'storeAfterStateAddedToStory', 'event' => StateAddedToStory::class]) + ->public() + + ->set(FoundryContext::class, FoundryContext::class) + ->autowire() + ->autoconfigure() + ->args([ + service('.zenstruck_foundry.behat.factory_resolver'), + service('.zenstruck_foundry.behat.object_registry'), + ]); +}; diff --git a/config/persistence.php b/config/persistence.php index d0f25d483..ddd82018c 100644 --- a/config/persistence.php +++ b/config/persistence.php @@ -19,6 +19,7 @@ use Zenstruck\Foundry\Persistence\PersistedObjectsTracker; use Zenstruck\Foundry\Persistence\PersistenceManager; use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; +use Zenstruck\Foundry\Story\FixtureStoryResolver; return static function(ContainerConfigurator $container): void { $container->services() @@ -34,12 +35,20 @@ ]) ->set('.zenstruck_foundry.command.load_fixtures', LoadFixturesCommand::class) + ->arg('$fixtureStoryResolver', service('.zenstruck_foundry.story.fixture_resolver')) ->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter')) ->arg('$kernel', service('kernel')) ->tag('console.command', [ 'command' => 'foundry:load-fixtures|foundry:load-stories|foundry:load-story', 'description' => 'Load stories which are marked with #[AsFixture] attribute.', ]) + + ->set('.zenstruck_foundry.story.fixture_resolver', FixtureStoryResolver::class) + ->args([ + abstract_arg('fixtureStories'), + abstract_arg('groupedStories'), + ]) + ->public() ; if (\PHP_VERSION_ID >= 80400) { @@ -48,7 +57,7 @@ ->tag('kernel.event_listener', ['event' => TerminateEvent::class, 'method' => 'refresh']) ->tag('kernel.event_listener', ['event' => ConsoleTerminateEvent::class, 'method' => 'refresh']) ->tag('kernel.event_listener', ['event' => WorkerMessageHandledEvent::class, 'method' => 'refresh']) // @phpstan-ignore class.notFound - ->tag('foundry.hook', ['class' => null, 'method' => 'afterPersistHook', 'event' => AfterPersist::class]) + ->tag('kernel.event_listener', ['method' => 'afterPersistHook', 'event' => AfterPersist::class]) ; } }; diff --git a/docs/index.rst b/docs/index.rst index ede9373c0..5951abd10 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2725,6 +2725,205 @@ This extension provides the following features: The PHPUnit extension is only compatible with PHPUnit 10+. +Behat Integration +----------------- + +Foundry provides a Behat extension that allows you to use factories and fixtures in your BDD tests. + +Installation +~~~~~~~~~~~~ + +1. Install Behat and the Symfony extension: + +.. code-block:: terminal + + $ composer require --dev behat/behat friends-of-behat/symfony-extension + +2. Enable the Foundry extension in your ``behat.yaml``: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario # or: feature, manual, disabled + FriendsOfBehat\SymfonyExtension: ~ + Behat\MinkExtension: + sessions: + symfony: + symfony: ~ + + suites: + default: + contexts: + - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Test\Behat\FoundryContext + +Database Reset Modes +~~~~~~~~~~~~~~~~~~~~ + +The ``database_reset_mode`` option controls when the database is reset: + +- ``scenario``: Reset before each scenario (default) +- ``feature``: Reset before each feature file +- ``manual``: Only reset when using the ``@resetDB`` tag +- ``disabled``: Never reset automatically + +DAMA DoctrineTestBundle Support +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For faster tests using database transactions: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: true + +.. note:: + + When using Foundry's DAMA support, do not enable the native DAMA Behat extension + (``DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension``). + +Available Steps +~~~~~~~~~~~~~~~ + +**Creating objects:** + +.. code-block:: gherkin + + # Create a single object + Given a contact is created + Given a contact "john" is created + + # Create with properties + Given a contact "john" is created with properties + | name | email | + | John Doe | john@email.com | + + # Create multiple objects + Given contacts are created with properties + | _ref | name | + | A | John Doe | + | B | Jane Doe | + +The ``_ref`` column is a special column that allows you to name the created objects for later reference. + +**Referencing objects:** + +Objects are automatically resolved based on property types. If a property expects an object +and you provide a string that matches a previously created object name, it will be resolved automatically: + +.. code-block:: gherkin + + Given a category "tech" is created + Given a post "my-post" is created with properties + | title | category | + | My Post | tech | + +The ``category`` property expects a ``Category`` object. Foundry detects this and looks up +the object named "tech" in the registry. + +**Assertions:** + +.. code-block:: gherkin + + Then 2 contacts should exist + Then contact "john" should have properties + | name | + | John Doe | + Then contact object named "john" should exist + Then contact object named "jane" should not exist + +**Using last created ID:** + +.. code-block:: gherkin + + Given a contact "john" is created + When I am on "/contacts/" + # Or for a specific type: + When I am on "/contacts/" + +Loading Fixtures with Tags +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the ``@withFixture`` tag to load a Story before a scenario: + +.. code-block:: gherkin + + @withFixture(my-contacts) + Scenario: View contacts + Then 3 contacts should exist + +First, mark your Story with the ``#[AsFixture]`` attribute: + +:: + + use Zenstruck\Foundry\Attribute\AsFixture; + use Zenstruck\Foundry\Story; + + #[AsFixture(name: 'my-contacts')] + final class ContactsStory extends Story + { + public function build(): void + { + $this->addState('john', ContactFactory::createOne(['name' => 'John'])); + $this->addState('jane', ContactFactory::createOne(['name' => 'Jane'])); + $this->addState('bob', ContactFactory::createOne(['name' => 'Bob'])); + } + } + +Objects added via ``addState()`` are automatically available in the object registry and can be +referenced in your scenarios. + +Manual Database Reset +~~~~~~~~~~~~~~~~~~~~~ + +When using ``database_reset_mode: manual`` or ``database_reset_mode: feature``, +you can force a reset using the ``@resetDB`` tag: + +.. code-block:: gherkin + + @resetDB + Scenario: Start with fresh database + Then 0 contacts should exist + +In ``database_reset_mode: scenario``, you can skip the reset with ``@noresetDB``: + +.. code-block:: gherkin + + @noresetDB + Scenario: Keep data from previous scenario + Then 1 contact should exist + +Customizing Factory Short Names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, factories are resolved by their class name (``ContactFactory`` → ``contact``). +You can customize this with the ``#[FactoryShortName]`` attribute: + +:: + + use Zenstruck\Foundry\Attribute\FactoryShortName; + + #[FactoryShortName('person', 'people')] + final class ContactFactory extends PersistentObjectFactory + { + // ... + } + +Now you can use: + +.. code-block:: gherkin + + Given a person is created + Given people are created with properties + | name | + | John | + | Jane | + Bundle Configuration -------------------- diff --git a/phpstan.neon b/phpstan.neon index 1fe298d15..2fa61342d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -20,6 +20,7 @@ parameters: bootstrapFiles: - bin/tools/phpbench/vendor/autoload.php + - bin/tools/behat/vendor/autoload.php ignoreErrors: # suppress strange behavior of PHPStan where it considers proxy() return type as *NEVER* diff --git a/phpunit-paratest.xml.dist b/phpunit-paratest.xml.dist index 0b254377c..7a0fdda7c 100644 --- a/phpunit-paratest.xml.dist +++ b/phpunit-paratest.xml.dist @@ -26,6 +26,8 @@ tests tests/Integration/ResetDatabase + tests/Unit/Test/Behat + tests/Unit/Integration/Behat diff --git a/src/Attribute/FactoryShortName.php b/src/Attribute/FactoryShortName.php new file mode 100644 index 000000000..c039ca87b --- /dev/null +++ b/src/Attribute/FactoryShortName.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Attribute; + +/** + * @author Nicolas PHILIPPE + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class FactoryShortName +{ + public function __construct( + public readonly string $shortName, + public readonly ?string $pluralName = null, + ) { + } +} diff --git a/src/Command/LoadFixturesCommand.php b/src/Command/LoadFixturesCommand.php index 04b7316aa..f9e9e89c1 100644 --- a/src/Command/LoadFixturesCommand.php +++ b/src/Command/LoadFixturesCommand.php @@ -22,7 +22,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\HttpKernel\KernelInterface; use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeFirstTestResetter; -use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Story\FixtureStoryResolver; /** * @author Nicolas PHILIPPE @@ -32,10 +32,7 @@ final class LoadFixturesCommand extends Command { public function __construct( - /** @var array> */ - private readonly array $stories, - /** @var array>> */ - private readonly array $groupedStories, + private readonly FixtureStoryResolver $fixtureStoryResolver, /** @var iterable */ private iterable $databaseResetters, private KernelInterface $kernel, @@ -46,14 +43,14 @@ public function __construct( protected function configure(): void { $this - ->addArgument('name', InputArgument::OPTIONAL, 'The name of the story to load.') + ->addArgument('name', InputArgument::OPTIONAL, "Story's name or stories group's name to load.") ->addOption('append', 'a', InputOption::VALUE_NONE, 'Skip resetting database and append data to the existing database.') ; } protected function execute(InputInterface $input, OutputInterface $output): int { - if (0 === \count($this->stories)) { + if (!$this->fixtureStoryResolver->hasAnyFixtures()) { throw new LogicException('No story as fixture available: add attribute #[AsFixture] to your story classes before running this command.'); } @@ -69,37 +66,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->resetDatabase(); } - $stories = []; - - if (null === ($name = $input->getArgument('name'))) { - if (1 === \count($this->stories)) { - $name = \array_keys($this->stories)[0]; - } else { - $storyNames = \array_keys($this->stories); - if (\count($this->groupedStories) > 0) { - $storyNames[] = '(choose a group of stories...)'; - } - $name = $io->choice('Choose a story to load:', $storyNames); - } - - if (!isset($this->stories[$name])) { - $groupsNames = \array_keys($this->groupedStories); - $name = $io->choice('Choose a group of stories:', $groupsNames); - } - } + $fixtureNameOrGroup = $input->getArgument('name') ?? $this->getNameWhenNotProvided($io); - if (isset($this->stories[$name])) { - $io->comment("Loading story with name \"{$name}\"..."); - $stories = [$name => $this->stories[$name]]; - } + $stories = $this->fixtureStoryResolver->resolve($fixtureNameOrGroup); - if (isset($this->groupedStories[$name])) { - $io->comment("Loading stories group \"{$name}\"..."); - $stories = $this->groupedStories[$name]; + if (!$stories) { + throw new InvalidArgumentException("Story with name or group \"{$fixtureNameOrGroup}\" does not exist."); } - if (!$stories) { - throw new InvalidArgumentException("Story with name \"{$name}\" does not exist."); + if ($this->fixtureStoryResolver->hasFixture($fixtureNameOrGroup)) { + $io->comment("Loading story with name \"{$fixtureNameOrGroup}\"..."); + } else { + $io->comment("Loading stories group \"{$fixtureNameOrGroup}\"..."); } foreach ($stories as $name => $storyClass) { @@ -126,4 +104,24 @@ private function resetDatabase(): void $databaseResetter->resetBeforeFirstTest($this->kernel); } } + + private function getNameWhenNotProvided(SymfonyStyle $io): string + { + if ($this->fixtureStoryResolver->hasOnlyOneFixture()) { + return $this->fixtureStoryResolver->availableFixtureNames()[0]; + } + + $storyNames = $this->fixtureStoryResolver->availableFixtureNames(); + if (\count($this->fixtureStoryResolver->availableGroupNames()) > 0) { + $storyNames[] = '(choose a group of stories...)'; + } + $name = $io->choice('Choose a story to load:', $storyNames); + + if (!$this->fixtureStoryResolver->hasFixture($name)) { + $groupsNames = $this->fixtureStoryResolver->availableGroupNames(); + $name = $io->choice('Choose a group of stories:', $groupsNames); + } + + return $name; + } } diff --git a/src/DependencyInjection/AsFixtureStoryCompilerPass.php b/src/DependencyInjection/AsFixtureStoryCompilerPass.php index 03599e638..3df748d8a 100644 --- a/src/DependencyInjection/AsFixtureStoryCompilerPass.php +++ b/src/DependencyInjection/AsFixtureStoryCompilerPass.php @@ -20,7 +20,7 @@ final class AsFixtureStoryCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - if (!$container->has('.zenstruck_foundry.command.load_fixtures')) { + if (!$container->has('.zenstruck_foundry.story.fixture_resolver')) { return; } @@ -58,8 +58,8 @@ public function process(ContainerBuilder $container): void throw new LogicException("Cannot use #[AsFixture] group(s) \"{$collisionNames}\", they collide with fixture names."); } - $container->findDefinition('.zenstruck_foundry.command.load_fixtures') - ->setArgument('$stories', $fixtureStories) + $container->findDefinition('.zenstruck_foundry.story.fixture_resolver') + ->setArgument('$fixtureStories', $fixtureStories) ->setArgument('$groupedStories', $groupedFixtureStories); } } diff --git a/src/Persistence/PersistedObjectsTracker.php b/src/Persistence/PersistedObjectsTracker.php index 51126459c..75db6f14c 100644 --- a/src/Persistence/PersistedObjectsTracker.php +++ b/src/Persistence/PersistedObjectsTracker.php @@ -23,7 +23,7 @@ final class PersistedObjectsTracker /** * This buffer of objects needs to be static to be kept between two kernel.reset events. * - * @var \WeakMap keys: objects, values: value ids + * @var \WeakMap> keys: objects, values: value ids */ private static \WeakMap $trackedObjects; diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index aafc2b87b..e91813a8a 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -30,8 +30,9 @@ * @author Kevin Bond * * @internal + * @final */ -final class PersistenceManager +class PersistenceManager { private bool $flush = true; private bool $persist = true; @@ -417,7 +418,10 @@ public function resetDatabaseManager(): ResetDatabaseManager return $this->resetDatabaseManager; } - public function getIdentifierValues(object $object): mixed + /** + * @return array + */ + public function getIdentifierValues(object $object): array { return $this->strategyFor($object::class)->getIdentifierValues($object); } diff --git a/src/Story.php b/src/Story.php index 70ea4c776..72bfc8ee3 100644 --- a/src/Story.php +++ b/src/Story.php @@ -16,6 +16,7 @@ use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; use Zenstruck\Foundry\Persistence\Proxy; use Zenstruck\Foundry\Persistence\ProxyGenerator; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; /** * @author Kevin Bond @@ -120,6 +121,10 @@ final protected function addState(string $name, mixed $value, ?string $pool = nu $this->state[$name] = $value; + if (\is_object($value) && Configuration::instance()->hasEventDispatcher()) { + Configuration::instance()->eventDispatcher()->dispatch(new StateAddedToStory($value, $name)); + } + if ($pool) { $this->addToPool($pool, $value); } diff --git a/src/Story/Event/StateAddedToStory.php b/src/Story/Event/StateAddedToStory.php new file mode 100644 index 000000000..c74a77a00 --- /dev/null +++ b/src/Story/Event/StateAddedToStory.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Story\Event; + +/** + * @internal + * + * @template T of object + */ +final class StateAddedToStory +{ + public function __construct( + public readonly object $object, + public readonly string $name, + ) { + } +} diff --git a/src/Story/FixtureStoryNotFound.php b/src/Story/FixtureStoryNotFound.php new file mode 100644 index 000000000..475020c34 --- /dev/null +++ b/src/Story/FixtureStoryNotFound.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Story; + +/** + * @internal + * + * @author Nicolas PHILIPPE + */ +final class FixtureStoryNotFound extends \RuntimeException +{ + /** + * @param list $availableFixtures + */ + public static function forNameOrGroup(string $fixtureName, array $availableFixtures): self + { + $message = "Fixture story with name or group \"{$fixtureName}\" not found:"; + + if ($availableFixtures) { + $message .= ' Available fixtures: '.\implode(', ', $availableFixtures); + } else { + $message .= ' No fixture stories are registered. Add #[AsFixture] attribute to your Story classes.'; + } + + return new self($message); + } + + /** + * @param list $availableGroups + */ + public static function forGroup(string $groupName, array $availableGroups): self + { + $message = "Fixture story group \"{$groupName}\" not found:"; + + if ($availableGroups) { + $message .= ' Available groups: '.\implode(', ', $availableGroups); + } else { + $message .= ' No fixture story groups are registered.'; + } + + return new self($message); + } +} diff --git a/src/Story/FixtureStoryResolver.php b/src/Story/FixtureStoryResolver.php new file mode 100644 index 000000000..8eec5b32e --- /dev/null +++ b/src/Story/FixtureStoryResolver.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Story; + +use Zenstruck\Foundry\Story; + +/** + * @internal + * + * @author Nicolas PHILIPPE + */ +final class FixtureStoryResolver +{ + public function __construct( + /** @var array> */ + private readonly array $fixtureStories, + /** @var array>> */ + private readonly array $groupedStories = [], + ) { + } + + /** + * @return array> + * + * @throws FixtureStoryNotFound + */ + public function resolve(string $fixtureOrGroupName): array + { + if ($this->hasFixture($fixtureOrGroupName)) { + return [$fixtureOrGroupName => $this->fixtureStories[$fixtureOrGroupName]]; + } + + if ($this->hasGroup($fixtureOrGroupName)) { + return $this->resolveGroup($fixtureOrGroupName); + } + + throw FixtureStoryNotFound::forNameOrGroup( + $fixtureOrGroupName, + [...$this->availableFixtureNames(), ...$this->availableGroupNames()] + ); + } + + public function hasAnyFixtures(): bool + { + return count($this->fixtureStories) > 0; + } + + public function hasFixture(string $name): bool + { + return isset($this->fixtureStories[$name]); + } + + public function hasOnlyOneFixture(): bool + { + return count($this->fixtureStories) === 1; + } + + /** + * @return list + */ + public function availableFixtureNames(): array + { + return \array_keys($this->fixtureStories); + } + + /** + * @return list + */ + public function availableGroupNames(): array + { + return \array_keys($this->groupedStories); + } + + /** + * @return array> + * + * @throws FixtureStoryNotFound + */ + private function resolveGroup(string $groupName): array + { + if (!isset($this->groupedStories[$groupName])) { + throw FixtureStoryNotFound::forGroup($groupName, $this->availableGroupNames()); + } + + return $this->groupedStories[$groupName]; + } + + private function hasGroup(string $name): bool + { + return isset($this->groupedStories[$name]); + } +} diff --git a/src/Test/Behat/DatabaseResetMode.php b/src/Test/Behat/DatabaseResetMode.php new file mode 100644 index 000000000..c2af16bbd --- /dev/null +++ b/src/Test/Behat/DatabaseResetMode.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +/** + * @internal + */ +enum DatabaseResetMode: string +{ + case DISABLED = 'disabled'; + case MANUAL = 'manual'; + case SCENARIO = 'scenario'; + case FEATURE = 'feature'; +} diff --git a/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php b/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php new file mode 100644 index 000000000..da7317ebd --- /dev/null +++ b/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class FactoryNotResolvable extends \RuntimeException +{ + public static function forName(string $name): self + { + return new self("Cannot resolve factory for name \"$name\": short name does not exist"); + } + + /** + * @param list $factories + */ + public static function conflict(string $name, array $factories): self + { + return new self(\sprintf( + 'Multiple factories found for name "%s": %s. Use #[FactoryShortName] to disambiguate.', + $name, + \implode(', ', $factories) + )); + } +} diff --git a/src/Test/Behat/Exception/InvalidObjectParameter.php b/src/Test/Behat/Exception/InvalidObjectParameter.php new file mode 100644 index 000000000..4c1b01e32 --- /dev/null +++ b/src/Test/Behat/Exception/InvalidObjectParameter.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\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class InvalidObjectParameter extends \RuntimeException +{ + public static function objectReferencedInTableDoesNotExist(string $column, ObjectNotFound $previous): self + { + return new self("A reference to an object cannot be resolved in the table, at column \"$column\": {$previous->getMessage()}", previous: $previous); + } + + public static function invalidDate(string $column, string $invalidDate, \Throwable $previous): self + { + return new self("Invalid date given \"$invalidDate\", at column \"$column\"", previous: $previous); + } + + public static function invalidEnumValue(string $column, string $invalidEnumValue): self + { + return new self("Invalid enum value given \"$invalidEnumValue\", at column \"$column\""); + } +} diff --git a/src/Test/Behat/Exception/InvalidResetDbTag.php b/src/Test/Behat/Exception/InvalidResetDbTag.php new file mode 100644 index 000000000..41de35f8b --- /dev/null +++ b/src/Test/Behat/Exception/InvalidResetDbTag.php @@ -0,0 +1,71 @@ +getFeature()->getFile(); + + if (!$file) { + parent::__construct($message); + + return; + } + + $suite = $event->getEnvironment()->getSuite(); + if ($suite->hasSetting('paths')) { + foreach ($suite->getSetting('paths') as $path) { + if (!$path) { + continue; + } + + if (str_contains($file, $path)) { + $file = substr($file, strpos($file, $path)); // @phpstan-ignore argument.type (strpos cannot be false if $path is contained in $file) + break; + } + } + } + + $errorFileAndLine = match($event::class){ + BeforeFeatureTested::class => "{$file}:{$event->getFeature()->getLine()}", + BeforeScenarioTested::class => "{$file}:{$event->getScenario()->getLine()}", + }; + + parent::__construct("$message\nAt $errorFileAndLine"); + } + + public static function bothTagsUsed(BeforeScenarioTested $event): self + { + return new self('Cannot use both "@resetDB" and "@noResetDB" tags at the same time.', $event); + } + + public static function resetDbWithScenarioMode(BeforeFeatureTested|BeforeScenarioTested $event): self + { + return new self('Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', $event); + } + + public static function noResetDbWithManualMode(BeforeFeatureTested|BeforeScenarioTested $event): self + { + return new self('Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', $event); + } + + public static function resetDbOnFeatureWithFeatureMode(BeforeFeatureTested $event): self + { + return new self('Cannot use "@resetDB" tag on a feature with database_reset_mode set as "feature".', $event); + } + + public static function resetDbOnScenarioWithScenarioMode(BeforeScenarioTested $event): self + { + return new self('Cannot use "@resetDB" tag on a scenario with database_reset_mode set as "scenario".', $event); + } + + public static function noResetDbWithFeatureMode(BeforeFeatureTested|BeforeScenarioTested $event): self + { + return new self('Cannot use "@noResetDB" with database_reset_mode set as "feature".', $event); + } +} diff --git a/src/Test/Behat/Exception/ObjectAlreadyRegistered.php b/src/Test/Behat/Exception/ObjectAlreadyRegistered.php new file mode 100644 index 000000000..53a997f5f --- /dev/null +++ b/src/Test/Behat/Exception/ObjectAlreadyRegistered.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class ObjectAlreadyRegistered extends \RuntimeException +{ + /** @param class-string $objectClass */ + public static function forClassAndName(string $objectClass, string $name): self + { + return new self("Object \"{$name}\" is already registered for class \"{$objectClass}\". This may happen when loading multiple Stories in a group that define objects with the same name."); + } +} diff --git a/src/Test/Behat/Exception/ObjectNotFound.php b/src/Test/Behat/Exception/ObjectNotFound.php new file mode 100644 index 000000000..7b8b77f46 --- /dev/null +++ b/src/Test/Behat/Exception/ObjectNotFound.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class ObjectNotFound extends \RuntimeException +{ + public static function forFactoryAndName(string $factoryShortName, string $name): self + { + return new self("Object \"$factoryShortName $name\" was not found."); + } + + /** + * @param class-string $objectName + */ + public static function forClassAndName(string $objectName, string $name): self + { + return new self("Object of class \"$objectName\" with name \"$name\" was not found."); + } +} diff --git a/src/Test/Behat/FactoryShortNameResolver.php b/src/Test/Behat/FactoryShortNameResolver.php new file mode 100644 index 000000000..61aa568ca --- /dev/null +++ b/src/Test/Behat/FactoryShortNameResolver.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Symfony\Component\String\Inflector\EnglishInflector; +use Zenstruck\Foundry\Attribute\FactoryShortName; +use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Test\Behat\Exception\FactoryNotResolvable; +use function Symfony\Component\String\u; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class FactoryShortNameResolver +{ + /** + * @var array>> + */ + private array $factoryMap = []; + + /** + * @param iterable> $factories + */ + public function __construct(iterable $factories) + { + $inflector = new EnglishInflector(); + + foreach ($factories as $factory) { + if (!$factory instanceof ObjectFactory) { + continue; + } + + $shortName = $this->shortNameFor($factory::class); + + // we allow multiple factories to have the same shortName: + // we'll only trigger an error when trying to access an unambiguous shortname + // we don't want to force the user to resolve all potential conflicts at startup. + $this->factoryMap[$shortName] ??= []; + $this->factoryMap[$shortName][] = $factory; + + $plural = \strtolower($this->factoryShortNameAttribute($factory::class)->pluralName ?? $inflector->pluralize($shortName)[0]); + $this->factoryMap[$plural] ??= []; + $this->factoryMap[$plural][] = $factory; + } + } + + /** + * @return ObjectFactory + * + * @throws FactoryNotResolvable + */ + public function factoryFor(string $shortName): ObjectFactory + { + $normalized = \strtolower($shortName); + + if (!isset($this->factoryMap[$normalized])) { + throw FactoryNotResolvable::forName($shortName); + } + + $factories = $this->factoryMap[$normalized]; + + if (\count($factories) > 1) { + throw FactoryNotResolvable::conflict($shortName, array_map(static fn(ObjectFactory $f) => $f::class, $factories)); + } + + return $factories[0]::new(); + } + + /** + * @return class-string + */ + public function targetObjectClassFor(string $shortName): string + { + return $this->factoryFor($shortName)::class(); + } + + /** + * @param class-string $className + */ + public function hasFactoryForClass(string $className): bool + { + return array_any( + $this->factoryMap, + static fn(array $factories) => array_any( + $factories, + static fn(ObjectFactory $factory) => $factory::class() === $className, + ) + ); + } + + /** + * @param class-string $className + */ + public function getShortNameForClass(string $className): string + { + return array_find_key( // @phpstan-ignore return.type (PHPStan bug) + $this->factoryMap, + static fn(array $factories) => array_any( + $factories, + static fn(ObjectFactory $factory) => $factory::class() === $className, + ) + ); + } + + /** + * @param class-string> $factoryClass + */ + private function shortNameFor(string $factoryClass): string + { + $attribute = $this->factoryShortNameAttribute($factoryClass); + + if ($attribute) { + return \strtolower($attribute->shortName); + } + + $shortClass = u((new \ReflectionClass($factoryClass))->getShortName()); + + if ($shortClass->endsWith('Factory')) { + $shortClass = $shortClass->slice(0, -7); + } + + return $shortClass + ->snake() + ->replace('_', ' ') + ->lower() + ->toString(); + } + + /** + * @param class-string> $factoryClass + */ + private function factoryShortNameAttribute(string $factoryClass): ?FactoryShortName + { + $reflection = new \ReflectionClass($factoryClass); + + $attributes = $reflection->getAttributes(FactoryShortName::class); + + return ($attributes[0] ?? null)?->newInstance(); + } +} diff --git a/src/Test/Behat/FoundryCallFilter.php b/src/Test/Behat/FoundryCallFilter.php new file mode 100644 index 000000000..bf70bd741 --- /dev/null +++ b/src/Test/Behat/FoundryCallFilter.php @@ -0,0 +1,211 @@ +factoryResolver = $symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.factory_resolver'); // @phpstan-ignore assign.propertyType + $this->objectRegistry = $symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore assign.propertyType + } + + public function supportsCall(Call $call): bool + { + return array_any( + $call->getArguments(), + static fn($argument) => $argument instanceof TableNode && !$argument instanceof ExampleTableNode + ); + } + + public function filterCall(Call $call): Call + { + if ( + !$call instanceof DefinitionCall + || !$call->getCallee()->getReflection() instanceof \ReflectionMethod + || $call->getCallee()->getReflection()->class !== FoundryContext::class + ) { + return $call; + } + + $arguments = $call->getArguments(); + + if (!isset($arguments['factoryShortName'])) { + throw new \InvalidArgumentException( + <<getEnvironment(), + $call->getFeature(), + $call->getStep(), + $call->getCallee(), + array_map( + fn(mixed $argument) => match ($argument instanceof TableNode) { + true => $this->normalizeObjectParameters($argument, $arguments['factoryShortName']), + false => $argument, + }, + $arguments + ), + $call->getErrorReportingLevel(), + ); + } + + private function normalizeObjectParameters(TableNode $tableNode, string $factoryShortName): TableNode + { + $table = $tableNode->getTable(); + + $headKey = array_key_first($table); + $thead = array_shift($table); + + return FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + (\Closure::bind( + fn () => $this->maxLineLength, + $tableNode, + TableNode::class + )()), + [ // @phpstan-ignore argument.type (TableNode has the same problem: array $table is not really lists) + $headKey => $thead, // @phpstan-ignore array.invalidKey + ...array_map( + function (array $parameters) use ($thead, $factoryShortName): array { + $normalized = []; + foreach ($parameters as $key => $value) { + if (!isset($thead[$key])) { + throw new \LogicException("Table has no column for parameter \"$key\". This should never happen, table integrity is checked in TableNode."); + } + + $propertyName = $thead[$key]; + + if ($propertyName === '_ref') { + $normalized['_ref'] = $value; + + continue; + } + + if ('null' === $value) { + $normalized[$propertyName] = null; + + continue; + } + + if ('true' === $value) { + $normalized[$propertyName] = true; + + continue; + } + + if ('false' === $value) { + $normalized[$propertyName] = false; + + continue; + } + + if (preg_match('/^[^,]+), (?[^)]+)\)>$/', $value, $matches)) { + try { + $normalized[$propertyName] = $this->objectRegistry->getByFactoryShortName($matches['factoryShortName'], $matches['objectName']); + } catch (ObjectNotFound $e) { + throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); + } + + continue; + } + + $targetClass = $this->factoryResolver->targetObjectClassFor($factoryShortName); + $expectedTypeClass = $this->getPropertyTypeIfClass(new \ReflectionClass($targetClass), $propertyName); + + if (!$expectedTypeClass) { + $normalized[$propertyName] = $value; + + continue; + } + + if ($this->factoryResolver->hasFactoryForClass($expectedTypeClass)) { + try { + $normalized[$propertyName] = $this->objectRegistry->getByObjectClass($expectedTypeClass, $value); + } catch (ObjectNotFound $e) { + throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); + } + + continue; + } + + if (is_a($expectedTypeClass, \DateTimeInterface::class, allow_string: true)) { + try { + $normalized[$propertyName] = new $expectedTypeClass($value); + + continue; + } catch (\Throwable $e) { // @phpstan-ignore catch.neverThrown + throw InvalidObjectParameter::invalidDate($propertyName, $value, $e); + } + } + + if (is_a($expectedTypeClass, \BackedEnum::class, allow_string: true)) { + $value = is_numeric($value) ? (int)$value : $value; + + $normalized[$propertyName] = $expectedTypeClass::tryFrom($value) ?? throw InvalidObjectParameter::invalidEnumValue($propertyName, (string)$value); + + continue; + } + + throw new \LogicException("Cannot normalize parameter \"$propertyName\" with value \"$value\"."); + } + + return $normalized; + }, + $table + ), + ] + ); + } + + /** + * @param \ReflectionClass $class + * + * @return class-string|null + */ + private function getPropertyTypeIfClass(\ReflectionClass $class, string $propertyName): ?string + { + try { + $property = $class->getProperty($propertyName); + } catch (\ReflectionException) { + if ($class = $class->getParentClass()) { + return $this->getPropertyTypeIfClass($class, $propertyName); + } + } + + if ( + !isset($property) + || !($type = $property->getType()) instanceof \ReflectionNamedType + || $type->isBuiltin() + || !class_exists($type->getName()) + ) { + return null; + } + + return $type->getName(); + } +} diff --git a/src/Test/Behat/FoundryContext.php b/src/Test/Behat/FoundryContext.php new file mode 100644 index 000000000..11cba6d9e --- /dev/null +++ b/src/Test/Behat/FoundryContext.php @@ -0,0 +1,178 @@ + + * + * @phpstan-import-type Parameters from Factory + * + * @final + */ +class FoundryContext implements Context +{ + public function __construct( + private readonly FactoryShortNameResolver $factoryResolver, + private readonly ObjectRegistry $objectRegistry, + ) { + } + + #[Given('a(n) :factoryShortName is created')] + #[Given('a(n) :factoryShortName :objectName is created')] + public function createObject(string $factoryShortName, ?string $objectName = null): void + { + $this->resolveFactory($factoryShortName, $objectName)->create(); + } + + /** + * @return ObjectFactory + */ + private function resolveFactory(string $factoryShortName, ?string $objectName = null): ObjectFactory + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$objectName) { + return $factory; + } + + return $factory->afterInstantiate( + fn(object $object) => $this->objectRegistry->store($object, $objectName) + ); + } + + #[Given('a(n) :factoryShortName is created with properties')] + #[Given('a(n) :factoryShortName :objectName is created with properties')] + public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void + { + $factory = $this->resolveFactory($factoryShortName, $objectName); + $parametersList = $table->getColumnsHash(); + + if (count($parametersList) !== 1) { + throw new \InvalidArgumentException('Expected exactly one line of properties, to create one object.'); + } + + $factory->create($parametersList[0]); + } + + #[Given(':factoryShortName are created with properties')] + public function createObjectsWithProperties(TableNode $table, string $factoryShortName): void + { + $parametersList = $table->getColumnsHash(); + + foreach ($parametersList as $parameters) { + $objectName = $parameters['_ref'] ?? null; + unset($parameters['_ref']); + + $this->resolveFactory($factoryShortName, $objectName) + ->create($parameters); + } + } + + #[Then('/^(\d+) "([^"]*)" should exist$/')] + #[Then('/^(\d+) ([^"]*) should exist$/')] + public function assertNbObjectsExist(int $nb, string $factoryShortName): void + { + $this->repositoryAssertionFor($factoryShortName) + ->count($nb); + } + + private function repositoryAssertionFor(string $factoryShortName): RepositoryAssertions + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$factory instanceof PersistentObjectFactory) { + throw new \LogicException( + \sprintf( + "Cannot make assertions with factory of class \"%s\" with short name \"%s\": it does not extend \"%s\".", + $factory::class, + $factoryShortName, + PersistentObjectFactory::class + ) + ); + } + + return $factory::assert(); + } + + #[Then(':factoryShortName :objectName should have properties')] + public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void + { + $parametersList = $table->getColumnsHash(); + + if (count($parametersList) !== 1) { + throw new \InvalidArgumentException('Expected exactly one line of properties.'); + } + + $object = $this->objectRegistry->getByFactoryShortName($factoryShortName, $objectName); + + if (!Configuration::autoRefreshWithLazyObjectsIsEnabled()) { + refresh($object); + } + + foreach ($parametersList[0] as $key => $valueExpected) { + $actualValue = get($object, $key); + + match(true) { + $valueExpected instanceof \DateTimeInterface => Assert::that($actualValue) + ->isInstanceOf(\DateTimeInterface::class) + ->and($actualValue->format('Y-m-d H:i:s')) + ->is($valueExpected->format('Y-m-d H:i:s')), + + is_object($valueExpected) => Assert::that($actualValue)->is($valueExpected), + + default => Assert::that($actualValue)->equals($valueExpected) + }; + } + } + + #[Then(':factoryShortName object named :objectName should exist')] + public function assertObjectExists(string $factoryShortName, string $objectName): void + { + Assert::that( + $this->objectRegistry->has( + $this->factoryResolver->targetObjectClassFor($factoryShortName), + $objectName + ) + )->is(true, "Object with name \"$objectName\" of type \"$factoryShortName\" does not exist although it should."); + } + + #[Then(':factoryShortName object named :objectName should not exist')] + public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void + { + Assert::that( + $this->objectRegistry->has( + $this->factoryResolver->targetObjectClassFor($factoryShortName), + $objectName + ) + )->is(false, "Object with name \"$objectName\" of type \"$factoryShortName\" exists although it should not."); + } + + #[Transform('/(.*)(.*)/')] + public function transformLastId(string $before, string $after): string + { + return "{$before}{$this->objectRegistry->lastId()}{$after}"; + } + + #[Transform('/(.*)(.*)/')] + public function transformLastIdForSpecificObject(string $before, string $factoryShortName, string $after): string + { + return "{$before}{$this->objectRegistry->lastIdFor($factoryShortName)}{$after}"; + } +} diff --git a/src/Test/Behat/FoundryExtension.php b/src/Test/Behat/FoundryExtension.php new file mode 100644 index 000000000..b38f213ac --- /dev/null +++ b/src/Test/Behat/FoundryExtension.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Behat\Behat\EventDispatcher\ServiceContainer\EventDispatcherExtension; +use Behat\Testwork\Call\ServiceContainer\CallExtension; +use Behat\Testwork\ServiceContainer\Extension; +use Behat\Testwork\ServiceContainer\ExtensionManager; +use DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Zenstruck\Foundry\Test\Behat\Exception\DamaNativeExtensionIncompatibility; +use Zenstruck\Foundry\Test\Behat\Listener\BootConfigurationListener; +use Zenstruck\Foundry\Test\Behat\Listener\DatabaseResetListener; +use Zenstruck\Foundry\Test\Behat\Listener\LoadFixturesListener; + +final class FoundryExtension implements Extension +{ + public function process(ContainerBuilder $container): void + { + } + + public function getConfigKey(): string + { + return 'zenstruck_foundry'; + } + + public function initialize(ExtensionManager $extensionManager): void + { + } + + public function configure(ArrayNodeDefinition $builder): void + { + $builder + ->children() + ->enumNode('database_reset_mode') + ->values(array_map(static fn(DatabaseResetMode $mode) => $mode->value, DatabaseResetMode::cases())) + ->defaultValue(DatabaseResetMode::MANUAL->value) + ->end() + ->booleanNode('enable_dama_support') + ->defaultFalse() + ->end() + ->end() + ->validate() + ->ifTrue(static fn(array $v): bool => $v['enable_dama_support'] && DatabaseResetMode::DISABLED->value === $v['database_reset_mode']) + ->thenInvalid('Foundry\'s DAMA support cannot be enabled when database reset is disabled.') + ->end(); + } + + public function load(ContainerBuilder $container, array $config): void + { + $container->register('.zenstruck_foundry.behat.listener.boot_configuration', BootConfigurationListener::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + + $container->register('.zenstruck_foundry.behat.listener.load_fixture', LoadFixturesListener::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + + $container->register(FoundryCallFilter::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->addTag(CallExtension::CALL_FILTER_TAG); + + $databaseResetMode = DatabaseResetMode::from($config['database_reset_mode']); + + if ($databaseResetMode === DatabaseResetMode::DISABLED) { + return; + } + + if ($this->damaNativeExtensionIsEnabled($container)) { + if ($config['enable_dama_support']) { + throw DamaNativeExtensionIncompatibility::withFoundryDamaSupport(); + } + + if ($databaseResetMode === DatabaseResetMode::FEATURE) { + throw DamaNativeExtensionIncompatibility::withFeatureResetDbMode(); + } + + if ($databaseResetMode === DatabaseResetMode::MANUAL) { + throw DamaNativeExtensionIncompatibility::withManualResetDbMode(); + } + } + + $container->register('.zenstruck_foundry.behat.listener.database_reset', DatabaseResetListener::class) + ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) + ->setArgument('$resetMode', $databaseResetMode) + ->setArgument('$damaSupportEnabled', $config['enable_dama_support']) + ->setArgument('$damaNativeExtensionIsEnabled', $this->damaNativeExtensionIsEnabled($container)) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + } + + private function damaNativeExtensionIsEnabled(ContainerBuilder $container): bool + { + return in_array(DoctrineExtension::class, (array) $container->getParameter('extensions'), true); + } +} diff --git a/src/Test/Behat/FoundryTableNode.php b/src/Test/Behat/FoundryTableNode.php new file mode 100644 index 000000000..238020420 --- /dev/null +++ b/src/Test/Behat/FoundryTableNode.php @@ -0,0 +1,88 @@ +> getHash() + * @method list> getColumnsHash() + * @method list getColumn(int $index) + * @method list> getRows() + * @method list getRow(int $index) + * @method array> getTable() + * @method array> getRowsHash() + * @method list getRowsHash() + */ +final class FoundryTableNode extends TableNode +{ + /** @var array */ + private array $maxLineLength = []; + + private FactoryShortNameResolver $factoryShortNameResolver; // @phpstan-ignore property.uninitialized + private ObjectRegistry $objectRegistry; // @phpstan-ignore property.uninitialized + + /** + * @param array $maxLineLength + * @param array> $table + */ + public static function create( + FactoryShortNameResolver $factoryShortNameResolver, + ObjectRegistry $objectRegistry, + array $maxLineLength, + array $table + ): static { + // let's neutralize the table node constructor: it checks if the table contains only scalar values, + // but we want our TableNode to carry objects + + // This is super hacky but seems to work well + + // All other sanity checks performed by the constructor + // are not relevant, since they have already been performed previously + + $tableNode = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + set($tableNode, 'table', $table); + + $tableNode->factoryShortNameResolver = $factoryShortNameResolver; + $tableNode->objectRegistry = $objectRegistry; + $tableNode->maxLineLength = $maxLineLength; + + return $tableNode; + } + + public function getRowAsString($rowNum): string + { + $values = []; + foreach ($this->getRow($rowNum) as $column => $value) { + $values[] = $this->padRight(' ' . $this->getValueAsString($value) . ' ', $this->maxLineLength[$column] + 2); + } + + return sprintf('|%s|', implode('|', $values)); + } + + public function getRowAsStringWithWrappedValues($rowNum, $wrapper): string + { + $values = []; + foreach ($this->getRow($rowNum) as $column => $value) { + $value = $this->padRight(' ' . $this->getValueAsString($value) . ' ', $this->maxLineLength[$column] + 2); + + $values[] = call_user_func($wrapper, $value, $column); + } + + return sprintf('|%s|', implode('|', $values)); + } + + private function getValueAsString(mixed $value): string + { + return match (true) { + !is_object($value) => (string)$value, + $value instanceof \DateTimeInterface => $value->format('Y-m-d H:i:s'), + $value instanceof \BackedEnum => (string)$value->value, + $this->objectRegistry->isStored($value) => "{$this->factoryShortNameResolver->getShortNameForClass($value::class)} {$this->objectRegistry->getNameFor($value)}", + default => throw new \LogicException("Unsupported value type: ".\get_debug_type($value)), + }; + } +} diff --git a/src/Test/Behat/Listener/BootConfigurationListener.php b/src/Test/Behat/Listener/BootConfigurationListener.php new file mode 100644 index 000000000..9e3cc4716 --- /dev/null +++ b/src/Test/Behat/Listener/BootConfigurationListener.php @@ -0,0 +1,70 @@ + + */ +final class BootConfigurationListener implements EventSubscriberInterface +{ + public function __construct( + private readonly KernelInterface $symfonyKernel, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ExerciseCompleted::BEFORE => ['bootFoundry', 100], + ExerciseCompleted::AFTER => ['shutdownFoundry', -100], + + FeatureTested::BEFORE => ['bootFoundry', 100], + FeatureTested::AFTER => ['shutdownFoundryAfterFeature', -100], + + ScenarioTested::BEFORE => ['bootFoundry', 100], + ExampleTested::BEFORE => ['bootFoundry', 100], + ]; + } + + public function bootFoundry(): void + { + if (Configuration::isBooted()) { + return; + } + + Configuration::boot( + fn() => $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.configuration') // @phpstan-ignore argument.type + ); + } + + public function shutdownFoundry(): void + { + Configuration::shutdown(); + } + + /** + * In any case, we want to shutdown Foundry after each feature: + * - to reset the object registry + * - to reset the story registry + */ + public function shutdownFoundryAfterFeature(): void + { + $this->objectRegistry()->reset(); + Configuration::shutdown(); + } + + private function objectRegistry(): ObjectRegistry + { + return $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/Listener/DatabaseResetListener.php b/src/Test/Behat/Listener/DatabaseResetListener.php new file mode 100644 index 000000000..e2ee1a1f9 --- /dev/null +++ b/src/Test/Behat/Listener/DatabaseResetListener.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\BeforeFeatureTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; +use Behat\Behat\EventDispatcher\Event\ExampleTested; +use Behat\Behat\EventDispatcher\Event\FeatureTested; +use Behat\Behat\EventDispatcher\Event\ScenarioTested; +use Behat\Gherkin\Node\TaggedNodeInterface; +use Behat\Testwork\EventDispatcher\Event\ExerciseCompleted; +use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; +use Zenstruck\Foundry\StoryRegistry; +use Zenstruck\Foundry\Test\Behat\DatabaseResetMode; +use Zenstruck\Foundry\Test\Behat\Exception\DamaNativeExtensionIncompatibility; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidResetDbTag; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class DatabaseResetListener implements EventSubscriberInterface +{ + private const RESET_DB_TAG = 'resetDB'; + private const NO_RESET_DB_TAG = 'noResetDB'; + + public function __construct( + private readonly KernelInterface $symfonyKernel, + private readonly DatabaseResetMode $resetMode, + private readonly bool $damaSupportEnabled, + private readonly bool $damaNativeExtensionIsEnabled, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ExerciseCompleted::BEFORE => ['resetBeforeSuite', -10], // -10 because it should occur after Dama + ExerciseCompleted::AFTER => 'disableStaticConnection', + + FeatureTested::BEFORE => [ + ['validateFeature', 10], + ['resetDatabaseIfNeeded'] + ], + ScenarioTested::BEFORE => [ + ['validateScenario', 10], + ['resetDatabaseIfNeeded'] + ], + ExampleTested::BEFORE => [ + ['validateScenario', 10], + ['resetDatabaseIfNeeded'] + ], + + // a shutdown is needed after each scenario to ensure StoriesRegistry is reset + ScenarioTested::AFTER => 'shutdownFoundryAfterScenario', + ExampleTested::AFTER => 'shutdownFoundryAfterScenario', + ]; + } + + public function resetBeforeSuite(): void + { + if ($this->damaSupportEnabled) { + StaticDriver::setKeepStaticConnections(true); + } + + if (DatabaseResetMode::MANUAL === $this->resetMode) { + return; + } + + ResetDatabaseManager::resetBeforeFirstTest($this->symfonyKernel); + } + + public function disableStaticConnection(): void + { + if ($this->damaSupportEnabled) { + StaticDriver::setKeepStaticConnections(false); + } + } + + public function validateFeature(BeforeFeatureTested $event): void + { + if ($this->hasResetDbTag($event) && $this->resetMode === DatabaseResetMode::FEATURE) { + throw InvalidResetDbTag::resetDbOnFeatureWithFeatureMode($event); + } + } + + public function validateScenario(BeforeScenarioTested $event): void + { + if ($this->hasResetDbTag($event) && $this->resetMode === DatabaseResetMode::SCENARIO) { + throw InvalidResetDbTag::resetDbOnScenarioWithScenarioMode($event); + } + + if ($this->hasResetDbTag($event) && $this->hasNoResetDbTag($event)) { + throw InvalidResetDbTag::bothTagsUsed($event); + } + } + + public function resetDatabaseIfNeeded(BeforeFeatureTested|BeforeScenarioTested $event): void + { + if (!$this->shouldResetDB($event)) { + return; + } + + $this->resetObjectRegistry(); + + // when the DB is reset, any stories should be able to reload + StoryRegistry::reset(); + + if (!ResetDatabaseManager::databaseHasBeenResetBeforeFirstTest()) { + ResetDatabaseManager::resetBeforeFirstTest($this->symfonyKernel); + } + + if ($this->damaSupportEnabled) { + StaticDriver::rollBack(); + StaticDriver::beginTransaction(); + + return; + } + + ResetDatabaseManager::resetBeforeEachTest($this->symfonyKernel); + } + + private function shouldResetDB(BeforeFeatureTested|BeforeScenarioTested $event): bool + { + if ($this->hasNoResetDbTag($event)) { + return false; + } + + if ($this->hasResetDbTag($event)) { + return true; + } + + return $event instanceof BeforeScenarioTested && $this->resetMode === DatabaseResetMode::SCENARIO + || $event instanceof BeforeFeatureTested && $this->resetMode === DatabaseResetMode::FEATURE; + } + + public function shutdownFoundryAfterScenario(): void + { + if (DatabaseResetMode::SCENARIO !== $this->resetMode) { + return; + } + + $this->resetObjectRegistry(); + Configuration::shutdown(); + } + + private function hasResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event): bool + { + $node = $event instanceof BeforeFeatureTested ? $event->getFeature() : $event->getScenario(); + + if (!$node instanceof TaggedNodeInterface) { + return false; + } + + $hasResetDbTag = $node->hasTag(self::RESET_DB_TAG); + + if (!$hasResetDbTag) { + return false; + } + + if ($this->resetMode === DatabaseResetMode::SCENARIO) { + throw InvalidResetDbTag::resetDbWithScenarioMode($event); + } + + return true; + } + + private function hasNoResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event): bool + { + $node = $event instanceof BeforeFeatureTested ? $event->getFeature() : $event->getScenario(); + + if (!$node instanceof TaggedNodeInterface) { + return false; + } + + $hasNoResetDbTag = $node->hasTag(self::NO_RESET_DB_TAG); + + if (!$hasNoResetDbTag) { + return false; + } + + if ($this->damaNativeExtensionIsEnabled) { + throw DamaNativeExtensionIncompatibility::withNoResetDbTag(); + } + + return match($this->resetMode) { + DatabaseResetMode::MANUAL => throw InvalidResetDbTag::noResetDbWithManualMode($event), + DatabaseResetMode::FEATURE => throw InvalidResetDbTag::noResetDbWithFeatureMode($event), + default => true, + }; + } + + private function resetObjectRegistry(): void + { + $this->objectRegistry()->reset(); + } + + private function objectRegistry(): ObjectRegistry + { + return $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/Listener/LoadFixturesListener.php b/src/Test/Behat/Listener/LoadFixturesListener.php new file mode 100644 index 000000000..d367d1d8d --- /dev/null +++ b/src/Test/Behat/Listener/LoadFixturesListener.php @@ -0,0 +1,89 @@ + + */ +final class LoadFixturesListener implements EventSubscriberInterface +{ + private const FIXTURE_TAG_PATTERN = '/^withFixture\(([^)]+)\)$/'; + + public function __construct( + private readonly KernelInterface $symfonyKernel, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ScenarioTested::AFTER_SETUP => 'loadFixtureIfTagged', + ExampleTested::AFTER_SETUP => 'loadFixtureIfTagged', + ]; + } + + public function loadFixtureIfTagged(AfterScenarioSetup $event): void + { + $scenario = $event->getScenario(); + $feature = $event->getFeature(); + + $tags = []; + + if ($feature instanceof TaggedNodeInterface) { + $tags = [...$tags, ...$feature->getTags()]; + } + + if ($scenario instanceof TaggedNodeInterface) { + $tags = [...$tags, ...$scenario->getTags()]; + } + + if (!$tags) { + return; + } + + $fixtureNames = $this->parseFixtureName($tags); + + if ([] === $fixtureNames) { + return; + } + + foreach ($fixtureNames as $fixtureName) { + $stories = $this->fixtureStoryResolver()->resolve($fixtureName); + + foreach ($stories as $storyClass) { + $storyClass::load(); + } + } + } + + /** + * @param list $tags + * @return list + */ + private function parseFixtureName(array $tags): array + { + $fixtureNames = []; + + foreach ($tags as $tag) { + if (\preg_match(self::FIXTURE_TAG_PATTERN, $tag, $matches)) { + $fixtureNames[] = $matches[1]; + } + } + + return $fixtureNames; + } + + private function fixtureStoryResolver(): FixtureStoryResolver + { + return $this->symfonyKernel->getContainer()->get('.zenstruck_foundry.story.fixture_resolver'); // @phpstan-ignore return.type + } +} diff --git a/src/Test/Behat/ObjectRegistry.php b/src/Test/Behat/ObjectRegistry.php new file mode 100644 index 000000000..ad2c6f899 --- /dev/null +++ b/src/Test/Behat/ObjectRegistry.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectNotFound; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class ObjectRegistry +{ + /** + * We need to use static properties in order that this is kept between kernel resets + */ + + /** @var array> */ + private static array $objects = []; + + /** @var array */ + private static array $lastId = []; + + public function __construct( + private readonly FactoryShortNameResolver $factoryShortNameResolver, + private readonly PersistenceManager $persistenceManager, + ) { + } + + /** + * @param StateAddedToStory $event + */ + public function storeAfterStateAddedToStory(StateAddedToStory $event): void + { + $this->store($event->object, $event->name); + } + + public function store(object $object, string $objectName): void + { + if ($this->has($object::class, $objectName)) { + throw ObjectAlreadyRegistered::forClassAndName($object::class, $objectName); + } + + self::$objects[$object::class][$objectName] = $object; + } + + /** + * @param class-string $objectClass + */ + public function has(string $objectClass, string $objectName): bool + { + return isset(self::$objects[$objectClass][$objectName]); + } + + /** + * @param AfterPersist $event + */ + public function storeLastId(AfterPersist $event): void + { + self::$lastId = $this->persistenceManager->getIdentifierValues($event->object); + } + + public function getByFactoryShortName(string $factoryShortName, string $objectName): object + { + $objectClass = $this->factoryShortNameResolver->targetObjectClassFor($factoryShortName); + + if (!$this->has($objectClass, $objectName)) { + throw ObjectNotFound::forFactoryAndName($factoryShortName, $objectName); + } + + return self::$objects[$objectClass][$objectName]; + } + + /** + * @param class-string $objectClass + * + * @throws ObjectNotFound + */ + public function getByObjectClass(string $objectClass, string $objectName): object + { + if (!$this->has($objectClass, $objectName)) { + throw ObjectNotFound::forClassAndName($objectClass, $objectName); + } + + return self::$objects[$objectClass][$objectName]; + } + + public function reset(): void + { + self::$objects = []; + self::$lastId = []; + } + + public function lastId(): int|string + { + if (!self::$lastId) { + throw new \RuntimeException('No last id found.'); + } + + return $this->coerceIdToScalar(self::$lastId); + } + + /** + * @param array $ids + */ + private function coerceIdToScalar(array $ids): int|string + { + if (count($ids) !== 1) { + throw new \InvalidArgumentException('Cannot get last id: generic entity must have exactly one identifier.'); + } + + $id = array_first($ids); + if (!is_int($id) && !is_string($id)) { + throw new \InvalidArgumentException(sprintf('Wrong type for the id: expected int or string, got "%s".', get_debug_type($id))); + } + + return $id; + } + + public function lastIdFor(string $factoryShortName): int|string + { + $objects = self::$objects[$this->factoryShortNameResolver->targetObjectClassFor($factoryShortName)] ?? []; + + if (count($objects) === 0) { + throw new \InvalidArgumentException("No object of type \"$factoryShortName\" found."); + } + + return $this->coerceIdToScalar( + $this->persistenceManager->getIdentifierValues( + array_last($objects) + ) + ); + } + + public function isStored(object $object): bool + { + return array_any( + self::$objects[$object::class] ?? [], + static fn(object $o) => $o === $object + ); + } + + public function getNameFor(object $object): string + { + return array_find_key( + self::$objects[$object::class] ?? [], + static fn(object $o) => $o === $object + ) ?? throw new \LogicException('Object is not stored in the registry.'); + } +} diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 354a5fb33..0869db407 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -33,6 +33,7 @@ use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\ORM\ResetDatabase\SchemaDatabaseResetter; +use Zenstruck\Foundry\Test\Behat\FoundryContext; /** * @author Kevin Bond @@ -238,6 +239,12 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $configurator->import('../config/services.php'); + // we load all services for Behat by default, and we remove them if we detect that Behat is not installed + // that's the only way that worked so far... + if (interface_exists(\Behat\Behat\Context\Context::class)) { + $configurator->import('../config/behat.php'); + } + $this->configureInstantiator($config['instantiator'], $container); $this->configureFaker($config['faker'], $container); $this->configureGlobalState($config['global_state'], $container); @@ -278,6 +285,8 @@ public function build(ContainerBuilder $container): void public function process(ContainerBuilder $container): void { + $this->removeBehatConfigIfNeeded($container); + // faker providers foreach ($container->findTaggedServiceIds('foundry.faker_provider') as $id => $tags) { $container @@ -302,6 +311,17 @@ public function process(ContainerBuilder $container): void } } + private function removeBehatConfigIfNeeded(ContainerBuilder $container): void + { + if ($container->has('behat.service_container')) { + return; + } + + $container->removeDefinition(FoundryContext::class); + $container->removeDefinition('.zenstruck_foundry.behat.object_registry'); + $container->removeDefinition('zenstruck_foundry.behat.context.foundry'); + } + /** * @param string[] $values */ diff --git a/tests/Fixture/App/Controller/HelloWorldController.php b/tests/Fixture/App/Controller/HelloWorldController.php new file mode 100644 index 000000000..249fd7a9d --- /dev/null +++ b/tests/Fixture/App/Controller/HelloWorldController.php @@ -0,0 +1,17 @@ +loadFromExtension('zenstruck_foundry', [ + 'persistence' => ['flush_once' => true], + 'enable_auto_refresh_with_lazy_objects' => self::usePHP84LazyObjects(), + 'orm' => [ + 'reset' => [ + 'mode' => ResetDatabaseMode::SCHEMA, + ], + ], + ]); + + $c->register(HelloWorldController::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); + $c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); + $c->register(ResetDisabledTestContext::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(TestFoundryContext::class) + ->setAutowired(true) + ->setAutoconfigured(true) + ->setArguments([new Reference('.zenstruck_foundry.behat.factory_resolver'), new Reference('.zenstruck_foundry.behat.object_registry')]) + ; + + $configurator->services() + ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Behat\\Factories\\', __DIR__.'/Factories') + ->autowire() + ->autoconfigure(); + + $configurator->services() + ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Factories\\', __DIR__.'/../Factories') + ->autowire() + ->autoconfigure(); + + $configurator->services() + ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Behat\\Stories\\', __DIR__.'/Stories') + ->autowire() + ->autoconfigure(); + + if (!self::runsWithBehat()) { + $c->register('behat.service_container', \stdClass::class); + } + } + + private static function runsWithBehat(): bool + { + return str_contains($_SERVER['SCRIPT_NAME'], 'behat'); + } +} diff --git a/tests/Fixture/Behat/Factories/Tag/TagFactory.php b/tests/Fixture/Behat/Factories/Tag/TagFactory.php new file mode 100644 index 000000000..e20bdb4c5 --- /dev/null +++ b/tests/Fixture/Behat/Factories/Tag/TagFactory.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Behat\Factories\Tag; + +use Zenstruck\Foundry\Attribute\FactoryShortName; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; + +/** + * @author Kevin Bond + * + * @extends PersistentObjectFactory + */ +#[FactoryShortName('tag2')] +final class TagFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return Tag::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } +} diff --git a/tests/Fixture/Behat/Factories/TagFactory.php b/tests/Fixture/Behat/Factories/TagFactory.php new file mode 100644 index 000000000..312f8acf1 --- /dev/null +++ b/tests/Fixture/Behat/Factories/TagFactory.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Behat\Factories; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; + +/** + * @author Kevin Bond + * + * @extends PersistentObjectFactory + */ +final class TagFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return Tag::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } +} diff --git a/tests/Fixture/Behat/ResetDisabledTestContext.php b/tests/Fixture/Behat/ResetDisabledTestContext.php new file mode 100644 index 000000000..8f0aaaef9 --- /dev/null +++ b/tests/Fixture/Behat/ResetDisabledTestContext.php @@ -0,0 +1,25 @@ +kernel); + } +} diff --git a/tests/Fixture/Behat/Stories/CategoryStory.php b/tests/Fixture/Behat/Stories/CategoryStory.php new file mode 100644 index 000000000..293a6f5ba --- /dev/null +++ b/tests/Fixture/Behat/Stories/CategoryStory.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Category\CategoryFactory; + +#[AsFixture(name: 'behat-category', groups: ['behat-stories'])] +final class CategoryStory extends Story +{ + public function build(): void + { + $this->addState( + 'category fixture', + CategoryFactory::createOne() + ); + } +} diff --git a/tests/Fixture/Behat/Stories/ConflictStory1.php b/tests/Fixture/Behat/Stories/ConflictStory1.php new file mode 100644 index 000000000..5302b8001 --- /dev/null +++ b/tests/Fixture/Behat/Stories/ConflictStory1.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory; + +#[AsFixture(name: 'conflict-story-1', groups: ['conflict-test'])] +final class ConflictStory1 extends Story +{ + public function build(): void + { + $this->addState( + 'duplicate', + ContactFactory::createOne(['name' => 'From Story 1']) + ); + } +} diff --git a/tests/Fixture/Behat/Stories/ConflictStory2.php b/tests/Fixture/Behat/Stories/ConflictStory2.php new file mode 100644 index 000000000..fd4c5f029 --- /dev/null +++ b/tests/Fixture/Behat/Stories/ConflictStory2.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory; + +#[AsFixture(name: 'conflict-story-2', groups: ['conflict-test'])] +final class ConflictStory2 extends Story +{ + public function build(): void + { + $this->addState( + 'duplicate', + ContactFactory::createOne(['name' => 'From Story 2']) + ); + } +} diff --git a/tests/Fixture/Behat/Stories/ContactStory.php b/tests/Fixture/Behat/Stories/ContactStory.php new file mode 100644 index 000000000..e3f97a0bf --- /dev/null +++ b/tests/Fixture/Behat/Stories/ContactStory.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; + +use Zenstruck\Foundry\Attribute\AsFixture; +use Zenstruck\Foundry\Story; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory; + +#[AsFixture(name: 'behat-contacts', groups: ['behat-stories'])] +final class ContactStory extends Story +{ + public function build(): void + { + $this->addState( + 'john-doe', + ContactFactory::createOne(['name' => 'John Doe']) + ); + } +} diff --git a/tests/Fixture/Behat/TestFoundryContext.php b/tests/Fixture/Behat/TestFoundryContext.php new file mode 100644 index 000000000..e6a444668 --- /dev/null +++ b/tests/Fixture/Behat/TestFoundryContext.php @@ -0,0 +1,11 @@ + false, @@ -194,4 +196,9 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register('logger', NullLogger::class); } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import(__DIR__.'/App/Controller/*.php', 'attribute'); + } } diff --git a/tests/Fixture/SomeEnum.php b/tests/Fixture/IntBackedEnum.php similarity index 78% rename from tests/Fixture/SomeEnum.php rename to tests/Fixture/IntBackedEnum.php index a874a6fbc..7eaded25f 100644 --- a/tests/Fixture/SomeEnum.php +++ b/tests/Fixture/IntBackedEnum.php @@ -11,7 +11,8 @@ namespace Zenstruck\Foundry\Tests\Fixture; -enum SomeEnum +enum IntBackedEnum: int { - case SOME_VALUE; + case SOME_VALUE_0 = 0; + case SOME_VALUE_1 = 1; } diff --git a/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php b/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php index 2842244da..57187de04 100644 --- a/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php +++ b/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php @@ -13,6 +13,8 @@ use Zenstruck\Foundry\Persistence\PersistentObjectFactory; use Zenstruck\Foundry\Tests\Fixture\Entity\GenericEntity; +use Zenstruck\Foundry\Tests\Fixture\IntBackedEnum; +use Zenstruck\Foundry\Tests\Fixture\StringBackedEnum; /** * @extends PersistentObjectFactory @@ -43,9 +45,14 @@ public static function class(): string protected function defaults(): array|callable { return [ + 'bool' => self::faker()->boolean(), 'date' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + 'dateMutable' => self::faker()->dateTime(), + 'float' => self::faker()->randomFloat(), + 'intEnum' => self::faker()->randomElement(IntBackedEnum::cases()), 'prop1' => self::faker()->text(), 'propInteger' => self::faker()->randomNumber(), + 'stringEnum' => self::faker()->randomElement(StringBackedEnum::cases()), ]; } diff --git a/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php b/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php index b8f26e75f..2251632d9 100644 --- a/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php +++ b/tests/Fixture/Maker/expected/can_create_factory_with_default_enum.php @@ -13,7 +13,7 @@ use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Tests\Fixture\ObjectWithEnum; -use Zenstruck\Foundry\Tests\Fixture\SomeEnum; +use Zenstruck\Foundry\Tests\Fixture\StringBackedEnum; /** * @extends ObjectFactory @@ -44,7 +44,7 @@ public static function class(): string protected function defaults(): array|callable { return [ - 'someEnum' => self::faker()->randomElement(SomeEnum::cases()), + 'someEnum' => self::faker()->randomElement(StringBackedEnum::cases()), ]; } diff --git a/tests/Fixture/Model/GenericModel.php b/tests/Fixture/Model/GenericModel.php index 7ac87edcc..6a8ceb042 100644 --- a/tests/Fixture/Model/GenericModel.php +++ b/tests/Fixture/Model/GenericModel.php @@ -13,6 +13,8 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB; use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\IntBackedEnum; +use Zenstruck\Foundry\Tests\Fixture\StringBackedEnum; /** * Used for ORM/Mongo tests. @@ -41,6 +43,26 @@ abstract class GenericModel #[MongoDB\Field(type: 'date_immutable', nullable: true)] private ?\DateTimeImmutable $date = null; + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'date', nullable: true)] + public ?\DateTime $dateMutable = null; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'bool', nullable: true)] + public ?bool $bool = null; + + #[ORM\Column(name: '`float`', nullable: true)] + #[MongoDB\Field(type: 'float', nullable: true)] + public ?float $float = null; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'string', nullable: true, enumType: StringBackedEnum::class)] + public ?StringBackedEnum $stringEnum = null; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'int', nullable: true, enumType: IntBackedEnum::class)] + public ?IntBackedEnum $intEnum = null; + public function __construct(string $prop1) { $this->prop1 = $prop1; diff --git a/tests/Fixture/ObjectWithEnum.php b/tests/Fixture/ObjectWithEnum.php index 5f70aaddf..f1bf6c5d5 100644 --- a/tests/Fixture/ObjectWithEnum.php +++ b/tests/Fixture/ObjectWithEnum.php @@ -14,7 +14,7 @@ final class ObjectWithEnum { public function __construct( - public readonly SomeEnum $someEnum, + public readonly StringBackedEnum $someEnum, ) { } } diff --git a/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php b/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php index af3fe79b4..cdd848e0c 100644 --- a/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php +++ b/tests/Fixture/ResetDatabase/ResetDatabaseTestKernel.php @@ -14,6 +14,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\Tests\Fixture\FoundryTestKernel; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalInvokableService; @@ -38,9 +39,9 @@ public static function usesSqlite(): bool return \str_starts_with((string) \getenv('DATABASE_URL'), 'sqlite:'); } - protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + protected function configureContainer(ContainerConfigurator $configurator, LoaderInterface $loader, ContainerBuilder $c): void { - parent::configureContainer($c, $loader); + parent::configureContainer($configurator, $loader, $c); $c->loadFromExtension('zenstruck_foundry', [ 'persistence' => ['flush_once' => true], diff --git a/tests/Fixture/StringBackedEnum.php b/tests/Fixture/StringBackedEnum.php new file mode 100644 index 000000000..195497023 --- /dev/null +++ b/tests/Fixture/StringBackedEnum.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture; + +enum StringBackedEnum: string +{ + case SOME_VALUE = 'some_value'; + case OTHER_VALUE = 'other_value'; +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index 66ce291a1..ff8c80f92 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -14,7 +14,7 @@ use Symfony\Bundle\MakerBundle\MakerBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\Tests\Fixture\App\Command\UpdateGenericModelCommand; use Zenstruck\Foundry\Tests\Fixture\App\Controller\CreateContact; @@ -50,9 +50,9 @@ public function registerBundles(): iterable yield new MakerBundle(); } - protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + protected function configureContainer(ContainerConfigurator $configurator, LoaderInterface $loader, ContainerBuilder $c): void { - parent::configureContainer($c, $loader); + parent::configureContainer($configurator, $loader, $c); $c->loadFromExtension('zenstruck_foundry', [ 'persistence' => ['flush_once' => true], @@ -98,9 +98,4 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(HelloWorld::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(UpdateGenericModelCommand::class)->setAutowired(true)->setAutoconfigured(true); } - - protected function configureRoutes(RoutingConfigurator $routes): void - { - $routes->import(__DIR__.'/App/Controller/*.php', 'attribute'); - } } diff --git a/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php b/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php new file mode 100644 index 000000000..ff984a414 --- /dev/null +++ b/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Integration\Behat\Listener; + +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Test\Behat\Listener\BootConfigurationListener; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; +use Zenstruck\Foundry\Tests\Fixture\Behat\BehatTestKernel; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory; +use Zenstruck\Foundry\Tests\Integration\RequiresORM; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class BootConfigurationListenerTest extends KernelTestCase +{ + use Factories, RequiresORM, ResetDatabase; + + protected static function getKernelClass(): string + { + return BehatTestKernel::class; + } + + #[Test] + public function it_boots_foundry_when_not_already_booted(): void + { + Configuration::shutdown(); + self::assertFalse(Configuration::isBooted()); + + $listener = $this->createListener(); + $listener->bootFoundry(); + + self::assertTrue(Configuration::isBooted()); + } + + #[Test] + public function it_shuts_down_foundry(): void + { + self::assertTrue(Configuration::isBooted()); + + $listener = $this->createListener(); + $listener->shutdownFoundry(); + + self::assertFalse(Configuration::isBooted()); + } + + #[Test] + public function it_shuts_down_foundry_after_feature_and_resets_registry(): void + { + $testObj = GenericEntityFactory::createOne(); + $registry = $this->objectRegistry(); + $registry->store($testObj, 'test-object'); + + self::assertTrue($registry->isStored($testObj)); + self::assertTrue(Configuration::isBooted()); + + $listener = $this->createListener(); + $listener->shutdownFoundryAfterFeature(); + + self::assertFalse($registry->isStored($testObj)); + self::assertFalse(Configuration::isBooted()); + } + + private function createListener(): BootConfigurationListener + { + return new BootConfigurationListener(self::$kernel ?? self::bootKernel()); + } + + private function objectRegistry(): ObjectRegistry + { + return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php b/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php new file mode 100644 index 000000000..70625ab00 --- /dev/null +++ b/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php @@ -0,0 +1,367 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Integration\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\BeforeFeatureTested; +use Behat\Behat\EventDispatcher\Event\BeforeScenarioTested; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\ScenarioNode; +use Behat\Testwork\Environment\StaticEnvironment; +use Behat\Testwork\Suite\GenericSuite; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Test\Behat\DatabaseResetMode; +use Zenstruck\Foundry\Test\Behat\Exception\DamaNativeExtensionIncompatibility; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidResetDbTag; +use Zenstruck\Foundry\Test\Behat\Listener\DatabaseResetListener; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; +use Zenstruck\Foundry\Tests\Fixture\Behat\BehatTestKernel; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory; +use Zenstruck\Foundry\Tests\Integration\RequiresORM; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class DatabaseResetListenerTest extends KernelTestCase +{ + use Factories, RequiresORM, ResetDatabase; + + protected static function getKernelClass(): string + { + return BehatTestKernel::class; + } + + protected function setUp(): void + { + $this->objectRegistry()->reset(); + } + + /** + * @param list $tags + * @param class-string<\Throwable> $exceptionClass + */ + #[Test] + #[DataProvider('validateFeatureExceptionProvider')] + public function it_throws_exception_on_validate_feature( + DatabaseResetMode $mode, + array $tags, + string $exceptionClass, + string $exceptionMessage + ): void { + $listener = $this->createListener($mode); + $event = $this->createFeatureEvent($tags); + + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $listener->validateFeature($event); + } + + public static function validateFeatureExceptionProvider(): iterable + { + yield 'resetDB tag on feature with feature mode' => [ + DatabaseResetMode::FEATURE, + ['resetDB'], + InvalidResetDbTag::class, + 'Cannot use "@resetDB" tag on a feature with database_reset_mode set as "feature".', + ]; + } + + /** + * @param list $tags + * @param class-string<\Throwable> $exceptionClass + */ + #[Test] + #[DataProvider('validateScenarioExceptionProvider')] + public function it_throws_exception_on_validate_scenario( + DatabaseResetMode $mode, + array $tags, + string $exceptionClass, + string $exceptionMessage + ): void { + $listener = $this->createListener($mode); + $event = $this->createScenarioEvent($tags); + + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $listener->validateScenario($event); + } + + public static function validateScenarioExceptionProvider(): iterable + { + yield 'resetDB tag on scenario with scenario mode' => [ + DatabaseResetMode::SCENARIO, + ['resetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', + ]; + + yield 'both resetDB and noResetDB tags on scenario' => [ + DatabaseResetMode::MANUAL, + ['resetDB', 'noResetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', + ]; + } + + /** + * @param list $tags + */ + #[Test] + #[DataProvider('resetDatabaseIfNeededBehaviorProvider')] + public function it_resets_database_and_registries_when_needed( + DatabaseResetMode $mode, + string $eventType, + array $tags, + bool $shouldReset, + ): void { + $listener = $this->createListener($mode, damaSupportEnabled: true); + $objectRegistry = $this->objectRegistry(); + + $testObject = GenericEntityFactory::createOne(); + GenericEntityFactory::assert()->count(1); + + $objectRegistry->store($testObject, 'test-object'); + self::assertTrue($objectRegistry->isStored($testObject)); + + $event = $eventType === 'feature' ? $this->createFeatureEvent($tags) : $this->createScenarioEvent($tags); + + $listener->resetDatabaseIfNeeded($event); + + if ($shouldReset) { + self::assertFalse($objectRegistry->isStored($testObject)); + GenericEntityFactory::assert()->count(0); + } else { + self::assertTrue($objectRegistry->isStored($testObject)); + GenericEntityFactory::assert()->count(1); + } + } + + public static function resetDatabaseIfNeededBehaviorProvider(): iterable + { + yield 'scenario mode resets on scenario without tags' => [ + DatabaseResetMode::SCENARIO, + 'scenario', + [], + true, + ]; + + yield 'scenario mode does not reset with noResetDB tag' => [ + DatabaseResetMode::SCENARIO, + 'scenario', + ['noResetDB'], + false, + ]; + + yield 'feature mode resets on feature without tags' => [ + DatabaseResetMode::FEATURE, + 'feature', + [], + true, + ]; + + yield 'manual mode does not reset without resetDB tag' => [ + DatabaseResetMode::MANUAL, + 'scenario', + [], + false, + ]; + + yield 'manual mode resets with resetDB tag' => [ + DatabaseResetMode::MANUAL, + 'scenario', + ['resetDB'], + true, + ]; + + yield 'scenario mode does not reset on feature' => [ + DatabaseResetMode::SCENARIO, + 'feature', + [], + false, + ]; + + yield 'feature mode does not reset on scenario' => [ + DatabaseResetMode::FEATURE, + 'scenario', + [], + false, + ]; + } + + /** + * @param list $tags + * @param class-string<\Throwable> $exceptionClass + */ + #[Test] + #[DataProvider('resetDatabaseIfNeededExceptionProvider')] + public function it_throws_exception_on_reset_database_if_needed( + DatabaseResetMode $mode, + array $tags, + string $exceptionClass, + string $exceptionMessage, + bool $damaSupportEnabled = false, + bool $damaNativeExtensionIsEnabled = false + ): void { + $listener = $this->createListener($mode, $damaSupportEnabled, $damaNativeExtensionIsEnabled); + $event = $this->createScenarioEvent($tags); + + $this->expectException($exceptionClass); + $this->expectExceptionMessage($exceptionMessage); + + $listener->resetDatabaseIfNeeded($event); + } + + public static function resetDatabaseIfNeededExceptionProvider(): iterable + { + yield 'noResetDB tag with dama native extension' => [ + DatabaseResetMode::SCENARIO, + ['noResetDB'], + DamaNativeExtensionIncompatibility::class, + 'Cannot use "@noResetDB" with native Behat extension for "dama/doctrine-test-bundle".', + false, + true, + ]; + + yield 'noResetDB tag with manual mode' => [ + DatabaseResetMode::MANUAL, + ['noResetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', + ]; + + yield 'noResetDB tag with feature mode' => [ + DatabaseResetMode::FEATURE, + ['noResetDB'], + InvalidResetDbTag::class, + 'Cannot use "@noResetDB" with database_reset_mode set as "feature".', + ]; + } + + /** + * @param list $tags + */ + #[Test] + #[DataProvider('validateScenarioNoExceptionProvider')] + public function it_does_not_throw_on_validate_scenario( + DatabaseResetMode $mode, + array $tags, + ): void { + $this->expectNotToPerformAssertions(); + + $listener = $this->createListener($mode); + $event = $this->createScenarioEvent($tags); + + $listener->validateScenario($event); + } + + public static function validateScenarioNoExceptionProvider(): iterable + { + yield 'resetDB tag with manual mode' => [ + DatabaseResetMode::MANUAL, + ['resetDB'], + ]; + + yield 'no tags with scenario mode' => [ + DatabaseResetMode::SCENARIO, + [], + ]; + } + + #[Test] + public function it_does_not_throw_for_noResetDB_tag_with_scenario_mode(): void + { + $this->expectNotToPerformAssertions(); + + $listener = $this->createListener(DatabaseResetMode::SCENARIO); + $event = $this->createScenarioEvent(['noResetDB']); + + $listener->resetDatabaseIfNeeded($event); + } + + #[Test] + public function it_validates_feature_without_resetDB_tag_in_feature_mode(): void + { + $this->expectNotToPerformAssertions(); + + $listener = $this->createListener(DatabaseResetMode::FEATURE); + $event = $this->createFeatureEvent([]); + + $listener->validateFeature($event); + } + + private function createListener( + DatabaseResetMode $mode, + bool $damaSupportEnabled = false, + bool $damaNativeExtensionIsEnabled = false + ): DatabaseResetListener { + return new DatabaseResetListener(self::$kernel ?? self::bootKernel(), $mode, $damaSupportEnabled, $damaNativeExtensionIsEnabled); + } + + private function objectRegistry(): ObjectRegistry + { + return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } + + private function createEnvironment(): StaticEnvironment + { + return new StaticEnvironment(new GenericSuite('default', ['paths' => ['/path/to']])); + } + + /** + * @param list $tags + */ + private function createFeatureEvent(array $tags): BeforeFeatureTested + { + $feature = new FeatureNode( + 'Test Feature', + 'Description', + $tags, + null, + [], + 'feature', + 'en', + '/path/to/test.feature', + 1 + ); + + return new BeforeFeatureTested($this->createEnvironment(), $feature); + } + + /** + * @param list $tags + */ + private function createScenarioEvent(array $tags): BeforeScenarioTested + { + $scenario = new ScenarioNode('Test Scenario', $tags, [], 'scenario', 10); + + $feature = new FeatureNode( + 'Test Feature', + 'Description', + [], + null, + [$scenario], + 'feature', + 'en', + '/path/to/test.feature', + 1 + ); + + return new BeforeScenarioTested($this->createEnvironment(), $feature, $scenario); + } +} diff --git a/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php b/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php new file mode 100644 index 000000000..da3a6053e --- /dev/null +++ b/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Integration\Behat\Listener; + +use Behat\Behat\EventDispatcher\Event\AfterScenarioSetup; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\OutlineNode; +use Behat\Gherkin\Node\ScenarioNode; +use Behat\Testwork\Environment\Environment; +use Behat\Testwork\Tester\Setup\Setup; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; +use Zenstruck\Foundry\Test\Behat\Listener\LoadFixturesListener; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; +use Zenstruck\Foundry\Tests\Fixture\Behat\BehatTestKernel; +use Zenstruck\Foundry\Tests\Fixture\Entity\Category; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Category\CategoryFactory; +use Zenstruck\Foundry\Tests\Integration\RequiresORM; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class LoadFixturesListenerTest extends KernelTestCase +{ + use Factories, RequiresORM, ResetDatabase; + + protected static function getKernelClass(): string + { + return BehatTestKernel::class; + } + + protected function setUp(): void + { + $this->objectRegistry()->reset(); + } + + /** + * @param list $featureTags + * @param list $scenarioTags + */ + #[Test] + #[DataProvider('noFixtureLoadedProvider')] + public function it_does_not_load_fixtures(array $featureTags, array $scenarioTags): void + { + $listener = $this->createListener(); + $event = $this->createAfterScenarioSetupEvent($featureTags, $scenarioTags); + + $listener->loadFixtureIfTagged($event); + + CategoryFactory::assert()->count(0); + } + + public static function noFixtureLoadedProvider(): iterable + { + yield 'no tags' => [[], []]; + yield 'no fixture tags' => [['someTag', 'anotherTag'], []]; + yield 'invalid tag: tag without parentheses' => [[], ['withFixture']]; + yield 'invalid tag: tag with empty parentheses' => [[], ['withFixture()']]; + yield 'invalid tag: tag with wrong prefix' => [[], ['loadFixture(behat-category)']]; + yield 'invalid tag: tag with extra closing parenthesis' => [[], ['withFixture(behat-category))']]; + } + + /** + * @param list $featureTags + * @param list $scenarioTags + */ + #[Test] + #[DataProvider('singleCategoryFixtureLoadedProvider')] + public function it_loads_single_category_fixture(array $featureTags, array $scenarioTags, bool $isOutline = false): void + { + $listener = $this->createListener(); + $event = $isOutline + ? $this->createAfterScenarioSetupEventWithOutline($scenarioTags) + : $this->createAfterScenarioSetupEvent($featureTags, $scenarioTags); + + $listener->loadFixtureIfTagged($event); + + CategoryFactory::assert()->count(1); + $this->objectRegistry()->has(Category::class, 'category fixture'); + } + + public static function singleCategoryFixtureLoadedProvider(): iterable + { + yield 'from scenario tag' => [[], ['withFixture(behat-category)']]; + yield 'from feature tag' => [['withFixture(behat-category)'], []]; + yield 'outline example scenario' => [[], ['withFixture(behat-category)'], true]; + yield 'combined feature and scenario tags' => [['withFixture(behat-category)'], ['withFixture(behat-category)']]; + } + + #[Test] + public function it_loads_fixture_group(): void + { + $listener = $this->createListener(); + $event = $this->createAfterScenarioSetupEvent([], ['withFixture(behat-stories)']); + + $listener->loadFixtureIfTagged($event); + + CategoryFactory::assert()->count(2); + $this->objectRegistry()->has(Category::class, 'category fixture'); + $this->objectRegistry()->has(Contact::class, 'john-doe'); + } + + #[Test] + public function it_throws_if_states_name_conflict_in_stories(): void + { + $this->expectException(ObjectAlreadyRegistered::class); + $this->expectExceptionMessage('Object "duplicate" is already registered for class "'.Contact::class);; + + $listener = $this->createListener(); + $event = $this->createAfterScenarioSetupEvent([], ['withFixture(conflict-test)']); + + $listener->loadFixtureIfTagged($event); + } + + private function createListener(): LoadFixturesListener + { + return new LoadFixturesListener(self::$kernel ?? self::bootKernel()); + } + + /** + * @param list $featureTags + * @param list $scenarioTags + */ + private function createAfterScenarioSetupEvent(array $featureTags, array $scenarioTags): AfterScenarioSetup + { + $scenario = new ScenarioNode('Test Scenario', $scenarioTags, [], 'scenario', 10); + + $feature = new FeatureNode( + 'Test Feature', 'Description', $featureTags, null, [$scenario], 'feature', 'en', '/path/to/test.feature', 1 + ); + + $environment = $this->createStub(Environment::class); + $setup = $this->createStub(Setup::class); + + return new AfterScenarioSetup($environment, $feature, $scenario, $setup); + } + + /** + * @param list $outlineTags + */ + private function createAfterScenarioSetupEventWithOutline(array $outlineTags): AfterScenarioSetup + { + $outline = new OutlineNode('Test Outline', $outlineTags, [], [], 'outline', 10); + + $feature = new FeatureNode( + 'Test Feature', 'Description', [], null, [$outline], 'feature', 'en', '/path/to/test.feature', 1 + ); + + $environment = $this->createStub(Environment::class); + $setup = $this->createStub(Setup::class); + + return new AfterScenarioSetup($environment, $feature, $outline, $setup); + } + + private function objectRegistry(): ObjectRegistry + { + return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type + } +} diff --git a/tests/Integration/Command/LoadFixturesCommandTest.php b/tests/Integration/Command/LoadFixturesCommandTest.php index 4be4a1988..3e676478d 100644 --- a/tests/Integration/Command/LoadFixturesCommandTest.php +++ b/tests/Integration/Command/LoadFixturesCommandTest.php @@ -15,10 +15,10 @@ use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Tester\CommandTester; +use Zenstruck\Foundry\Story\FixtureStoryNotFound; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; use Zenstruck\Foundry\Tests\Fixture\Entity\GlobalEntity; @@ -27,7 +27,6 @@ use Zenstruck\Foundry\Tests\Fixture\Stories\Fixtures\FixtureStoryWithNameCollision; use Zenstruck\Foundry\Tests\Fixture\TestKernel; use Zenstruck\Foundry\Tests\Integration\RequiresORM; - use function Zenstruck\Foundry\Persistence\repository; final class LoadFixturesCommandTest extends KernelTestCase @@ -52,8 +51,8 @@ public function it_throws_if_no_story_marked_as_fixture(): void #[Test] public function it_throws_if_story_does_not_exist(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Story with name "invalid-name" does not exist'); + $this->expectException(FixtureStoryNotFound::class); + $this->expectExceptionMessage('Fixture story with name or group "invalid-name" not found'); $this->commandTester(['environment' => 'stories_as_fixtures'])->execute(['name' => 'invalid-name', '--append' => true]); } @@ -286,8 +285,10 @@ private function commandTester(array $options = []): CommandTester 'foundry:load-story', ]; - return new CommandTester((new Application(self::bootKernel($options)))->find( - $commands[\array_rand($commands)] - )); + return new CommandTester( + (new Application(self::bootKernel($options)))->find( + $commands[\array_rand($commands)] + ) + ); } } diff --git a/tests/Unit/Test/Behat/FactoryResolverTest.php b/tests/Unit/Test/Behat/FactoryResolverTest.php new file mode 100644 index 000000000..958273543 --- /dev/null +++ b/tests/Unit/Test/Behat/FactoryResolverTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Unit\Test\Behat; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Zenstruck\Foundry\Attribute\FactoryShortName; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Test\Behat\Exception\FactoryNotResolvable; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class FactoryResolverTest extends TestCase +{ + public static function factoriesWithConflictingShortNames(): iterable + { + yield 'same short name in attribute' => [[new Article1Factory(), new Article2Factory()]]; + yield 'same generated short name' => [[new Article1Factory(), new ArticleFactory()]]; + } + + #[Test] + public function it_resolves_factory_by_auto_generated_name(): void + { + $resolver = new FactoryShortNameResolver([$factory = new PostFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('post')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('posts')); + } + + #[Test] + public function it_resolves_factory_case_insensitively(): void + { + $resolver = new FactoryShortNameResolver([$factory = new PostFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('Post')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('POST')); + } + + #[Test] + public function it_resolves_factory_complex_short_name(): void + { + $resolver = new FactoryShortNameResolver([$factory = new BlogPostFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('blog post')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('blog posts')); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('BlOg PoSt')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('BlOg PoSts')); + } + + #[Test] + public function it_uses_attribute_short_name(): void + { + $resolver = new FactoryShortNameResolver([$factory = new CustomNameFactory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('custom')); + self::assertInstanceOf($factory::class, $resolver->factoryFor('customs')); + } + + #[Test] + public function it_can_resolve_with_custom_plural_form(): void + { + $resolver = new FactoryShortNameResolver([$factory = new Article1Factory()]); + + self::assertInstanceOf($factory::class, $resolver->factoryFor('several articles')); + } + + #[Test] + public function it_throws_when_factory_not_found(): void + { + $resolver = new FactoryShortNameResolver([new PostFactory()]); + + $this->expectException(FactoryNotResolvable::class); + $this->expectExceptionMessage('Cannot resolve factory for name "unknown"'); + + $resolver->factoryFor('unknown'); + } + + #[Test] + #[DataProvider('factoriesWithConflictingShortNames')] + public function it_throws_on_conflict(array $factories): void + { + $resolver = new FactoryShortNameResolver($factories); + + $this->expectException(FactoryNotResolvable::class); + $this->expectExceptionMessage('Multiple factories found for name "article"'); + + $resolver->factoryFor('article'); + } + + #[Test] + public function it_checks_if_factory_exists_for_class(): void + { + $resolver = new FactoryShortNameResolver([new PostFactory()]); + + self::assertTrue($resolver->hasFactoryForClass(\stdClass::class)); + self::assertFalse($resolver->hasFactoryForClass(\DateTime::class)); + } + + #[Test] + public function it_gets_short_name_for_class(): void + { + $resolver = new FactoryShortNameResolver([new PostFactory()]); + + self::assertSame('post', $resolver->getShortNameForClass(\stdClass::class)); + } + + #[Test] + public function it_gets_short_name_for_class_with_custom_attribute(): void + { + $resolver = new FactoryShortNameResolver([new CustomNameFactory()]); + + self::assertSame('custom', $resolver->getShortNameForClass(\stdClass::class)); + } +} + +/** @extends ObjectFactory<\stdClass> */ +final class PostFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +#[FactoryShortName('custom')] +final class CustomNameFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +final class BlogPostFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +final class ArticleFactory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +#[FactoryShortName('article', 'several articles')] +final class Article1Factory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + +/** @extends ObjectFactory<\stdClass> */ +#[FactoryShortName('article')] +final class Article2Factory extends ObjectFactory +{ + public static function class(): string + { + return \stdClass::class; + } + + protected function defaults(): array + { + return []; + } +} + diff --git a/tests/Unit/Test/Behat/FoundryCallFilterTest.php b/tests/Unit/Test/Behat/FoundryCallFilterTest.php new file mode 100644 index 000000000..c3faec025 --- /dev/null +++ b/tests/Unit/Test/Behat/FoundryCallFilterTest.php @@ -0,0 +1,507 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Unit\Test\Behat; + +use Behat\Behat\Definition\Call\DefinitionCall; +use Behat\Behat\Definition\Definition; +use Behat\Gherkin\Node\ExampleTableNode; +use Behat\Gherkin\Node\FeatureNode; +use Behat\Gherkin\Node\StepNode; +use Behat\Gherkin\Node\TableNode; +use Behat\Testwork\Call\Call; +use Behat\Testwork\Environment\Environment; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\KernelInterface; +use Zenstruck\Foundry\Attribute\FactoryShortName; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\FoundryCallFilter; +use Zenstruck\Foundry\Test\Behat\FoundryContext; +use Zenstruck\Foundry\Test\Behat\FoundryTableNode; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidObjectParameter; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class FoundryCallFilterTest extends TestCase +{ + private FoundryCallFilter $filter; + private FactoryShortNameResolver $factoryResolver; + private ObjectRegistry $objectRegistry; + + #[Test] + public function it_supports_call_with_table_node_argument(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn([new TableNode([['foo'], ['bar']])]); + + self::assertTrue($this->filter->supportsCall($call)); + } + + #[Test] + public function it_does_not_support_call_without_table_node(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn(['foo', 'bar']); + + self::assertFalse($this->filter->supportsCall($call)); + } + + #[Test] + public function it_does_not_support_call_with_example_table_node(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn([new ExampleTableNode([['foo'], ['bar']], 'example')]); + + self::assertFalse($this->filter->supportsCall($call)); + } + + #[Test] + public function it_returns_call_unchanged_when_not_definition_call(): void + { + $call = $this->createStub(Call::class); + $call->method('getArguments')->willReturn([new TableNode([['foo'], ['bar']])]); + + $result = $this->filter->filterCall($call); + + self::assertSame($call, $result); + } + + #[Test] + public function it_throws_when_no_factory_short_name_argument(): void + { + $call = $this->createDefinitionCallForFoundryContext( + ['table' => new TableNode([['property'], ['value']])], + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot filter call without a "$factoryShortName" argument.'); + + $this->filter->filterCall($call); + } + + #[Test] + #[DataProvider('tableNormalizationProvider')] + public function it_normalizes_table_values(array $input, array $expected): void + { + $table = $this->createTableNodeFromRow($input); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'test entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + self::assertInstanceOf(FoundryTableNode::class, $normalizedTable); + + $rows = $normalizedTable->getColumnsHash(); + self::assertCount(1, $rows); + self::assertEquals($expected, $rows[0]); + } + + public static function tableNormalizationProvider(): iterable + { + yield 'null value' => [ + ['property' => 'null'], + ['property' => null], + ]; + + yield 'true value' => [ + ['property' => 'true'], + ['property' => true], + ]; + + yield 'false value' => [ + ['property' => 'false'], + ['property' => false], + ]; + + yield 'string value kept as-is' => [ + ['property' => 'some text'], + ['property' => 'some text'], + ]; + + yield 'numeric string kept as-is for unknown property' => [ + ['property' => '42'], + ['property' => '42'], + ]; + + yield '_ref column is passed through' => [ + ['_ref' => 'my-ref'], + ['_ref' => 'my-ref'], + ]; + } + + /** + * @param array $row + */ + private function createTableNodeFromRow(array $row): TableNode + { + return new TableNode([ + array_keys($row), + array_values($row), + ]); + } + + #[Test] + public function it_normalizes_object_reference_with_explicit_factory(): void + { + $referencedObject = new TestEntity(id: 1, name: 'Referenced'); + $this->objectRegistry->store($referencedObject, 'the-ref'); + + $table = $this->createTableNodeFromRow(['relation' => '']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'test entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame($referencedObject, $rows[0]['relation']); + } + + #[Test] + public function it_throws_on_invalid_object_reference(): void + { + $table = $this->createTableNodeFromRow(['relation' => '']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'test entity', + 'table' => $table, + ]); + + $this->expectException(InvalidObjectParameter::class); + $this->expectExceptionMessage('A reference to an object cannot be resolved in the table, at column "relation"'); + + $this->filter->filterCall($call); + } + + #[Test] + public function it_normalizes_date_value(): void + { + $table = $this->createTableNodeFromRow(['createdAt' => '2024-01-15 10:30:00']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'dated entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertInstanceOf(\DateTimeImmutable::class, $rows[0]['createdAt']); + self::assertSame('2024-01-15 10:30:00', $rows[0]['createdAt']->format('Y-m-d H:i:s')); + } + + #[Test] + public function it_throws_on_invalid_date_value(): void + { + $table = $this->createTableNodeFromRow(['createdAt' => 'not-a-date']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'dated entity', + 'table' => $table, + ]); + + $this->expectException(InvalidObjectParameter::class); + $this->expectExceptionMessage('Invalid date given "not-a-date", at column "createdAt"'); + + $this->filter->filterCall($call); + } + + #[Test] + public function it_normalizes_string_enum_value(): void + { + $table = $this->createTableNodeFromRow(['status' => 'active']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'enum entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame(TestStatus::ACTIVE, $rows[0]['status']); + } + + #[Test] + public function it_normalizes_int_enum_value(): void + { + $table = $this->createTableNodeFromRow(['priority' => '2']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'enum entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame(TestPriority::MEDIUM, $rows[0]['priority']); + } + + #[Test] + public function it_throws_on_invalid_enum_value(): void + { + $table = $this->createTableNodeFromRow(['status' => 'invalid_status']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'enum entity', + 'table' => $table, + ]); + + $this->expectException(InvalidObjectParameter::class); + $this->expectExceptionMessage('Invalid enum value given "invalid_status", at column "status"'); + + $this->filter->filterCall($call); + } + + #[Test] + public function it_normalizes_object_by_type_inference(): void + { + $referencedEntity = new TestEntity(id: 1, name: 'Referenced'); + $this->objectRegistry->store($referencedEntity, 'the-relation'); + + $table = $this->createTableNodeFromRow(['relation' => 'the-relation']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'relation entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame($referencedEntity, $rows[0]['relation']); + } + + #[Test] + public function it_handles_inherited_property_from_parent_class(): void + { + $table = $this->createTableNodeFromRow(['inheritedProperty' => 'value']); + $call = $this->createDefinitionCallForFoundryContext([ + 'factoryShortName' => 'child entity', + 'table' => $table, + ]); + + $result = $this->filter->filterCall($call); + self::assertInstanceOf(DefinitionCall::class, $result); + + $normalizedTable = $result->getArguments()['table']; + $rows = $normalizedTable->getColumnsHash(); + + self::assertSame('value', $rows[0]['inheritedProperty']); + } + + protected function setUp(): void + { + $this->factoryResolver = new FactoryShortNameResolver([ + new TestEntityFactory(), + new DatedEntityFactory(), + new EnumEntityFactory(), + new RelationEntityFactory(), + new ChildEntityFactory(), + ]); + $this->objectRegistry = new ObjectRegistry( + $this->factoryResolver, $this->createStub(PersistenceManager::class) + ); + $this->objectRegistry->reset(); + + $this->filter = $this->createFilterWithMockedKernel(); + } + + private function createFilterWithMockedKernel(): FoundryCallFilter + { + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + '.zenstruck_foundry.behat.factory_resolver' => $this->factoryResolver, + '.zenstruck_foundry.behat.object_registry' => $this->objectRegistry, + default => throw new \InvalidArgumentException("Unknown service: $id"), + }); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('getContainer')->willReturn($container); + + return new FoundryCallFilter($kernel); + } + + /** + * @param array $arguments + */ + private function createDefinitionCallForFoundryContext(array $arguments): DefinitionCall + { + $reflection = new \ReflectionMethod(FoundryContext::class, 'createObjectWithProperties'); + + $definition = $this->createStub(Definition::class); + $definition->method('getReflection')->willReturn($reflection); + + $environment = $this->createStub(Environment::class); + $feature = $this->createStub(FeatureNode::class); + $step = $this->createStub(StepNode::class); + + return new DefinitionCall( + $environment, $feature, $step, $definition, $arguments + ); + } +} + +class TestEntity +{ + public function __construct( + public int $id, public string $name, + ) { + } +} + +class DatedEntity +{ + public function __construct( + public int $id, public \DateTimeImmutable $createdAt, + ) { + } +} + +class EnumEntity +{ + public function __construct( + public int $id, public TestStatus $status, public TestPriority $priority, + ) { + } +} + +class RelationEntity +{ + public function __construct( + public int $id, public TestEntity $relation, + ) { + } +} + +abstract class ParentEntity +{ + public string $inheritedProperty = ''; +} + +class ChildEntity extends ParentEntity +{ + public function __construct( + public int $id, + ) { + } +} + +enum TestStatus: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +enum TestPriority: int +{ + case LOW = 1; + case MEDIUM = 2; + case HIGH = 3; +} + +/** @extends ObjectFactory */ +#[FactoryShortName('test entity')] +final class TestEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return TestEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'name' => 'Test']; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('dated entity')] +final class DatedEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return DatedEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'createdAt' => new \DateTimeImmutable()]; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('enum entity')] +final class EnumEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return EnumEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'status' => TestStatus::ACTIVE, 'priority' => TestPriority::LOW]; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('relation entity')] +final class RelationEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return RelationEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'relation' => new TestEntity(id: 0, name: 'default')]; + } +} + +/** @extends ObjectFactory */ +#[FactoryShortName('child entity')] +final class ChildEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return ChildEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'inheritedProperty' => 'default']; + } +} diff --git a/tests/Unit/Test/Behat/FoundryTableNodeTest.php b/tests/Unit/Test/Behat/FoundryTableNodeTest.php new file mode 100644 index 000000000..50caff8b8 --- /dev/null +++ b/tests/Unit/Test/Behat/FoundryTableNodeTest.php @@ -0,0 +1,247 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Unit\Test\Behat; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Zenstruck\Foundry\Attribute\FactoryShortName; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\FoundryTableNode; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class FoundryTableNodeTest extends TestCase +{ + private FactoryShortNameResolver $factoryResolver; + private ObjectRegistry $objectRegistry; + + #[Test] + public function it_creates_table_node_with_factory_method(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 10, 1 => 10], + [ + 0 => ['name', 'value'], + 1 => ['foo', 'bar'], + ] + ); + + self::assertSame([['name' => 'foo', 'value' => 'bar']], $table->getColumnsHash()); + } + + #[Test] + #[DataProvider('valueFormattingProvider')] + public function it_formats_scalar_values_as_string(mixed $value, string $expected): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['col'], + 1 => [$value], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString($expected, $row); + } + + public static function valueFormattingProvider(): iterable + { + yield 'string value' => ['hello', 'hello']; + yield 'integer value' => [42, '42']; + yield 'float value' => [3.14, '3.14']; + yield 'boolean true' => [true, '1']; + yield 'boolean false' => [false, '']; + yield 'null value' => [null, '']; + } + + #[Test] + public function it_formats_datetime_value(): void + { + $date = new \DateTimeImmutable('2024-06-15 14:30:45'); + + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 30], + [ + 0 => ['date'], + 1 => [$date], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString('2024-06-15 14:30:45', $row); + } + + #[Test] + public function it_formats_backed_enum_value(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['status'], + 1 => [TableTestStatus::ACTIVE], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString('active', $row); + } + + #[Test] + public function it_formats_int_backed_enum_value(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['priority'], + 1 => [TableTestPriority::HIGH], + ] + ); + + $row = $table->getRowAsString(1); + + self::assertStringContainsString('3', $row); + } + + #[Test] + public function it_formats_stored_object_with_factory_name_and_registry_name(): void + { + $entity = new TableTestEntity(id: 1, name: 'Test'); + $this->objectRegistry->store($entity, 'my-entity'); + + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 30], + [ + 0 => ['entity'], + 1 => [$entity], + ] + ); + + $row = $table->getRowAsString(1); + + // Should contain the short name and the registered name + self::assertStringContainsString('table test entity', $row); + self::assertStringContainsString('my-entity', $row); + } + + #[Test] + public function it_throws_for_unsupported_object_type(): void + { + $unsupportedObject = new \stdClass(); + + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 20], + [ + 0 => ['obj'], + 1 => [$unsupportedObject], + ] + ); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Unsupported value type: stdClass'); + + $table->getRowAsString(1); + } + + #[Test] + public function it_formats_row_with_wrapped_values(): void + { + $table = FoundryTableNode::create( + $this->factoryResolver, + $this->objectRegistry, + [0 => 10, 1 => 10], + [ + 0 => ['a', 'b'], + 1 => ['foo', 'bar'], + ] + ); + + $row = $table->getRowAsStringWithWrappedValues(1, static fn(string $value, int $column) => "[{$column}:{$value}]"); + + self::assertStringContainsString('[0:', $row); + self::assertStringContainsString('[1:', $row); + } + + protected function setUp(): void + { + $this->factoryResolver = new FactoryShortNameResolver([new TableTestEntityFactory()]); + + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturnCallback( + static fn(object $object): array => ['id' => $object->id ?? 0] + ); + + $this->objectRegistry = new ObjectRegistry($this->factoryResolver, $persistenceManager); + $this->objectRegistry->reset(); + } +} + +class TableTestEntity +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} + +enum TableTestStatus: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +enum TableTestPriority: int +{ + case LOW = 1; + case MEDIUM = 2; + case HIGH = 3; +} + +/** @extends ObjectFactory */ +#[FactoryShortName('table test entity')] +final class TableTestEntityFactory extends ObjectFactory +{ + public static function class(): string + { + return TableTestEntity::class; + } + + protected function defaults(): array + { + return ['id' => 1, 'name' => 'Test']; + } +} diff --git a/tests/Unit/Test/Behat/ObjectRegistryTest.php b/tests/Unit/Test/Behat/ObjectRegistryTest.php new file mode 100644 index 000000000..5befe9ff8 --- /dev/null +++ b/tests/Unit/Test/Behat/ObjectRegistryTest.php @@ -0,0 +1,331 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Unit\Test\Behat; + +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\Event\AfterPersist; +use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; +use Zenstruck\Foundry\Test\Behat\Exception\ObjectNotFound; +use Zenstruck\Foundry\Test\Behat\ObjectRegistry; + +/** @requires PHP 9 */ +#[RequiresPhp('9')] +final class ObjectRegistryTest extends TestCase +{ + private ObjectRegistry $registry; + private FactoryShortNameResolver $resolver; + private PersistenceManager $persistenceManager; + + #[Test] + public function it_stores_an_object(): void + { + $user = new User(id: 1, name: 'John'); + + $this->registry->store($user, 'john'); + + self::assertTrue($this->registry->has(User::class, 'john')); + } + + #[Test] + public function it_throws_when_storing_duplicate_object_name(): void + { + $user1 = new User(id: 1, name: 'John'); + $user2 = new User(id: 2, name: 'Jane'); + + $this->registry->store($user1, 'john'); + + $this->expectException(ObjectAlreadyRegistered::class); + $this->expectExceptionMessage('Object "john" is already registered for class "Zenstruck\Foundry\Tests\Unit\Test\Behat\User".'); + + $this->registry->store($user2, 'john'); + } + + #[Test] + public function it_allows_same_name_for_different_classes(): void + { + $user = new User(id: 1, name: 'John'); + $post = new Post(id: 1, title: 'John'); + + $this->registry->store($user, 'john'); + $this->registry->store($post, 'john'); + + self::assertTrue($this->registry->has(User::class, 'john')); + self::assertTrue($this->registry->has(Post::class, 'john')); + } + + #[Test] + public function it_checks_if_object_exists(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john'); + + self::assertTrue($this->registry->has(User::class, 'john')); + self::assertFalse($this->registry->has(User::class, 'jane')); + self::assertFalse($this->registry->has(Post::class, 'john')); + } + + #[Test] + public function it_gets_stored_object(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john'); + + $retrieved = $this->registry->getByObjectClass(User::class, 'john'); + + self::assertSame($user, $retrieved); + } + + #[Test] + public function it_throws_when_getting_non_existent_object(): void + { + $this->expectException(ObjectNotFound::class); + $this->expectExceptionMessage('Object of class "Zenstruck\Foundry\Tests\Unit\Test\Behat\User" with name "john" was not found.'); + + $this->registry->getByObjectClass(User::class, 'john'); + } + + #[Test] + public function it_resets_all_stored_objects(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john'); + + $this->registry->reset(); + + self::assertFalse($this->registry->has(User::class, 'john')); + } + + #[Test] + public function it_stores_last_id_from_after_persist_event(): void + { + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $this->registry->storeLastId($event); + + self::assertSame(42, $this->registry->lastId()); + } + + #[Test] + public function it_stores_string_id_from_after_persist_event(): void + { + $user = new User(id: 'uuid-123', name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $this->registry->storeLastId($event); + + self::assertSame('uuid-123', $this->registry->lastId()); + } + + #[Test] + public function it_throws_when_no_last_id_available(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No last id found.'); + + $this->registry->lastId(); + } + + #[Test] + public function it_resets_last_id(): void + { + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + $this->registry->storeLastId($event); + + $this->registry->reset(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No last id found.'); + + $this->registry->lastId(); + } + + #[Test] + public function it_gets_last_id_for_specific_factory(): void + { + $user1 = new User(id: 1, name: 'John'); + $user2 = new User(id: 2, name: 'Jane'); + + $this->registry->store($user1, 'john'); + $this->registry->store($user2, 'jane'); + + self::assertSame(2, $this->registry->lastIdFor('user')); + } + + #[Test] + public function it_stores_object_from_state_added_event(): void + { + $user = new User(id: 1, name: 'John'); + $event = new StateAddedToStory($user, 'john'); + + $this->registry->storeAfterStateAddedToStory($event); + + self::assertTrue($this->registry->has(User::class, 'john')); + self::assertSame($user, $this->registry->getByObjectClass(User::class, 'john')); + } + + #[Test] + public function it_throws_when_storing_duplicate_from_story_event(): void + { + $user1 = new User(id: 1, name: 'John'); + $user2 = new User(id: 2, name: 'Jane'); + + $event1 = new StateAddedToStory($user1, 'duplicate'); + $event2 = new StateAddedToStory($user2, 'duplicate'); + + $this->registry->storeAfterStateAddedToStory($event1); + + $this->expectException(ObjectAlreadyRegistered::class); + $this->expectExceptionMessage('Object "duplicate" is already registered for class "Zenstruck\Foundry\Tests\Unit\Test\Behat\User".'); + + $this->registry->storeAfterStateAddedToStory($event2); + } + + #[Test] + public function it_throws_when_no_objects_for_factory(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No object of type "user" found.'); + + $this->registry->lastIdFor('user'); + } + + #[Test] + public function it_throws_when_entity_has_multiple_identifiers(): void + { + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturn(['id1' => 1, 'id2' => 2]); + + $registry = new ObjectRegistry($this->resolver, $persistenceManager); + + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $registry->storeLastId($event); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot get last id: generic entity must have exactly one identifier.'); + + $registry->lastId(); + } + + #[Test] + public function it_throws_when_id_type_is_invalid(): void + { + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturn(['id' => ['invalid']]); + + $registry = new ObjectRegistry($this->resolver, $persistenceManager); + + $user = new User(id: 42, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $registry->storeLastId($event); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Wrong type for the id: expected int or string, got "array".'); + + $registry->lastId(); + } + + #[Test] + public function it_checks_if_object_is_stored(): void + { + $user = new User(id: 1, name: 'John'); + $otherUser = new User(id: 2, name: 'Jane'); + + $this->registry->store($user, 'john'); + + self::assertTrue($this->registry->isStored($user)); + self::assertFalse($this->registry->isStored($otherUser)); + } + + #[Test] + public function it_gets_name_for_stored_object(): void + { + $user = new User(id: 1, name: 'John'); + $this->registry->store($user, 'john-doe'); + + self::assertSame('john-doe', $this->registry->getNameFor($user)); + } + + #[Test] + public function it_throws_when_getting_name_for_unstored_object(): void + { + $user = new User(id: 1, name: 'John'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Object is not stored in the registry.'); + + $this->registry->getNameFor($user); + } + + protected function setUp(): void + { + $this->resolver = new FactoryShortNameResolver([new UserFactory()]); + $this->persistenceManager = $this->createStub(PersistenceManager::class); + $this->persistenceManager->method('getIdentifierValues')->willReturnCallback( + static function (object $object): array { + assert($object instanceof User); + + return ['id' => $object->id]; + } + ); + $this->registry = new ObjectRegistry($this->resolver, $this->persistenceManager); + $this->registry->reset(); + } +} + +final class User +{ + public function __construct( + public int|string $id, + public string $name, + ) { + } +} + +final class Post +{ + public function __construct( + public int $id, + public string $title, + ) { + } +} + +/** @extends ObjectFactory */ +final class UserFactory extends ObjectFactory +{ + public static function class(): string + { + return User::class; + } + + protected function defaults(): array + { + return [ + 'id' => 1, + 'name' => 'John Doe', + ]; + } +} diff --git a/tests/behatFeatures/main/create-objects.feature b/tests/behatFeatures/main/create-objects.feature new file mode 100644 index 000000000..3646db232 --- /dev/null +++ b/tests/behatFeatures/main/create-objects.feature @@ -0,0 +1,189 @@ +Feature: Test objects creation + + Scenario: Can create entity with properties via PyTable + Given a contact A is created with properties + | name | + | John Doe | + Then 1 contact should exist + Then contact A should have properties + | name | + | John Doe | + + Scenario: Can create one named entity with two lines in the PyTable (!) + Given a contact A is created with properties + | name | + | John Doe | + | Jane Doe | + Then an "InvalidArgumentException" exception should be thrown containing message "Expected exactly one line of properties, to create one object" + + Scenario: Can create entity with properties via PyTable (!) + Given a "i don't exist" is created + Then an "FactoryNotResolvable" exception should be thrown containing message "Cannot resolve factory for name \"i don't exist\"" + + Scenario: Multiple objects created with the same reference is handled (!) + Given a contact A is created + And a contact A is created + Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" + + Scenario: Reference to a non existent objet handled (!) + Then contact "I don't exist" should have properties + | foo | + | bar | + Then an "ObjectNotFound" exception should be thrown containing message "Object \"contact I don't exist\" was not found" + + Scenario: Invalid property name handled (!) + Given a contact A is created with properties + | foo | + | bar | + Then an "InvalidArgumentException" exception should be thrown containing message "Cannot set attribute \"foo\" for object" + + Scenario: Can create multiple entities via PyTable + Then 0 contacts should exist + Given contacts are created with properties + | _ref | name | + | A | John Doe | + | B | Jane Doe | + Then 2 contacts should exist + Then contact A should have properties + | name | + | John Doe | + Then contact B should have properties + | name | + | Jane Doe | + + Scenario: Multiple objects created within a table with the same reference is handled (!) + Given contacts are created with properties + | _ref | name | + | A | John Doe | + | A | Jane Doe | + Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" + + Scenario: Can reference another object + Given a category MyCategory is created + And an address "the address" is created + And a contact A is created with properties + | name | category | address | + | John Doe | | | + When I am on "/" + Then contact A should have properties + | name | category | address | + | John Doe | | | + Then 1 contact should exist + Then 1 category should exist + Then 1 address should exist + + Scenario: Can reference another object with short syntax + Given a category MyCategory is created + And an address "the address" is created + And a contact A is created with properties + | name | category | address | + | John Doe | MyCategory | the address | + When I am on "/" + Then contact A should have properties + | name | category | address | + | John Doe | MyCategory | the address | + + Scenario: Can reference object with date + Given a "generic entity" "GE" is created with properties + | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | + | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | + When I am on "/" + Then "generic entity" "GE" should have properties + | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | + | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | + + Scenario: Wrong assertion on string correctly handled (!) + Given a "generic entity" "GE" is created with properties + | prop1 | + | foo | + Then "generic entity" "GE" should have properties + | prop1 | + | bar | + Then an "AssertionFailedError" exception should be thrown matching pattern "/foo(.*)bar/" + + Scenario: Wrong assertion on string correctly handled (!) + Given a "generic entity" "GE" is created with properties + | prop1 | propInteger | + | foo | 1 | + Then "generic entity" "GE" should have properties + | propInteger | + | 42 | + Then an "AssertionFailedError" exception should be thrown matching pattern "/1(.*)42/" + + Scenario: Wrong assertion on date correctly handled (!) + Given a "generic entity" "GE" is created with properties + | prop1 | date | + | foo | 2026-01-01 | + Then "generic entity" "GE" should have properties + | date | + | 2026-01-02 | + Then an "AssertionFailedError" exception should be thrown matching pattern "/2026/" + + Scenario: Wrong assertion on bool correctly handled (!) + Given a "generic entity" "GE" is created with properties + | prop1 | bool | + | foo | true | + Then "generic entity" "GE" should have properties + | bool | + | false | + Then an "AssertionFailedError" exception should be thrown matching pattern "/true(.*)false/" + + Scenario: Wrong assertion on bool correctly handled (!) + Given a "generic entity" "GE" is created with properties + | prop1 | bool | + | foo | false | + Then "generic entity" "GE" should have properties + | bool | + | true | + Then an "AssertionFailedError" exception should be thrown matching pattern "/false(.*)true/" + + Scenario: Wrong assertion on enum correctly handled (!) + Given a "generic entity" "GE" is created with properties + | prop1 | stringEnum | + | foo | some_value | + Then "generic entity" "GE" should have properties + | stringEnum | + | other_value | + Then an "AssertionFailedError" exception should be thrown matching pattern "/StringBackedEnum/" + + Scenario: Can compare null + Given a "generic entity" "GE" is created with properties + | prop1 | bool | + | foo | null | + When I am on "/" + Then "generic entity" "GE" should have properties + | prop1 | bool | + | foo | null | + + Scenario: Wrong assertion with null works (!) + Given a "generic entity" "GE" is created with properties + | prop1 | bool | + | foo | null | + When I am on "/" + Then "generic entity" "GE" should have properties + | prop1 | bool | + | foo | "null" | + Then an "AssertionFailedError" exception should be thrown matching pattern "/null(.*)null/" + + Scenario: Wrong assertion with null works in the other way (!) + Given a "generic entity" "GE" is created with properties + | prop1 | date | + | foo | 2026-01-01 | + When I am on "/" + Then "generic entity" "GE" should have properties + | prop1 | date | + | foo | null | + Then an "AssertionFailedError" exception should be thrown matching pattern "/DateTimeImmutable(.*)null/" + + Scenario: Can use a factory with disambiguated name + Given a "tag2" is created + Then 1 tag2 should exist + + Scenario: Can use a factory with changed name & plural + Given a "child of contact" is created + And a "child of contact" is created + Then 2 "children of contact" should exist + + Scenario: Cannot use a factory with ambiguous name (!) + Given a "tag" is created + Then an "FactoryNotResolvable" exception should be thrown containing message "Multiple factories found for name \"tag\"" diff --git a/tests/behatFeatures/main/no-reset-db.feature b/tests/behatFeatures/main/no-reset-db.feature new file mode 100644 index 000000000..489b84a8c --- /dev/null +++ b/tests/behatFeatures/main/no-reset-db.feature @@ -0,0 +1,15 @@ +@skip-with-native-dama +Feature: Skip database reset with @noResetDB tag + + Scenario: First scenario creates data + Given a contact is created + Then 1 contact should exist + + @noResetDB + Scenario: Data persists with @noResetDB tag + Then 1 contact should exist + Given a contact is created + Then 2 contacts should exist + + Scenario: Normal reset resumes after @noResetDB + Then 0 contacts should exist diff --git a/tests/behatFeatures/main/persist-entities.feature b/tests/behatFeatures/main/persist-entities.feature new file mode 100644 index 000000000..952ee5e40 --- /dev/null +++ b/tests/behatFeatures/main/persist-entities.feature @@ -0,0 +1,65 @@ +Feature: Test persisting entities + + Scenario: View homepage + When I am on "/" + Then the response status code should be 200 + Then I should see "Hello World" + + Scenario: Can persist entities + # Can name entities + Given a contact A is created + # Can create unnamed entities + And a contact is created + When I am on "/" + Then the response status code should be 200 + Then I should see "Hello World" + Then 2 contacts should exist + + Scenario: Can visit pages twice and still access to EM + Given a contact is created + When I am on "/" + Then I should see "Hello World" + When I am on "/" + Then I should see "Hello World" + Then 1 contact should exist + + Scenario Outline: Persist entity + Given a contact is created + When I am on "/" + Then the response status code should be 200 + Then I should see "" + Then 1 contact should exist + + Examples: + | data | + | Hello | + | World | + + Scenario: Can access last created entity ID + Given a "generic entity" "the object" is created with properties + | prop1 | + | foo | + When I am on "/orm/update//bar" + Then the response status code should be 200 + Then "generic entity" "the object" should have properties + | prop1 | + | bar | + + Scenario: Throws if last id is not found (!) + When I am on "/orm/update//bar" + Then an "RuntimeException" exception should be thrown containing message "No last id found" + + Scenario: Can access last created entity ID + Given a "generic entity" "the object" is created with properties + | prop1 | + | foo | + And a contact is created + When I am on "/orm/update//bar" + Then the response status code should be 200 + Then "generic entity" "the object" should have properties + | prop1 | + | bar | + + Scenario: Throws if last id is not found (!) + When I am on "/orm/update//bar" + Then an "InvalidArgumentException" exception should be thrown containing message "No object of type \"generic entity\" found" diff --git a/tests/behatFeatures/main/with-fixture-on-feature.feature b/tests/behatFeatures/main/with-fixture-on-feature.feature new file mode 100644 index 000000000..8a4ff3f52 --- /dev/null +++ b/tests/behatFeatures/main/with-fixture-on-feature.feature @@ -0,0 +1,9 @@ +@withFixture(behat-contacts) +Feature: Test @withFixture tag + + Scenario: Load behat-contacts fixture with @withFixture tag + Given a contact "jane-doe" is created + Then 2 contacts should exist + + Scenario: Ensure DB is fresh + Then 1 contact should exist diff --git a/tests/behatFeatures/main/with-fixture.feature b/tests/behatFeatures/main/with-fixture.feature new file mode 100644 index 000000000..beb59aac9 --- /dev/null +++ b/tests/behatFeatures/main/with-fixture.feature @@ -0,0 +1,47 @@ +Feature: Test @withFixture tag + + @withFixture(behat-contacts) + Scenario: Load behat-contacts fixture with @withFixture tag + Then 1 contact should exist + + Scenario: Ensure DB is fresh + Then 0 contact should exist + + @withFixture(behat-contacts) + Scenario Outline: Works with scenario outline + When I am on "/" + Then the response status code should be 200 + Then I should see "" + Then 1 contact should exist + + Examples: + | data | + | Hello | + | World | + + @withFixture(behat-contacts) + Scenario: Can access entities from fixture + Then 1 contact should exist + Then contact "john-doe" should have properties + | name | + | John Doe | + + @withFixture(behat-category) + Scenario: Can use entities from fixture in another entity + Given a contact "jane-doe" is created with properties + | name | category | + | Jane Doe | category fixture | + Then 1 contact should exist + Then contact "jane-doe" should have properties + | name | category | + | Jane Doe | category fixture | + + @withFixture(behat-category) @withFixture(behat-contacts) + Scenario: Can load multiple fixtures + Then 1 contact should exist + Then 2 categories should exist + + @withFixture(behat-stories) + Scenario: Can load grouped fixtures + Then 1 contact should exist + Then 2 categories should exist diff --git a/tests/behatFeatures/reset-disabled/manual-isolation-1.feature b/tests/behatFeatures/reset-disabled/manual-isolation-1.feature new file mode 100644 index 000000000..c6bc99cd0 --- /dev/null +++ b/tests/behatFeatures/reset-disabled/manual-isolation-1.feature @@ -0,0 +1,16 @@ +Feature: No database isolation (disabled mode) - Part 1 + + Scenario: First scenario creates data + Given a contact A is created + Then 1 contact should exist + + Scenario: Second scenario sees previous data (no reset) + Then 1 contact should exist + Then contact object named A should exist + Given a contact B is created + Then 2 contacts should exist + + Scenario: Third scenario sees all accumulated data + Then 2 contacts should exist + Then contact object named A should exist + Then contact object named B should exist diff --git a/tests/behatFeatures/reset-disabled/manual-isolation-2.feature b/tests/behatFeatures/reset-disabled/manual-isolation-2.feature new file mode 100644 index 000000000..aee69bf87 --- /dev/null +++ b/tests/behatFeatures/reset-disabled/manual-isolation-2.feature @@ -0,0 +1,13 @@ +# resetDB should have no effect +@resetDB +Feature: No database isolation (disabled mode) - Part 2 + + Scenario: Contacts still exist from previous feature + Then 2 contacts should exist + + # ObjectRegistry is reset between features even when isolation is disabled + Then contact object named A should not exist + + @resetDB + Scenario: DB should be reset + Then 2 contacts should exist diff --git a/tests/behatFeatures/reset-feature/isolation.feature b/tests/behatFeatures/reset-feature/isolation.feature new file mode 100644 index 000000000..a06ffff6a --- /dev/null +++ b/tests/behatFeatures/reset-feature/isolation.feature @@ -0,0 +1,24 @@ +Feature: Database isolation per feature - Part 1 + + Scenario: First scenario creates data + Given a contact A is created with properties + | name | + | John Doe | + Then 1 contact should exist + + Scenario: Second scenario sees first scenario's data (no reset within feature) + Then 1 contact should exist + + Scenario: Third scenario also sees accumulated data + Given a contact B is created with properties + | name | + | Jane Doe | + Then 2 contacts should exist + + Scenario: Fourth scenario can access objects created in previous scenarios via ObjectRegistry + Then contact A should have properties + | name | + | John Doe | + And contact B should have properties + | name | + | Jane Doe | diff --git a/tests/behatFeatures/reset-feature/isolation2.feature b/tests/behatFeatures/reset-feature/isolation2.feature new file mode 100644 index 000000000..6502ca967 --- /dev/null +++ b/tests/behatFeatures/reset-feature/isolation2.feature @@ -0,0 +1,18 @@ +Feature: Database isolation per feature - Part 2 + + Scenario: First scenario of new feature should have empty database (reset between features) + Then 0 contacts should exist + + Scenario: Second scenario in new feature sees first scenario's data + Given a "contact" "C" is created with properties + | name | + | Alice Doe | + Then 1 contact should exist + + Scenario: Third scenario confirms data persists within feature + Then 1 contact should exist + + Scenario: Could access data created in previous scenario + Then "contact" "C" should have properties + | name | + | Alice Doe | diff --git a/tests/behatFeatures/reset-feature/reset-db.feature b/tests/behatFeatures/reset-feature/reset-db.feature new file mode 100644 index 000000000..f9ebc5c64 --- /dev/null +++ b/tests/behatFeatures/reset-feature/reset-db.feature @@ -0,0 +1,22 @@ +Feature: Manual database reset with @resetDB tag + + Scenario: Ensure fresh DB + Then 0 contact should exist + + Scenario: Create one contact + Given a contact A is created + Then 1 contact should exist + + Scenario: Ensure contact still exists + Then 1 contact should exist + Then contact object named A should exist + + @resetDB + Scenario: Database is reset with @resetDB tag + Then 0 contacts should exist + Then contact object named A should not exist + Given a contact is created + Then 1 contact should exist + + Scenario: Data from tagged scenario persists + Then 1 contact should exist diff --git a/tests/behatFeatures/reset-feature/with-fixture-on-feature.feature b/tests/behatFeatures/reset-feature/with-fixture-on-feature.feature new file mode 100644 index 000000000..f6c018e53 --- /dev/null +++ b/tests/behatFeatures/reset-feature/with-fixture-on-feature.feature @@ -0,0 +1,17 @@ +@withFixture(behat-contacts) +Feature: "@withFixture" on feature + + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given a contact is created + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB and reload fixture + Then 1 contact should exist + diff --git a/tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature b/tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature new file mode 100644 index 000000000..52c6ef49c --- /dev/null +++ b/tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature @@ -0,0 +1,20 @@ +Feature: "@withFixture" on scenario + + Scenario: Ensure DB is fresh + Then 0 contacts should exist + + @withFixture(behat-contacts) + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given a contact is created + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB + Then 0 contacts should exist + diff --git a/tests/behatFeatures/reset-manual/manual-isolation-1.feature b/tests/behatFeatures/reset-manual/manual-isolation-1.feature new file mode 100644 index 000000000..0422f95d9 --- /dev/null +++ b/tests/behatFeatures/reset-manual/manual-isolation-1.feature @@ -0,0 +1,17 @@ +@resetDB +Feature: Manual database isolation (disabled mode) - Part 1 + + Scenario: First scenario creates data + Given a contact A is created + Then 1 contact should exist + + Scenario: Second scenario sees previous data (no reset) + Then 1 contact should exist + Then contact object named A should exist + Given a contact B is created + Then 2 contacts should exist + + Scenario: Third scenario sees all accumulated data + Then 2 contacts should exist + Then contact object named A should exist + Then contact object named B should exist diff --git a/tests/behatFeatures/reset-manual/manual-isolation-2.feature b/tests/behatFeatures/reset-manual/manual-isolation-2.feature new file mode 100644 index 000000000..5c7458879 --- /dev/null +++ b/tests/behatFeatures/reset-manual/manual-isolation-2.feature @@ -0,0 +1,15 @@ +Feature: Manual database isolation (disabled mode) - Part 2 + + Scenario: Contacts still exist from previous feature + Then 2 contacts should exist + + # ObjectRegistry is reset between features even when isolation is disabled + Then contact object named A should not exist + + @resetDB + Scenario: DB should be reset + Then 0 contacts should exist + + Scenario: Create another contact + Given a contact is created + Then 1 contact should exist diff --git a/tests/behatFeatures/reset-manual/manual-isolation-3.feature b/tests/behatFeatures/reset-manual/manual-isolation-3.feature new file mode 100644 index 000000000..fc63dead0 --- /dev/null +++ b/tests/behatFeatures/reset-manual/manual-isolation-3.feature @@ -0,0 +1,12 @@ +@resetDB +Feature: Manual database isolation (disabled mode) - Part 3 + + Scenario: Ensure DB is fresh + Then 0 contacts should exist + + Scenario: Create a contact + Given a contact is created + Then 1 contacts should exist + + Scenario: Ensure contact still exists + Then 1 contacts should exist diff --git a/tests/behatFeatures/reset-manual/with-fixture-on-feature.feature b/tests/behatFeatures/reset-manual/with-fixture-on-feature.feature new file mode 100644 index 000000000..991861237 --- /dev/null +++ b/tests/behatFeatures/reset-manual/with-fixture-on-feature.feature @@ -0,0 +1,18 @@ +@resetDB +@withFixture(behat-contacts) +Feature: "@withFixture" on feature + + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given a contact is created + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB and reload fixture + Then 1 contact should exist + diff --git a/tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature b/tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature new file mode 100644 index 000000000..01e968de1 --- /dev/null +++ b/tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature @@ -0,0 +1,23 @@ +@resetDB +Feature: "@withFixture" on scenario + + Scenario: Ensure DB is fresh + Then 0 contacts should exist + + @withFixture(behat-contacts) + Scenario: Ensure fixture is loaded + Then 1 contact should exist + + Scenario: Ensure fixture is loaded once + Then 1 contact should exist + + Scenario: Can add new data + Given a contact is created + Then 2 contacts should exist + + @resetDB + Scenario: Reset DB should clear DB + Then 0 contacts should exist + Given a contact is created + Then 1 contact should exist + From 673897f581baf9433eb8f8ffaa3145313c94edab Mon Sep 17 00:00:00 2001 From: nikophil <10139766+nikophil@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:41:06 +0000 Subject: [PATCH 02/10] bot: fix cs [skip ci] --- config/behat.php | 2 +- src/Story/FixtureStoryResolver.php | 9 +- .../DamaNativeExtensionIncompatibility.php | 9 ++ .../Behat/Exception/FactoryNotResolvable.php | 2 +- .../Exception/InvalidObjectParameter.php | 6 +- .../Behat/Exception/InvalidResetDbTag.php | 17 +++- src/Test/Behat/Exception/ObjectNotFound.php | 4 +- src/Test/Behat/FactoryShortNameResolver.php | 9 +- src/Test/Behat/FoundryCallFilter.php | 55 +++++++----- src/Test/Behat/FoundryContext.php | 87 ++++++++++--------- src/Test/Behat/FoundryExtension.php | 10 +-- src/Test/Behat/FoundryTableNode.php | 44 ++++++---- .../Listener/BootConfigurationListener.php | 9 ++ .../Behat/Listener/DatabaseResetListener.php | 38 ++++---- .../Behat/Listener/LoadFixturesListener.php | 11 ++- src/Test/Behat/ObjectRegistry.php | 40 ++++----- src/ZenstruckFoundryBundle.php | 2 +- .../App/Controller/HelloWorldController.php | 9 ++ tests/Fixture/Behat/BehatTestKernel.php | 11 ++- .../Behat/ResetDisabledTestContext.php | 9 ++ tests/Fixture/Behat/TestFoundryContext.php | 9 ++ tests/Fixture/Model/GenericModel.php | 24 ++--- .../BootConfigurationListenerTest.php | 10 +-- .../Listener/DatabaseResetListenerTest.php | 30 +++---- .../Listener/LoadFixturesListenerTest.php | 12 +-- .../Command/LoadFixturesCommandTest.php | 1 + tests/Unit/Test/Behat/FactoryResolverTest.php | 1 - .../Unit/Test/Behat/FoundryCallFilterTest.php | 52 +++++------ .../Unit/Test/Behat/FoundryTableNodeTest.php | 26 +++--- tests/Unit/Test/Behat/ObjectRegistryTest.php | 32 +++---- 30 files changed, 334 insertions(+), 246 deletions(-) diff --git a/config/behat.php b/config/behat.php index 384f481fc..304046ac1 100644 --- a/config/behat.php +++ b/config/behat.php @@ -17,7 +17,7 @@ use Zenstruck\Foundry\Test\Behat\FoundryContext; use Zenstruck\Foundry\Test\Behat\ObjectRegistry; -return static function (ContainerConfigurator $container): void { +return static function(ContainerConfigurator $container): void { $container->services() ->set('.zenstruck_foundry.behat.factory_resolver', FactoryShortNameResolver::class) ->args([ diff --git a/src/Story/FixtureStoryResolver.php b/src/Story/FixtureStoryResolver.php index 8eec5b32e..510868253 100644 --- a/src/Story/FixtureStoryResolver.php +++ b/src/Story/FixtureStoryResolver.php @@ -43,15 +43,12 @@ public function resolve(string $fixtureOrGroupName): array return $this->resolveGroup($fixtureOrGroupName); } - throw FixtureStoryNotFound::forNameOrGroup( - $fixtureOrGroupName, - [...$this->availableFixtureNames(), ...$this->availableGroupNames()] - ); + throw FixtureStoryNotFound::forNameOrGroup($fixtureOrGroupName, [...$this->availableFixtureNames(), ...$this->availableGroupNames()]); } public function hasAnyFixtures(): bool { - return count($this->fixtureStories) > 0; + return \count($this->fixtureStories) > 0; } public function hasFixture(string $name): bool @@ -61,7 +58,7 @@ public function hasFixture(string $name): bool public function hasOnlyOneFixture(): bool { - return count($this->fixtureStories) === 1; + return 1 === \count($this->fixtureStories); } /** diff --git a/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php b/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php index da7317ebd..8d00182b9 100644 --- a/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php +++ b/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat\Exception; final class DamaNativeExtensionIncompatibility extends \LogicException diff --git a/src/Test/Behat/Exception/FactoryNotResolvable.php b/src/Test/Behat/Exception/FactoryNotResolvable.php index 5d0625ddf..9f3a440cc 100644 --- a/src/Test/Behat/Exception/FactoryNotResolvable.php +++ b/src/Test/Behat/Exception/FactoryNotResolvable.php @@ -21,7 +21,7 @@ final class FactoryNotResolvable extends \RuntimeException { public static function forName(string $name): self { - return new self("Cannot resolve factory for name \"$name\": short name does not exist"); + return new self("Cannot resolve factory for name \"{$name}\": short name does not exist"); } /** diff --git a/src/Test/Behat/Exception/InvalidObjectParameter.php b/src/Test/Behat/Exception/InvalidObjectParameter.php index 4c1b01e32..0a047d72e 100644 --- a/src/Test/Behat/Exception/InvalidObjectParameter.php +++ b/src/Test/Behat/Exception/InvalidObjectParameter.php @@ -21,16 +21,16 @@ final class InvalidObjectParameter extends \RuntimeException { public static function objectReferencedInTableDoesNotExist(string $column, ObjectNotFound $previous): self { - return new self("A reference to an object cannot be resolved in the table, at column \"$column\": {$previous->getMessage()}", previous: $previous); + return new self("A reference to an object cannot be resolved in the table, at column \"{$column}\": {$previous->getMessage()}", previous: $previous); } public static function invalidDate(string $column, string $invalidDate, \Throwable $previous): self { - return new self("Invalid date given \"$invalidDate\", at column \"$column\"", previous: $previous); + return new self("Invalid date given \"{$invalidDate}\", at column \"{$column}\"", previous: $previous); } public static function invalidEnumValue(string $column, string $invalidEnumValue): self { - return new self("Invalid enum value given \"$invalidEnumValue\", at column \"$column\""); + return new self("Invalid enum value given \"{$invalidEnumValue}\", at column \"{$column}\""); } } diff --git a/src/Test/Behat/Exception/InvalidResetDbTag.php b/src/Test/Behat/Exception/InvalidResetDbTag.php index 41de35f8b..a0aa9a33b 100644 --- a/src/Test/Behat/Exception/InvalidResetDbTag.php +++ b/src/Test/Behat/Exception/InvalidResetDbTag.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat\Exception; use Behat\Behat\EventDispatcher\Event\BeforeFeatureTested; @@ -24,19 +33,19 @@ public function __construct(string $message, BeforeFeatureTested|BeforeScenarioT continue; } - if (str_contains($file, $path)) { - $file = substr($file, strpos($file, $path)); // @phpstan-ignore argument.type (strpos cannot be false if $path is contained in $file) + if (\str_contains($file, $path)) { + $file = \mb_substr($file, \mb_strpos($file, $path)); // @phpstan-ignore argument.type (strpos cannot be false if $path is contained in $file) break; } } } - $errorFileAndLine = match($event::class){ + $errorFileAndLine = match ($event::class) { BeforeFeatureTested::class => "{$file}:{$event->getFeature()->getLine()}", BeforeScenarioTested::class => "{$file}:{$event->getScenario()->getLine()}", }; - parent::__construct("$message\nAt $errorFileAndLine"); + parent::__construct("{$message}\nAt {$errorFileAndLine}"); } public static function bothTagsUsed(BeforeScenarioTested $event): self diff --git a/src/Test/Behat/Exception/ObjectNotFound.php b/src/Test/Behat/Exception/ObjectNotFound.php index 7b8b77f46..88e2cde97 100644 --- a/src/Test/Behat/Exception/ObjectNotFound.php +++ b/src/Test/Behat/Exception/ObjectNotFound.php @@ -21,7 +21,7 @@ final class ObjectNotFound extends \RuntimeException { public static function forFactoryAndName(string $factoryShortName, string $name): self { - return new self("Object \"$factoryShortName $name\" was not found."); + return new self("Object \"{$factoryShortName} {$name}\" was not found."); } /** @@ -29,6 +29,6 @@ public static function forFactoryAndName(string $factoryShortName, string $name) */ public static function forClassAndName(string $objectName, string $name): self { - return new self("Object of class \"$objectName\" with name \"$name\" was not found."); + return new self("Object of class \"{$objectName}\" with name \"{$name}\" was not found."); } } diff --git a/src/Test/Behat/FactoryShortNameResolver.php b/src/Test/Behat/FactoryShortNameResolver.php index 61aa568ca..4bef1e61c 100644 --- a/src/Test/Behat/FactoryShortNameResolver.php +++ b/src/Test/Behat/FactoryShortNameResolver.php @@ -18,6 +18,7 @@ use Zenstruck\Foundry\Factory; use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Test\Behat\Exception\FactoryNotResolvable; + use function Symfony\Component\String\u; /** @@ -51,7 +52,7 @@ public function __construct(iterable $factories) $this->factoryMap[$shortName] ??= []; $this->factoryMap[$shortName][] = $factory; - $plural = \strtolower($this->factoryShortNameAttribute($factory::class)->pluralName ?? $inflector->pluralize($shortName)[0]); + $plural = \mb_strtolower($this->factoryShortNameAttribute($factory::class)->pluralName ?? $inflector->pluralize($shortName)[0]); $this->factoryMap[$plural] ??= []; $this->factoryMap[$plural][] = $factory; } @@ -64,7 +65,7 @@ public function __construct(iterable $factories) */ public function factoryFor(string $shortName): ObjectFactory { - $normalized = \strtolower($shortName); + $normalized = \mb_strtolower($shortName); if (!isset($this->factoryMap[$normalized])) { throw FactoryNotResolvable::forName($shortName); @@ -73,7 +74,7 @@ public function factoryFor(string $shortName): ObjectFactory $factories = $this->factoryMap[$normalized]; if (\count($factories) > 1) { - throw FactoryNotResolvable::conflict($shortName, array_map(static fn(ObjectFactory $f) => $f::class, $factories)); + throw FactoryNotResolvable::conflict($shortName, \array_map(static fn(ObjectFactory $f) => $f::class, $factories)); } return $factories[0]::new(); @@ -123,7 +124,7 @@ private function shortNameFor(string $factoryClass): string $attribute = $this->factoryShortNameAttribute($factoryClass); if ($attribute) { - return \strtolower($attribute->shortName); + return \mb_strtolower($attribute->shortName); } $shortClass = u((new \ReflectionClass($factoryClass))->getShortName()); diff --git a/src/Test/Behat/FoundryCallFilter.php b/src/Test/Behat/FoundryCallFilter.php index bf70bd741..20482d1e3 100644 --- a/src/Test/Behat/FoundryCallFilter.php +++ b/src/Test/Behat/FoundryCallFilter.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat; use Behat\Behat\Definition\Call\DefinitionCall; @@ -14,7 +23,7 @@ /** * @internal * - * Transforms TableNodes into FoundryTableNodes where all types are resolved. + * Transforms TableNodes into FoundryTableNodes where all types are resolved */ final class FoundryCallFilter implements CallFilter { @@ -41,7 +50,7 @@ public function filterCall(Call $call): Call if ( !$call instanceof DefinitionCall || !$call->getCallee()->getReflection() instanceof \ReflectionMethod - || $call->getCallee()->getReflection()->class !== FoundryContext::class + || FoundryContext::class !== $call->getCallee()->getReflection()->class ) { return $call; } @@ -49,20 +58,18 @@ public function filterCall(Call $call): Call $arguments = $call->getArguments(); if (!isset($arguments['factoryShortName'])) { - throw new \InvalidArgumentException( - <<getEnvironment(), $call->getFeature(), $call->getStep(), $call->getCallee(), - array_map( + \array_map( fn(mixed $argument) => match ($argument instanceof TableNode) { true => $this->normalizeObjectParameters($argument, $arguments['factoryShortName']), false => $argument, @@ -77,30 +84,30 @@ private function normalizeObjectParameters(TableNode $tableNode, string $factory { $table = $tableNode->getTable(); - $headKey = array_key_first($table); - $thead = array_shift($table); + $headKey = \array_key_first($table); + $thead = \array_shift($table); return FoundryTableNode::create( $this->factoryResolver, $this->objectRegistry, - (\Closure::bind( - fn () => $this->maxLineLength, + \Closure::bind( + fn() => $this->maxLineLength, $tableNode, TableNode::class - )()), + )(), [ // @phpstan-ignore argument.type (TableNode has the same problem: array $table is not really lists) $headKey => $thead, // @phpstan-ignore array.invalidKey - ...array_map( - function (array $parameters) use ($thead, $factoryShortName): array { + ...\array_map( + function(array $parameters) use ($thead, $factoryShortName): array { $normalized = []; foreach ($parameters as $key => $value) { if (!isset($thead[$key])) { - throw new \LogicException("Table has no column for parameter \"$key\". This should never happen, table integrity is checked in TableNode."); + throw new \LogicException("Table has no column for parameter \"{$key}\". This should never happen, table integrity is checked in TableNode."); } $propertyName = $thead[$key]; - if ($propertyName === '_ref') { + if ('_ref' === $propertyName) { $normalized['_ref'] = $value; continue; @@ -124,7 +131,7 @@ function (array $parameters) use ($thead, $factoryShortName): array { continue; } - if (preg_match('/^[^,]+), (?[^)]+)\)>$/', $value, $matches)) { + if (\preg_match('/^[^,]+), (?[^)]+)\)>$/', $value, $matches)) { try { $normalized[$propertyName] = $this->objectRegistry->getByFactoryShortName($matches['factoryShortName'], $matches['objectName']); } catch (ObjectNotFound $e) { @@ -153,7 +160,7 @@ function (array $parameters) use ($thead, $factoryShortName): array { continue; } - if (is_a($expectedTypeClass, \DateTimeInterface::class, allow_string: true)) { + if (\is_a($expectedTypeClass, \DateTimeInterface::class, allow_string: true)) { try { $normalized[$propertyName] = new $expectedTypeClass($value); @@ -163,15 +170,15 @@ function (array $parameters) use ($thead, $factoryShortName): array { } } - if (is_a($expectedTypeClass, \BackedEnum::class, allow_string: true)) { - $value = is_numeric($value) ? (int)$value : $value; + if (\is_a($expectedTypeClass, \BackedEnum::class, allow_string: true)) { + $value = \is_numeric($value) ? (int) $value : $value; - $normalized[$propertyName] = $expectedTypeClass::tryFrom($value) ?? throw InvalidObjectParameter::invalidEnumValue($propertyName, (string)$value); + $normalized[$propertyName] = $expectedTypeClass::tryFrom($value) ?? throw InvalidObjectParameter::invalidEnumValue($propertyName, (string) $value); continue; } - throw new \LogicException("Cannot normalize parameter \"$propertyName\" with value \"$value\"."); + throw new \LogicException("Cannot normalize parameter \"{$propertyName}\" with value \"{$value}\"."); } return $normalized; @@ -201,7 +208,7 @@ private function getPropertyTypeIfClass(\ReflectionClass $class, string $propert !isset($property) || !($type = $property->getType()) instanceof \ReflectionNamedType || $type->isBuiltin() - || !class_exists($type->getName()) + || !\class_exists($type->getName()) ) { return null; } diff --git a/src/Test/Behat/FoundryContext.php b/src/Test/Behat/FoundryContext.php index 11cba6d9e..b63322a59 100644 --- a/src/Test/Behat/FoundryContext.php +++ b/src/Test/Behat/FoundryContext.php @@ -1,11 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat; use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeStepScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\BeforeStep; use Behat\Step\Given; use Behat\Step\Then; use Behat\Transformation\Transform; @@ -15,6 +22,7 @@ use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Persistence\PersistentObjectFactory; use Zenstruck\Foundry\Persistence\RepositoryAssertions; + use function Zenstruck\Foundry\get; use function Zenstruck\Foundry\Persistence\refresh; @@ -41,22 +49,6 @@ public function createObject(string $factoryShortName, ?string $objectName = nul $this->resolveFactory($factoryShortName, $objectName)->create(); } - /** - * @return ObjectFactory - */ - private function resolveFactory(string $factoryShortName, ?string $objectName = null): ObjectFactory - { - $factory = $this->factoryResolver->factoryFor($factoryShortName); - - if (!$objectName) { - return $factory; - } - - return $factory->afterInstantiate( - fn(object $object) => $this->objectRegistry->store($object, $objectName) - ); - } - #[Given('a(n) :factoryShortName is created with properties')] #[Given('a(n) :factoryShortName :objectName is created with properties')] public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void @@ -64,7 +56,7 @@ public function createObjectWithProperties(TableNode $table, string $factoryShor $factory = $this->resolveFactory($factoryShortName, $objectName); $parametersList = $table->getColumnsHash(); - if (count($parametersList) !== 1) { + if (1 !== \count($parametersList)) { throw new \InvalidArgumentException('Expected exactly one line of properties, to create one object.'); } @@ -93,30 +85,12 @@ public function assertNbObjectsExist(int $nb, string $factoryShortName): void ->count($nb); } - private function repositoryAssertionFor(string $factoryShortName): RepositoryAssertions - { - $factory = $this->factoryResolver->factoryFor($factoryShortName); - - if (!$factory instanceof PersistentObjectFactory) { - throw new \LogicException( - \sprintf( - "Cannot make assertions with factory of class \"%s\" with short name \"%s\": it does not extend \"%s\".", - $factory::class, - $factoryShortName, - PersistentObjectFactory::class - ) - ); - } - - return $factory::assert(); - } - #[Then(':factoryShortName :objectName should have properties')] public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void { $parametersList = $table->getColumnsHash(); - if (count($parametersList) !== 1) { + if (1 !== \count($parametersList)) { throw new \InvalidArgumentException('Expected exactly one line of properties.'); } @@ -129,15 +103,15 @@ public function assertObjectHasProperties(FoundryTableNode $table, string $facto foreach ($parametersList[0] as $key => $valueExpected) { $actualValue = get($object, $key); - match(true) { + match (true) { $valueExpected instanceof \DateTimeInterface => Assert::that($actualValue) ->isInstanceOf(\DateTimeInterface::class) ->and($actualValue->format('Y-m-d H:i:s')) ->is($valueExpected->format('Y-m-d H:i:s')), - is_object($valueExpected) => Assert::that($actualValue)->is($valueExpected), + \is_object($valueExpected) => Assert::that($actualValue)->is($valueExpected), - default => Assert::that($actualValue)->equals($valueExpected) + default => Assert::that($actualValue)->equals($valueExpected), }; } } @@ -150,7 +124,7 @@ public function assertObjectExists(string $factoryShortName, string $objectName) $this->factoryResolver->targetObjectClassFor($factoryShortName), $objectName ) - )->is(true, "Object with name \"$objectName\" of type \"$factoryShortName\" does not exist although it should."); + )->is(true, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" does not exist although it should."); } #[Then(':factoryShortName object named :objectName should not exist')] @@ -161,7 +135,7 @@ public function assertObjectDoesNotExist(string $factoryShortName, string $objec $this->factoryResolver->targetObjectClassFor($factoryShortName), $objectName ) - )->is(false, "Object with name \"$objectName\" of type \"$factoryShortName\" exists although it should not."); + )->is(false, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" exists although it should not."); } #[Transform('/(.*)(.*)/')] @@ -175,4 +149,31 @@ public function transformLastIdForSpecificObject(string $before, string $factory { return "{$before}{$this->objectRegistry->lastIdFor($factoryShortName)}{$after}"; } + + /** + * @return ObjectFactory + */ + private function resolveFactory(string $factoryShortName, ?string $objectName = null): ObjectFactory + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$objectName) { + return $factory; + } + + return $factory->afterInstantiate( + fn(object $object) => $this->objectRegistry->store($object, $objectName) + ); + } + + private function repositoryAssertionFor(string $factoryShortName): RepositoryAssertions + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$factory instanceof PersistentObjectFactory) { + throw new \LogicException(\sprintf('Cannot make assertions with factory of class "%s" with short name "%s": it does not extend "%s".', $factory::class, $factoryShortName, PersistentObjectFactory::class)); + } + + return $factory::assert(); + } } diff --git a/src/Test/Behat/FoundryExtension.php b/src/Test/Behat/FoundryExtension.php index b38f213ac..a1fff8d17 100644 --- a/src/Test/Behat/FoundryExtension.php +++ b/src/Test/Behat/FoundryExtension.php @@ -46,7 +46,7 @@ public function configure(ArrayNodeDefinition $builder): void $builder ->children() ->enumNode('database_reset_mode') - ->values(array_map(static fn(DatabaseResetMode $mode) => $mode->value, DatabaseResetMode::cases())) + ->values(\array_map(static fn(DatabaseResetMode $mode) => $mode->value, DatabaseResetMode::cases())) ->defaultValue(DatabaseResetMode::MANUAL->value) ->end() ->booleanNode('enable_dama_support') @@ -75,7 +75,7 @@ public function load(ContainerBuilder $container, array $config): void $databaseResetMode = DatabaseResetMode::from($config['database_reset_mode']); - if ($databaseResetMode === DatabaseResetMode::DISABLED) { + if (DatabaseResetMode::DISABLED === $databaseResetMode) { return; } @@ -84,11 +84,11 @@ public function load(ContainerBuilder $container, array $config): void throw DamaNativeExtensionIncompatibility::withFoundryDamaSupport(); } - if ($databaseResetMode === DatabaseResetMode::FEATURE) { + if (DatabaseResetMode::FEATURE === $databaseResetMode) { throw DamaNativeExtensionIncompatibility::withFeatureResetDbMode(); } - if ($databaseResetMode === DatabaseResetMode::MANUAL) { + if (DatabaseResetMode::MANUAL === $databaseResetMode) { throw DamaNativeExtensionIncompatibility::withManualResetDbMode(); } } @@ -103,6 +103,6 @@ public function load(ContainerBuilder $container, array $config): void private function damaNativeExtensionIsEnabled(ContainerBuilder $container): bool { - return in_array(DoctrineExtension::class, (array) $container->getParameter('extensions'), true); + return \in_array(DoctrineExtension::class, (array) $container->getParameter('extensions'), true); } } diff --git a/src/Test/Behat/FoundryTableNode.php b/src/Test/Behat/FoundryTableNode.php index 238020420..aedcfe85b 100644 --- a/src/Test/Behat/FoundryTableNode.php +++ b/src/Test/Behat/FoundryTableNode.php @@ -1,21 +1,31 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat; use Behat\Gherkin\Node\TableNode; + use function Zenstruck\Foundry\set; /** * @internal * - * @method list> getHash() - * @method list> getColumnsHash() - * @method list getColumn(int $index) - * @method list> getRows() - * @method list getRow(int $index) - * @method array> getTable() + * @method list> getHash() + * @method list> getColumnsHash() + * @method list getColumn(int $index) + * @method list> getRows() + * @method list getRow(int $index) + * @method array> getTable() * @method array> getRowsHash() - * @method list getRowsHash() + * @method list getRowsHash() */ final class FoundryTableNode extends TableNode { @@ -26,14 +36,14 @@ final class FoundryTableNode extends TableNode private ObjectRegistry $objectRegistry; // @phpstan-ignore property.uninitialized /** - * @param array $maxLineLength + * @param array $maxLineLength * @param array> $table */ public static function create( FactoryShortNameResolver $factoryShortNameResolver, ObjectRegistry $objectRegistry, array $maxLineLength, - array $table + array $table, ): static { // let's neutralize the table node constructor: it checks if the table contains only scalar values, // but we want our TableNode to carry objects @@ -57,32 +67,32 @@ public function getRowAsString($rowNum): string { $values = []; foreach ($this->getRow($rowNum) as $column => $value) { - $values[] = $this->padRight(' ' . $this->getValueAsString($value) . ' ', $this->maxLineLength[$column] + 2); + $values[] = $this->padRight(' '.$this->getValueAsString($value).' ', $this->maxLineLength[$column] + 2); } - return sprintf('|%s|', implode('|', $values)); + return \sprintf('|%s|', \implode('|', $values)); } public function getRowAsStringWithWrappedValues($rowNum, $wrapper): string { $values = []; foreach ($this->getRow($rowNum) as $column => $value) { - $value = $this->padRight(' ' . $this->getValueAsString($value) . ' ', $this->maxLineLength[$column] + 2); + $value = $this->padRight(' '.$this->getValueAsString($value).' ', $this->maxLineLength[$column] + 2); - $values[] = call_user_func($wrapper, $value, $column); + $values[] = \call_user_func($wrapper, $value, $column); } - return sprintf('|%s|', implode('|', $values)); + return \sprintf('|%s|', \implode('|', $values)); } private function getValueAsString(mixed $value): string { return match (true) { - !is_object($value) => (string)$value, + !\is_object($value) => (string) $value, $value instanceof \DateTimeInterface => $value->format('Y-m-d H:i:s'), - $value instanceof \BackedEnum => (string)$value->value, + $value instanceof \BackedEnum => (string) $value->value, $this->objectRegistry->isStored($value) => "{$this->factoryShortNameResolver->getShortNameForClass($value::class)} {$this->objectRegistry->getNameFor($value)}", - default => throw new \LogicException("Unsupported value type: ".\get_debug_type($value)), + default => throw new \LogicException('Unsupported value type: '.\get_debug_type($value)), }; } } diff --git a/src/Test/Behat/Listener/BootConfigurationListener.php b/src/Test/Behat/Listener/BootConfigurationListener.php index 9e3cc4716..20876176f 100644 --- a/src/Test/Behat/Listener/BootConfigurationListener.php +++ b/src/Test/Behat/Listener/BootConfigurationListener.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat\Listener; use Behat\Behat\EventDispatcher\Event\ExampleTested; diff --git a/src/Test/Behat/Listener/DatabaseResetListener.php b/src/Test/Behat/Listener/DatabaseResetListener.php index e2ee1a1f9..0e9a457d9 100644 --- a/src/Test/Behat/Listener/DatabaseResetListener.php +++ b/src/Test/Behat/Listener/DatabaseResetListener.php @@ -56,15 +56,15 @@ public static function getSubscribedEvents(): array FeatureTested::BEFORE => [ ['validateFeature', 10], - ['resetDatabaseIfNeeded'] + ['resetDatabaseIfNeeded'], ], ScenarioTested::BEFORE => [ ['validateScenario', 10], - ['resetDatabaseIfNeeded'] + ['resetDatabaseIfNeeded'], ], ExampleTested::BEFORE => [ ['validateScenario', 10], - ['resetDatabaseIfNeeded'] + ['resetDatabaseIfNeeded'], ], // a shutdown is needed after each scenario to ensure StoriesRegistry is reset @@ -95,14 +95,14 @@ public function disableStaticConnection(): void public function validateFeature(BeforeFeatureTested $event): void { - if ($this->hasResetDbTag($event) && $this->resetMode === DatabaseResetMode::FEATURE) { + if ($this->hasResetDbTag($event) && DatabaseResetMode::FEATURE === $this->resetMode) { throw InvalidResetDbTag::resetDbOnFeatureWithFeatureMode($event); } } public function validateScenario(BeforeScenarioTested $event): void { - if ($this->hasResetDbTag($event) && $this->resetMode === DatabaseResetMode::SCENARIO) { + if ($this->hasResetDbTag($event) && DatabaseResetMode::SCENARIO === $this->resetMode) { throw InvalidResetDbTag::resetDbOnScenarioWithScenarioMode($event); } @@ -136,6 +136,16 @@ public function resetDatabaseIfNeeded(BeforeFeatureTested|BeforeScenarioTested $ ResetDatabaseManager::resetBeforeEachTest($this->symfonyKernel); } + public function shutdownFoundryAfterScenario(): void + { + if (DatabaseResetMode::SCENARIO !== $this->resetMode) { + return; + } + + $this->resetObjectRegistry(); + Configuration::shutdown(); + } + private function shouldResetDB(BeforeFeatureTested|BeforeScenarioTested $event): bool { if ($this->hasNoResetDbTag($event)) { @@ -146,18 +156,8 @@ private function shouldResetDB(BeforeFeatureTested|BeforeScenarioTested $event): return true; } - return $event instanceof BeforeScenarioTested && $this->resetMode === DatabaseResetMode::SCENARIO - || $event instanceof BeforeFeatureTested && $this->resetMode === DatabaseResetMode::FEATURE; - } - - public function shutdownFoundryAfterScenario(): void - { - if (DatabaseResetMode::SCENARIO !== $this->resetMode) { - return; - } - - $this->resetObjectRegistry(); - Configuration::shutdown(); + return $event instanceof BeforeScenarioTested && DatabaseResetMode::SCENARIO === $this->resetMode + || $event instanceof BeforeFeatureTested && DatabaseResetMode::FEATURE === $this->resetMode; } private function hasResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event): bool @@ -174,7 +174,7 @@ private function hasResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event): return false; } - if ($this->resetMode === DatabaseResetMode::SCENARIO) { + if (DatabaseResetMode::SCENARIO === $this->resetMode) { throw InvalidResetDbTag::resetDbWithScenarioMode($event); } @@ -199,7 +199,7 @@ private function hasNoResetDbTag(BeforeFeatureTested|BeforeScenarioTested $event throw DamaNativeExtensionIncompatibility::withNoResetDbTag(); } - return match($this->resetMode) { + return match ($this->resetMode) { DatabaseResetMode::MANUAL => throw InvalidResetDbTag::noResetDbWithManualMode($event), DatabaseResetMode::FEATURE => throw InvalidResetDbTag::noResetDbWithFeatureMode($event), default => true, diff --git a/src/Test/Behat/Listener/LoadFixturesListener.php b/src/Test/Behat/Listener/LoadFixturesListener.php index d367d1d8d..fcc6de71c 100644 --- a/src/Test/Behat/Listener/LoadFixturesListener.php +++ b/src/Test/Behat/Listener/LoadFixturesListener.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Test\Behat\Listener; use Behat\Behat\EventDispatcher\Event\AfterScenarioSetup; @@ -66,7 +75,7 @@ public function loadFixtureIfTagged(AfterScenarioSetup $event): void } /** - * @param list $tags + * @param list $tags * @return list */ private function parseFixtureName(array $tags): array diff --git a/src/Test/Behat/ObjectRegistry.php b/src/Test/Behat/ObjectRegistry.php index ad2c6f899..679ceffc2 100644 --- a/src/Test/Behat/ObjectRegistry.php +++ b/src/Test/Behat/ObjectRegistry.php @@ -26,7 +26,7 @@ final class ObjectRegistry { /** - * We need to use static properties in order that this is kept between kernel resets + * We need to use static properties in order that this is kept between kernel resets. */ /** @var array> */ @@ -114,29 +114,12 @@ public function lastId(): int|string return $this->coerceIdToScalar(self::$lastId); } - /** - * @param array $ids - */ - private function coerceIdToScalar(array $ids): int|string - { - if (count($ids) !== 1) { - throw new \InvalidArgumentException('Cannot get last id: generic entity must have exactly one identifier.'); - } - - $id = array_first($ids); - if (!is_int($id) && !is_string($id)) { - throw new \InvalidArgumentException(sprintf('Wrong type for the id: expected int or string, got "%s".', get_debug_type($id))); - } - - return $id; - } - public function lastIdFor(string $factoryShortName): int|string { $objects = self::$objects[$this->factoryShortNameResolver->targetObjectClassFor($factoryShortName)] ?? []; - if (count($objects) === 0) { - throw new \InvalidArgumentException("No object of type \"$factoryShortName\" found."); + if (0 === \count($objects)) { + throw new \InvalidArgumentException("No object of type \"{$factoryShortName}\" found."); } return $this->coerceIdToScalar( @@ -161,4 +144,21 @@ public function getNameFor(object $object): string static fn(object $o) => $o === $object ) ?? throw new \LogicException('Object is not stored in the registry.'); } + + /** + * @param array $ids + */ + private function coerceIdToScalar(array $ids): int|string + { + if (1 !== \count($ids)) { + throw new \InvalidArgumentException('Cannot get last id: generic entity must have exactly one identifier.'); + } + + $id = array_first($ids); + if (!\is_int($id) && !\is_string($id)) { + throw new \InvalidArgumentException(\sprintf('Wrong type for the id: expected int or string, got "%s".', \get_debug_type($id))); + } + + return $id; + } } diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 0869db407..8d43b8ba3 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -241,7 +241,7 @@ public function loadExtension(array $config, ContainerConfigurator $configurator // we load all services for Behat by default, and we remove them if we detect that Behat is not installed // that's the only way that worked so far... - if (interface_exists(\Behat\Behat\Context\Context::class)) { + if (\interface_exists(\Behat\Behat\Context\Context::class)) { $configurator->import('../config/behat.php'); } diff --git a/tests/Fixture/App/Controller/HelloWorldController.php b/tests/Fixture/App/Controller/HelloWorldController.php index 249fd7a9d..d1325741e 100644 --- a/tests/Fixture/App/Controller/HelloWorldController.php +++ b/tests/Fixture/App/Controller/HelloWorldController.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Tests\Fixture\App\Controller; use Symfony\Component\HttpFoundation\Response; diff --git a/tests/Fixture/Behat/BehatTestKernel.php b/tests/Fixture/Behat/BehatTestKernel.php index 6a622a793..db637f533 100644 --- a/tests/Fixture/Behat/BehatTestKernel.php +++ b/tests/Fixture/Behat/BehatTestKernel.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Tests\Fixture\Behat; use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; @@ -66,6 +75,6 @@ protected function configureContainer(ContainerConfigurator $configurator, Loade private static function runsWithBehat(): bool { - return str_contains($_SERVER['SCRIPT_NAME'], 'behat'); + return \str_contains($_SERVER['SCRIPT_NAME'], 'behat'); } } diff --git a/tests/Fixture/Behat/ResetDisabledTestContext.php b/tests/Fixture/Behat/ResetDisabledTestContext.php index 8f0aaaef9..bb8af4aa9 100644 --- a/tests/Fixture/Behat/ResetDisabledTestContext.php +++ b/tests/Fixture/Behat/ResetDisabledTestContext.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Tests\Fixture\Behat; use Behat\Behat\Context\Context; diff --git a/tests/Fixture/Behat/TestFoundryContext.php b/tests/Fixture/Behat/TestFoundryContext.php index e6a444668..eb5545e2b 100644 --- a/tests/Fixture/Behat/TestFoundryContext.php +++ b/tests/Fixture/Behat/TestFoundryContext.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Zenstruck\Foundry\Tests\Fixture\Behat; use Yceruto\BehatExtension\Context\ExceptionAssertionTrait; diff --git a/tests/Fixture/Model/GenericModel.php b/tests/Fixture/Model/GenericModel.php index 6a8ceb042..a9a776b53 100644 --- a/tests/Fixture/Model/GenericModel.php +++ b/tests/Fixture/Model/GenericModel.php @@ -31,18 +31,6 @@ abstract class GenericModel #[MongoDB\Id(type: 'int', strategy: 'INCREMENT')] public ?int $id = null; - #[ORM\Column] - #[MongoDB\Field(type: 'string')] - private ?string $prop1 = null; // @phpstan-ignore doctrine.columnType (made on purpose, Doctrine does not handle nullable properties the same way) - - #[ORM\Column] - #[MongoDB\Field(type: 'int')] - private int $propInteger = 0; - - #[ORM\Column(nullable: true)] - #[MongoDB\Field(type: 'date_immutable', nullable: true)] - private ?\DateTimeImmutable $date = null; - #[ORM\Column(nullable: true)] #[MongoDB\Field(type: 'date', nullable: true)] public ?\DateTime $dateMutable = null; @@ -63,6 +51,18 @@ abstract class GenericModel #[MongoDB\Field(type: 'int', nullable: true, enumType: IntBackedEnum::class)] public ?IntBackedEnum $intEnum = null; + #[ORM\Column] + #[MongoDB\Field(type: 'string')] + private ?string $prop1 = null; // @phpstan-ignore doctrine.columnType (made on purpose, Doctrine does not handle nullable properties the same way) + + #[ORM\Column] + #[MongoDB\Field(type: 'int')] + private int $propInteger = 0; + + #[ORM\Column(nullable: true)] + #[MongoDB\Field(type: 'date_immutable', nullable: true)] + private ?\DateTimeImmutable $date = null; + public function __construct(string $prop1) { $this->prop1 = $prop1; diff --git a/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php b/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php index ff984a414..a24c1fb62 100644 --- a/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php +++ b/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php @@ -31,11 +31,6 @@ final class BootConfigurationListenerTest extends KernelTestCase { use Factories, RequiresORM, ResetDatabase; - protected static function getKernelClass(): string - { - return BehatTestKernel::class; - } - #[Test] public function it_boots_foundry_when_not_already_booted(): void { @@ -76,6 +71,11 @@ public function it_shuts_down_foundry_after_feature_and_resets_registry(): void self::assertFalse(Configuration::isBooted()); } + protected static function getKernelClass(): string + { + return BehatTestKernel::class; + } + private function createListener(): BootConfigurationListener { return new BootConfigurationListener(self::$kernel ?? self::bootKernel()); diff --git a/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php b/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php index 70625ab00..3c15cc50b 100644 --- a/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php +++ b/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php @@ -40,18 +40,13 @@ final class DatabaseResetListenerTest extends KernelTestCase { use Factories, RequiresORM, ResetDatabase; - protected static function getKernelClass(): string - { - return BehatTestKernel::class; - } - protected function setUp(): void { $this->objectRegistry()->reset(); } /** - * @param list $tags + * @param list $tags * @param class-string<\Throwable> $exceptionClass */ #[Test] @@ -60,7 +55,7 @@ public function it_throws_exception_on_validate_feature( DatabaseResetMode $mode, array $tags, string $exceptionClass, - string $exceptionMessage + string $exceptionMessage, ): void { $listener = $this->createListener($mode); $event = $this->createFeatureEvent($tags); @@ -82,7 +77,7 @@ public static function validateFeatureExceptionProvider(): iterable } /** - * @param list $tags + * @param list $tags * @param class-string<\Throwable> $exceptionClass */ #[Test] @@ -91,7 +86,7 @@ public function it_throws_exception_on_validate_scenario( DatabaseResetMode $mode, array $tags, string $exceptionClass, - string $exceptionMessage + string $exceptionMessage, ): void { $listener = $this->createListener($mode); $event = $this->createScenarioEvent($tags); @@ -139,7 +134,7 @@ public function it_resets_database_and_registries_when_needed( $objectRegistry->store($testObject, 'test-object'); self::assertTrue($objectRegistry->isStored($testObject)); - $event = $eventType === 'feature' ? $this->createFeatureEvent($tags) : $this->createScenarioEvent($tags); + $event = 'feature' === $eventType ? $this->createFeatureEvent($tags) : $this->createScenarioEvent($tags); $listener->resetDatabaseIfNeeded($event); @@ -205,7 +200,7 @@ public static function resetDatabaseIfNeededBehaviorProvider(): iterable } /** - * @param list $tags + * @param list $tags * @param class-string<\Throwable> $exceptionClass */ #[Test] @@ -216,7 +211,7 @@ public function it_throws_exception_on_reset_database_if_needed( string $exceptionClass, string $exceptionMessage, bool $damaSupportEnabled = false, - bool $damaNativeExtensionIsEnabled = false + bool $damaNativeExtensionIsEnabled = false, ): void { $listener = $this->createListener($mode, $damaSupportEnabled, $damaNativeExtensionIsEnabled); $event = $this->createScenarioEvent($tags); @@ -284,7 +279,7 @@ public static function validateScenarioNoExceptionProvider(): iterable } #[Test] - public function it_does_not_throw_for_noResetDB_tag_with_scenario_mode(): void + public function it_does_not_throw_for_no_reset_d_b_tag_with_scenario_mode(): void { $this->expectNotToPerformAssertions(); @@ -295,7 +290,7 @@ public function it_does_not_throw_for_noResetDB_tag_with_scenario_mode(): void } #[Test] - public function it_validates_feature_without_resetDB_tag_in_feature_mode(): void + public function it_validates_feature_without_reset_d_b_tag_in_feature_mode(): void { $this->expectNotToPerformAssertions(); @@ -305,10 +300,15 @@ public function it_validates_feature_without_resetDB_tag_in_feature_mode(): void $listener->validateFeature($event); } + protected static function getKernelClass(): string + { + return BehatTestKernel::class; + } + private function createListener( DatabaseResetMode $mode, bool $damaSupportEnabled = false, - bool $damaNativeExtensionIsEnabled = false + bool $damaNativeExtensionIsEnabled = false, ): DatabaseResetListener { return new DatabaseResetListener(self::$kernel ?? self::bootKernel(), $mode, $damaSupportEnabled, $damaNativeExtensionIsEnabled); } diff --git a/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php b/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php index da3a6053e..1ab10391c 100644 --- a/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php +++ b/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php @@ -40,11 +40,6 @@ final class LoadFixturesListenerTest extends KernelTestCase { use Factories, RequiresORM, ResetDatabase; - protected static function getKernelClass(): string - { - return BehatTestKernel::class; - } - protected function setUp(): void { $this->objectRegistry()->reset(); @@ -120,7 +115,7 @@ public function it_loads_fixture_group(): void public function it_throws_if_states_name_conflict_in_stories(): void { $this->expectException(ObjectAlreadyRegistered::class); - $this->expectExceptionMessage('Object "duplicate" is already registered for class "'.Contact::class);; + $this->expectExceptionMessage('Object "duplicate" is already registered for class "'.Contact::class); $listener = $this->createListener(); $event = $this->createAfterScenarioSetupEvent([], ['withFixture(conflict-test)']); @@ -128,6 +123,11 @@ public function it_throws_if_states_name_conflict_in_stories(): void $listener->loadFixtureIfTagged($event); } + protected static function getKernelClass(): string + { + return BehatTestKernel::class; + } + private function createListener(): LoadFixturesListener { return new LoadFixturesListener(self::$kernel ?? self::bootKernel()); diff --git a/tests/Integration/Command/LoadFixturesCommandTest.php b/tests/Integration/Command/LoadFixturesCommandTest.php index 3e676478d..2abe43a70 100644 --- a/tests/Integration/Command/LoadFixturesCommandTest.php +++ b/tests/Integration/Command/LoadFixturesCommandTest.php @@ -27,6 +27,7 @@ use Zenstruck\Foundry\Tests\Fixture\Stories\Fixtures\FixtureStoryWithNameCollision; use Zenstruck\Foundry\Tests\Fixture\TestKernel; use Zenstruck\Foundry\Tests\Integration\RequiresORM; + use function Zenstruck\Foundry\Persistence\repository; final class LoadFixturesCommandTest extends KernelTestCase diff --git a/tests/Unit/Test/Behat/FactoryResolverTest.php b/tests/Unit/Test/Behat/FactoryResolverTest.php index 958273543..c9eeb47ad 100644 --- a/tests/Unit/Test/Behat/FactoryResolverTest.php +++ b/tests/Unit/Test/Behat/FactoryResolverTest.php @@ -214,4 +214,3 @@ protected function defaults(): array return []; } } - diff --git a/tests/Unit/Test/Behat/FoundryCallFilterTest.php b/tests/Unit/Test/Behat/FoundryCallFilterTest.php index c3faec025..179c1f583 100644 --- a/tests/Unit/Test/Behat/FoundryCallFilterTest.php +++ b/tests/Unit/Test/Behat/FoundryCallFilterTest.php @@ -30,11 +30,11 @@ use Zenstruck\Foundry\Attribute\FactoryShortName; use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Test\Behat\Exception\InvalidObjectParameter; use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; use Zenstruck\Foundry\Test\Behat\FoundryCallFilter; use Zenstruck\Foundry\Test\Behat\FoundryContext; use Zenstruck\Foundry\Test\Behat\FoundryTableNode; -use Zenstruck\Foundry\Test\Behat\Exception\InvalidObjectParameter; use Zenstruck\Foundry\Test\Behat\ObjectRegistry; /** @requires PHP 9 */ @@ -45,6 +45,23 @@ final class FoundryCallFilterTest extends TestCase private FactoryShortNameResolver $factoryResolver; private ObjectRegistry $objectRegistry; + protected function setUp(): void + { + $this->factoryResolver = new FactoryShortNameResolver([ + new TestEntityFactory(), + new DatedEntityFactory(), + new EnumEntityFactory(), + new RelationEntityFactory(), + new ChildEntityFactory(), + ]); + $this->objectRegistry = new ObjectRegistry( + $this->factoryResolver, $this->createStub(PersistenceManager::class) + ); + $this->objectRegistry->reset(); + + $this->filter = $this->createFilterWithMockedKernel(); + } + #[Test] public function it_supports_call_with_table_node_argument(): void { @@ -150,17 +167,6 @@ public static function tableNormalizationProvider(): iterable ]; } - /** - * @param array $row - */ - private function createTableNodeFromRow(array $row): TableNode - { - return new TableNode([ - array_keys($row), - array_values($row), - ]); - } - #[Test] public function it_normalizes_object_reference_with_explicit_factory(): void { @@ -321,21 +327,15 @@ public function it_handles_inherited_property_from_parent_class(): void self::assertSame('value', $rows[0]['inheritedProperty']); } - protected function setUp(): void + /** + * @param array $row + */ + private function createTableNodeFromRow(array $row): TableNode { - $this->factoryResolver = new FactoryShortNameResolver([ - new TestEntityFactory(), - new DatedEntityFactory(), - new EnumEntityFactory(), - new RelationEntityFactory(), - new ChildEntityFactory(), + return new TableNode([ + \array_keys($row), + \array_values($row), ]); - $this->objectRegistry = new ObjectRegistry( - $this->factoryResolver, $this->createStub(PersistenceManager::class) - ); - $this->objectRegistry->reset(); - - $this->filter = $this->createFilterWithMockedKernel(); } private function createFilterWithMockedKernel(): FoundryCallFilter @@ -344,7 +344,7 @@ private function createFilterWithMockedKernel(): FoundryCallFilter $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { '.zenstruck_foundry.behat.factory_resolver' => $this->factoryResolver, '.zenstruck_foundry.behat.object_registry' => $this->objectRegistry, - default => throw new \InvalidArgumentException("Unknown service: $id"), + default => throw new \InvalidArgumentException("Unknown service: {$id}"), }); $kernel = $this->createStub(KernelInterface::class); diff --git a/tests/Unit/Test/Behat/FoundryTableNodeTest.php b/tests/Unit/Test/Behat/FoundryTableNodeTest.php index 50caff8b8..202eea35e 100644 --- a/tests/Unit/Test/Behat/FoundryTableNodeTest.php +++ b/tests/Unit/Test/Behat/FoundryTableNodeTest.php @@ -31,6 +31,19 @@ final class FoundryTableNodeTest extends TestCase private FactoryShortNameResolver $factoryResolver; private ObjectRegistry $objectRegistry; + protected function setUp(): void + { + $this->factoryResolver = new FactoryShortNameResolver([new TableTestEntityFactory()]); + + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturnCallback( + static fn(object $object): array => ['id' => $object->id ?? 0] + ); + + $this->objectRegistry = new ObjectRegistry($this->factoryResolver, $persistenceManager); + $this->objectRegistry->reset(); + } + #[Test] public function it_creates_table_node_with_factory_method(): void { @@ -194,19 +207,6 @@ public function it_formats_row_with_wrapped_values(): void self::assertStringContainsString('[0:', $row); self::assertStringContainsString('[1:', $row); } - - protected function setUp(): void - { - $this->factoryResolver = new FactoryShortNameResolver([new TableTestEntityFactory()]); - - $persistenceManager = $this->createStub(PersistenceManager::class); - $persistenceManager->method('getIdentifierValues')->willReturnCallback( - static fn(object $object): array => ['id' => $object->id ?? 0] - ); - - $this->objectRegistry = new ObjectRegistry($this->factoryResolver, $persistenceManager); - $this->objectRegistry->reset(); - } } class TableTestEntity diff --git a/tests/Unit/Test/Behat/ObjectRegistryTest.php b/tests/Unit/Test/Behat/ObjectRegistryTest.php index 5befe9ff8..24fec98f0 100644 --- a/tests/Unit/Test/Behat/ObjectRegistryTest.php +++ b/tests/Unit/Test/Behat/ObjectRegistryTest.php @@ -21,9 +21,9 @@ use Zenstruck\Foundry\Persistence\PersistenceManager; use Zenstruck\Foundry\Persistence\PersistentObjectFactory; use Zenstruck\Foundry\Story\Event\StateAddedToStory; -use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; use Zenstruck\Foundry\Test\Behat\Exception\ObjectAlreadyRegistered; use Zenstruck\Foundry\Test\Behat\Exception\ObjectNotFound; +use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; use Zenstruck\Foundry\Test\Behat\ObjectRegistry; /** @requires PHP 9 */ @@ -34,6 +34,21 @@ final class ObjectRegistryTest extends TestCase private FactoryShortNameResolver $resolver; private PersistenceManager $persistenceManager; + protected function setUp(): void + { + $this->resolver = new FactoryShortNameResolver([new UserFactory()]); + $this->persistenceManager = $this->createStub(PersistenceManager::class); + $this->persistenceManager->method('getIdentifierValues')->willReturnCallback( + static function(object $object): array { + \assert($object instanceof User); + + return ['id' => $object->id]; + } + ); + $this->registry = new ObjectRegistry($this->resolver, $this->persistenceManager); + $this->registry->reset(); + } + #[Test] public function it_stores_an_object(): void { @@ -278,21 +293,6 @@ public function it_throws_when_getting_name_for_unstored_object(): void $this->registry->getNameFor($user); } - - protected function setUp(): void - { - $this->resolver = new FactoryShortNameResolver([new UserFactory()]); - $this->persistenceManager = $this->createStub(PersistenceManager::class); - $this->persistenceManager->method('getIdentifierValues')->willReturnCallback( - static function (object $object): array { - assert($object instanceof User); - - return ['id' => $object->id]; - } - ); - $this->registry = new ObjectRegistry($this->resolver, $this->persistenceManager); - $this->registry->reset(); - } } final class User From 01913d05cb21a93fb3ac211de442783430ea63bf Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Fri, 6 Feb 2026 15:37:17 +0100 Subject: [PATCH 03/10] chore: `zenstruck/foundry-behat` a subpackage (#1084) * refactor: src/Test/Behat as a subpackage * chore(behat): make tests phpunit pass * chore(behat): make phpstan & behat tests working --- .gitattributes | 1 + .github/workflows/bc-check.yml | 10 +- .github/workflows/behat.yml | 88 ++++- .github/workflows/static-analysis.yml | 6 +- bin/console | 9 +- bin/tools/behat/.gitignore | 1 - bin/tools/behat/composer.json | 17 - bin/tools/behat/symfony.lock | 49 --- composer.json | 8 +- docs/index.rst | 324 +++++++++++++----- phpstan.neon | 4 +- phpunit-9.xml.dist | 3 + phpunit-paratest.xml.dist | 5 +- phpunit.xml.dist | 3 + .../NoPersistenceObjectsAutoCompleter.php | 7 + src/Test/Behat/.env | 4 + src/Test/Behat/.gitattributes | 8 + src/Test/Behat/.gitignore | 6 + src/Test/Behat/README.md | 7 + behat.yml => src/Test/Behat/behat.yml | 24 +- src/Test/Behat/bin/console | 18 + src/Test/Behat/composer.json | 74 ++++ {config => src/Test/Behat/config}/behat.php | 16 +- .../features}/main/create-objects.feature | 74 ++-- .../Behat/features}/main/no-reset-db.feature | 4 +- .../features}/main/persist-entities.feature | 28 +- .../main/with-fixture-on-feature.feature | 2 +- .../Behat/features}/main/with-fixture.feature | 2 +- .../reset-disabled/manual-isolation-1.feature | 10 +- .../reset-disabled/manual-isolation-2.feature | 2 +- .../features}/reset-feature/isolation.feature | 4 +- .../reset-feature/isolation2.feature | 2 +- .../features}/reset-feature/reset-db.feature | 8 +- .../with-fixture-on-feature.feature | 2 +- .../with-fixture-on-scenario.feature | 2 +- .../reset-manual/manual-isolation-1.feature | 10 +- .../reset-manual/manual-isolation-2.feature | 4 +- .../reset-manual/manual-isolation-3.feature | 2 +- .../with-fixture-on-feature.feature | 2 +- .../with-fixture-on-scenario.feature | 4 +- src/Test/Behat/phpstan.neon | 33 ++ src/Test/Behat/phpunit.dist.xml | 49 +++ .../AbstractFoundryContext.php} | 25 +- .../Behat/src}/Attribute/FactoryShortName.php | 2 +- .../Behat/{ => src}/DatabaseResetMode.php | 2 - .../BehatServicesCompilerPass.php | 35 ++ .../DamaNativeExtensionIncompatibility.php | 0 .../Exception/FactoryNotResolvable.php | 2 - .../Exception/InvalidObjectParameter.php | 2 - .../{ => src}/Exception/InvalidResetDbTag.php | 12 +- .../Exception/ObjectAlreadyRegistered.php | 2 - .../{ => src}/Exception/ObjectNotFound.php | 2 - .../{ => src}/FactoryShortNameResolver.php | 4 +- .../Behat/{ => src}/FoundryCallFilter.php | 0 src/Test/Behat/src/FoundryContext.php | 115 +++++++ src/Test/Behat/{ => src}/FoundryExtension.php | 4 +- src/Test/Behat/{ => src}/FoundryTableNode.php | 0 .../Listener/BootConfigurationListener.php | 0 .../Listener/DatabaseResetListener.php | 2 - .../Listener/LoadFixturesListener.php | 0 src/Test/Behat/{ => src}/ObjectRegistry.php | 11 +- src/Test/Behat/symfony.lock | 163 +++++++++ src/Test/Behat/symlink-vendor.sh | 26 ++ .../Behat/tests/Fixture}/BehatTestKernel.php | 25 +- .../Fixture}/Factories/Tag/TagFactory.php | 6 +- .../tests/Fixture}/Factories/TagFactory.php | 4 +- .../Fixture}/ResetDisabledTestContext.php | 2 +- .../tests/Fixture}/Stories/CategoryStory.php | 2 +- .../tests/Fixture}/Stories/ConflictStory1.php | 2 +- .../tests/Fixture}/Stories/ConflictStory2.php | 2 +- .../tests/Fixture}/Stories/ContactStory.php | 2 +- .../tests/Fixture}/TestFoundryContext.php | 2 +- .../BootConfigurationListenerTest.php | 22 +- .../Listener/DatabaseResetListenerTest.php | 20 +- .../Listener/LoadFixturesListenerTest.php | 18 +- .../Behat/tests/Unit}/FactoryResolverTest.php | 9 +- .../tests/Unit}/FoundryCallFilterTest.php | 9 +- .../tests/Unit}/FoundryTableNodeTest.php | 9 +- .../Behat/tests/Unit}/ObjectRegistryTest.php | 13 +- src/Test/Behat/tests/bootstrap.php | 23 ++ src/ZenstruckFoundryBundle.php | 25 +- .../Entity/Contact/ChildContactFactory.php | 4 +- tests/Fixture/FoundryTestKernel.php | 15 +- .../ResetDatabase/ResetDatabaseTestCase.php | 13 +- 84 files changed, 1109 insertions(+), 463 deletions(-) delete mode 100644 bin/tools/behat/.gitignore delete mode 100644 bin/tools/behat/composer.json delete mode 100644 bin/tools/behat/symfony.lock create mode 100644 src/Test/Behat/.env create mode 100644 src/Test/Behat/.gitattributes create mode 100644 src/Test/Behat/.gitignore create mode 100644 src/Test/Behat/README.md rename behat.yml => src/Test/Behat/behat.yml (78%) create mode 100755 src/Test/Behat/bin/console create mode 100644 src/Test/Behat/composer.json rename {config => src/Test/Behat/config}/behat.php (83%) rename {tests/behatFeatures => src/Test/Behat/features}/main/create-objects.feature (79%) rename {tests/behatFeatures => src/Test/Behat/features}/main/no-reset-db.feature (85%) rename {tests/behatFeatures => src/Test/Behat/features}/main/persist-entities.feature (68%) rename {tests/behatFeatures => src/Test/Behat/features}/main/with-fixture-on-feature.feature (83%) rename {tests/behatFeatures => src/Test/Behat/features}/main/with-fixture.feature (95%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-disabled/manual-isolation-1.feature (62%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-disabled/manual-isolation-2.feature (87%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-feature/isolation.feature (87%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-feature/isolation2.feature (91%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-feature/reset-db.feature (74%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-feature/with-fixture-on-feature.feature (92%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-feature/with-fixture-on-scenario.feature (93%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-manual/manual-isolation-1.feature (63%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-manual/manual-isolation-2.feature (82%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-manual/manual-isolation-3.feature (89%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-manual/with-fixture-on-feature.feature (92%) rename {tests/behatFeatures => src/Test/Behat/features}/reset-manual/with-fixture-on-scenario.feature (88%) create mode 100644 src/Test/Behat/phpstan.neon create mode 100644 src/Test/Behat/phpunit.dist.xml rename src/Test/Behat/{FoundryContext.php => src/AbstractFoundryContext.php} (86%) rename src/{ => Test/Behat/src}/Attribute/FactoryShortName.php (91%) rename src/Test/Behat/{ => src}/DatabaseResetMode.php (94%) create mode 100644 src/Test/Behat/src/DependencyInjection/BehatServicesCompilerPass.php rename src/Test/Behat/{ => src}/Exception/DamaNativeExtensionIncompatibility.php (100%) rename src/Test/Behat/{ => src}/Exception/FactoryNotResolvable.php (97%) rename src/Test/Behat/{ => src}/Exception/InvalidObjectParameter.php (97%) rename src/Test/Behat/{ => src}/Exception/InvalidResetDbTag.php (96%) rename src/Test/Behat/{ => src}/Exception/ObjectAlreadyRegistered.php (96%) rename src/Test/Behat/{ => src}/Exception/ObjectNotFound.php (97%) rename src/Test/Behat/{ => src}/FactoryShortNameResolver.php (98%) rename src/Test/Behat/{ => src}/FoundryCallFilter.php (100%) create mode 100644 src/Test/Behat/src/FoundryContext.php rename src/Test/Behat/{ => src}/FoundryExtension.php (97%) rename src/Test/Behat/{ => src}/FoundryTableNode.php (100%) rename src/Test/Behat/{ => src}/Listener/BootConfigurationListener.php (100%) rename src/Test/Behat/{ => src}/Listener/DatabaseResetListener.php (99%) rename src/Test/Behat/{ => src}/Listener/LoadFixturesListener.php (100%) rename src/Test/Behat/{ => src}/ObjectRegistry.php (94%) create mode 100644 src/Test/Behat/symfony.lock create mode 100755 src/Test/Behat/symlink-vendor.sh rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/BehatTestKernel.php (73%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/Factories/Tag/TagFactory.php (81%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/Factories/TagFactory.php (86%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/ResetDisabledTestContext.php (93%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/Stories/CategoryStory.php (91%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/Stories/ConflictStory1.php (91%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/Stories/ConflictStory2.php (91%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/Stories/ContactStory.php (91%) rename {tests/Fixture/Behat => src/Test/Behat/tests/Fixture}/TestFoundryContext.php (90%) rename {tests/Integration/Behat => src/Test/Behat/tests/Integration}/Listener/BootConfigurationListenerTest.php (80%) rename {tests/Integration/Behat => src/Test/Behat/tests/Integration}/Listener/DatabaseResetListenerTest.php (94%) rename {tests/Integration/Behat => src/Test/Behat/tests/Integration}/Listener/LoadFixturesListenerTest.php (91%) rename {tests/Unit/Test/Behat => src/Test/Behat/tests/Unit}/FactoryResolverTest.php (96%) rename {tests/Unit/Test/Behat => src/Test/Behat/tests/Unit}/FoundryCallFilterTest.php (98%) rename {tests/Unit/Test/Behat => src/Test/Behat/tests/Unit}/FoundryTableNodeTest.php (96%) rename {tests/Unit/Test/Behat => src/Test/Behat/tests/Unit}/ObjectRegistryTest.php (96%) create mode 100644 src/Test/Behat/tests/bootstrap.php diff --git a/.gitattributes b/.gitattributes index f7b781fda..fc7972c4f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,7 @@ /phpunit-9.xml.dist export-ignore /phpunit-paratest.xml.dist export-ignore /psalm.xml export-ignore +/src/Test/Behat export-ignore /stubs export-ignore /symfony.lock export-ignore /tests export-ignore diff --git a/.github/workflows/bc-check.yml b/.github/workflows/bc-check.yml index e99f34fd0..577d3e7a1 100644 --- a/.github/workflows/bc-check.yml +++ b/.github/workflows/bc-check.yml @@ -22,16 +22,16 @@ jobs: with: fetch-depth: 0 - - name: Install dependencies - uses: ramsey/composer-install@v2 - with: - composer-options: --prefer-dist - - name: Install PHP with extensions. uses: shivammathur/setup-php@v2 with: php-version: "8.5" + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + composer-options: --prefer-dist + - name: Install roave/backward-compatibility-check. run: composer bin bc-check require roave/backward-compatibility-check diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index d04cdd72e..07400a321 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -4,23 +4,24 @@ on: push: paths: &paths - .github/workflows/behat.yml - - bin/tools/behat - - src/Behat/** - - tests/Behat/** - - behat.yml - - composer.json + - src/Test/Behat/** pull_request: paths: *paths schedule: - cron: '0 0 1,16 * *' +defaults: + run: + shell: bash + working-directory: ./src/Test/Behat + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: behat: - name: P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} + name: Behat - P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -28,11 +29,6 @@ jobs: php: [ 8.3, 8.5 ] symfony: [ 6.4.*, 7.4.* ] deps: [ highest, lowest ] - env: - DATABASE_URL: 'sqlite:///%kernel.project_dir%/var/data.db' - USE_DAMA_DOCTRINE_TEST_BUNDLE: 1 - USE_PHP_84_LAZY_OBJECTS: 1 - MONGO_URL: '' steps: - name: Checkout code uses: actions/checkout@v3 @@ -51,16 +47,12 @@ jobs: - name: Install dependencies uses: ramsey/composer-install@v2 with: + working-directory: "src/Test/Behat" dependency-versions: ${{ matrix.deps }} composer-options: --prefer-dist env: SYMFONY_REQUIRE: ${{ matrix.symfony }} - - name: Install Behat - run: composer bin behat update ${{ matrix.deps == 'lowest' && '--prefer-lowest' || '' }} --prefer-dist - env: - SYMFONY_REQUIRE: ${{ matrix.symfony }} - - name: "Main test suite: reset DB at scenario level, with Dama support" run: vendor/bin/behat --colors -vvv @@ -84,3 +76,67 @@ jobs: - name: Reset DB disabled run: vendor/bin/behat --colors -vvv --profile=reset-disabled + + phpunit: + name: PHPUnit - P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ 8.3, 8.5 ] + symfony: [ 6.4.*, 7.4.* ] + deps: [ highest, lowest ] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: flex + + - name: Add the polyfill compatible with Doctrine ^2.16 + if: ${{ matrix.deps == 'lowest' }} + run: composer require --dev symfony/polyfill-php80:^1.16 --no-update + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: "src/Test/Behat" + dependency-versions: ${{ matrix.deps }} + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: Run tests + run: vendor/bin/phpunit + + phpstan: + name: PhpStan + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + coverage: none + tools: flex + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: "src/Test/Behat" + composer-options: --prefer-dist + env: + SYMFONY_REQUIRE: 7.4.* + + - name: Install PHPStan + run: composer bin phpstan install + + - name: Run PHPStan + run: ../../../bin/tools/phpstan/vendor/phpstan/phpstan/phpstan diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 09a83e6d5..0957a6c90 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -47,10 +47,8 @@ jobs: - name: Install PHPStan run: composer bin phpstan install - - name: Install PHPBench & Behat - run: | - composer bin phpbench install - composer bin behat install + - name: Install PHPBench + run: composer bin phpbench install - name: Run PHPStan run: bin/tools/phpstan/vendor/phpstan/phpstan/phpstan analyse diff --git a/bin/console b/bin/console index 3bd74f042..4f02aa12c 100755 --- a/bin/console +++ b/bin/console @@ -6,13 +6,6 @@ use Zenstruck\Foundry\Tests\Fixture\TestKernel; require_once __DIR__ . '/../tests/bootstrap.php'; -foreach ($argv ?? [] as $i => $arg) { - if (($arg === '--env' || $arg === '-e') && isset($argv[$i + 1])) { - $_ENV['APP_ENV'] = $argv[$i + 1]; - break; - } -} - -$application = new Application(new TestKernel($_ENV['APP_ENV'], true)); +$application = new Application(new TestKernel('test', true)); $application->run(); diff --git a/bin/tools/behat/.gitignore b/bin/tools/behat/.gitignore deleted file mode 100644 index 17fb14310..000000000 --- a/bin/tools/behat/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/composer.lock diff --git a/bin/tools/behat/composer.json b/bin/tools/behat/composer.json deleted file mode 100644 index 86c281381..000000000 --- a/bin/tools/behat/composer.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "require": { - "behat/behat": "^3.22", - "behat/mink-browserkit-driver": "^2.0", - "friends-of-behat/mink-extension": "^2.0", - "friends-of-behat/symfony-extension": "^2.0", - "symfony/flex": "^2.10", - "yceruto/behat-extension": "^1.0.2", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33" - }, - "config": { - "allow-plugins": { - "symfony/flex": true - } - } -} diff --git a/bin/tools/behat/symfony.lock b/bin/tools/behat/symfony.lock deleted file mode 100644 index dade0c32b..000000000 --- a/bin/tools/behat/symfony.lock +++ /dev/null @@ -1,49 +0,0 @@ -{ - "friends-of-behat/symfony-extension": { - "version": "2.6", - "recipe": { - "repo": "github.com/symfony/recipes-contrib", - "branch": "main", - "version": "2.0", - "ref": "1e012e04f573524ca83795cd19df9ea690adb604" - } - }, - "symfony/console": { - "version": "7.4", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "5.3", - "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" - }, - "files": [ - "bin/console" - ] - }, - "symfony/flex": { - "version": "2.10", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "2.4", - "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" - }, - "files": [ - ".env", - ".env.dev" - ] - }, - "symfony/translation": { - "version": "7.4", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "6.3", - "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" - }, - "files": [ - "config/packages/translation.yaml", - "translations/.gitignore" - ] - } -} diff --git a/composer.json b/composer.json index 143cd65f0..79762284a 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "symfony/polyfill-php85": "^1.33", "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/string": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2|^8.0", "zenstruck/assert": "^1.4" }, @@ -47,7 +46,6 @@ "symfony/framework-bundle": "^6.4|^7.0|^8.0", "symfony/maker-bundle": "^1.55", "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", - "symfony/polyfill-php80": "^1.16", "symfony/routing": "^6.4|^7.0|^8.0", "symfony/runtime": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^3.4", @@ -66,7 +64,8 @@ "src/functions.php", "src/Persistence/functions.php", "src/symfony_console.php" - ] + ], + "exclude-from-classmap": ["src/Test/Behat/"] }, "autoload-dev": { "psr-4": { @@ -103,9 +102,6 @@ "allow-contrib": false } }, - "scripts": { - "post-install-cmd": ["@composer bin phpstan install", "@composer bin phpbench install", "@composer bin behat install"] - }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/docs/index.rst b/docs/index.rst index 5951abd10..d77c843e6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1634,6 +1634,8 @@ You can use the ``#[WithStory]`` attribute to load stories in your tests: If used on the class, the story will be loaded before each test method. +.. _as-fixture-attribute: + Local Development Fixtures -------------------------- @@ -2728,16 +2730,16 @@ This extension provides the following features: Behat Integration ----------------- -Foundry provides a Behat extension that allows you to use factories and fixtures in your BDD tests. +Foundry provides a Behat extension that correctly boots the Foundry for you. Installation ~~~~~~~~~~~~ -1. Install Behat and the Symfony extension: +1. The Behat extension is shipped as an independent packagist package, so you need to install it in addition to the main Foundry package: .. code-block:: terminal - $ composer require --dev behat/behat friends-of-behat/symfony-extension + $ composer require --dev zenstruck/foundry-behat 2. Enable the Foundry extension in your ``behat.yaml``: @@ -2745,34 +2747,59 @@ Installation default: extensions: - Zenstruck\Foundry\Test\Behat\FoundryExtension: - database_reset_mode: scenario # or: feature, manual, disabled - FriendsOfBehat\SymfonyExtension: ~ - Behat\MinkExtension: - sessions: - symfony: - symfony: ~ + Zenstruck\Foundry\Test\Behat\FoundryExtension: ~ - suites: - default: - contexts: - - Behat\MinkExtension\Context\MinkContext - - Zenstruck\Foundry\Test\Behat\FoundryContext + # SymfonyExtension is required for the Foundry Behat extension to work, so make sure to enable it as well + FriendsOfBehat\SymfonyExtension: ~ Database Reset Modes ~~~~~~~~~~~~~~~~~~~~ +Like in PHPUnit, using the ``ResetDatabase`` attribute, Foundry can be configured to automatically reset the database +in your Behat tests. This is useful to ensure a clean state for your tests. You can configure this in your ``behat.yaml``: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario # or "feature" or "manual" + The ``database_reset_mode`` option controls when the database is reset: -- ``scenario``: Reset before each scenario (default) +- ``disabled``: Never reset automatically (default) +- ``scenario``: Reset before each scenario - ``feature``: Reset before each feature file - ``manual``: Only reset when using the ``@resetDB`` tag -- ``disabled``: Never reset automatically + +.. tip:: + + With ``scenario`` mode, you can skip the reset for a specific scenario with the ``@noResetDB`` tag. + + .. code-block:: gherkin + + @noResetDB + Scenario: Keep data from previous scenario + Then 1 contact should exist + +.. tip:: + + When using ``database_reset_mode: manual`` or ``database_reset_mode: feature``, + you can force a reset using the ``@resetDB`` tag: + + .. code-block:: gherkin + + @resetDB + Scenario: Start with fresh database + Then 0 contacts should exist DAMA DoctrineTestBundle Support -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +............................... + +This Behat extension supports `DAMADoctrineTestBundle`_ out of the box. But some features are not compatible with its +native extension, such as the ``@noResetDB`` tag or the ``feature`` and ``manual`` database reset modes. -For faster tests using database transactions: +To use these features along with DAMA DoctrineTestBundle, enable Foundry's own DAMA support: .. code-block:: yaml @@ -2787,77 +2814,209 @@ For faster tests using database transactions: When using Foundry's DAMA support, do not enable the native DAMA Behat extension (``DAMA\DoctrineTestBundle\Behat\ServiceContainer\DoctrineExtension``). -Available Steps -~~~~~~~~~~~~~~~ +Built-in Behat Context +~~~~~~~~~~~~~~~~~~~~~~ -**Creating objects:** +Foundry ships a built-in context with steps definitions, which helps to create objects, access them and make assertions on them. +To use it, add the following to your ``behat.yaml``: + +.. code-block:: yaml + + default: + suites: + main: + contexts: + - Zenstruck\Foundry\Test\Behat\FoundryContext + +Create objects +.............. .. code-block:: gherkin # Create a single object - Given a contact is created - Given a contact "john" is created + # "contact" is a short name resolved from the factory class name (ContactFactory → contact) + Given there is a contact - # Create with properties - Given a contact "john" is created with properties + # Create a named object: this name will be useful for later reference + Given there is a contact "john" + + # You can also use "called" or "named" keywords + Given there is a contact called "john" + Given there is a contact named "john" + + # Create an object with properties + Given there is a contact called "john" with | name | email | | John Doe | john@email.com | # Create multiple objects - Given contacts are created with properties + # Notice that you can use the plural form of the factory name + Given there are contacts with | _ref | name | | A | John Doe | | B | Jane Doe | The ``_ref`` column is a special column that allows you to name the created objects for later reference. -**Referencing objects:** +.. tip:: + + Factory short names and object names can be either quoted or unquoted. Quoting is required when the name + contains spaces (e.g. ``"blog post"``): + + .. code-block:: gherkin + + # both are equivalent + Given there is a "contact" "john" + Given there is a contact john + + # quoting is required for multi-word factory names + Given there is a "blog post" named "Foundry rocks" -Objects are automatically resolved based on property types. If a property expects an object -and you provide a string that matches a previously created object name, it will be resolved automatically: +.. note:: + + You can use custom names or disambiguate factory names by using the attribute ``#[FactoryShortName]``: + + :: + + use Zenstruck\Foundry\Attribute\FactoryShortName; + + #[FactoryShortName(shortName: 'person', pluralName: 'people')] + final class PersonFactory extends PersistentObjectFactory + {} + +Type handling in table values +............................. + +When using properties in Gherkin tables, Foundry automatically converts string values based on the target property type: + +.. code-block:: gherkin + + Given there is a post "my-post" with + | title | category | publishedAt | status | body | + | My Post | tech | 2026-01-15 | published | null | + +- **Object references**: If the property type has a registered factory, Foundry looks up the object by name in the registry + (e.g. ``tech`` resolves to the ``Category`` object named "tech") - see below +- **null**: The literal string ``null`` is converted to PHP ``null`` +- **Booleans**: ``true`` and ``false`` are converted to PHP booleans +- **Dates**: If the property type implements ``DateTimeInterface``, the value is parsed using PHP's native date parsing + (e.g. ``2026-01-15``, ``yesterday``, ``+1 week``) +- **Enums**: If the property type is a ``BackedEnum``, the value is resolved using ``::from()`` + +Referencing objects in another object +..................................... .. code-block:: gherkin - Given a category "tech" is created - Given a post "my-post" is created with properties - | title | category | - | My Post | tech | + Given there is a category "tech" + Given there is a post "my-post" with + | title | category | + | My Post | tech | -The ``category`` property expects a ``Category`` object. Foundry detects this and looks up -the object named "tech" in the registry. +The property ``Post::$category`` expects a ``Category`` object. Foundry detects this and looks up +the category named "tech" in the registry. -**Assertions:** +.. tip:: + + If for any reason the property type cannot be resolved, you can use the special syntax ````: + + .. code-block:: gherkin + + Given there is a category "tech" + Given there is a post "my-post" with + | title | category | + | My Post | | + +.. note:: + + Objects can be referenced by their name until the next database reset occurs. How long they persist depends on your + ``database_reset_mode``. In any case, the object registry is always cleared between features, even when using + ``database_reset_mode: disabled`` (the data may still exist in the database, but the named references are lost). + This is also true for the assertions and id access described below. + +Assertions +.......... .. code-block:: gherkin + # count objects of a type Then 2 contacts should exist + + # assert a specific object has properties Then contact "john" should have properties | name | | John Doe | - Then contact object named "john" should exist - Then contact object named "jane" should not exist -**Using last created ID:** + # You can also use various natural language forms: + Then the contact "john" should exist and have properties + | name | + | John Doe | + + # assert if objects are persisted or not + Then contact "john" should exist + Then contact "jane" should not exist + +Accessing ids of created objects +................................ .. code-block:: gherkin - Given a contact "john" is created + Given there is a contact "john" + + # Access the last id created When I am on "/contacts/" - # Or for a specific type: + + # Or the last id for specific type: When I am on "/contacts/" -Loading Fixtures with Tags -~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Be even more specific with the name: + When I am on "/contacts/" -Use the ``@withFixture`` tag to load a Story before a scenario: +.. warning:: -.. code-block:: gherkin + The ```` and ```` syntax only work for object which have "simple" ids (integer, string or Uuid). + This means it won't work for objects with `composite keys `_ + nor for `derived entities `_. - @withFixture(my-contacts) - Scenario: View contacts - Then 3 contacts should exist +.. note:: + + As for object references, the ids can also be accessed until the next database reset occurs. And they are also cleared after each feature. + +Overriding built-in step definitions +.................................... + +If the built-in step definitions don't fit your need or conflict with your own contexts and step definitions, you can +define you own "Foundry context" with the definitions' name of your choice: +- Create your own context class which extends ``Zenstruck\Foundry\Test\Behat\AbstractFoundryContext`` +- Declare a service for your own context: + +.. code-block:: yaml -First, mark your Story with the ``#[AsFixture]`` attribute: + # config/services.yaml + when@test: + services: + App\Tests\Behat\Context\CustomFoundryContext: + parent: zenstruck_foundry.behat.context.parent + +You can use ``Zenstruck\Foundry\Test\Behat\FoundryContext`` as an example of how to implement the step definitions, and +define your own language to create objects with Foundry. + +.. warning:: + + Your custom step definitions must use the same "capturing groups" with the same names as the built-in ones + (``factoryShortName`` and ``objectName``). Otherwise, some of the definitions won't work. + +.. warning:: + + By overriding built-in step definitions, you won't automatically benefit from the potentially new definition + that we will add to the built-in context in the future. You will need to manually add them to your own context. + +Loading Fixtures with Tags +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Foundry's Behat extension leverages :ref:`the #[AsFixture] attribute <_as-fixture-attribute>` in order to load stories. + +First, mark a Story with the ``#[AsFixture]`` attribute: :: @@ -2875,54 +3034,53 @@ First, mark your Story with the ``#[AsFixture]`` attribute: } } -Objects added via ``addState()`` are automatically available in the object registry and can be -referenced in your scenarios. - -Manual Database Reset -~~~~~~~~~~~~~~~~~~~~~ - -When using ``database_reset_mode: manual`` or ``database_reset_mode: feature``, -you can force a reset using the ``@resetDB`` tag: +Then use the ``@withFixture`` tag to load a Story before a scenario: .. code-block:: gherkin - @resetDB - Scenario: Start with fresh database - Then 0 contacts should exist + @withFixture(my-contacts) + Scenario: View contacts + Then 3 contacts should exist -In ``database_reset_mode: scenario``, you can skip the reset with ``@noresetDB``: + Scenario: Assert john exists + # Objects added with "addState()" are automatically available in the objects registry and can be + # referenced in your scenarios. + Then contact "john" should have properties + | name | + | John | + +The ``@withFixture`` tag can also be used on a **feature** to load fixtures once for all scenarios in that feature: .. code-block:: gherkin - @noresetDB - Scenario: Keep data from previous scenario - Then 1 contact should exist + @withFixture(my-contacts) + Feature: Contacts management + Scenario: List contacts + Then 3 contacts should exist -Customizing Factory Short Names -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Scenario: Find john + Then contact "john" should exist -By default, factories are resolved by their class name (``ContactFactory`` → ``contact``). -You can customize this with the ``#[FactoryShortName]`` attribute: +.. tip:: -:: + When combining ``@withFixture`` with ``@resetDB`` (either on a scenario or inherited from the feature), the fixtures + are automatically reloaded after the database is reset. - use Zenstruck\Foundry\Attribute\FactoryShortName; + .. code-block:: gherkin - #[FactoryShortName('person', 'people')] - final class ContactFactory extends PersistentObjectFactory - { - // ... - } + @withFixture(my-contacts) + Feature: Contacts management + Scenario: List all contacts + Then 3 contacts should exist -Now you can use: + @resetDB + Scenario: Start fresh but still have fixtures + # Database was reset, but fixtures were reloaded + Then 3 contacts should exist -.. code-block:: gherkin +.. note:: - Given a person is created - Given people are created with properties - | name | - | John | - | Jane | + ``@withFixture`` also works with Scenario Outlines (``Examples`` tables). Bundle Configuration -------------------- diff --git a/phpstan.neon b/phpstan.neon index 2fa61342d..3ac64ac3f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -20,7 +20,6 @@ parameters: bootstrapFiles: - bin/tools/phpbench/vendor/autoload.php - - bin/tools/behat/vendor/autoload.php ignoreErrors: # suppress strange behavior of PHPStan where it considers proxy() return type as *NEVER* @@ -66,6 +65,7 @@ parameters: excludePaths: - config/reference.php (?) + - tests/Fixture/Maker/tmp (?) - tests/Fixture/Maker/expected/can_create_factory_with_auto_activated_not_persisted_option.php - tests/Fixture/Maker/expected/can_create_factory_interactively.php @@ -76,3 +76,5 @@ parameters: - ./src/Maker/Factory/LegacyORMDefaultPropertiesGuesser.php - ./src/Maker/Factory/DoctrineScalarFieldsDefaultPropertiesGuesser.php - ./src/ORM/OrmV2PersistenceStrategy.php + + - ./src/Test/Behat/ diff --git a/phpunit-9.xml.dist b/phpunit-9.xml.dist index 327cb3f02..6dab9a281 100644 --- a/phpunit-9.xml.dist +++ b/phpunit-9.xml.dist @@ -42,6 +42,9 @@ src + + src/Test/Behat + diff --git a/phpunit-paratest.xml.dist b/phpunit-paratest.xml.dist index 7a0fdda7c..5ce50bb3a 100644 --- a/phpunit-paratest.xml.dist +++ b/phpunit-paratest.xml.dist @@ -26,14 +26,15 @@ tests tests/Integration/ResetDatabase - tests/Unit/Test/Behat - tests/Unit/Integration/Behat src + + src/Test/Behat + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 415e87b18..122b7c9f6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -36,5 +36,8 @@ src + + src/Test/Behat + diff --git a/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php b/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php index 307b6c179..f0f9adc60 100644 --- a/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php +++ b/src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php @@ -13,6 +13,8 @@ /** * @internal + * + * todo: ⚠️ this class is problematic and should be at least refactored or removed */ final class NoPersistenceObjectsAutoCompleter { @@ -38,6 +40,11 @@ public function getAutocompleteValues(): array continue; } + if (\str_contains($phpFile->getRealPath(), 'var/cache') + || \str_contains($phpFile->getRealPath(), 'src/Test/Behat')) { + continue; + } + $class = $this->toPSR4($rootPath, $phpFile, $namespacePrefix); if (\in_array($class, ['Zenstruck\Foundry\Proxy', 'Zenstruck\Foundry\RepositoryProxy', 'Zenstruck\Foundry\RepositoryAssertions'])) { diff --git a/src/Test/Behat/.env b/src/Test/Behat/.env new file mode 100644 index 000000000..27cb0691b --- /dev/null +++ b/src/Test/Behat/.env @@ -0,0 +1,4 @@ +DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" +MONGO_URL="" +USE_DAMA_DOCTRINE_TEST_BUNDLE="1" +USE_PHP_84_LAZY_OBJECTS="1" diff --git a/src/Test/Behat/.gitattributes b/src/Test/Behat/.gitattributes new file mode 100644 index 000000000..87ff4750b --- /dev/null +++ b/src/Test/Behat/.gitattributes @@ -0,0 +1,8 @@ +* text=auto + +/.gitattributes export-ignore +/.gitignore export-ignore +/behat.yml export-ignore +/features export-ignore +/symfony.lock export-ignore +/tests export-ignore diff --git a/src/Test/Behat/.gitignore b/src/Test/Behat/.gitignore new file mode 100644 index 000000000..6c328ae65 --- /dev/null +++ b/src/Test/Behat/.gitignore @@ -0,0 +1,6 @@ +/composer.lock +/vendor/ +/var/ +/.env.local +/.phpunit.cache/ +/config/reference.php diff --git a/src/Test/Behat/README.md b/src/Test/Behat/README.md new file mode 100644 index 000000000..23e48db79 --- /dev/null +++ b/src/Test/Behat/README.md @@ -0,0 +1,7 @@ +# Behat extension for Foundry + +This is a subpackage of [Foundry](https://github.com/zenstruck/foundry). + +Please open any issue or pull request on the main repository. + +See docs at: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#behat-integration diff --git a/behat.yml b/src/Test/Behat/behat.yml similarity index 78% rename from behat.yml rename to src/Test/Behat/behat.yml index 952679851..b9a7d57fa 100644 --- a/behat.yml +++ b/src/Test/Behat/behat.yml @@ -17,14 +17,14 @@ default: FriendsOfBehat\SymfonyExtension: bootstrap: tests/bootstrap.php kernel: - class: Zenstruck\Foundry\Tests\Fixture\Behat\BehatTestKernel + class: Zenstruck\Foundry\Test\Behat\Tests\Fixture\BehatTestKernel suites: main: - paths: [tests/behatFeatures/main] + paths: [features/main] contexts: &common_contexts - Behat\MinkExtension\Context\MinkContext - - Zenstruck\Foundry\Tests\Fixture\Behat\TestFoundryContext + - Zenstruck\Foundry\Test\Behat\Tests\Fixture\TestFoundryContext main-no-dama: extensions: @@ -35,7 +35,7 @@ main-no-dama: suites: main: false main-no-dama: - paths: [tests/behatFeatures/main] + paths: [features/main] contexts: *common_contexts main-native-dama: @@ -48,7 +48,7 @@ main-native-dama: suites: main: false main-native-dama: - paths: [tests/behatFeatures/main] + paths: [features/main] contexts: *common_contexts reset-manual: @@ -60,7 +60,7 @@ reset-manual: suites: main: false reset-manual: - paths: [tests/behatFeatures/reset-manual] + paths: [features/reset-manual] contexts: *common_contexts reset-manual-dama: @@ -72,7 +72,7 @@ reset-manual-dama: suites: main: false reset-manual-dama: - paths: [tests/behatFeatures/reset-manual] + paths: [features/reset-manual] contexts: *common_contexts reset-feature: @@ -84,7 +84,7 @@ reset-feature: suites: main: false reset-feature: - paths: [tests/behatFeatures/reset-feature] + paths: [features/reset-feature] contexts: *common_contexts @@ -97,7 +97,7 @@ reset-feature-dama: suites: main: false reset-feature-dama: - paths: [tests/behatFeatures/reset-feature] + paths: [features/reset-feature] contexts: *common_contexts reset-disabled: @@ -109,8 +109,8 @@ reset-disabled: suites: main: false reset-disabled: - paths: [tests/behatFeatures/reset-disabled] + paths: [features/reset-disabled] contexts: - Behat\MinkExtension\Context\MinkContext - - Zenstruck\Foundry\Tests\Fixture\Behat\TestFoundryContext - - Zenstruck\Foundry\Tests\Fixture\Behat\ResetDisabledTestContext + - Zenstruck\Foundry\Test\Behat\Tests\Fixture\TestFoundryContext + - Zenstruck\Foundry\Test\Behat\Tests\Fixture\ResetDisabledTestContext diff --git a/src/Test/Behat/bin/console b/src/Test/Behat/bin/console new file mode 100755 index 000000000..46a950573 --- /dev/null +++ b/src/Test/Behat/bin/console @@ -0,0 +1,18 @@ +#!/usr/bin/env php + $arg) { + if (($arg === '--env' || $arg === '-e') && isset($argv[$i + 1])) { + $_ENV['APP_ENV'] = $argv[$i + 1]; + break; + } +} + +$application = new Application(new BehatTestKernel($_ENV['APP_ENV'], true)); + +$application->run(); diff --git a/src/Test/Behat/composer.json b/src/Test/Behat/composer.json new file mode 100644 index 000000000..2f3239d95 --- /dev/null +++ b/src/Test/Behat/composer.json @@ -0,0 +1,74 @@ +{ + "name": "zenstruck/foundry-behat", + "type": "behat-extension", + "description": "Behat extension for zenstruck/foundry.", + "homepage": "https://github.com/zenstruck/foundry", + "license": "MIT", + "keywords": ["fixture", "factory", "test", "symfony", "dev", "behat"], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + }, + { + "name": "Nicolas PHILIPPE", + "email": "nikophil@gmail.com" + } + ], + "autoload": { + "psr-4": { + "Zenstruck\\Foundry\\Test\\Behat\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Zenstruck\\Foundry\\Test\\Behat\\Tests\\": "tests", + "Zenstruck\\Foundry\\Tests\\Fixture\\": "../../../tests/Fixture" + } + }, + "require": { + "php": "^8.1", + "behat/behat": "^3.22", + "friends-of-behat/symfony-extension": "^2.0", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/string": "^6.4|^7.0", + "zenstruck/foundry": "dev-behat-extension" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.9", + "behat/mink-browserkit-driver": "^2.0", + "dama/doctrine-test-bundle": "^8.6", + "doctrine/common": "^3.2.2", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.16|^3.0", + "friends-of-behat/mink-extension": "^2.0", + "phpunit/phpunit": "^12.5", + "symfony/dotenv": "^6.4|^7.0", + "symfony/flex": "^2.10", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "yceruto/behat-extension": "^1.0.2" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "symfony/flex": true, + "bamarni/composer-bin-plugin": true + } + }, + "extra": { + "symfony": { + "allow-contrib": false + }, + "bamarni-bin": { + "target-directory": "../../../bin/tools", + "forward-command": false + } + }, + "scripts": { + "post-install-cmd": ["./symlink-vendor.sh"], + "post-update-cmd": ["./symlink-vendor.sh"] + } +} diff --git a/config/behat.php b/src/Test/Behat/config/behat.php similarity index 83% rename from config/behat.php rename to src/Test/Behat/config/behat.php index 304046ac1..bdb75a268 100644 --- a/config/behat.php +++ b/src/Test/Behat/config/behat.php @@ -13,6 +13,7 @@ use Zenstruck\Foundry\Persistence\Event\AfterPersist; use Zenstruck\Foundry\Story\Event\StateAddedToStory; +use Zenstruck\Foundry\Test\Behat\AbstractFoundryContext; use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; use Zenstruck\Foundry\Test\Behat\FoundryContext; use Zenstruck\Foundry\Test\Behat\ObjectRegistry; @@ -34,11 +35,18 @@ ->tag('kernel.event_listener', ['method' => 'storeAfterStateAddedToStory', 'event' => StateAddedToStory::class]) ->public() - ->set(FoundryContext::class, FoundryContext::class) - ->autowire() - ->autoconfigure() + ->set('zenstruck_foundry.behat.context.parent', AbstractFoundryContext::class) ->args([ service('.zenstruck_foundry.behat.factory_resolver'), service('.zenstruck_foundry.behat.object_registry'), - ]); + ]) + ->abstract() + ->public() + ->autowire() + ->autoconfigure() + + // service name is not hidden on purpose + ->set(FoundryContext::class, FoundryContext::class) + ->parent('zenstruck_foundry.behat.context.parent') + ; }; diff --git a/tests/behatFeatures/main/create-objects.feature b/src/Test/Behat/features/main/create-objects.feature similarity index 79% rename from tests/behatFeatures/main/create-objects.feature rename to src/Test/Behat/features/main/create-objects.feature index 3646db232..26847ab88 100644 --- a/tests/behatFeatures/main/create-objects.feature +++ b/src/Test/Behat/features/main/create-objects.feature @@ -1,7 +1,7 @@ Feature: Test objects creation Scenario: Can create entity with properties via PyTable - Given a contact A is created with properties + Given there is a contact A with | name | | John Doe | Then 1 contact should exist @@ -9,20 +9,38 @@ Feature: Test objects creation | name | | John Doe | + Scenario: Can create entity with "called" variant + Given there is a contact called B with + | name | + | Jane Doe | + Then 1 contact should exist + Then the contact called B should have properties + | name | + | Jane Doe | + + Scenario: Can create entity with "named" variant + Given there is a contact named C with + | name | + | Bob Doe | + Then 1 contact should exist + Then contact named C should have properties + | name | + | Bob Doe | + Scenario: Can create one named entity with two lines in the PyTable (!) - Given a contact A is created with properties + Given there is a contact A with | name | | John Doe | | Jane Doe | Then an "InvalidArgumentException" exception should be thrown containing message "Expected exactly one line of properties, to create one object" Scenario: Can create entity with properties via PyTable (!) - Given a "i don't exist" is created + Given there is a "i don't exist" Then an "FactoryNotResolvable" exception should be thrown containing message "Cannot resolve factory for name \"i don't exist\"" Scenario: Multiple objects created with the same reference is handled (!) - Given a contact A is created - And a contact A is created + Given there is a contact A + And there is a contact A Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" Scenario: Reference to a non existent objet handled (!) @@ -32,14 +50,14 @@ Feature: Test objects creation Then an "ObjectNotFound" exception should be thrown containing message "Object \"contact I don't exist\" was not found" Scenario: Invalid property name handled (!) - Given a contact A is created with properties + Given there is a contact A with | foo | | bar | Then an "InvalidArgumentException" exception should be thrown containing message "Cannot set attribute \"foo\" for object" Scenario: Can create multiple entities via PyTable Then 0 contacts should exist - Given contacts are created with properties + Given there are contacts with | _ref | name | | A | John Doe | | B | Jane Doe | @@ -52,16 +70,16 @@ Feature: Test objects creation | Jane Doe | Scenario: Multiple objects created within a table with the same reference is handled (!) - Given contacts are created with properties + Given there are contacts with | _ref | name | | A | John Doe | | A | Jane Doe | Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" Scenario: Can reference another object - Given a category MyCategory is created - And an address "the address" is created - And a contact A is created with properties + Given there is a category MyCategory + And there is an address "the address" + And there is a contact A with | name | category | address | | John Doe | | | When I am on "/" @@ -73,9 +91,9 @@ Feature: Test objects creation Then 1 address should exist Scenario: Can reference another object with short syntax - Given a category MyCategory is created - And an address "the address" is created - And a contact A is created with properties + Given there is a category MyCategory + And there is an address "the address" + And there is a contact A with | name | category | address | | John Doe | MyCategory | the address | When I am on "/" @@ -84,7 +102,7 @@ Feature: Test objects creation | John Doe | MyCategory | the address | Scenario: Can reference object with date - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | When I am on "/" @@ -93,7 +111,7 @@ Feature: Test objects creation | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | Scenario: Wrong assertion on string correctly handled (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | | foo | Then "generic entity" "GE" should have properties @@ -102,7 +120,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/foo(.*)bar/" Scenario: Wrong assertion on string correctly handled (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | propInteger | | foo | 1 | Then "generic entity" "GE" should have properties @@ -111,7 +129,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/1(.*)42/" Scenario: Wrong assertion on date correctly handled (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | date | | foo | 2026-01-01 | Then "generic entity" "GE" should have properties @@ -120,7 +138,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/2026/" Scenario: Wrong assertion on bool correctly handled (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | bool | | foo | true | Then "generic entity" "GE" should have properties @@ -129,7 +147,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/true(.*)false/" Scenario: Wrong assertion on bool correctly handled (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | bool | | foo | false | Then "generic entity" "GE" should have properties @@ -138,7 +156,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/false(.*)true/" Scenario: Wrong assertion on enum correctly handled (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | stringEnum | | foo | some_value | Then "generic entity" "GE" should have properties @@ -147,7 +165,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/StringBackedEnum/" Scenario: Can compare null - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | bool | | foo | null | When I am on "/" @@ -156,7 +174,7 @@ Feature: Test objects creation | foo | null | Scenario: Wrong assertion with null works (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | bool | | foo | null | When I am on "/" @@ -166,7 +184,7 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/null(.*)null/" Scenario: Wrong assertion with null works in the other way (!) - Given a "generic entity" "GE" is created with properties + Given there is a "generic entity" "GE" with | prop1 | date | | foo | 2026-01-01 | When I am on "/" @@ -176,14 +194,14 @@ Feature: Test objects creation Then an "AssertionFailedError" exception should be thrown matching pattern "/DateTimeImmutable(.*)null/" Scenario: Can use a factory with disambiguated name - Given a "tag2" is created + Given there is a "tag2" Then 1 tag2 should exist Scenario: Can use a factory with changed name & plural - Given a "child of contact" is created - And a "child of contact" is created + Given there is a "child of contact" + And there is a "child of contact" Then 2 "children of contact" should exist Scenario: Cannot use a factory with ambiguous name (!) - Given a "tag" is created + Given there is a "tag" Then an "FactoryNotResolvable" exception should be thrown containing message "Multiple factories found for name \"tag\"" diff --git a/tests/behatFeatures/main/no-reset-db.feature b/src/Test/Behat/features/main/no-reset-db.feature similarity index 85% rename from tests/behatFeatures/main/no-reset-db.feature rename to src/Test/Behat/features/main/no-reset-db.feature index 489b84a8c..9851db900 100644 --- a/tests/behatFeatures/main/no-reset-db.feature +++ b/src/Test/Behat/features/main/no-reset-db.feature @@ -2,13 +2,13 @@ Feature: Skip database reset with @noResetDB tag Scenario: First scenario creates data - Given a contact is created + Given there is a contact Then 1 contact should exist @noResetDB Scenario: Data persists with @noResetDB tag Then 1 contact should exist - Given a contact is created + Given there is a contact Then 2 contacts should exist Scenario: Normal reset resumes after @noResetDB diff --git a/tests/behatFeatures/main/persist-entities.feature b/src/Test/Behat/features/main/persist-entities.feature similarity index 68% rename from tests/behatFeatures/main/persist-entities.feature rename to src/Test/Behat/features/main/persist-entities.feature index 952ee5e40..5c0b3bdff 100644 --- a/tests/behatFeatures/main/persist-entities.feature +++ b/src/Test/Behat/features/main/persist-entities.feature @@ -7,16 +7,16 @@ Feature: Test persisting entities Scenario: Can persist entities # Can name entities - Given a contact A is created + Given there is a contact A # Can create unnamed entities - And a contact is created + And there is a contact When I am on "/" Then the response status code should be 200 Then I should see "Hello World" Then 2 contacts should exist Scenario: Can visit pages twice and still access to EM - Given a contact is created + Given there is a contact When I am on "/" Then I should see "Hello World" When I am on "/" @@ -24,7 +24,7 @@ Feature: Test persisting entities Then 1 contact should exist Scenario Outline: Persist entity - Given a contact is created + Given there is a contact When I am on "/" Then the response status code should be 200 Then I should see "" @@ -36,7 +36,7 @@ Feature: Test persisting entities | World | Scenario: Can access last created entity ID - Given a "generic entity" "the object" is created with properties + Given there is a "generic entity" "the object" with | prop1 | | foo | When I am on "/orm/update//bar" @@ -50,10 +50,10 @@ Feature: Test persisting entities Then an "RuntimeException" exception should be thrown containing message "No last id found" Scenario: Can access last created entity ID - Given a "generic entity" "the object" is created with properties + Given there is a "generic entity" "the object" with | prop1 | | foo | - And a contact is created + And there is a contact When I am on "/orm/update//bar" Then the response status code should be 200 Then "generic entity" "the object" should have properties @@ -63,3 +63,17 @@ Feature: Test persisting entities Scenario: Throws if last id is not found (!) When I am on "/orm/update//bar" Then an "InvalidArgumentException" exception should be thrown containing message "No object of type \"generic entity\" found" + + Scenario: Can access an ID from reference + Given there is a "generic entity" "the object" with + | prop1 | + | foo | + When I am on "/orm/update//bar" + Then the response status code should be 200 + Then "generic entity" "the object" should have properties + | prop1 | + | bar | + + Scenario: Throws if the reference is not found (!) + When I am on "/orm/update//bar" + Then an "ObjectNotFound" exception should be thrown containing message "Object \"generic entity the object\" was not found" diff --git a/tests/behatFeatures/main/with-fixture-on-feature.feature b/src/Test/Behat/features/main/with-fixture-on-feature.feature similarity index 83% rename from tests/behatFeatures/main/with-fixture-on-feature.feature rename to src/Test/Behat/features/main/with-fixture-on-feature.feature index 8a4ff3f52..b1346533e 100644 --- a/tests/behatFeatures/main/with-fixture-on-feature.feature +++ b/src/Test/Behat/features/main/with-fixture-on-feature.feature @@ -2,7 +2,7 @@ Feature: Test @withFixture tag Scenario: Load behat-contacts fixture with @withFixture tag - Given a contact "jane-doe" is created + Given there is a contact "jane-doe" Then 2 contacts should exist Scenario: Ensure DB is fresh diff --git a/tests/behatFeatures/main/with-fixture.feature b/src/Test/Behat/features/main/with-fixture.feature similarity index 95% rename from tests/behatFeatures/main/with-fixture.feature rename to src/Test/Behat/features/main/with-fixture.feature index beb59aac9..a15a722e0 100644 --- a/tests/behatFeatures/main/with-fixture.feature +++ b/src/Test/Behat/features/main/with-fixture.feature @@ -28,7 +28,7 @@ Feature: Test @withFixture tag @withFixture(behat-category) Scenario: Can use entities from fixture in another entity - Given a contact "jane-doe" is created with properties + Given there is a contact "jane-doe" with | name | category | | Jane Doe | category fixture | Then 1 contact should exist diff --git a/tests/behatFeatures/reset-disabled/manual-isolation-1.feature b/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature similarity index 62% rename from tests/behatFeatures/reset-disabled/manual-isolation-1.feature rename to src/Test/Behat/features/reset-disabled/manual-isolation-1.feature index c6bc99cd0..85aa11726 100644 --- a/tests/behatFeatures/reset-disabled/manual-isolation-1.feature +++ b/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature @@ -1,16 +1,16 @@ Feature: No database isolation (disabled mode) - Part 1 Scenario: First scenario creates data - Given a contact A is created + Given there is a contact A Then 1 contact should exist Scenario: Second scenario sees previous data (no reset) Then 1 contact should exist - Then contact object named A should exist - Given a contact B is created + Then contact A should exist + Given there is a contact B Then 2 contacts should exist Scenario: Third scenario sees all accumulated data Then 2 contacts should exist - Then contact object named A should exist - Then contact object named B should exist + Then contact A should exist + Then contact B should exist diff --git a/tests/behatFeatures/reset-disabled/manual-isolation-2.feature b/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature similarity index 87% rename from tests/behatFeatures/reset-disabled/manual-isolation-2.feature rename to src/Test/Behat/features/reset-disabled/manual-isolation-2.feature index aee69bf87..821766f95 100644 --- a/tests/behatFeatures/reset-disabled/manual-isolation-2.feature +++ b/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature @@ -6,7 +6,7 @@ Feature: No database isolation (disabled mode) - Part 2 Then 2 contacts should exist # ObjectRegistry is reset between features even when isolation is disabled - Then contact object named A should not exist + Then contact A should not exist @resetDB Scenario: DB should be reset diff --git a/tests/behatFeatures/reset-feature/isolation.feature b/src/Test/Behat/features/reset-feature/isolation.feature similarity index 87% rename from tests/behatFeatures/reset-feature/isolation.feature rename to src/Test/Behat/features/reset-feature/isolation.feature index a06ffff6a..4e5ac2895 100644 --- a/tests/behatFeatures/reset-feature/isolation.feature +++ b/src/Test/Behat/features/reset-feature/isolation.feature @@ -1,7 +1,7 @@ Feature: Database isolation per feature - Part 1 Scenario: First scenario creates data - Given a contact A is created with properties + Given there is a contact A with | name | | John Doe | Then 1 contact should exist @@ -10,7 +10,7 @@ Feature: Database isolation per feature - Part 1 Then 1 contact should exist Scenario: Third scenario also sees accumulated data - Given a contact B is created with properties + Given there is a contact B with | name | | Jane Doe | Then 2 contacts should exist diff --git a/tests/behatFeatures/reset-feature/isolation2.feature b/src/Test/Behat/features/reset-feature/isolation2.feature similarity index 91% rename from tests/behatFeatures/reset-feature/isolation2.feature rename to src/Test/Behat/features/reset-feature/isolation2.feature index 6502ca967..7a9866632 100644 --- a/tests/behatFeatures/reset-feature/isolation2.feature +++ b/src/Test/Behat/features/reset-feature/isolation2.feature @@ -4,7 +4,7 @@ Feature: Database isolation per feature - Part 2 Then 0 contacts should exist Scenario: Second scenario in new feature sees first scenario's data - Given a "contact" "C" is created with properties + Given there is a "contact" "C" with | name | | Alice Doe | Then 1 contact should exist diff --git a/tests/behatFeatures/reset-feature/reset-db.feature b/src/Test/Behat/features/reset-feature/reset-db.feature similarity index 74% rename from tests/behatFeatures/reset-feature/reset-db.feature rename to src/Test/Behat/features/reset-feature/reset-db.feature index f9ebc5c64..b3e815c7c 100644 --- a/tests/behatFeatures/reset-feature/reset-db.feature +++ b/src/Test/Behat/features/reset-feature/reset-db.feature @@ -4,18 +4,18 @@ Feature: Manual database reset with @resetDB tag Then 0 contact should exist Scenario: Create one contact - Given a contact A is created + Given there is a contact A Then 1 contact should exist Scenario: Ensure contact still exists Then 1 contact should exist - Then contact object named A should exist + Then contact A should exist @resetDB Scenario: Database is reset with @resetDB tag Then 0 contacts should exist - Then contact object named A should not exist - Given a contact is created + Then contact A should not exist + Given there is a contact Then 1 contact should exist Scenario: Data from tagged scenario persists diff --git a/tests/behatFeatures/reset-feature/with-fixture-on-feature.feature b/src/Test/Behat/features/reset-feature/with-fixture-on-feature.feature similarity index 92% rename from tests/behatFeatures/reset-feature/with-fixture-on-feature.feature rename to src/Test/Behat/features/reset-feature/with-fixture-on-feature.feature index f6c018e53..714bd7ac5 100644 --- a/tests/behatFeatures/reset-feature/with-fixture-on-feature.feature +++ b/src/Test/Behat/features/reset-feature/with-fixture-on-feature.feature @@ -8,7 +8,7 @@ Feature: "@withFixture" on feature Then 1 contact should exist Scenario: Can add new data - Given a contact is created + Given there is a contact Then 2 contacts should exist @resetDB diff --git a/tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature b/src/Test/Behat/features/reset-feature/with-fixture-on-scenario.feature similarity index 93% rename from tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature rename to src/Test/Behat/features/reset-feature/with-fixture-on-scenario.feature index 52c6ef49c..d981813b7 100644 --- a/tests/behatFeatures/reset-feature/with-fixture-on-scenario.feature +++ b/src/Test/Behat/features/reset-feature/with-fixture-on-scenario.feature @@ -11,7 +11,7 @@ Feature: "@withFixture" on scenario Then 1 contact should exist Scenario: Can add new data - Given a contact is created + Given there is a contact Then 2 contacts should exist @resetDB diff --git a/tests/behatFeatures/reset-manual/manual-isolation-1.feature b/src/Test/Behat/features/reset-manual/manual-isolation-1.feature similarity index 63% rename from tests/behatFeatures/reset-manual/manual-isolation-1.feature rename to src/Test/Behat/features/reset-manual/manual-isolation-1.feature index 0422f95d9..4542ffcd5 100644 --- a/tests/behatFeatures/reset-manual/manual-isolation-1.feature +++ b/src/Test/Behat/features/reset-manual/manual-isolation-1.feature @@ -2,16 +2,16 @@ Feature: Manual database isolation (disabled mode) - Part 1 Scenario: First scenario creates data - Given a contact A is created + Given there is a contact A Then 1 contact should exist Scenario: Second scenario sees previous data (no reset) Then 1 contact should exist - Then contact object named A should exist - Given a contact B is created + Then contact A should exist + Given there is a contact B Then 2 contacts should exist Scenario: Third scenario sees all accumulated data Then 2 contacts should exist - Then contact object named A should exist - Then contact object named B should exist + Then contact A should exist + Then contact B should exist diff --git a/tests/behatFeatures/reset-manual/manual-isolation-2.feature b/src/Test/Behat/features/reset-manual/manual-isolation-2.feature similarity index 82% rename from tests/behatFeatures/reset-manual/manual-isolation-2.feature rename to src/Test/Behat/features/reset-manual/manual-isolation-2.feature index 5c7458879..6a6514a6c 100644 --- a/tests/behatFeatures/reset-manual/manual-isolation-2.feature +++ b/src/Test/Behat/features/reset-manual/manual-isolation-2.feature @@ -4,12 +4,12 @@ Feature: Manual database isolation (disabled mode) - Part 2 Then 2 contacts should exist # ObjectRegistry is reset between features even when isolation is disabled - Then contact object named A should not exist + Then contact A should not exist @resetDB Scenario: DB should be reset Then 0 contacts should exist Scenario: Create another contact - Given a contact is created + Given there is a contact Then 1 contact should exist diff --git a/tests/behatFeatures/reset-manual/manual-isolation-3.feature b/src/Test/Behat/features/reset-manual/manual-isolation-3.feature similarity index 89% rename from tests/behatFeatures/reset-manual/manual-isolation-3.feature rename to src/Test/Behat/features/reset-manual/manual-isolation-3.feature index fc63dead0..a622ef9e3 100644 --- a/tests/behatFeatures/reset-manual/manual-isolation-3.feature +++ b/src/Test/Behat/features/reset-manual/manual-isolation-3.feature @@ -5,7 +5,7 @@ Feature: Manual database isolation (disabled mode) - Part 3 Then 0 contacts should exist Scenario: Create a contact - Given a contact is created + Given there is a contact Then 1 contacts should exist Scenario: Ensure contact still exists diff --git a/tests/behatFeatures/reset-manual/with-fixture-on-feature.feature b/src/Test/Behat/features/reset-manual/with-fixture-on-feature.feature similarity index 92% rename from tests/behatFeatures/reset-manual/with-fixture-on-feature.feature rename to src/Test/Behat/features/reset-manual/with-fixture-on-feature.feature index 991861237..d2ec7c330 100644 --- a/tests/behatFeatures/reset-manual/with-fixture-on-feature.feature +++ b/src/Test/Behat/features/reset-manual/with-fixture-on-feature.feature @@ -9,7 +9,7 @@ Feature: "@withFixture" on feature Then 1 contact should exist Scenario: Can add new data - Given a contact is created + Given there is a contact Then 2 contacts should exist @resetDB diff --git a/tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature b/src/Test/Behat/features/reset-manual/with-fixture-on-scenario.feature similarity index 88% rename from tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature rename to src/Test/Behat/features/reset-manual/with-fixture-on-scenario.feature index 01e968de1..ff171fa4b 100644 --- a/tests/behatFeatures/reset-manual/with-fixture-on-scenario.feature +++ b/src/Test/Behat/features/reset-manual/with-fixture-on-scenario.feature @@ -12,12 +12,12 @@ Feature: "@withFixture" on scenario Then 1 contact should exist Scenario: Can add new data - Given a contact is created + Given there is a contact Then 2 contacts should exist @resetDB Scenario: Reset DB should clear DB Then 0 contacts should exist - Given a contact is created + Given there is a contact Then 1 contact should exist diff --git a/src/Test/Behat/phpstan.neon b/src/Test/Behat/phpstan.neon new file mode 100644 index 000000000..3f119d58a --- /dev/null +++ b/src/Test/Behat/phpstan.neon @@ -0,0 +1,33 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: 8 + + treatPhpDocTypesAsCertain: false + inferPrivatePropertyTypeFromConstructor: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + + paths: + - ./config + - ./src + - ./tests + + banned_code: + non_ignorable: false + + bootstrapFiles: + - ./vendor/autoload.php + + ignoreErrors: + # prevent PHPStan to force to type data providers + - identifier: missingType.iterableValue + path: tests/ + + # not relevant for files outside from Foundry's namespace + - identifier: classConstant.internalClass + path: config/ + + excludePaths: + - config/reference.php (?) diff --git a/src/Test/Behat/phpunit.dist.xml b/src/Test/Behat/phpunit.dist.xml new file mode 100644 index 000000000..a51ccbc11 --- /dev/null +++ b/src/Test/Behat/phpunit.dist.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + + + + + diff --git a/src/Test/Behat/FoundryContext.php b/src/Test/Behat/src/AbstractFoundryContext.php similarity index 86% rename from src/Test/Behat/FoundryContext.php rename to src/Test/Behat/src/AbstractFoundryContext.php index b63322a59..5ee09aff9 100644 --- a/src/Test/Behat/FoundryContext.php +++ b/src/Test/Behat/src/AbstractFoundryContext.php @@ -13,9 +13,6 @@ use Behat\Behat\Context\Context; use Behat\Gherkin\Node\TableNode; -use Behat\Step\Given; -use Behat\Step\Then; -use Behat\Transformation\Transform; use Zenstruck\Assert; use Zenstruck\Foundry\Configuration; use Zenstruck\Foundry\Factory; @@ -27,14 +24,11 @@ use function Zenstruck\Foundry\Persistence\refresh; /** - * @internal * @author Nicolas PHILIPPE * * @phpstan-import-type Parameters from Factory - * - * @final */ -class FoundryContext implements Context +abstract class AbstractFoundryContext implements Context { public function __construct( private readonly FactoryShortNameResolver $factoryResolver, @@ -42,15 +36,11 @@ public function __construct( ) { } - #[Given('a(n) :factoryShortName is created')] - #[Given('a(n) :factoryShortName :objectName is created')] public function createObject(string $factoryShortName, ?string $objectName = null): void { $this->resolveFactory($factoryShortName, $objectName)->create(); } - #[Given('a(n) :factoryShortName is created with properties')] - #[Given('a(n) :factoryShortName :objectName is created with properties')] public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void { $factory = $this->resolveFactory($factoryShortName, $objectName); @@ -63,7 +53,6 @@ public function createObjectWithProperties(TableNode $table, string $factoryShor $factory->create($parametersList[0]); } - #[Given(':factoryShortName are created with properties')] public function createObjectsWithProperties(TableNode $table, string $factoryShortName): void { $parametersList = $table->getColumnsHash(); @@ -77,15 +66,12 @@ public function createObjectsWithProperties(TableNode $table, string $factorySho } } - #[Then('/^(\d+) "([^"]*)" should exist$/')] - #[Then('/^(\d+) ([^"]*) should exist$/')] public function assertNbObjectsExist(int $nb, string $factoryShortName): void { $this->repositoryAssertionFor($factoryShortName) ->count($nb); } - #[Then(':factoryShortName :objectName should have properties')] public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void { $parametersList = $table->getColumnsHash(); @@ -116,7 +102,6 @@ public function assertObjectHasProperties(FoundryTableNode $table, string $facto } } - #[Then(':factoryShortName object named :objectName should exist')] public function assertObjectExists(string $factoryShortName, string $objectName): void { Assert::that( @@ -127,7 +112,6 @@ public function assertObjectExists(string $factoryShortName, string $objectName) )->is(true, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" does not exist although it should."); } - #[Then(':factoryShortName object named :objectName should not exist')] public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void { Assert::that( @@ -138,18 +122,21 @@ public function assertObjectDoesNotExist(string $factoryShortName, string $objec )->is(false, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" exists although it should not."); } - #[Transform('/(.*)(.*)/')] public function transformLastId(string $before, string $after): string { return "{$before}{$this->objectRegistry->lastId()}{$after}"; } - #[Transform('/(.*)(.*)/')] public function transformLastIdForSpecificObject(string $before, string $factoryShortName, string $after): string { return "{$before}{$this->objectRegistry->lastIdFor($factoryShortName)}{$after}"; } + public function transformIdForSpecificObject(string $before, string $factoryShortName, string $objectName, string $after): string + { + return "{$before}{$this->objectRegistry->idFor($factoryShortName, $objectName)}{$after}"; + } + /** * @return ObjectFactory */ diff --git a/src/Attribute/FactoryShortName.php b/src/Test/Behat/src/Attribute/FactoryShortName.php similarity index 91% rename from src/Attribute/FactoryShortName.php rename to src/Test/Behat/src/Attribute/FactoryShortName.php index c039ca87b..52ce092ac 100644 --- a/src/Attribute/FactoryShortName.php +++ b/src/Test/Behat/src/Attribute/FactoryShortName.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Attribute; +namespace Zenstruck\Foundry\Test\Behat\Attribute; /** * @author Nicolas PHILIPPE diff --git a/src/Test/Behat/DatabaseResetMode.php b/src/Test/Behat/src/DatabaseResetMode.php similarity index 94% rename from src/Test/Behat/DatabaseResetMode.php rename to src/Test/Behat/src/DatabaseResetMode.php index c2af16bbd..f3f1b22c3 100644 --- a/src/Test/Behat/DatabaseResetMode.php +++ b/src/Test/Behat/src/DatabaseResetMode.php @@ -1,7 +1,5 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\DependencyInjection; + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class BehatServicesCompilerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->has('behat.service_container')) { + // we're not in a Behat context + return; + } + + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__, 2).'/config')); + $loader->load('behat.php'); + } +} diff --git a/src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php b/src/Test/Behat/src/Exception/DamaNativeExtensionIncompatibility.php similarity index 100% rename from src/Test/Behat/Exception/DamaNativeExtensionIncompatibility.php rename to src/Test/Behat/src/Exception/DamaNativeExtensionIncompatibility.php diff --git a/src/Test/Behat/Exception/FactoryNotResolvable.php b/src/Test/Behat/src/Exception/FactoryNotResolvable.php similarity index 97% rename from src/Test/Behat/Exception/FactoryNotResolvable.php rename to src/Test/Behat/src/Exception/FactoryNotResolvable.php index 9f3a440cc..24c7c3976 100644 --- a/src/Test/Behat/Exception/FactoryNotResolvable.php +++ b/src/Test/Behat/src/Exception/FactoryNotResolvable.php @@ -1,7 +1,5 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat; + +use Behat\Behat\Context\Context; +use Behat\Gherkin\Node\TableNode; +use Behat\Step\Given; +use Behat\Step\Then; +use Behat\Transformation\Transform; +use Zenstruck\Foundry\Factory; + +/** + * @author Nicolas PHILIPPE + * + * @phpstan-import-type Parameters from Factory + * + * @internal + * @final + */ +class FoundryContext extends AbstractFoundryContext implements Context +{ + #[\Override] + #[Given('there is a(n) :factoryShortName')] + #[Given('there is a(n) :factoryShortName :objectName')] + #[Given('there is a(n) :factoryShortName called :objectName')] + #[Given('there is a(n) :factoryShortName named :objectName')] + public function createObject(string $factoryShortName, ?string $objectName = null): void + { + parent::createObject($factoryShortName, $objectName); + } + + #[\Override] + #[Given('there is a(n) :factoryShortName with')] + #[Given('there is a(n) :factoryShortName :objectName with')] + #[Given('there is a(n) :factoryShortName called :objectName with')] + #[Given('there is a(n) :factoryShortName named :objectName with')] + public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void + { + parent::createObjectWithProperties($table, $factoryShortName, $objectName); + } + + #[\Override] + #[Given('there are :factoryShortName with')] + public function createObjectsWithProperties(TableNode $table, string $factoryShortName): void + { + parent::createObjectsWithProperties($table, $factoryShortName); + } + + #[\Override] + #[Then('/^(\d+) "([^"]*)" should exist$/')] + #[Then('/^(\d+) ([^"]*) should exist$/')] + public function assertNbObjectsExist(int $nb, string $factoryShortName): void + { + parent::assertNbObjectsExist($nb, $factoryShortName); + } + + /** + * Captures: + * - factoryShortName: quoted or unquoted factory/entity name + * - objectName: quoted or unquoted object reference + * + * Supports optional articles ("the", "a", "an"), optional "called"/"named", + * optional "exist and", and optional trailing "properties". + */ + #[\Override] + #[Then('/^(?:the |an? )?(?!\d)(?|"(?P[^"]+)"|(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should (?:exist and )?have(?: properties)?$/')] + public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void + { + parent::assertObjectHasProperties($table, $factoryShortName, $objectName); + } + + #[\Override] + #[Then('/^(?:the |an? )?(?!\d)(?|"(?P[^"]+)"|(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should exist$/')] + public function assertObjectExists(string $factoryShortName, string $objectName): void + { + parent::assertObjectExists($factoryShortName, $objectName); + } + + #[\Override] + #[Then('/^(?:the |an? )?(?!\d)(?|"(?P[^"]+)"|(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should not exist$/')] + public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void + { + parent::assertObjectDoesNotExist($factoryShortName, $objectName); + } + + #[\Override] + #[Transform('/(.*)(.*)/')] + public function transformLastId(string $before, string $after): string + { + return parent::transformLastId($before, $after); + } + + #[\Override] + #[Transform('/(.*)(.*)/')] + public function transformLastIdForSpecificObject(string $before, string $factoryShortName, string $after): string + { + return parent::transformLastIdForSpecificObject($before, $factoryShortName, $after); + } + + #[\Override] + #[Transform('/(.*)(.*)/')] + public function transformIdForSpecificObject(string $before, string $factoryShortName, string $objectName, string $after): string + { + return parent::transformIdForSpecificObject($before, $factoryShortName, $objectName, $after); + } +} diff --git a/src/Test/Behat/FoundryExtension.php b/src/Test/Behat/src/FoundryExtension.php similarity index 97% rename from src/Test/Behat/FoundryExtension.php rename to src/Test/Behat/src/FoundryExtension.php index a1fff8d17..30214d568 100644 --- a/src/Test/Behat/FoundryExtension.php +++ b/src/Test/Behat/src/FoundryExtension.php @@ -1,7 +1,5 @@ children() ->enumNode('database_reset_mode') ->values(\array_map(static fn(DatabaseResetMode $mode) => $mode->value, DatabaseResetMode::cases())) - ->defaultValue(DatabaseResetMode::MANUAL->value) + ->defaultValue(DatabaseResetMode::DISABLED->value) ->end() ->booleanNode('enable_dama_support') ->defaultFalse() diff --git a/src/Test/Behat/FoundryTableNode.php b/src/Test/Behat/src/FoundryTableNode.php similarity index 100% rename from src/Test/Behat/FoundryTableNode.php rename to src/Test/Behat/src/FoundryTableNode.php diff --git a/src/Test/Behat/Listener/BootConfigurationListener.php b/src/Test/Behat/src/Listener/BootConfigurationListener.php similarity index 100% rename from src/Test/Behat/Listener/BootConfigurationListener.php rename to src/Test/Behat/src/Listener/BootConfigurationListener.php diff --git a/src/Test/Behat/Listener/DatabaseResetListener.php b/src/Test/Behat/src/Listener/DatabaseResetListener.php similarity index 99% rename from src/Test/Behat/Listener/DatabaseResetListener.php rename to src/Test/Behat/src/Listener/DatabaseResetListener.php index 0e9a457d9..79d1b9cb5 100644 --- a/src/Test/Behat/Listener/DatabaseResetListener.php +++ b/src/Test/Behat/src/Listener/DatabaseResetListener.php @@ -1,7 +1,5 @@ getByFactoryShortName($factoryShortName, $objectName); + + return $this->coerceIdToScalar( + $this->persistenceManager->getIdentifierValues($object) + ); + } + public function isStored(object $object): bool { return array_any( diff --git a/src/Test/Behat/symfony.lock b/src/Test/Behat/symfony.lock new file mode 100644 index 000000000..476395496 --- /dev/null +++ b/src/Test/Behat/symfony.lock @@ -0,0 +1,163 @@ +{ + "dama/doctrine-test-bundle": { + "version": "8.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "8.3", + "ref": "dfc51177476fb39d014ed89944cde53dc3326d23" + } + }, + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } + }, + "doctrine/doctrine-bundle": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "18ee08e513ba0303fd09a01fc1c934870af06ffa" + }, + "files": [ + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" + ] + }, + "friends-of-behat/symfony-extension": { + "version": "2.6", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "2.0", + "ref": "1e012e04f573524ca83795cd19df9ea690adb604" + } + }, + "phpunit/phpunit": { + "version": "12.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "11.1", + "ref": "1117deb12541f35793eec9fff7494d7aa12283fc" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php", + "bin/phpunit" + ] + }, + "symfony/console": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" + }, + "files": [ + "bin/console" + ] + }, + "symfony/flex": { + "version": "2.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" + }, + "files": [ + ".env", + ".env.dev" + ] + }, + "symfony/framework-bundle": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d" + }, + "files": [ + "config/packages/cache.yaml", + "config/packages/framework.yaml", + "config/preload.php", + "config/routes/framework.yaml", + "config/services.yaml", + "public/index.php", + "src/Controller/.gitignore", + "src/Kernel.php", + ".editorconfig" + ] + }, + "symfony/property-info": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.3", + "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" + }, + "files": [ + "config/packages/property_info.yaml" + ] + }, + "symfony/routing": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded" + }, + "files": [ + "config/packages/routing.yaml", + "config/routes.yaml" + ] + }, + "symfony/translation": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, + "symfony/uid": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, + "zenstruck/foundry": { + "version": "2.9", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.7", + "ref": "7fc98f546dfeaa83cc2110634f8ff078d070b965" + }, + "files": [ + "config/packages/zenstruck_foundry.yaml", + "src/Story/AppStory.php" + ] + } +} diff --git a/src/Test/Behat/symlink-vendor.sh b/src/Test/Behat/symlink-vendor.sh new file mode 100755 index 000000000..cab8c58a2 --- /dev/null +++ b/src/Test/Behat/symlink-vendor.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_SRC="$(cd "${SCRIPT_DIR}/../../.." && pwd)/src" +VENDOR_SRC="${SCRIPT_DIR}/vendor/zenstruck/foundry/src" + +if [ ! -d "${VENDOR_SRC}" ]; then + echo "Directory vendor/zenstruck/foundry/src does not exist yet." + exit 1 +fi + +for item in "${ROOT_SRC}"/*; do + name=$(basename "$item") + + if [ "$name" = "Test" ]; then + continue + fi + + target="${VENDOR_SRC}/${name}" + + rm -rf "$target" + + ln -s "$item" "$target" +done + +echo -e "\nSymlinks created in ${VENDOR_SRC}\n" diff --git a/tests/Fixture/Behat/BehatTestKernel.php b/src/Test/Behat/tests/Fixture/BehatTestKernel.php similarity index 73% rename from tests/Fixture/Behat/BehatTestKernel.php rename to src/Test/Behat/tests/Fixture/BehatTestKernel.php index db637f533..66dacebe5 100644 --- a/tests/Fixture/Behat/BehatTestKernel.php +++ b/src/Test/Behat/tests/Fixture/BehatTestKernel.php @@ -9,14 +9,15 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; -use Symfony\Component\DependencyInjection\Reference; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; +use Zenstruck\Foundry\Test\Behat\FoundryContext; use Zenstruck\Foundry\Tests\Fixture\App\Controller\HelloWorldController; use Zenstruck\Foundry\Tests\Fixture\App\Controller\UpdateGenericModel; use Zenstruck\Foundry\Tests\Fixture\FoundryTestKernel; @@ -47,34 +48,26 @@ protected function configureContainer(ContainerConfigurator $configurator, Loade $c->register(HelloWorldController::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(ResetDisabledTestContext::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(TestFoundryContext::class) - ->setAutowired(true) - ->setAutoconfigured(true) - ->setArguments([new Reference('.zenstruck_foundry.behat.factory_resolver'), new Reference('.zenstruck_foundry.behat.object_registry')]) - ; + $c->setDefinition(TestFoundryContext::class, new ChildDefinition(FoundryContext::class)); $configurator->services() - ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Behat\\Factories\\', __DIR__.'/Factories') + ->load('Zenstruck\\Foundry\\Test\\Behat\\Tests\\Fixture\\Factories\\', __DIR__.'/Factories') ->autowire() ->autoconfigure(); $configurator->services() - ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Factories\\', __DIR__.'/../Factories') + ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Factories\\', __DIR__.'/../../../../../tests/Fixture/Factories') ->autowire() ->autoconfigure(); $configurator->services() - ->load('Zenstruck\\Foundry\\Tests\\Fixture\\Behat\\Stories\\', __DIR__.'/Stories') + ->load('Zenstruck\\Foundry\\Test\\Behat\\Tests\\Fixture\\Stories\\', __DIR__.'/Stories') ->autowire() ->autoconfigure(); - - if (!self::runsWithBehat()) { - $c->register('behat.service_container', \stdClass::class); - } } - private static function runsWithBehat(): bool + protected function baseFixturePath(): string { - return \str_contains($_SERVER['SCRIPT_NAME'], 'behat'); + return '%kernel.project_dir%/../../../tests/Fixture'; } } diff --git a/tests/Fixture/Behat/Factories/Tag/TagFactory.php b/src/Test/Behat/tests/Fixture/Factories/Tag/TagFactory.php similarity index 81% rename from tests/Fixture/Behat/Factories/Tag/TagFactory.php rename to src/Test/Behat/tests/Fixture/Factories/Tag/TagFactory.php index e20bdb4c5..f9dff4462 100644 --- a/tests/Fixture/Behat/Factories/Tag/TagFactory.php +++ b/src/Test/Behat/tests/Fixture/Factories/Tag/TagFactory.php @@ -9,15 +9,13 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat\Factories\Tag; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Factories\Tag; -use Zenstruck\Foundry\Attribute\FactoryShortName; use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; /** - * @author Kevin Bond - * * @extends PersistentObjectFactory */ #[FactoryShortName('tag2')] diff --git a/tests/Fixture/Behat/Factories/TagFactory.php b/src/Test/Behat/tests/Fixture/Factories/TagFactory.php similarity index 86% rename from tests/Fixture/Behat/Factories/TagFactory.php rename to src/Test/Behat/tests/Fixture/Factories/TagFactory.php index 312f8acf1..2541bad2b 100644 --- a/tests/Fixture/Behat/Factories/TagFactory.php +++ b/src/Test/Behat/tests/Fixture/Factories/TagFactory.php @@ -9,14 +9,12 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat\Factories; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Factories; use Zenstruck\Foundry\Persistence\PersistentObjectFactory; use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; /** - * @author Kevin Bond - * * @extends PersistentObjectFactory */ final class TagFactory extends PersistentObjectFactory diff --git a/tests/Fixture/Behat/ResetDisabledTestContext.php b/src/Test/Behat/tests/Fixture/ResetDisabledTestContext.php similarity index 93% rename from tests/Fixture/Behat/ResetDisabledTestContext.php rename to src/Test/Behat/tests/Fixture/ResetDisabledTestContext.php index bb8af4aa9..9f4588a06 100644 --- a/tests/Fixture/Behat/ResetDisabledTestContext.php +++ b/src/Test/Behat/tests/Fixture/ResetDisabledTestContext.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; use Behat\Behat\Context\Context; use Behat\Hook\BeforeScenario; diff --git a/tests/Fixture/Behat/Stories/CategoryStory.php b/src/Test/Behat/tests/Fixture/Stories/CategoryStory.php similarity index 91% rename from tests/Fixture/Behat/Stories/CategoryStory.php rename to src/Test/Behat/tests/Fixture/Stories/CategoryStory.php index 293a6f5ba..f87f3e4f3 100644 --- a/tests/Fixture/Behat/Stories/CategoryStory.php +++ b/src/Test/Behat/tests/Fixture/Stories/CategoryStory.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; use Zenstruck\Foundry\Attribute\AsFixture; use Zenstruck\Foundry\Story; diff --git a/tests/Fixture/Behat/Stories/ConflictStory1.php b/src/Test/Behat/tests/Fixture/Stories/ConflictStory1.php similarity index 91% rename from tests/Fixture/Behat/Stories/ConflictStory1.php rename to src/Test/Behat/tests/Fixture/Stories/ConflictStory1.php index 5302b8001..f82a74708 100644 --- a/tests/Fixture/Behat/Stories/ConflictStory1.php +++ b/src/Test/Behat/tests/Fixture/Stories/ConflictStory1.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; use Zenstruck\Foundry\Attribute\AsFixture; use Zenstruck\Foundry\Story; diff --git a/tests/Fixture/Behat/Stories/ConflictStory2.php b/src/Test/Behat/tests/Fixture/Stories/ConflictStory2.php similarity index 91% rename from tests/Fixture/Behat/Stories/ConflictStory2.php rename to src/Test/Behat/tests/Fixture/Stories/ConflictStory2.php index fd4c5f029..c8ca91b34 100644 --- a/tests/Fixture/Behat/Stories/ConflictStory2.php +++ b/src/Test/Behat/tests/Fixture/Stories/ConflictStory2.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; use Zenstruck\Foundry\Attribute\AsFixture; use Zenstruck\Foundry\Story; diff --git a/tests/Fixture/Behat/Stories/ContactStory.php b/src/Test/Behat/tests/Fixture/Stories/ContactStory.php similarity index 91% rename from tests/Fixture/Behat/Stories/ContactStory.php rename to src/Test/Behat/tests/Fixture/Stories/ContactStory.php index e3f97a0bf..78202c55d 100644 --- a/tests/Fixture/Behat/Stories/ContactStory.php +++ b/src/Test/Behat/tests/Fixture/Stories/ContactStory.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat\Stories; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Stories; use Zenstruck\Foundry\Attribute\AsFixture; use Zenstruck\Foundry\Story; diff --git a/tests/Fixture/Behat/TestFoundryContext.php b/src/Test/Behat/tests/Fixture/TestFoundryContext.php similarity index 90% rename from tests/Fixture/Behat/TestFoundryContext.php rename to src/Test/Behat/tests/Fixture/TestFoundryContext.php index eb5545e2b..2af06a8fa 100644 --- a/tests/Fixture/Behat/TestFoundryContext.php +++ b/src/Test/Behat/tests/Fixture/TestFoundryContext.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Fixture\Behat; +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; use Yceruto\BehatExtension\Context\ExceptionAssertionTrait; use Zenstruck\Foundry\Test\Behat\FoundryContext; diff --git a/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php b/src/Test/Behat/tests/Integration/Listener/BootConfigurationListenerTest.php similarity index 80% rename from tests/Integration/Behat/Listener/BootConfigurationListenerTest.php rename to src/Test/Behat/tests/Integration/Listener/BootConfigurationListenerTest.php index a24c1fb62..3800603dc 100644 --- a/tests/Integration/Behat/Listener/BootConfigurationListenerTest.php +++ b/src/Test/Behat/tests/Integration/Listener/BootConfigurationListenerTest.php @@ -1,7 +1,5 @@ get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type } private function createListener(): BootConfigurationListener { return new BootConfigurationListener(self::$kernel ?? self::bootKernel()); } - - private function objectRegistry(): ObjectRegistry - { - return self::getContainer()->get('.zenstruck_foundry.behat.object_registry'); // @phpstan-ignore return.type - } } diff --git a/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php b/src/Test/Behat/tests/Integration/Listener/DatabaseResetListenerTest.php similarity index 94% rename from tests/Integration/Behat/Listener/DatabaseResetListenerTest.php rename to src/Test/Behat/tests/Integration/Listener/DatabaseResetListenerTest.php index 3c15cc50b..98c23f9a3 100644 --- a/tests/Integration/Behat/Listener/DatabaseResetListenerTest.php +++ b/src/Test/Behat/tests/Integration/Listener/DatabaseResetListenerTest.php @@ -1,7 +1,5 @@ objectRegistry()->reset(); @@ -103,7 +92,7 @@ public static function validateScenarioExceptionProvider(): iterable DatabaseResetMode::SCENARIO, ['resetDB'], InvalidResetDbTag::class, - 'Cannot use "@noResetDB" tag with database_reset_mode set as "manual".', + 'Cannot use "@resetDB" tag with database_reset_mode set as "scenario".', ]; yield 'both resetDB and noResetDB tags on scenario' => [ @@ -300,11 +289,6 @@ public function it_validates_feature_without_reset_d_b_tag_in_feature_mode(): vo $listener->validateFeature($event); } - protected static function getKernelClass(): string - { - return BehatTestKernel::class; - } - private function createListener( DatabaseResetMode $mode, bool $damaSupportEnabled = false, diff --git a/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php b/src/Test/Behat/tests/Integration/Listener/LoadFixturesListenerTest.php similarity index 91% rename from tests/Integration/Behat/Listener/LoadFixturesListenerTest.php rename to src/Test/Behat/tests/Integration/Listener/LoadFixturesListenerTest.php index 1ab10391c..6076457bd 100644 --- a/tests/Integration/Behat/Listener/LoadFixturesListenerTest.php +++ b/src/Test/Behat/tests/Integration/Listener/LoadFixturesListenerTest.php @@ -1,7 +1,5 @@ objectRegistry()->reset(); @@ -123,11 +112,6 @@ public function it_throws_if_states_name_conflict_in_stories(): void $listener->loadFixtureIfTagged($event); } - protected static function getKernelClass(): string - { - return BehatTestKernel::class; - } - private function createListener(): LoadFixturesListener { return new LoadFixturesListener(self::$kernel ?? self::bootKernel()); diff --git a/tests/Unit/Test/Behat/FactoryResolverTest.php b/src/Test/Behat/tests/Unit/FactoryResolverTest.php similarity index 96% rename from tests/Unit/Test/Behat/FactoryResolverTest.php rename to src/Test/Behat/tests/Unit/FactoryResolverTest.php index c9eeb47ad..b84ff1c88 100644 --- a/tests/Unit/Test/Behat/FactoryResolverTest.php +++ b/src/Test/Behat/tests/Unit/FactoryResolverTest.php @@ -1,7 +1,5 @@ registry->store($user1, 'john'); $this->expectException(ObjectAlreadyRegistered::class); - $this->expectExceptionMessage('Object "john" is already registered for class "Zenstruck\Foundry\Tests\Unit\Test\Behat\User".'); + $this->expectExceptionMessage('Object "john" is already registered for class "Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat\User".'); $this->registry->store($user2, 'john'); } @@ -112,7 +107,7 @@ public function it_gets_stored_object(): void public function it_throws_when_getting_non_existent_object(): void { $this->expectException(ObjectNotFound::class); - $this->expectExceptionMessage('Object of class "Zenstruck\Foundry\Tests\Unit\Test\Behat\User" with name "john" was not found.'); + $this->expectExceptionMessage('Object of class "Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat\User" with name "john" was not found.'); $this->registry->getByObjectClass(User::class, 'john'); } @@ -210,7 +205,7 @@ public function it_throws_when_storing_duplicate_from_story_event(): void $this->registry->storeAfterStateAddedToStory($event1); $this->expectException(ObjectAlreadyRegistered::class); - $this->expectExceptionMessage('Object "duplicate" is already registered for class "Zenstruck\Foundry\Tests\Unit\Test\Behat\User".'); + $this->expectExceptionMessage('Object "duplicate" is already registered for class "Zenstruck\Foundry\Test\Behat\Tests\Unit\Test\Behat\User".'); $this->registry->storeAfterStateAddedToStory($event2); } diff --git a/src/Test/Behat/tests/bootstrap.php b/src/Test/Behat/tests/bootstrap.php new file mode 100644 index 000000000..a5e4583c6 --- /dev/null +++ b/src/Test/Behat/tests/bootstrap.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Dotenv\Dotenv; +use Symfony\Component\Filesystem\Filesystem; + +$command = \implode(' ', $_SERVER['argv']); + +require \dirname(__DIR__).'/vendor/autoload.php'; + +$fs = new Filesystem(); + +$fs->remove(__DIR__.'/../var/cache'); + +(new Dotenv())->usePutenv()->loadEnv(__DIR__.'/../.env', testEnvs: []); diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 8d43b8ba3..9b896b9ac 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -33,7 +33,7 @@ use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\ORM\ResetDatabase\SchemaDatabaseResetter; -use Zenstruck\Foundry\Test\Behat\FoundryContext; +use Zenstruck\Foundry\Test\Behat\DependencyInjection\BehatServicesCompilerPass; /** * @author Kevin Bond @@ -239,12 +239,6 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $configurator->import('../config/services.php'); - // we load all services for Behat by default, and we remove them if we detect that Behat is not installed - // that's the only way that worked so far... - if (\interface_exists(\Behat\Behat\Context\Context::class)) { - $configurator->import('../config/behat.php'); - } - $this->configureInstantiator($config['instantiator'], $container); $this->configureFaker($config['faker'], $container); $this->configureGlobalState($config['global_state'], $container); @@ -281,12 +275,14 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass($this); $container->addCompilerPass(new InMemoryCompilerPass()); $container->addCompilerPass(new AsFixtureStoryCompilerPass()); + + if (\class_exists(BehatServicesCompilerPass::class)) { + $container->addCompilerPass(new BehatServicesCompilerPass()); // @phpstan-ignore argument.type + } } public function process(ContainerBuilder $container): void { - $this->removeBehatConfigIfNeeded($container); - // faker providers foreach ($container->findTaggedServiceIds('foundry.faker_provider') as $id => $tags) { $container @@ -311,17 +307,6 @@ public function process(ContainerBuilder $container): void } } - private function removeBehatConfigIfNeeded(ContainerBuilder $container): void - { - if ($container->has('behat.service_container')) { - return; - } - - $container->removeDefinition(FoundryContext::class); - $container->removeDefinition('.zenstruck_foundry.behat.object_registry'); - $container->removeDefinition('zenstruck_foundry.behat.context.foundry'); - } - /** * @param string[] $values */ diff --git a/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php b/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php index 7d792dea5..4eb6cfa7f 100644 --- a/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php +++ b/tests/Fixture/Factories/Entity/Contact/ChildContactFactory.php @@ -11,10 +11,10 @@ namespace Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact; -use Zenstruck\Foundry\Attribute\FactoryShortName; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; use Zenstruck\Foundry\Tests\Fixture\Entity\ChildContact; -#[FactoryShortName('child of contact', 'children of contact')] +#[FactoryShortName('child of contact', 'children of contact')] // @phpstan-ignore attribute.notFound final class ChildContactFactory extends ContactFactory { public static function class(): string diff --git a/tests/Fixture/FoundryTestKernel.php b/tests/Fixture/FoundryTestKernel.php index b1ebe8dae..b58b5e0fa 100644 --- a/tests/Fixture/FoundryTestKernel.php +++ b/tests/Fixture/FoundryTestKernel.php @@ -115,14 +115,14 @@ protected function configureContainer(ContainerConfigurator $configurator, Loade 'Entity' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Entity', + 'dir' => "{$this->baseFixturePath()}/Entity", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Entity', 'alias' => 'Entity', ], 'Model' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Model', + 'dir' => "{$this->baseFixturePath()}/Model", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Model', 'alias' => 'Model', ], @@ -134,7 +134,7 @@ protected function configureContainer(ContainerConfigurator $configurator, Loade 'EntityInAnotherSchema' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/EntityInAnotherSchema', + 'dir' => "{$this->baseFixturePath()}/EntityInAnotherSchema", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\EntityInAnotherSchema', 'alias' => 'Migrate', ], @@ -177,14 +177,14 @@ protected function configureContainer(ContainerConfigurator $configurator, Loade 'Document' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Document', + 'dir' => "{$this->baseFixturePath()}/Document", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Document', 'alias' => 'Document', ], 'Model' => [ 'is_bundle' => false, 'type' => 'attribute', - 'dir' => '%kernel.project_dir%/tests/Fixture/Model', + 'dir' => "{$this->baseFixturePath()}/Model", 'prefix' => 'Zenstruck\Foundry\Tests\Fixture\Model', 'alias' => 'Model', ], @@ -201,4 +201,9 @@ protected function configureRoutes(RoutingConfigurator $routes): void { $routes->import(__DIR__.'/App/Controller/*.php', 'attribute'); } + + protected function baseFixturePath(): string + { + return '%kernel.project_dir%/tests/Fixture'; + } } diff --git a/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php b/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php index afeb9221e..8c43e53e9 100644 --- a/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php +++ b/tests/Integration/ResetDatabase/ResetDatabaseTestCase.php @@ -13,6 +13,7 @@ namespace Zenstruck\Foundry\Tests\Integration\ResetDatabase; +use Composer\InstalledVersions; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -39,10 +40,14 @@ public function it_generates_valid_schema(): void $application = new Application(self::bootKernel()); $application->setAutoExit(false); - $exit = $application->run( - new ArrayInput(['command' => 'doctrine:schema:validate', '-v' => true]), - $output = new BufferedOutput() - ); + $parameters = ['command' => 'doctrine:schema:validate', '-v' => true]; + + // enums in GenericModel are not well handled with --prefer-lowest + if (\version_compare(InstalledVersions::getVersion('doctrine/orm') ?? '', '3.0', '<')) { + $parameters['--skip-mapping'] = true; + } + + $exit = $application->run(new ArrayInput($parameters), $output = new BufferedOutput()); if (FoundryTestKernel::usesMigrations()) { // The command actually fails, because of a bug in doctrine ORM 3! From d992d4f0b1ea9000e72226515a931030bdb1c05a Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 7 Feb 2026 21:41:11 +0100 Subject: [PATCH 04/10] refactor: extract table normalization into private methods in FoundryCallFilter Extract the large array_map closure into 3 private methods: normalizeTableRow(), resolveExplicitObjectReference(), resolveObjectReferenceBasedOnPropertyType(). Use match expression instead of if/continue chain. Add null coalescing throw for array_shift and fix trailing whitespace. Co-Authored-By: Claude Opus 4.6 --- src/Test/Behat/src/FoundryCallFilter.php | 149 +++++++++++------------ 1 file changed, 73 insertions(+), 76 deletions(-) diff --git a/src/Test/Behat/src/FoundryCallFilter.php b/src/Test/Behat/src/FoundryCallFilter.php index 20482d1e3..9acea3032 100644 --- a/src/Test/Behat/src/FoundryCallFilter.php +++ b/src/Test/Behat/src/FoundryCallFilter.php @@ -59,7 +59,7 @@ public function filterCall(Call $call): Call if (!isset($arguments['factoryShortName'])) { throw new \InvalidArgumentException(<<getTable(); $headKey = \array_key_first($table); - $thead = \array_shift($table); + $thead = \array_shift($table) ?? throw new \LogicException('Table has no header row.'); return FoundryTableNode::create( $this->factoryResolver, @@ -98,95 +98,92 @@ private function normalizeObjectParameters(TableNode $tableNode, string $factory [ // @phpstan-ignore argument.type (TableNode has the same problem: array $table is not really lists) $headKey => $thead, // @phpstan-ignore array.invalidKey ...\array_map( - function(array $parameters) use ($thead, $factoryShortName): array { - $normalized = []; - foreach ($parameters as $key => $value) { - if (!isset($thead[$key])) { - throw new \LogicException("Table has no column for parameter \"{$key}\". This should never happen, table integrity is checked in TableNode."); - } - - $propertyName = $thead[$key]; - - if ('_ref' === $propertyName) { - $normalized['_ref'] = $value; - - continue; - } - - if ('null' === $value) { - $normalized[$propertyName] = null; - - continue; - } - - if ('true' === $value) { - $normalized[$propertyName] = true; - - continue; - } - - if ('false' === $value) { - $normalized[$propertyName] = false; + fn(array $parameters) => $this->normalizeTableRow($parameters, $thead, $factoryShortName), + $table + ), + ] + ); + } - continue; - } + /** + * @param list $parameters + * @param list $thead + * + * @return array + */ + private function normalizeTableRow(array $parameters, array $thead, string $factoryShortName): array + { + $normalized = []; + foreach ($parameters as $key => $value) { + if (!isset($thead[$key])) { + throw new \LogicException("Table has no column for parameter \"{$key}\". This should never happen, table integrity is checked in TableNode."); + } - if (\preg_match('/^[^,]+), (?[^)]+)\)>$/', $value, $matches)) { - try { - $normalized[$propertyName] = $this->objectRegistry->getByFactoryShortName($matches['factoryShortName'], $matches['objectName']); - } catch (ObjectNotFound $e) { - throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); - } + $propertyName = $thead[$key]; - continue; - } + if ('_ref' === $propertyName) { + $normalized['_ref'] = $value; - $targetClass = $this->factoryResolver->targetObjectClassFor($factoryShortName); - $expectedTypeClass = $this->getPropertyTypeIfClass(new \ReflectionClass($targetClass), $propertyName); + continue; + } - if (!$expectedTypeClass) { - $normalized[$propertyName] = $value; + $normalized[$propertyName] = match (true) { + 'null' === $value => null, + 'true' === $value => true, + 'false' === $value => false, + default => $this->resolveExplicitObjectReference($propertyName, $value) + ?? $this->resolveObjectReferenceBasedOnPropertyType($propertyName, $value, $factoryShortName), + }; + } - continue; - } + return $normalized; + } - if ($this->factoryResolver->hasFactoryForClass($expectedTypeClass)) { - try { - $normalized[$propertyName] = $this->objectRegistry->getByObjectClass($expectedTypeClass, $value); - } catch (ObjectNotFound $e) { - throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); - } + private function resolveExplicitObjectReference(string $propertyName, string $value): ?object + { + if (!\preg_match('/^[^,]+), (?[^)]+)\)>$/', $value, $matches)) { + return null; + } - continue; - } + try { + return $this->objectRegistry->getByFactoryShortName($matches['factoryShortName'], $matches['objectName']); + } catch (ObjectNotFound $e) { + throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); + } + } - if (\is_a($expectedTypeClass, \DateTimeInterface::class, allow_string: true)) { - try { - $normalized[$propertyName] = new $expectedTypeClass($value); + private function resolveObjectReferenceBasedOnPropertyType(string $propertyName, string $value, string $factoryShortName): mixed + { + $targetClass = $this->factoryResolver->targetObjectClassFor($factoryShortName); + $expectedTypeClass = $this->getPropertyTypeIfClass(new \ReflectionClass($targetClass), $propertyName); - continue; - } catch (\Throwable $e) { // @phpstan-ignore catch.neverThrown - throw InvalidObjectParameter::invalidDate($propertyName, $value, $e); - } - } + if (!$expectedTypeClass) { + return $value; + } - if (\is_a($expectedTypeClass, \BackedEnum::class, allow_string: true)) { - $value = \is_numeric($value) ? (int) $value : $value; + if ($this->factoryResolver->hasFactoryForClass($expectedTypeClass)) { + try { + return $this->objectRegistry->getByObjectClass($expectedTypeClass, $value); + } catch (ObjectNotFound $e) { + throw InvalidObjectParameter::objectReferencedInTableDoesNotExist($propertyName, $e); + } + } - $normalized[$propertyName] = $expectedTypeClass::tryFrom($value) ?? throw InvalidObjectParameter::invalidEnumValue($propertyName, (string) $value); + if (\is_a($expectedTypeClass, \DateTimeInterface::class, allow_string: true)) { + try { + return new $expectedTypeClass($value); + } catch (\Throwable $e) { // @phpstan-ignore catch.neverThrown + throw InvalidObjectParameter::invalidDate($propertyName, $value, $e); + } + } - continue; - } + if (\is_a($expectedTypeClass, \BackedEnum::class, allow_string: true)) { + $value = \is_numeric($value) ? (int) $value : $value; - throw new \LogicException("Cannot normalize parameter \"{$propertyName}\" with value \"{$value}\"."); - } + return $expectedTypeClass::tryFrom($value) ?? throw InvalidObjectParameter::invalidEnumValue($propertyName, (string) $value); + } - return $normalized; - }, - $table - ), - ] - ); + throw new \LogicException("Cannot normalize parameter \"{$propertyName}\" with value \"{$value}\"."); } /** From 33d55ed31f50cfd333466980328fbf8f3c5d584e Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 7 Feb 2026 21:41:16 +0100 Subject: [PATCH 05/10] perf: optimize class-to-factory lookups with index map in FactoryShortNameResolver Replace nested array_any/array_find_key calls with a classToShortName cache built at boot() time, enabling O(1) lookups via isset() and direct key access. Co-Authored-By: Claude Opus 4.6 --- .../Behat/src/FactoryShortNameResolver.php | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Test/Behat/src/FactoryShortNameResolver.php b/src/Test/Behat/src/FactoryShortNameResolver.php index d2b2d7aba..2d7f549bb 100644 --- a/src/Test/Behat/src/FactoryShortNameResolver.php +++ b/src/Test/Behat/src/FactoryShortNameResolver.php @@ -30,6 +30,9 @@ final class FactoryShortNameResolver */ private array $factoryMap = []; + /** @var array */ + private array $classToShortName = []; + /** * @param iterable> $factories */ @@ -53,6 +56,8 @@ public function __construct(iterable $factories) $plural = \mb_strtolower($this->factoryShortNameAttribute($factory::class)->pluralName ?? $inflector->pluralize($shortName)[0]); $this->factoryMap[$plural] ??= []; $this->factoryMap[$plural][] = $factory; + + $this->classToShortName[$factory::class()] = $shortName; } } @@ -91,13 +96,7 @@ public function targetObjectClassFor(string $shortName): string */ public function hasFactoryForClass(string $className): bool { - return array_any( - $this->factoryMap, - static fn(array $factories) => array_any( - $factories, - static fn(ObjectFactory $factory) => $factory::class() === $className, - ) - ); + return isset($this->classToShortName[$className]); } /** @@ -105,13 +104,7 @@ public function hasFactoryForClass(string $className): bool */ public function getShortNameForClass(string $className): string { - return array_find_key( // @phpstan-ignore return.type (PHPStan bug) - $this->factoryMap, - static fn(array $factories) => array_any( - $factories, - static fn(ObjectFactory $factory) => $factory::class() === $className, - ) - ); + return $this->classToShortName[$className] ?? throw new \LogicException("No factory found for class \"{$className}\"."); } /** From 7414c39c06ea4a06dad4a856a43ffdd2da7b9cb3 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 7 Feb 2026 21:41:20 +0100 Subject: [PATCH 06/10] fix: improve error messages in AbstractFoundryContext Make error messages more descriptive: include the number of rows received and suggest using the "there are X with" step when trying to create multiple objects. Co-Authored-By: Claude Opus 4.6 --- src/Test/Behat/features/main/create-objects.feature | 2 +- src/Test/Behat/src/AbstractFoundryContext.php | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Test/Behat/features/main/create-objects.feature b/src/Test/Behat/features/main/create-objects.feature index 26847ab88..67b09274b 100644 --- a/src/Test/Behat/features/main/create-objects.feature +++ b/src/Test/Behat/features/main/create-objects.feature @@ -32,7 +32,7 @@ Feature: Test objects creation | name | | John Doe | | Jane Doe | - Then an "InvalidArgumentException" exception should be thrown containing message "Expected exactly one line of properties, to create one object" + Then an "InvalidArgumentException" exception should be thrown containing message "Expected exactly one line of properties to create one object, got 2 lines" Scenario: Can create entity with properties via PyTable (!) Given there is a "i don't exist" diff --git a/src/Test/Behat/src/AbstractFoundryContext.php b/src/Test/Behat/src/AbstractFoundryContext.php index 5ee09aff9..80910c1dc 100644 --- a/src/Test/Behat/src/AbstractFoundryContext.php +++ b/src/Test/Behat/src/AbstractFoundryContext.php @@ -47,7 +47,11 @@ public function createObjectWithProperties(TableNode $table, string $factoryShor $parametersList = $table->getColumnsHash(); if (1 !== \count($parametersList)) { - throw new \InvalidArgumentException('Expected exactly one line of properties, to create one object.'); + throw new \InvalidArgumentException(\sprintf( + 'Expected exactly one line of properties to create one object, got %d lines. Use "there are %s with" to create multiple objects.', + \count($parametersList), + $factoryShortName, + )); } $factory->create($parametersList[0]); @@ -77,7 +81,10 @@ public function assertObjectHasProperties(FoundryTableNode $table, string $facto $parametersList = $table->getColumnsHash(); if (1 !== \count($parametersList)) { - throw new \InvalidArgumentException('Expected exactly one line of properties.'); + throw new \InvalidArgumentException(\sprintf( + 'Expected exactly one line of properties for assertion, got %d lines.', + \count($parametersList), + )); } $object = $this->objectRegistry->getByFactoryShortName($factoryShortName, $objectName); From 53e72f8fad6012d1bb27d9b777b0c2d5a96f8059 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 7 Feb 2026 21:41:26 +0100 Subject: [PATCH 07/10] fix: allow quoted object names starting with digits in regex patterns Move the (?!\d) negative lookahead inside the branch reset group, just before the unquoted \S+ alternative. This allows quoted names like "007" to start with a digit while still preventing ambiguity with count patterns for unquoted names. Co-Authored-By: Claude Opus 4.6 --- .../main/object-names-with-digits.feature | 21 +++++++++++++++++++ src/Test/Behat/src/FoundryContext.php | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 src/Test/Behat/features/main/object-names-with-digits.feature diff --git a/src/Test/Behat/features/main/object-names-with-digits.feature b/src/Test/Behat/features/main/object-names-with-digits.feature new file mode 100644 index 000000000..f7eb50045 --- /dev/null +++ b/src/Test/Behat/features/main/object-names-with-digits.feature @@ -0,0 +1,21 @@ +Feature: Test object names starting with digits + + Scenario: Quoted object names can start with digits + Given there is a "generic entity" "123" with + | prop1 | + | foo | + Then "generic entity" "123" should exist + And "generic entity" "123" should have properties + | prop1 | + | foo | + + Scenario: Quoted object names can be pure numbers + Given there is a contact "007" + Then contact "007" should exist + + Scenario: Count assertions still work correctly alongside digit names + Given there is a contact "1" + And there is a contact "2" + Then 2 contacts should exist + And contact "1" should exist + And contact "2" should exist diff --git a/src/Test/Behat/src/FoundryContext.php b/src/Test/Behat/src/FoundryContext.php index 9f1bf035b..490ef0b5c 100644 --- a/src/Test/Behat/src/FoundryContext.php +++ b/src/Test/Behat/src/FoundryContext.php @@ -72,21 +72,21 @@ public function assertNbObjectsExist(int $nb, string $factoryShortName): void * optional "exist and", and optional trailing "properties". */ #[\Override] - #[Then('/^(?:the |an? )?(?!\d)(?|"(?P[^"]+)"|(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should (?:exist and )?have(?: properties)?$/')] + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should (?:exist and )?have(?: properties)?$/')] public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void { parent::assertObjectHasProperties($table, $factoryShortName, $objectName); } #[\Override] - #[Then('/^(?:the |an? )?(?!\d)(?|"(?P[^"]+)"|(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should exist$/')] + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should exist$/')] public function assertObjectExists(string $factoryShortName, string $objectName): void { parent::assertObjectExists($factoryShortName, $objectName); } #[\Override] - #[Then('/^(?:the |an? )?(?!\d)(?|"(?P[^"]+)"|(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should not exist$/')] + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should not exist$/')] public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void { parent::assertObjectDoesNotExist($factoryShortName, $objectName); From 7c31fd418f11242f0e2a455781891cdc1dbd54df Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 7 Feb 2026 21:41:31 +0100 Subject: [PATCH 08/10] feat: support UUID-based entity ids in ObjectRegistry Add AbstractUid support via toRfc4122() conversion for entities using UUID/ULID identifiers. Add unit test and Behat functional scenario. Co-Authored-By: Claude Opus 4.6 --- .../Behat/features/main/access-uuid.feature | 22 +++++++++++++ src/Test/Behat/src/ObjectRegistry.php | 8 ++++- .../Factories/EntityWithUidFactory.php | 33 +++++++++++++++++++ .../Behat/tests/Unit/ObjectRegistryTest.php | 22 ++++++++++++- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 src/Test/Behat/features/main/access-uuid.feature create mode 100644 src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php diff --git a/src/Test/Behat/features/main/access-uuid.feature b/src/Test/Behat/features/main/access-uuid.feature new file mode 100644 index 000000000..29b37f416 --- /dev/null +++ b/src/Test/Behat/features/main/access-uuid.feature @@ -0,0 +1,22 @@ +Feature: Test accessing Uuid-based entity ids + + Scenario: Can create and reference Uuid entity + Given there is an "entity with uid" named "the object" + Then "entity with uid" "the object" should exist + + Scenario: Can access last id for Uuid entity via lastId transform + Given there is an "entity with uid" named "A" + # The transform resolves the Uuid to its RFC 4122 string format. + # We just need to verify the transform doesn't throw, the 404 is expected. + When I am on "/" + Then the response status code should be 404 + + Scenario: Can access last id for specific Uuid entity type + Given there is an "entity with uid" named "B" + When I am on "/" + Then the response status code should be 404 + + Scenario: Can access id from reference for Uuid entity + Given there is an "entity with uid" named "my-uid-entity" + When I am on "/" + Then the response status code should be 404 diff --git a/src/Test/Behat/src/ObjectRegistry.php b/src/Test/Behat/src/ObjectRegistry.php index e7148a6f3..41daff993 100644 --- a/src/Test/Behat/src/ObjectRegistry.php +++ b/src/Test/Behat/src/ObjectRegistry.php @@ -11,6 +11,7 @@ namespace Zenstruck\Foundry\Test\Behat; +use Symfony\Component\Uid\AbstractUid; use Zenstruck\Foundry\Persistence\Event\AfterPersist; use Zenstruck\Foundry\Persistence\PersistenceManager; use Zenstruck\Foundry\Story\Event\StateAddedToStory; @@ -162,8 +163,13 @@ private function coerceIdToScalar(array $ids): int|string } $id = array_first($ids); + + if ($id instanceof AbstractUid) { + return $id->toRfc4122(); + } + if (!\is_int($id) && !\is_string($id)) { - throw new \InvalidArgumentException(\sprintf('Wrong type for the id: expected int or string, got "%s".', \get_debug_type($id))); + throw new \InvalidArgumentException(\sprintf('Wrong type for the id: expected int, string or Uid, got "%s".', \get_debug_type($id))); } return $id; diff --git a/src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php b/src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php new file mode 100644 index 000000000..56365c322 --- /dev/null +++ b/src/Test/Behat/tests/Fixture/Factories/EntityWithUidFactory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture\Factories; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Test\Behat\Attribute\FactoryShortName; +use Zenstruck\Foundry\Tests\Fixture\Entity\EntityWithUid; + +/** + * @extends PersistentObjectFactory + */ +#[FactoryShortName(shortName: 'entity with uid', pluralName: 'entities with uid')] +final class EntityWithUidFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return EntityWithUid::class; + } + + protected function defaults(): array + { + return []; + } +} diff --git a/src/Test/Behat/tests/Unit/ObjectRegistryTest.php b/src/Test/Behat/tests/Unit/ObjectRegistryTest.php index 9659456b4..6e8ae998c 100644 --- a/src/Test/Behat/tests/Unit/ObjectRegistryTest.php +++ b/src/Test/Behat/tests/Unit/ObjectRegistryTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Persistence\Event\AfterPersist; use Zenstruck\Foundry\Persistence\PersistenceManager; @@ -145,6 +146,25 @@ public function it_stores_string_id_from_after_persist_event(): void self::assertSame('uuid-123', $this->registry->lastId()); } + #[Test] + public function it_stores_uuid_from_after_persist_event(): void + { + $uuid = Uuid::v7(); + + $persistenceManager = $this->createStub(PersistenceManager::class); + $persistenceManager->method('getIdentifierValues')->willReturn(['id' => $uuid]); + + $registry = new ObjectRegistry($this->resolver, $persistenceManager); + $registry->reset(); + + $user = new User(id: 1, name: 'John'); + $event = new AfterPersist($user, [], $this->createStub(PersistentObjectFactory::class)); + + $registry->storeLastId($event); + + self::assertSame($uuid->toRfc4122(), $registry->lastId()); + } + #[Test] public function it_throws_when_no_last_id_available(): void { @@ -252,7 +272,7 @@ public function it_throws_when_id_type_is_invalid(): void $registry->storeLastId($event); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Wrong type for the id: expected int or string, got "array".'); + $this->expectExceptionMessage('Wrong type for the id: expected int, string or Uid, got "array".'); $registry->lastId(); } From fdd32f09d71e2be56afa79338b20faf271a76746 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 7 Feb 2026 21:41:36 +0100 Subject: [PATCH 09/10] docs: clarify ref(type, name) syntax as an escape hatch Add a note explaining that the syntax is a fallback mechanism for edge cases, and that automatic resolution should be preferred. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/phpunit.yml | 2 +- docs/index.rst | 3 +++ src/Test/Behat/src/AbstractFoundryContext.php | 11 ++--------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index b5edd507b..fd89b765d 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -249,7 +249,7 @@ jobs: - name: Test run: | - vendor/bin/phpunit -c phpunit-10.xml.dist tests/Unit + vendor/bin/phpunit tests/Unit test-with-paratest: name: Test with paratest diff --git a/docs/index.rst b/docs/index.rst index d77c843e6..502ebbbcb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2927,6 +2927,9 @@ the category named "tech" in the registry. | title | category | | My Post | | + The ```` syntax is an escape hatch for edge cases where automatic type resolution fails. + Prefer using automatic resolution (just the object name) whenever possible, as it is cleaner and more readable. + .. note:: Objects can be referenced by their name until the next database reset occurs. How long they persist depends on your diff --git a/src/Test/Behat/src/AbstractFoundryContext.php b/src/Test/Behat/src/AbstractFoundryContext.php index 80910c1dc..9f30cfd91 100644 --- a/src/Test/Behat/src/AbstractFoundryContext.php +++ b/src/Test/Behat/src/AbstractFoundryContext.php @@ -47,11 +47,7 @@ public function createObjectWithProperties(TableNode $table, string $factoryShor $parametersList = $table->getColumnsHash(); if (1 !== \count($parametersList)) { - throw new \InvalidArgumentException(\sprintf( - 'Expected exactly one line of properties to create one object, got %d lines. Use "there are %s with" to create multiple objects.', - \count($parametersList), - $factoryShortName, - )); + throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties to create one object, got %d lines. Use "there are %s with" to create multiple objects.', \count($parametersList), $factoryShortName)); } $factory->create($parametersList[0]); @@ -81,10 +77,7 @@ public function assertObjectHasProperties(FoundryTableNode $table, string $facto $parametersList = $table->getColumnsHash(); if (1 !== \count($parametersList)) { - throw new \InvalidArgumentException(\sprintf( - 'Expected exactly one line of properties for assertion, got %d lines.', - \count($parametersList), - )); + throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties for assertion, got %d lines.', \count($parametersList))); } $object = $this->objectRegistry->getByFactoryShortName($factoryShortName, $objectName); From 14c69a232ab0ed2cc9dceadbed6868a4c1dcb70f Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Mon, 22 Jun 2026 17:52:43 +0200 Subject: [PATCH 10/10] feat: override steps using translation --- .github/workflows/behat-subtree-split.yaml | 22 +++ .github/workflows/behat.yml | 3 + docs/index.rst | 106 +++++++---- src/Test/Behat/behat.yml | 19 ++ src/Test/Behat/config/behat.php | 8 +- .../Behat/features/main/access-uuid.feature | 2 +- .../features/main/create-objects.feature | 98 +++++------ .../main/object-names-with-digits.feature | 18 +- .../features/main/persist-entities.feature | 14 +- .../main/with-fixture-on-feature.feature | 2 +- .../Behat/features/main/with-fixture.feature | 6 +- .../override-steps/override-steps.feature | 21 +++ .../reset-disabled/manual-isolation-1.feature | 10 +- .../reset-disabled/manual-isolation-2.feature | 2 +- .../features/reset-feature/isolation.feature | 8 +- .../features/reset-feature/isolation2.feature | 4 +- .../features/reset-feature/reset-db.feature | 6 +- .../reset-manual/manual-isolation-1.feature | 10 +- .../reset-manual/manual-isolation-2.feature | 2 +- src/Test/Behat/src/AbstractFoundryContext.php | 166 ------------------ .../UnsupportedTranslationResource.php | 24 +++ src/Test/Behat/src/FoundryContext.php | 145 +++++++++++---- src/Test/Behat/src/FoundryExtension.php | 27 +++ .../src/Listener/LoadFixturesListener.php | 4 +- .../src/Listener/StepTranslationsListener.php | 70 ++++++++ .../Behat/tests/Fixture/BehatTestKernel.php | 4 +- .../tests/Fixture/TestFoundryContext.php | 7 +- .../Fixture/translations/foundry-steps.xliff | 11 ++ .../Listener/StepTranslationsListenerTest.php | 115 ++++++++++++ 29 files changed, 584 insertions(+), 350 deletions(-) create mode 100644 .github/workflows/behat-subtree-split.yaml create mode 100644 src/Test/Behat/features/override-steps/override-steps.feature delete mode 100644 src/Test/Behat/src/AbstractFoundryContext.php create mode 100644 src/Test/Behat/src/Exception/UnsupportedTranslationResource.php create mode 100644 src/Test/Behat/src/Listener/StepTranslationsListener.php create mode 100644 src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff create mode 100644 src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php diff --git a/.github/workflows/behat-subtree-split.yaml b/.github/workflows/behat-subtree-split.yaml new file mode 100644 index 000000000..c9d4bb642 --- /dev/null +++ b/.github/workflows/behat-subtree-split.yaml @@ -0,0 +1,22 @@ +name: 'Packages Split' + +on: + push: + branches: [2.x] + +jobs: + packages_split: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: danharrin/monorepo-split-github-action@v2.4.5 + env: + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} + with: + package_directory: 'src/Test/Behat' + repository_organization: 'zenstruck' + repository_name: 'foundry-behat' + user_name: 'nikophil' + user_email: 'nicolas.philippe@mapado.com' + branch: '0.x' diff --git a/.github/workflows/behat.yml b/.github/workflows/behat.yml index 07400a321..ae75d2f62 100644 --- a/.github/workflows/behat.yml +++ b/.github/workflows/behat.yml @@ -77,6 +77,9 @@ jobs: - name: Reset DB disabled run: vendor/bin/behat --colors -vvv --profile=reset-disabled + - name: Overriding built-in step definitions + run: vendor/bin/behat --colors -vvv --profile=override-steps + phpunit: name: PHPUnit - P:${{ matrix.php }}, S:${{ matrix.symfony }}${{ matrix.deps == 'lowest' && ' (lowest)' || '' }} runs-on: ubuntu-latest diff --git a/docs/index.rst b/docs/index.rst index 502ebbbcb..ad66ce299 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2838,20 +2838,16 @@ Create objects Given there is a contact # Create a named object: this name will be useful for later reference - Given there is a contact "john" - - # You can also use "called" or "named" keywords - Given there is a contact called "john" Given there is a contact named "john" # Create an object with properties - Given there is a contact called "john" with + Given there is a contact named "john" with: | name | email | | John Doe | john@email.com | # Create multiple objects # Notice that you can use the plural form of the factory name - Given there are contacts with + Given there are contacts with: | _ref | name | | A | John Doe | | B | Jane Doe | @@ -2866,8 +2862,8 @@ The ``_ref`` column is a special column that allows you to name the created obje .. code-block:: gherkin # both are equivalent - Given there is a "contact" "john" - Given there is a contact john + Given there is a "contact" named "john" + Given there is a contact named john # quoting is required for multi-word factory names Given there is a "blog post" named "Foundry rocks" @@ -2891,7 +2887,7 @@ When using properties in Gherkin tables, Foundry automatically converts string v .. code-block:: gherkin - Given there is a post "my-post" with + Given there is a post named "my-post" with: | title | category | publishedAt | status | body | | My Post | tech | 2026-01-15 | published | null | @@ -2908,8 +2904,8 @@ Referencing objects in another object .. code-block:: gherkin - Given there is a category "tech" - Given there is a post "my-post" with + Given there is a category named "tech" + Given there is a post named "my-post" with: | title | category | | My Post | tech | @@ -2922,8 +2918,8 @@ the category named "tech" in the registry. .. code-block:: gherkin - Given there is a category "tech" - Given there is a post "my-post" with + Given there is a category named "tech" + Given there is a post named "my-post" with: | title | category | | My Post | | @@ -2946,25 +2942,25 @@ Assertions Then 2 contacts should exist # assert a specific object has properties - Then contact "john" should have properties + Then contact named "john" should have properties: | name | | John Doe | # You can also use various natural language forms: - Then the contact "john" should exist and have properties + Then the contact named "john" should exist and have properties: | name | | John Doe | # assert if objects are persisted or not - Then contact "john" should exist - Then contact "jane" should not exist + Then contact named "john" should exist + Then contact named "jane" should not exist Accessing ids of created objects ................................ .. code-block:: gherkin - Given there is a contact "john" + Given there is a contact named "john" # Access the last id created When I am on "/contacts/" @@ -2989,30 +2985,70 @@ Overriding built-in step definitions .................................... If the built-in step definitions don't fit your need or conflict with your own contexts and step definitions, you can -define you own "Foundry context" with the definitions' name of your choice: -- Create your own context class which extends ``Zenstruck\Foundry\Test\Behat\AbstractFoundryContext`` -- Declare a service for your own context: +**re-word** them right from the extension configuration, without writing any PHP. Map each built-in pattern to your own +wording under the ``steps`` key: .. code-block:: yaml - # config/services.yaml - when@test: - services: - App\Tests\Behat\Context\CustomFoundryContext: - parent: zenstruck_foundry.behat.context.parent + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + steps: + # "built-in pattern": "your wording" + 'there is a(n) :factoryShortName named :objectName': 'create a :factoryShortName called :objectName' + 'there are :factoryShortName with:': 'the following :factoryShortName exist:' -You can use ``Zenstruck\Foundry\Test\Behat\FoundryContext`` as an example of how to implement the step definitions, and -define your own language to create objects with Foundry. +You can now write: -.. warning:: +.. code-block:: gherkin + + Given create a contact called "john" + +The built-in wording is fully replaced: only the patterns you override change, every other built-in step keeps working +(and you automatically benefit from new built-in steps added in the future). - Your custom step definitions must use the same "capturing groups" with the same names as the built-in ones - (``factoryShortName`` and ``objectName``). Otherwise, some of the definitions won't work. +For larger customizations, you can point to one or several translation catalogues (``xliff``, ``yaml`` or ``php``) instead +of (or in addition to) the inline map: + +.. code-block:: yaml + + default: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + translations: '%paths.base%/tests/behat/foundry-steps.xliff' + +.. code-block:: xml + + + + + + + + there is a(n) :factoryShortName named :objectName + create a :factoryShortName called :objectName + + + + .. warning:: - By overriding built-in step definitions, you won't automatically benefit from the potentially new definition - that we will add to the built-in context in the future. You will need to manually add them to your own context. + Your wording must keep the same **placeholders / capturing groups** as the built-in pattern (``:factoryShortName`` + and ``:objectName`` for turnip patterns, ``(?P...)`` and ``(?P...)`` for regex + patterns). Otherwise the step won't be able to call the underlying method. + +.. note:: + + Overrides apply to the language of your ``.feature`` files (the ``# language:`` header, defaulting to ``en``), **not** + to the CLI ``--lang`` option. If your features are written in another language, set the ``locale`` option accordingly: + + .. code-block:: yaml + + Zenstruck\Foundry\Test\Behat\FoundryExtension: + locale: fr + steps: + 'there is a(n) :factoryShortName named :objectName': 'il existe un(e) :factoryShortName nommé :objectName' Loading Fixtures with Tags ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3048,7 +3084,7 @@ Then use the ``@withFixture`` tag to load a Story before a scenario: Scenario: Assert john exists # Objects added with "addState()" are automatically available in the objects registry and can be # referenced in your scenarios. - Then contact "john" should have properties + Then contact named "john" should have properties: | name | | John | @@ -3062,7 +3098,7 @@ The ``@withFixture`` tag can also be used on a **feature** to load fixtures once Then 3 contacts should exist Scenario: Find john - Then contact "john" should exist + Then contact named "john" should exist .. tip:: diff --git a/src/Test/Behat/behat.yml b/src/Test/Behat/behat.yml index b9a7d57fa..fd5898395 100644 --- a/src/Test/Behat/behat.yml +++ b/src/Test/Behat/behat.yml @@ -24,6 +24,7 @@ default: paths: [features/main] contexts: &common_contexts - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Test\Behat\FoundryContext - Zenstruck\Foundry\Test\Behat\Tests\Fixture\TestFoundryContext main-no-dama: @@ -112,5 +113,23 @@ reset-disabled: paths: [features/reset-disabled] contexts: - Behat\MinkExtension\Context\MinkContext + - Zenstruck\Foundry\Test\Behat\FoundryContext - Zenstruck\Foundry\Test\Behat\Tests\Fixture\TestFoundryContext - Zenstruck\Foundry\Test\Behat\Tests\Fixture\ResetDisabledTestContext + +override-steps: + extensions: + Zenstruck\Foundry\Test\Behat\FoundryExtension: + database_reset_mode: scenario + enable_dama_support: true + steps: + 'there is a(n) :factoryShortName named :objectName': 'create a :factoryShortName called :objectName' + 'there are :factoryShortName with:': 'the following :factoryShortName exist:' + # transformations (#[Transform]) go through the same translator: they can be re-worded too + '/(.*)(.*)/': '/(.*)\[lastId\](.*)/' + + suites: + main: false + override-steps: + paths: [features/override-steps] + contexts: *common_contexts diff --git a/src/Test/Behat/config/behat.php b/src/Test/Behat/config/behat.php index bdb75a268..2e653741b 100644 --- a/src/Test/Behat/config/behat.php +++ b/src/Test/Behat/config/behat.php @@ -13,7 +13,6 @@ use Zenstruck\Foundry\Persistence\Event\AfterPersist; use Zenstruck\Foundry\Story\Event\StateAddedToStory; -use Zenstruck\Foundry\Test\Behat\AbstractFoundryContext; use Zenstruck\Foundry\Test\Behat\FactoryShortNameResolver; use Zenstruck\Foundry\Test\Behat\FoundryContext; use Zenstruck\Foundry\Test\Behat\ObjectRegistry; @@ -35,18 +34,13 @@ ->tag('kernel.event_listener', ['method' => 'storeAfterStateAddedToStory', 'event' => StateAddedToStory::class]) ->public() - ->set('zenstruck_foundry.behat.context.parent', AbstractFoundryContext::class) + ->set(FoundryContext::class, FoundryContext::class) ->args([ service('.zenstruck_foundry.behat.factory_resolver'), service('.zenstruck_foundry.behat.object_registry'), ]) - ->abstract() ->public() ->autowire() ->autoconfigure() - - // service name is not hidden on purpose - ->set(FoundryContext::class, FoundryContext::class) - ->parent('zenstruck_foundry.behat.context.parent') ; }; diff --git a/src/Test/Behat/features/main/access-uuid.feature b/src/Test/Behat/features/main/access-uuid.feature index 29b37f416..430707890 100644 --- a/src/Test/Behat/features/main/access-uuid.feature +++ b/src/Test/Behat/features/main/access-uuid.feature @@ -2,7 +2,7 @@ Feature: Test accessing Uuid-based entity ids Scenario: Can create and reference Uuid entity Given there is an "entity with uid" named "the object" - Then "entity with uid" "the object" should exist + Then "entity with uid" named "the object" should exist Scenario: Can access last id for Uuid entity via lastId transform Given there is an "entity with uid" named "A" diff --git a/src/Test/Behat/features/main/create-objects.feature b/src/Test/Behat/features/main/create-objects.feature index 67b09274b..e640b4e93 100644 --- a/src/Test/Behat/features/main/create-objects.feature +++ b/src/Test/Behat/features/main/create-objects.feature @@ -1,34 +1,16 @@ Feature: Test objects creation Scenario: Can create entity with properties via PyTable - Given there is a contact A with + Given there is a contact named A with: | name | | John Doe | Then 1 contact should exist - Then contact A should have properties + Then contact named A should have properties: | name | | John Doe | - Scenario: Can create entity with "called" variant - Given there is a contact called B with - | name | - | Jane Doe | - Then 1 contact should exist - Then the contact called B should have properties - | name | - | Jane Doe | - - Scenario: Can create entity with "named" variant - Given there is a contact named C with - | name | - | Bob Doe | - Then 1 contact should exist - Then contact named C should have properties - | name | - | Bob Doe | - - Scenario: Can create one named entity with two lines in the PyTable (!) - Given there is a contact A with + Scenario: Can create one entity with two lines in the PyTable (!) + Given there is a contact named A with: | name | | John Doe | | Jane Doe | @@ -39,51 +21,51 @@ Feature: Test objects creation Then an "FactoryNotResolvable" exception should be thrown containing message "Cannot resolve factory for name \"i don't exist\"" Scenario: Multiple objects created with the same reference is handled (!) - Given there is a contact A - And there is a contact A + Given there is a contact named A + And there is a contact named A Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" Scenario: Reference to a non existent objet handled (!) - Then contact "I don't exist" should have properties + Then contact named "I don't exist" should have properties: | foo | | bar | Then an "ObjectNotFound" exception should be thrown containing message "Object \"contact I don't exist\" was not found" Scenario: Invalid property name handled (!) - Given there is a contact A with + Given there is a contact named A with: | foo | | bar | Then an "InvalidArgumentException" exception should be thrown containing message "Cannot set attribute \"foo\" for object" Scenario: Can create multiple entities via PyTable Then 0 contacts should exist - Given there are contacts with + Given there are contacts with: | _ref | name | | A | John Doe | | B | Jane Doe | Then 2 contacts should exist - Then contact A should have properties + Then contact named A should have properties: | name | | John Doe | - Then contact B should have properties + Then contact named B should have properties: | name | | Jane Doe | Scenario: Multiple objects created within a table with the same reference is handled (!) - Given there are contacts with + Given there are contacts with: | _ref | name | | A | John Doe | | A | Jane Doe | Then an "ObjectAlreadyRegistered" exception should be thrown containing message "Object \"A\" is already registered" Scenario: Can reference another object - Given there is a category MyCategory - And there is an address "the address" - And there is a contact A with + Given there is a category named MyCategory + And there is an address named "the address" + And there is a contact named A with: | name | category | address | | John Doe | | | When I am on "/" - Then contact A should have properties + Then contact named A should have properties: | name | category | address | | John Doe | | | Then 1 contact should exist @@ -91,104 +73,104 @@ Feature: Test objects creation Then 1 address should exist Scenario: Can reference another object with short syntax - Given there is a category MyCategory - And there is an address "the address" - And there is a contact A with + Given there is a category named MyCategory + And there is an address named "the address" + And there is a contact named A with: | name | category | address | | John Doe | MyCategory | the address | When I am on "/" - Then contact A should have properties + Then contact named A should have properties: | name | category | address | | John Doe | MyCategory | the address | Scenario: Can reference object with date - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | When I am on "/" - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | prop1 | propInteger | date | dateMutable | bool | float | stringEnum | intEnum | | foo | 1 | 2026-01-01 | 2026-01-02 | false | 3.14 | some_value | 0 | Scenario: Wrong assertion on string correctly handled (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | | foo | - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | prop1 | | bar | Then an "AssertionFailedError" exception should be thrown matching pattern "/foo(.*)bar/" Scenario: Wrong assertion on string correctly handled (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | propInteger | | foo | 1 | - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | propInteger | | 42 | Then an "AssertionFailedError" exception should be thrown matching pattern "/1(.*)42/" Scenario: Wrong assertion on date correctly handled (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | date | | foo | 2026-01-01 | - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | date | | 2026-01-02 | Then an "AssertionFailedError" exception should be thrown matching pattern "/2026/" Scenario: Wrong assertion on bool correctly handled (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | bool | | foo | true | - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | bool | | false | Then an "AssertionFailedError" exception should be thrown matching pattern "/true(.*)false/" Scenario: Wrong assertion on bool correctly handled (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | bool | | foo | false | - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | bool | | true | Then an "AssertionFailedError" exception should be thrown matching pattern "/false(.*)true/" Scenario: Wrong assertion on enum correctly handled (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | stringEnum | | foo | some_value | - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | stringEnum | | other_value | Then an "AssertionFailedError" exception should be thrown matching pattern "/StringBackedEnum/" Scenario: Can compare null - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | bool | | foo | null | When I am on "/" - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | prop1 | bool | | foo | null | Scenario: Wrong assertion with null works (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | bool | | foo | null | When I am on "/" - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | prop1 | bool | | foo | "null" | Then an "AssertionFailedError" exception should be thrown matching pattern "/null(.*)null/" Scenario: Wrong assertion with null works in the other way (!) - Given there is a "generic entity" "GE" with + Given there is a "generic entity" named "GE" with: | prop1 | date | | foo | 2026-01-01 | When I am on "/" - Then "generic entity" "GE" should have properties + Then "generic entity" named "GE" should have properties: | prop1 | date | | foo | null | Then an "AssertionFailedError" exception should be thrown matching pattern "/DateTimeImmutable(.*)null/" diff --git a/src/Test/Behat/features/main/object-names-with-digits.feature b/src/Test/Behat/features/main/object-names-with-digits.feature index f7eb50045..89ae6a370 100644 --- a/src/Test/Behat/features/main/object-names-with-digits.feature +++ b/src/Test/Behat/features/main/object-names-with-digits.feature @@ -1,21 +1,21 @@ Feature: Test object names starting with digits Scenario: Quoted object names can start with digits - Given there is a "generic entity" "123" with + Given there is a "generic entity" named "123" with: | prop1 | | foo | - Then "generic entity" "123" should exist - And "generic entity" "123" should have properties + Then "generic entity" named "123" should exist + And "generic entity" named "123" should have properties: | prop1 | | foo | Scenario: Quoted object names can be pure numbers - Given there is a contact "007" - Then contact "007" should exist + Given there is a contact named "007" + Then contact named "007" should exist Scenario: Count assertions still work correctly alongside digit names - Given there is a contact "1" - And there is a contact "2" + Given there is a contact named "1" + And there is a contact named "2" Then 2 contacts should exist - And contact "1" should exist - And contact "2" should exist + And contact named "1" should exist + And contact named "2" should exist diff --git a/src/Test/Behat/features/main/persist-entities.feature b/src/Test/Behat/features/main/persist-entities.feature index 5c0b3bdff..08633a4de 100644 --- a/src/Test/Behat/features/main/persist-entities.feature +++ b/src/Test/Behat/features/main/persist-entities.feature @@ -7,7 +7,7 @@ Feature: Test persisting entities Scenario: Can persist entities # Can name entities - Given there is a contact A + Given there is a contact named A # Can create unnamed entities And there is a contact When I am on "/" @@ -36,12 +36,12 @@ Feature: Test persisting entities | World | Scenario: Can access last created entity ID - Given there is a "generic entity" "the object" with + Given there is a "generic entity" named "the object" with: | prop1 | | foo | When I am on "/orm/update//bar" Then the response status code should be 200 - Then "generic entity" "the object" should have properties + Then "generic entity" named "the object" should have properties: | prop1 | | bar | @@ -50,13 +50,13 @@ Feature: Test persisting entities Then an "RuntimeException" exception should be thrown containing message "No last id found" Scenario: Can access last created entity ID - Given there is a "generic entity" "the object" with + Given there is a "generic entity" named "the object" with: | prop1 | | foo | And there is a contact When I am on "/orm/update//bar" Then the response status code should be 200 - Then "generic entity" "the object" should have properties + Then "generic entity" named "the object" should have properties: | prop1 | | bar | @@ -65,12 +65,12 @@ Feature: Test persisting entities Then an "InvalidArgumentException" exception should be thrown containing message "No object of type \"generic entity\" found" Scenario: Can access an ID from reference - Given there is a "generic entity" "the object" with + Given there is a "generic entity" named "the object" with: | prop1 | | foo | When I am on "/orm/update//bar" Then the response status code should be 200 - Then "generic entity" "the object" should have properties + Then "generic entity" named "the object" should have properties: | prop1 | | bar | diff --git a/src/Test/Behat/features/main/with-fixture-on-feature.feature b/src/Test/Behat/features/main/with-fixture-on-feature.feature index b1346533e..f343f3183 100644 --- a/src/Test/Behat/features/main/with-fixture-on-feature.feature +++ b/src/Test/Behat/features/main/with-fixture-on-feature.feature @@ -2,7 +2,7 @@ Feature: Test @withFixture tag Scenario: Load behat-contacts fixture with @withFixture tag - Given there is a contact "jane-doe" + Given there is a contact named "jane-doe" Then 2 contacts should exist Scenario: Ensure DB is fresh diff --git a/src/Test/Behat/features/main/with-fixture.feature b/src/Test/Behat/features/main/with-fixture.feature index a15a722e0..e0a800e72 100644 --- a/src/Test/Behat/features/main/with-fixture.feature +++ b/src/Test/Behat/features/main/with-fixture.feature @@ -22,17 +22,17 @@ Feature: Test @withFixture tag @withFixture(behat-contacts) Scenario: Can access entities from fixture Then 1 contact should exist - Then contact "john-doe" should have properties + Then contact named "john-doe" should have properties: | name | | John Doe | @withFixture(behat-category) Scenario: Can use entities from fixture in another entity - Given there is a contact "jane-doe" with + Given there is a contact named "jane-doe" with: | name | category | | Jane Doe | category fixture | Then 1 contact should exist - Then contact "jane-doe" should have properties + Then contact named "jane-doe" should have properties: | name | category | | Jane Doe | category fixture | diff --git a/src/Test/Behat/features/override-steps/override-steps.feature b/src/Test/Behat/features/override-steps/override-steps.feature new file mode 100644 index 000000000..a1e7f8646 --- /dev/null +++ b/src/Test/Behat/features/override-steps/override-steps.feature @@ -0,0 +1,21 @@ +Feature: Overriding built-in step definitions + + Scenario: Re-worded single creation step + Given create a contact called "john" + Then 1 contact should exist + Then contact named "john" should exist + + Scenario: Re-worded table creation step keeps placeholders and table normalization + Given the following contacts exist: + | _ref | name | + | A | John Doe | + | B | Jane Doe | + Then 2 contacts should exist + Then contact named A should have properties: + | name | + | John Doe | + + Scenario: Re-worded id transform (#[Transform] patterns can be overridden too) + Given create a contact called "john" + When I am on "/[lastId]" + Then the response status code should be 404 diff --git a/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature b/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature index 85aa11726..a689c5d44 100644 --- a/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature +++ b/src/Test/Behat/features/reset-disabled/manual-isolation-1.feature @@ -1,16 +1,16 @@ Feature: No database isolation (disabled mode) - Part 1 Scenario: First scenario creates data - Given there is a contact A + Given there is a contact named A Then 1 contact should exist Scenario: Second scenario sees previous data (no reset) Then 1 contact should exist - Then contact A should exist - Given there is a contact B + Then contact named A should exist + Given there is a contact named B Then 2 contacts should exist Scenario: Third scenario sees all accumulated data Then 2 contacts should exist - Then contact A should exist - Then contact B should exist + Then contact named A should exist + Then contact named B should exist diff --git a/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature b/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature index 821766f95..570ce0644 100644 --- a/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature +++ b/src/Test/Behat/features/reset-disabled/manual-isolation-2.feature @@ -6,7 +6,7 @@ Feature: No database isolation (disabled mode) - Part 2 Then 2 contacts should exist # ObjectRegistry is reset between features even when isolation is disabled - Then contact A should not exist + Then contact named A should not exist @resetDB Scenario: DB should be reset diff --git a/src/Test/Behat/features/reset-feature/isolation.feature b/src/Test/Behat/features/reset-feature/isolation.feature index 4e5ac2895..b834e21cd 100644 --- a/src/Test/Behat/features/reset-feature/isolation.feature +++ b/src/Test/Behat/features/reset-feature/isolation.feature @@ -1,7 +1,7 @@ Feature: Database isolation per feature - Part 1 Scenario: First scenario creates data - Given there is a contact A with + Given there is a contact named A with: | name | | John Doe | Then 1 contact should exist @@ -10,15 +10,15 @@ Feature: Database isolation per feature - Part 1 Then 1 contact should exist Scenario: Third scenario also sees accumulated data - Given there is a contact B with + Given there is a contact named B with: | name | | Jane Doe | Then 2 contacts should exist Scenario: Fourth scenario can access objects created in previous scenarios via ObjectRegistry - Then contact A should have properties + Then contact named A should have properties: | name | | John Doe | - And contact B should have properties + And contact named B should have properties: | name | | Jane Doe | diff --git a/src/Test/Behat/features/reset-feature/isolation2.feature b/src/Test/Behat/features/reset-feature/isolation2.feature index 7a9866632..b56acd026 100644 --- a/src/Test/Behat/features/reset-feature/isolation2.feature +++ b/src/Test/Behat/features/reset-feature/isolation2.feature @@ -4,7 +4,7 @@ Feature: Database isolation per feature - Part 2 Then 0 contacts should exist Scenario: Second scenario in new feature sees first scenario's data - Given there is a "contact" "C" with + Given there is a "contact" named "C" with: | name | | Alice Doe | Then 1 contact should exist @@ -13,6 +13,6 @@ Feature: Database isolation per feature - Part 2 Then 1 contact should exist Scenario: Could access data created in previous scenario - Then "contact" "C" should have properties + Then "contact" named "C" should have properties: | name | | Alice Doe | diff --git a/src/Test/Behat/features/reset-feature/reset-db.feature b/src/Test/Behat/features/reset-feature/reset-db.feature index b3e815c7c..5f1064646 100644 --- a/src/Test/Behat/features/reset-feature/reset-db.feature +++ b/src/Test/Behat/features/reset-feature/reset-db.feature @@ -4,17 +4,17 @@ Feature: Manual database reset with @resetDB tag Then 0 contact should exist Scenario: Create one contact - Given there is a contact A + Given there is a contact named A Then 1 contact should exist Scenario: Ensure contact still exists Then 1 contact should exist - Then contact A should exist + Then contact named A should exist @resetDB Scenario: Database is reset with @resetDB tag Then 0 contacts should exist - Then contact A should not exist + Then contact named A should not exist Given there is a contact Then 1 contact should exist diff --git a/src/Test/Behat/features/reset-manual/manual-isolation-1.feature b/src/Test/Behat/features/reset-manual/manual-isolation-1.feature index 4542ffcd5..7cf95c3d9 100644 --- a/src/Test/Behat/features/reset-manual/manual-isolation-1.feature +++ b/src/Test/Behat/features/reset-manual/manual-isolation-1.feature @@ -2,16 +2,16 @@ Feature: Manual database isolation (disabled mode) - Part 1 Scenario: First scenario creates data - Given there is a contact A + Given there is a contact named A Then 1 contact should exist Scenario: Second scenario sees previous data (no reset) Then 1 contact should exist - Then contact A should exist - Given there is a contact B + Then contact named A should exist + Given there is a contact named B Then 2 contacts should exist Scenario: Third scenario sees all accumulated data Then 2 contacts should exist - Then contact A should exist - Then contact B should exist + Then contact named A should exist + Then contact named B should exist diff --git a/src/Test/Behat/features/reset-manual/manual-isolation-2.feature b/src/Test/Behat/features/reset-manual/manual-isolation-2.feature index 6a6514a6c..67fb81523 100644 --- a/src/Test/Behat/features/reset-manual/manual-isolation-2.feature +++ b/src/Test/Behat/features/reset-manual/manual-isolation-2.feature @@ -4,7 +4,7 @@ Feature: Manual database isolation (disabled mode) - Part 2 Then 2 contacts should exist # ObjectRegistry is reset between features even when isolation is disabled - Then contact A should not exist + Then contact named A should not exist @resetDB Scenario: DB should be reset diff --git a/src/Test/Behat/src/AbstractFoundryContext.php b/src/Test/Behat/src/AbstractFoundryContext.php deleted file mode 100644 index 9f30cfd91..000000000 --- a/src/Test/Behat/src/AbstractFoundryContext.php +++ /dev/null @@ -1,166 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Zenstruck\Foundry\Test\Behat; - -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\TableNode; -use Zenstruck\Assert; -use Zenstruck\Foundry\Configuration; -use Zenstruck\Foundry\Factory; -use Zenstruck\Foundry\ObjectFactory; -use Zenstruck\Foundry\Persistence\PersistentObjectFactory; -use Zenstruck\Foundry\Persistence\RepositoryAssertions; - -use function Zenstruck\Foundry\get; -use function Zenstruck\Foundry\Persistence\refresh; - -/** - * @author Nicolas PHILIPPE - * - * @phpstan-import-type Parameters from Factory - */ -abstract class AbstractFoundryContext implements Context -{ - public function __construct( - private readonly FactoryShortNameResolver $factoryResolver, - private readonly ObjectRegistry $objectRegistry, - ) { - } - - public function createObject(string $factoryShortName, ?string $objectName = null): void - { - $this->resolveFactory($factoryShortName, $objectName)->create(); - } - - public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void - { - $factory = $this->resolveFactory($factoryShortName, $objectName); - $parametersList = $table->getColumnsHash(); - - if (1 !== \count($parametersList)) { - throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties to create one object, got %d lines. Use "there are %s with" to create multiple objects.', \count($parametersList), $factoryShortName)); - } - - $factory->create($parametersList[0]); - } - - public function createObjectsWithProperties(TableNode $table, string $factoryShortName): void - { - $parametersList = $table->getColumnsHash(); - - foreach ($parametersList as $parameters) { - $objectName = $parameters['_ref'] ?? null; - unset($parameters['_ref']); - - $this->resolveFactory($factoryShortName, $objectName) - ->create($parameters); - } - } - - public function assertNbObjectsExist(int $nb, string $factoryShortName): void - { - $this->repositoryAssertionFor($factoryShortName) - ->count($nb); - } - - public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void - { - $parametersList = $table->getColumnsHash(); - - if (1 !== \count($parametersList)) { - throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties for assertion, got %d lines.', \count($parametersList))); - } - - $object = $this->objectRegistry->getByFactoryShortName($factoryShortName, $objectName); - - if (!Configuration::autoRefreshWithLazyObjectsIsEnabled()) { - refresh($object); - } - - foreach ($parametersList[0] as $key => $valueExpected) { - $actualValue = get($object, $key); - - match (true) { - $valueExpected instanceof \DateTimeInterface => Assert::that($actualValue) - ->isInstanceOf(\DateTimeInterface::class) - ->and($actualValue->format('Y-m-d H:i:s')) - ->is($valueExpected->format('Y-m-d H:i:s')), - - \is_object($valueExpected) => Assert::that($actualValue)->is($valueExpected), - - default => Assert::that($actualValue)->equals($valueExpected), - }; - } - } - - public function assertObjectExists(string $factoryShortName, string $objectName): void - { - Assert::that( - $this->objectRegistry->has( - $this->factoryResolver->targetObjectClassFor($factoryShortName), - $objectName - ) - )->is(true, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" does not exist although it should."); - } - - public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void - { - Assert::that( - $this->objectRegistry->has( - $this->factoryResolver->targetObjectClassFor($factoryShortName), - $objectName - ) - )->is(false, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" exists although it should not."); - } - - public function transformLastId(string $before, string $after): string - { - return "{$before}{$this->objectRegistry->lastId()}{$after}"; - } - - public function transformLastIdForSpecificObject(string $before, string $factoryShortName, string $after): string - { - return "{$before}{$this->objectRegistry->lastIdFor($factoryShortName)}{$after}"; - } - - public function transformIdForSpecificObject(string $before, string $factoryShortName, string $objectName, string $after): string - { - return "{$before}{$this->objectRegistry->idFor($factoryShortName, $objectName)}{$after}"; - } - - /** - * @return ObjectFactory - */ - private function resolveFactory(string $factoryShortName, ?string $objectName = null): ObjectFactory - { - $factory = $this->factoryResolver->factoryFor($factoryShortName); - - if (!$objectName) { - return $factory; - } - - return $factory->afterInstantiate( - fn(object $object) => $this->objectRegistry->store($object, $objectName) - ); - } - - private function repositoryAssertionFor(string $factoryShortName): RepositoryAssertions - { - $factory = $this->factoryResolver->factoryFor($factoryShortName); - - if (!$factory instanceof PersistentObjectFactory) { - throw new \LogicException(\sprintf('Cannot make assertions with factory of class "%s" with short name "%s": it does not extend "%s".', $factory::class, $factoryShortName, PersistentObjectFactory::class)); - } - - return $factory::assert(); - } -} diff --git a/src/Test/Behat/src/Exception/UnsupportedTranslationResource.php b/src/Test/Behat/src/Exception/UnsupportedTranslationResource.php new file mode 100644 index 000000000..e0def1f25 --- /dev/null +++ b/src/Test/Behat/src/Exception/UnsupportedTranslationResource.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Exception; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class UnsupportedTranslationResource extends \InvalidArgumentException +{ + public static function forPath(string $path): self + { + return new self("Cannot register step translations from \"{$path}\": only \"xliff\", \"yaml\" and \"php\" files are supported."); + } +} diff --git a/src/Test/Behat/src/FoundryContext.php b/src/Test/Behat/src/FoundryContext.php index 490ef0b5c..3bafc83ca 100644 --- a/src/Test/Behat/src/FoundryContext.php +++ b/src/Test/Behat/src/FoundryContext.php @@ -16,7 +16,15 @@ use Behat\Step\Given; use Behat\Step\Then; use Behat\Transformation\Transform; +use Zenstruck\Assert; +use Zenstruck\Foundry\Configuration; use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\ObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Persistence\RepositoryAssertions; + +use function Zenstruck\Foundry\get; +use function Zenstruck\Foundry\Persistence\refresh; /** * @author Nicolas PHILIPPE @@ -24,43 +32,56 @@ * @phpstan-import-type Parameters from Factory * * @internal - * @final */ -class FoundryContext extends AbstractFoundryContext implements Context +final class FoundryContext implements Context { - #[\Override] + public function __construct( + private readonly FactoryShortNameResolver $factoryResolver, + private readonly ObjectRegistry $objectRegistry, + ) { + } + #[Given('there is a(n) :factoryShortName')] - #[Given('there is a(n) :factoryShortName :objectName')] - #[Given('there is a(n) :factoryShortName called :objectName')] #[Given('there is a(n) :factoryShortName named :objectName')] public function createObject(string $factoryShortName, ?string $objectName = null): void { - parent::createObject($factoryShortName, $objectName); + $this->resolveFactory($factoryShortName, $objectName)->create(); } - #[\Override] - #[Given('there is a(n) :factoryShortName with')] - #[Given('there is a(n) :factoryShortName :objectName with')] - #[Given('there is a(n) :factoryShortName called :objectName with')] - #[Given('there is a(n) :factoryShortName named :objectName with')] + #[Given('there is a(n) :factoryShortName with:')] + #[Given('there is a(n) :factoryShortName named :objectName with:')] public function createObjectWithProperties(TableNode $table, string $factoryShortName, ?string $objectName = null): void { - parent::createObjectWithProperties($table, $factoryShortName, $objectName); + $factory = $this->resolveFactory($factoryShortName, $objectName); + $parametersList = $table->getColumnsHash(); + + if (1 !== \count($parametersList)) { + throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties to create one object, got %d lines. Use "there are %s with:" to create multiple objects.', \count($parametersList), $factoryShortName)); + } + + $factory->create($parametersList[0]); } - #[\Override] - #[Given('there are :factoryShortName with')] + #[Given('there are :factoryShortName with:')] public function createObjectsWithProperties(TableNode $table, string $factoryShortName): void { - parent::createObjectsWithProperties($table, $factoryShortName); + $parametersList = $table->getColumnsHash(); + + foreach ($parametersList as $parameters) { + $objectName = $parameters['_ref'] ?? null; + unset($parameters['_ref']); + + $this->resolveFactory($factoryShortName, $objectName) + ->create($parameters); + } } - #[\Override] #[Then('/^(\d+) "([^"]*)" should exist$/')] #[Then('/^(\d+) ([^"]*) should exist$/')] public function assertNbObjectsExist(int $nb, string $factoryShortName): void { - parent::assertNbObjectsExist($nb, $factoryShortName); + $this->repositoryAssertionFor($factoryShortName) + ->count($nb); } /** @@ -68,48 +89,104 @@ public function assertNbObjectsExist(int $nb, string $factoryShortName): void * - factoryShortName: quoted or unquoted factory/entity name * - objectName: quoted or unquoted object reference * - * Supports optional articles ("the", "a", "an"), optional "called"/"named", - * optional "exist and", and optional trailing "properties". + * Supports optional articles ("the", "a", "an"), optional "exist and", + * and optional trailing "properties". */ - #[\Override] - #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should (?:exist and )?have(?: properties)?$/')] + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) named (?|"(?P[^"]+)"|(?P\S+)) should (?:exist and )?have(?: properties)?:$/')] public function assertObjectHasProperties(FoundryTableNode $table, string $factoryShortName, string $objectName): void { - parent::assertObjectHasProperties($table, $factoryShortName, $objectName); + $parametersList = $table->getColumnsHash(); + + if (1 !== \count($parametersList)) { + throw new \InvalidArgumentException(\sprintf('Expected exactly one line of properties for assertion, got %d lines.', \count($parametersList))); + } + + $object = $this->objectRegistry->getByFactoryShortName($factoryShortName, $objectName); + + if (!Configuration::autoRefreshWithLazyObjectsIsEnabled()) { + refresh($object); + } + + foreach ($parametersList[0] as $key => $valueExpected) { + $actualValue = get($object, $key); + + match (true) { + $valueExpected instanceof \DateTimeInterface => Assert::that($actualValue) + ->isInstanceOf(\DateTimeInterface::class) + ->and($actualValue->format('Y-m-d H:i:s')) + ->is($valueExpected->format('Y-m-d H:i:s')), + + \is_object($valueExpected) => Assert::that($actualValue)->is($valueExpected), + + default => Assert::that($actualValue)->equals($valueExpected), + }; + } } - #[\Override] - #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should exist$/')] + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) named (?|"(?P[^"]+)"|(?P\S+)) should exist$/')] public function assertObjectExists(string $factoryShortName, string $objectName): void { - parent::assertObjectExists($factoryShortName, $objectName); + Assert::that( + $this->objectRegistry->has( + $this->factoryResolver->targetObjectClassFor($factoryShortName), + $objectName + ) + )->is(true, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" does not exist although it should."); } - #[\Override] - #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) (?:(?:called |named ))?(?|"(?P[^"]+)"|(?P\S+)) should not exist$/')] + #[Then('/^(?:the |an? )?(?|"(?P[^"]+)"|(?!\d)(?P\S+)) named (?|"(?P[^"]+)"|(?P\S+)) should not exist$/')] public function assertObjectDoesNotExist(string $factoryShortName, string $objectName): void { - parent::assertObjectDoesNotExist($factoryShortName, $objectName); + Assert::that( + $this->objectRegistry->has( + $this->factoryResolver->targetObjectClassFor($factoryShortName), + $objectName + ) + )->is(false, "Object with name \"{$objectName}\" of type \"{$factoryShortName}\" exists although it should not."); } - #[\Override] #[Transform('/(.*)(.*)/')] public function transformLastId(string $before, string $after): string { - return parent::transformLastId($before, $after); + return "{$before}{$this->objectRegistry->lastId()}{$after}"; } - #[\Override] #[Transform('/(.*)(.*)/')] public function transformLastIdForSpecificObject(string $before, string $factoryShortName, string $after): string { - return parent::transformLastIdForSpecificObject($before, $factoryShortName, $after); + return "{$before}{$this->objectRegistry->lastIdFor($factoryShortName)}{$after}"; } - #[\Override] #[Transform('/(.*)(.*)/')] public function transformIdForSpecificObject(string $before, string $factoryShortName, string $objectName, string $after): string { - return parent::transformIdForSpecificObject($before, $factoryShortName, $objectName, $after); + return "{$before}{$this->objectRegistry->idFor($factoryShortName, $objectName)}{$after}"; + } + + /** + * @return ObjectFactory + */ + private function resolveFactory(string $factoryShortName, ?string $objectName = null): ObjectFactory + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$objectName) { + return $factory; + } + + return $factory->afterInstantiate( + fn(object $object) => $this->objectRegistry->store($object, $objectName) + ); + } + + private function repositoryAssertionFor(string $factoryShortName): RepositoryAssertions + { + $factory = $this->factoryResolver->factoryFor($factoryShortName); + + if (!$factory instanceof PersistentObjectFactory) { + throw new \LogicException(\sprintf('Cannot make assertions with factory of class "%s" with short name "%s": it does not extend "%s".', $factory::class, $factoryShortName, PersistentObjectFactory::class)); + } + + return $factory::assert(); } } diff --git a/src/Test/Behat/src/FoundryExtension.php b/src/Test/Behat/src/FoundryExtension.php index 30214d568..f83efa82b 100644 --- a/src/Test/Behat/src/FoundryExtension.php +++ b/src/Test/Behat/src/FoundryExtension.php @@ -23,6 +23,7 @@ use Zenstruck\Foundry\Test\Behat\Listener\BootConfigurationListener; use Zenstruck\Foundry\Test\Behat\Listener\DatabaseResetListener; use Zenstruck\Foundry\Test\Behat\Listener\LoadFixturesListener; +use Zenstruck\Foundry\Test\Behat\Listener\StepTranslationsListener; final class FoundryExtension implements Extension { @@ -50,6 +51,23 @@ public function configure(ArrayNodeDefinition $builder): void ->booleanNode('enable_dama_support') ->defaultFalse() ->end() + ->arrayNode('steps') + ->info('Override built-in step definitions: map a canonical pattern to your own wording (keep the same placeholders/capture groups).') + ->normalizeKeys(false) + ->scalarPrototype()->end() + ->end() + ->arrayNode('translations') + ->info('Paths to translation catalogues (xliff/yaml/php) overriding built-in step patterns.') + ->beforeNormalization() + ->ifString() + ->then(static fn(string $v): array => [$v]) + ->end() + ->scalarPrototype()->end() + ->end() + ->scalarNode('locale') + ->info('Locale the step overrides apply to. Matches the ".feature" language (defaults to "en"), not the CLI "--lang" option.') + ->defaultValue('en') + ->end() ->end() ->validate() ->ifTrue(static fn(array $v): bool => $v['enable_dama_support'] && DatabaseResetMode::DISABLED->value === $v['database_reset_mode']) @@ -71,6 +89,15 @@ public function load(ContainerBuilder $container, array $config): void ->setArgument('$symfonyKernel', new Reference('fob_symfony.kernel')) ->addTag(CallExtension::CALL_FILTER_TAG); + if ($config['steps'] || $config['translations']) { + $container->register('.zenstruck_foundry.behat.listener.step_translations', StepTranslationsListener::class) + ->setArgument('$translator', new Reference('translator')) + ->setArgument('$steps', $config['steps']) + ->setArgument('$translations', $config['translations']) + ->setArgument('$locale', $config['locale']) + ->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); + } + $databaseResetMode = DatabaseResetMode::from($config['database_reset_mode']); if (DatabaseResetMode::DISABLED === $databaseResetMode) { diff --git a/src/Test/Behat/src/Listener/LoadFixturesListener.php b/src/Test/Behat/src/Listener/LoadFixturesListener.php index fcc6de71c..0209beec7 100644 --- a/src/Test/Behat/src/Listener/LoadFixturesListener.php +++ b/src/Test/Behat/src/Listener/LoadFixturesListener.php @@ -47,9 +47,7 @@ public function loadFixtureIfTagged(AfterScenarioSetup $event): void $tags = []; - if ($feature instanceof TaggedNodeInterface) { - $tags = [...$tags, ...$feature->getTags()]; - } + $tags = [...$tags, ...$feature->getTags()]; if ($scenario instanceof TaggedNodeInterface) { $tags = [...$tags, ...$scenario->getTags()]; diff --git a/src/Test/Behat/src/Listener/StepTranslationsListener.php b/src/Test/Behat/src/Listener/StepTranslationsListener.php new file mode 100644 index 000000000..c7941b7aa --- /dev/null +++ b/src/Test/Behat/src/Listener/StepTranslationsListener.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Listener; + +use Behat\Testwork\EventDispatcher\Event\BeforeSuiteTested; +use Behat\Testwork\EventDispatcher\Event\SuiteTested; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Translation\Translator; +use Zenstruck\Foundry\Test\Behat\Exception\UnsupportedTranslationResource; + +/** + * Overrides the built-in step definition patterns by registering translations + * for the canonical patterns onto Behat's translator. + * + * @internal + * @author Nicolas PHILIPPE + */ +final class StepTranslationsListener implements EventSubscriberInterface +{ + /** + * @param array $steps canonical pattern => overriding pattern + * @param list $translations paths to xliff/yaml/php catalogues + */ + public function __construct( + private readonly Translator $translator, + private readonly array $steps, + private readonly array $translations, + private readonly string $locale, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + SuiteTested::BEFORE => ['registerTranslations', 200], + ]; + } + + public function registerTranslations(BeforeSuiteTested $event): void + { + $suiteName = $event->getSuite()->getName(); + + if ($this->steps) { + $this->translator->addResource('array', $this->steps, $this->locale, $suiteName); + } + + foreach ($this->translations as $path) { + $this->translator->addResource(self::loaderFor($path), $path, $this->locale, $suiteName); + } + } + + private static function loaderFor(string $path): string + { + return match (\mb_strtolower(\pathinfo($path, \PATHINFO_EXTENSION))) { + 'yaml', 'yml' => 'yaml', + 'xliff', 'xlf' => 'xliff', + 'php' => 'php', + default => throw UnsupportedTranslationResource::forPath($path), + }; + } +} diff --git a/src/Test/Behat/tests/Fixture/BehatTestKernel.php b/src/Test/Behat/tests/Fixture/BehatTestKernel.php index 66dacebe5..c7e3b357e 100644 --- a/src/Test/Behat/tests/Fixture/BehatTestKernel.php +++ b/src/Test/Behat/tests/Fixture/BehatTestKernel.php @@ -13,11 +13,9 @@ use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; -use Zenstruck\Foundry\Test\Behat\FoundryContext; use Zenstruck\Foundry\Tests\Fixture\App\Controller\HelloWorldController; use Zenstruck\Foundry\Tests\Fixture\App\Controller\UpdateGenericModel; use Zenstruck\Foundry\Tests\Fixture\FoundryTestKernel; @@ -48,7 +46,7 @@ protected function configureContainer(ContainerConfigurator $configurator, Loade $c->register(HelloWorldController::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(ResetDisabledTestContext::class)->setAutowired(true)->setAutoconfigured(true); - $c->setDefinition(TestFoundryContext::class, new ChildDefinition(FoundryContext::class)); + $c->register(TestFoundryContext::class)->setAutowired(true)->setAutoconfigured(true); $configurator->services() ->load('Zenstruck\\Foundry\\Test\\Behat\\Tests\\Fixture\\Factories\\', __DIR__.'/Factories') diff --git a/src/Test/Behat/tests/Fixture/TestFoundryContext.php b/src/Test/Behat/tests/Fixture/TestFoundryContext.php index 2af06a8fa..bdacb1d54 100644 --- a/src/Test/Behat/tests/Fixture/TestFoundryContext.php +++ b/src/Test/Behat/tests/Fixture/TestFoundryContext.php @@ -11,10 +11,13 @@ namespace Zenstruck\Foundry\Test\Behat\Tests\Fixture; +use Behat\Behat\Context\Context; use Yceruto\BehatExtension\Context\ExceptionAssertionTrait; -use Zenstruck\Foundry\Test\Behat\FoundryContext; -final class TestFoundryContext extends FoundryContext // @phpstan-ignore class.extendsFinalByPhpDoc +/** + * Provides the exception assertion steps alongside the built-in FoundryContext. + */ +final class TestFoundryContext implements Context { use ExceptionAssertionTrait; } diff --git a/src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff b/src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff new file mode 100644 index 000000000..7c70e85ed --- /dev/null +++ b/src/Test/Behat/tests/Fixture/translations/foundry-steps.xliff @@ -0,0 +1,11 @@ + + + + + + there is a(n) :factoryShortName named :objectName + create a :factoryShortName called :objectName + + + + diff --git a/src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php b/src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php new file mode 100644 index 000000000..912e49a44 --- /dev/null +++ b/src/Test/Behat/tests/Unit/Listener/StepTranslationsListenerTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\Behat\Tests\Unit\Listener; + +use Behat\Testwork\Environment\Environment; +use Behat\Testwork\EventDispatcher\Event\BeforeSuiteTested; +use Behat\Testwork\Specification\SpecificationIterator; +use Behat\Testwork\Suite\Suite; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Loader\ArrayLoader; +use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Translator; +use Zenstruck\Foundry\Test\Behat\Exception\UnsupportedTranslationResource; +use Zenstruck\Foundry\Test\Behat\Listener\StepTranslationsListener; + +final class StepTranslationsListenerTest extends TestCase +{ + private const CANONICAL_PATTERN = 'there is a(n) :factoryShortName named :objectName'; + private const OVERRIDING_PATTERN = 'create a :factoryShortName called :objectName'; + + #[Test] + public function it_registers_inline_step_overrides_scoped_to_the_suite(): void + { + $translator = self::createTranslator(); + + $listener = new StepTranslationsListener( + $translator, + [self::CANONICAL_PATTERN => self::OVERRIDING_PATTERN], + [], + 'en', + ); + + $listener->registerTranslations(self::createEvent('my-suite')); + + self::assertSame(self::OVERRIDING_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'en')); + // another suite (i.e. another translation domain) is left untouched + self::assertSame(self::CANONICAL_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'another-suite', 'en')); + } + + #[Test] + public function it_registers_overrides_from_a_translation_file(): void + { + $translator = self::createTranslator(); + + $listener = new StepTranslationsListener( + $translator, + [], + [__DIR__.'/../../Fixture/translations/foundry-steps.xliff'], + 'en', + ); + + $listener->registerTranslations(self::createEvent('my-suite')); + + self::assertSame(self::OVERRIDING_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'en')); + } + + #[Test] + public function it_applies_overrides_to_the_configured_locale(): void + { + $translator = self::createTranslator(); + + $listener = new StepTranslationsListener( + $translator, + [self::CANONICAL_PATTERN => 'il existe un(e) :factoryShortName nommé :objectName'], + [], + 'fr', + ); + + $listener->registerTranslations(self::createEvent('my-suite')); + + self::assertSame('il existe un(e) :factoryShortName nommé :objectName', $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'fr')); + // the "en" catalogue (i.e. default ".feature" language) is not affected + self::assertSame(self::CANONICAL_PATTERN, $translator->trans(self::CANONICAL_PATTERN, [], 'my-suite', 'en')); + } + + #[Test] + public function it_throws_on_an_unsupported_translation_resource(): void + { + $listener = new StepTranslationsListener(self::createTranslator(), [], ['/path/to/steps.txt'], 'en'); + + $this->expectException(UnsupportedTranslationResource::class); + + $listener->registerTranslations(self::createEvent('my-suite')); + } + + private static function createTranslator(): Translator + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addLoader('xliff', new XliffFileLoader()); + + return $translator; + } + + private static function createEvent(string $suiteName): BeforeSuiteTested + { + $suite = self::createStub(Suite::class); + $suite->method('getName')->willReturn($suiteName); + + $environment = self::createStub(Environment::class); + $environment->method('getSuite')->willReturn($suite); + + return new BeforeSuiteTested($environment, self::createStub(SpecificationIterator::class)); + } +}