From 5176404273e5e59a8b6af2ebd05c164d769b6c00 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 7 Jul 2026 21:09:20 +1000 Subject: [PATCH 1/3] [#677] Added shared entity registry with reverse-order scenario cleanup. Claude-Session: https://claude.ai/code/session_01LuFNrVPiPDmpEDsxE13bn2 --- MIGRATION.md | 22 ++++ README.md | 17 +++ STEPS.md | 32 ++--- docs.php | 1 + src/Drupal/BlockTrait.php | 48 +------ src/Drupal/ContentBlockTrait.php | 39 +----- src/Drupal/EckTrait.php | 53 +------- src/Drupal/FileTrait.php | 19 +-- src/Drupal/HelperTrait.php | 121 +++++++++++++++++- src/Drupal/MediaTrait.php | 31 +---- src/Drupal/MenuTrait.php | 45 +------ src/Drupal/ParagraphsTrait.php | 32 +---- src/Drupal/RedirectTrait.php | 57 +-------- src/Drupal/WebformTrait.php | 41 +----- tests/behat/features/drupal_block.feature | 2 +- .../features/drupal_content_block.feature | 2 +- tests/behat/features/drupal_eck.feature | 4 +- .../features/drupal_entity_cleanup.feature | 58 +++++++++ tests/phpunit/src/Drupal/HelperTraitTest.php | 60 +++++++++ 19 files changed, 321 insertions(+), 363 deletions(-) create mode 100644 MIGRATION.md create mode 100644 tests/behat/features/drupal_entity_cleanup.feature diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..80519558 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,22 @@ +# Migration guide + +## Unified entity cleanup + +Traits that create Drupal entities now register them in a single shared registry and delete them in reverse creation order through one `entityCleanupAfterScenario` hook, instead of each trait running its own after-scenario cleanup. + +The per-trait cleanup skip tags have been removed. Replace them as follows: + +| Removed tag | Replacement | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `@behat-steps-skip:mediaAfterScenario` | `@behat-steps-entity-cleanup-skip:media` | +| `@behat-steps-skip:contentBlockAfterScenario` | `@behat-steps-entity-cleanup-skip:block_content` | +| `@behat-steps-skip:paragraphsAfterScenario` | `@behat-steps-entity-cleanup-skip:paragraph` | +| `@behat-steps-skip:eckAfterScenario` | `@behat-steps-entity-cleanup-skip:ENTITY_TYPE_ID` | +| `@behat-steps-skip:menuAfterScenario` | `@behat-steps-entity-cleanup-skip:menu` and/or `@behat-steps-entity-cleanup-skip:menu_link_content` | +| `@behat-steps-skip:redirectAfterScenario` | `@behat-steps-entity-cleanup-skip:redirect` | +| `@behat-steps-skip:blockAfterScenario` | `@behat-steps-entity-cleanup-skip:block` | +| `@behat-steps-skip:webformAfterScenario` | `@behat-steps-entity-cleanup-skip:webform` | + +To skip cleanup of every registered entity at once, use `@behat-steps-skip:entityCleanupAfterScenario`. + +`FileTrait` keeps its own `@behat-steps-skip:fileAfterScenario` tag, which now covers only unmanaged files; managed file entities it creates are cleaned up by the shared registry and can be kept with `@behat-steps-entity-cleanup-skip:file`. diff --git a/README.md b/README.md index 7ddf7b1a..b1747864 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,23 @@ by adding `@behat-steps-skip:METHOD_NAME` tag to your test. For example, to skip `beforeScenario` hook from `ElementTrait`, add `@behat-steps-skip:ElementTrait` tag to the feature. +### Automatic entity cleanup + +Traits that create Drupal entities (content blocks, media, managed files, +paragraphs, ECK entities, menus and menu links, redirects, blocks and webforms) +register each created entity in a shared registry and delete them in reverse +creation order at the end of the scenario, keeping the test database clean +across long suites. Nodes, users, taxonomy terms and roles are cleaned up by the +base Drupal Extension and are never registered here, so there is no +double-deletion. + +To keep **all** registered entities after a scenario, add +`@behat-steps-skip:entityCleanupAfterScenario` to the scenario or feature. + +To keep only entities of a **named type**, add +`@behat-steps-entity-cleanup-skip:ENTITY_TYPE_ID` (for example +`@behat-steps-entity-cleanup-skip:media`). Repeat the tag to keep several types. + ## Writing tests with AI assistants Copy and paste below into your project's `CLAUDE.md` or `AGENTS.md` file. diff --git a/STEPS.md b/STEPS.md index 0e1bcbc4..cbed70fd 100644 --- a/STEPS.md +++ b/STEPS.md @@ -3366,9 +3366,7 @@ Then the response should be a valid Atom feed > Manage Drupal blocks. > - Create and configure blocks with custom visibility conditions. > - Place blocks in regions and verify their rendering in the page. -> - Automatically clean up created blocks after scenario completion. -> -> Skip processing with tag: `@behat-steps-skip:blockAfterScenario` +> - Created blocks are automatically removed at the end of the scenario.
@@ -3648,9 +3646,7 @@ Given the render cache has been cleared > Manage Drupal content blocks. > - Define reusable custom block content with structured field data. > - Create, edit, and verify block_content entities by type and description. -> - Automatically clean up created entities after scenario completion. -> -> Skip processing with tag: `@behat-steps-skip:contentBlockAfterScenario` +> - Created entities are automatically removed at the end of the scenario.
@@ -3947,9 +3943,7 @@ When I save the draggable views items of the view "draggableviews_demo" and the > Manage Drupal ECK entities with custom type and bundle creation. > - Create structured ECK entities with defined field values. > - Assert entity type registration and visit entity pages. -> - Automatically clean up created entities after scenario completion. -> -> Skip processing with tag: `@behat-steps-skip:eckAfterScenario` +> - Created entities are automatically removed at the end of the scenario.
@@ -4502,9 +4496,7 @@ Then an unmanaged file at the URI "public://config.txt" should not contain "debu > - Create structured media items with proper file reference handling. > - Assert media browser functionality and edit media entity fields. > - Support for multiple media types with field value expansion handling. -> - Automatically clean up created entities after scenario completion. -> -> Skip processing with tag: `@behat-steps-skip:mediaAfterScenario` +> - Created entities are automatically removed at the end of the scenario.
@@ -4691,9 +4683,7 @@ Then the "image" media with the name "Test media image" should not exist > - Assert menu items by label, path, and containment hierarchy. > - Assert menu link visibility and active states in different regions. > - Create and manage menu hierarchies with parent-child relationships. -> - Automatically clean up created menu links after scenario completion. -> -> Skip processing with tag: `@behat-steps-skip:menuAfterScenario` +> - Created menus and menu links are automatically removed at the end of the scenario.
@@ -4920,9 +4910,7 @@ Then the following modules should be disabled: > - Create paragraph items with type-specific field values. > - Test nested paragraph structures and reference field handling. > - Attach paragraphs to various entity types with parent-child relationships. -> - Automatically clean up created paragraph items after scenario completion. -> -> Skip processing with tag: `@behat-steps-skip:paragraphsAfterScenario` +> - Created paragraph items are automatically removed at the end of the scenario.
@@ -5033,13 +5021,11 @@ Then the "myqueue" queue should be empty > - Create one or more redirects from a table of source/destination/status. > - Delete redirects by source path. > - Assert that redirects do or do not exist for given source paths. -> - Automatically clean up created redirects after scenario completion. +> - Created redirects are automatically removed at the end of the scenario. > > Requires the `redirect` contrib module to be installed and enabled in the > consumer project: add `drupal/redirect` to `composer.json` and enable the > module as part of the site's standard setup (e.g. in `core.extension.yml`). ->

-> Skip processing with tag: `@behat-steps-skip:redirectAfterScenario`
@@ -5807,11 +5793,9 @@ Then the user "John" should not be blocked > Manage Drupal webforms. > - Delete webforms matching a given title for test isolation. > - Clone webform templates into new webforms for scenario setup. -> - Automatically clean up cloned webforms after scenario completion. +> - Cloned webforms are automatically removed at the end of the scenario. > > Requires `drupal/webform` module. ->

-> Skip processing with tag: `@behat-steps-skip:webformAfterScenario`
diff --git a/docs.php b/docs.php index 5d193d72..4ce8d9bc 100644 --- a/docs.php +++ b/docs.php @@ -722,6 +722,7 @@ function tag_registry(): array { return [ // Parametrized tags - expect a `:value` suffix. 'behat-steps-skip' => 'parametrized', + 'behat-steps-entity-cleanup-skip' => 'parametrized', 'module' => 'parametrized', 'breakpoint' => 'parametrized', 'email' => 'parametrized', diff --git a/src/Drupal/BlockTrait.php b/src/Drupal/BlockTrait.php index e042f166..fc2c5f83 100644 --- a/src/Drupal/BlockTrait.php +++ b/src/Drupal/BlockTrait.php @@ -6,57 +6,20 @@ use Behat\Step\Given; use Behat\Step\Then; -use Behat\Behat\Hook\Scope\AfterScenarioScope; -use Behat\Hook\AfterScenario; use Behat\Gherkin\Node\TableNode; use Behat\Mink\Exception\ExpectationException; use Drupal\block\Entity\Block; -use Drupal\Core\Entity\EntityStorageException; /** * Manage Drupal blocks. * * - Create and configure blocks with custom visibility conditions. * - Place blocks in regions and verify their rendering in the page. - * - Automatically clean up created blocks after scenario completion. - * - * Skip processing with tag: `@behat-steps-skip:blockAfterScenario` + * - Created blocks are automatically removed at the end of the scenario. */ trait BlockTrait { - /** - * Array of created block instances. - * - * @var array - */ - protected static array $blockInstances = []; - - /** - * Clean up all blocks created during the scenario. - * - * This method automatically runs after each scenario to ensure clean - * test state. - * Add the tag @behat-steps-skip:blockAfterScenario to your scenario to - * prevent automatic cleanup of blocks. - */ - #[AfterScenario('@api')] - public function blockAfterScenario(AfterScenarioScope $scope): void { - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - - foreach (static::$blockInstances as $key => $block) { - try { - $block->delete(); - } - // @codeCoverageIgnoreStart - catch (EntityStorageException) { - // Ignore "already deleted" errors to keep teardown resilient. - } - // @codeCoverageIgnoreEnd - unset(static::$blockInstances[$key]); - } - } + use HelperTrait; /** * Create a block instance. @@ -106,7 +69,7 @@ public function blockCreateInstance(string $admin_label, TableNode $fields): voi $this->blockConfigure($admin_label, $fields); - static::$blockInstances[] = $block; + $this->entityRegister($block); } /** @@ -178,11 +141,6 @@ public function blockRemove(string $label): void { while ($block = $this->blockLoadByLabel($label)) { $block->delete(); } - - static::$blockInstances = array_filter( - static::$blockInstances, - fn(Block $b): bool => $b->get('settings')['label'] !== $label - ); } /** diff --git a/src/Drupal/ContentBlockTrait.php b/src/Drupal/ContentBlockTrait.php index 2b00ef8b..1be21239 100644 --- a/src/Drupal/ContentBlockTrait.php +++ b/src/Drupal/ContentBlockTrait.php @@ -7,14 +7,10 @@ use Behat\Step\Then; use Behat\Step\Given; use Behat\Step\When; -use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; use Behat\Mink\Exception\ExpectationException; -use DrevOps\BehatSteps\HelperTrait; use Drupal\block_content\BlockContentTypeInterface; use Drupal\block_content\Entity\BlockContent; -use Drupal\Core\Entity\EntityStorageException; use Drupal\Driver\Entity\EntityStub; /** @@ -22,43 +18,12 @@ * * - Define reusable custom block content with structured field data. * - Create, edit, and verify block_content entities by type and description. - * - Automatically clean up created entities after scenario completion. - * - * Skip processing with tag: `@behat-steps-skip:contentBlockAfterScenario` + * - Created entities are automatically removed at the end of the scenario. */ trait ContentBlockTrait { use HelperTrait; - /** - * Array of created block_content entities. - * - * @var array - */ - protected static $contentBlockEntities = []; - - /** - * Clean up all content block entities created during the scenario. - */ - #[AfterScenario('@api')] - public function contentBlockAfterScenario(AfterScenarioScope $scope): void { - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - - foreach (static::$contentBlockEntities as $key => $block_content) { - try { - $block_content->delete(); - } - // @codeCoverageIgnoreStart - catch (EntityStorageException) { - // Ignore "already deleted" errors to keep teardown resilient. - } - // @codeCoverageIgnoreEnd - unset(static::$contentBlockEntities[$key]); - } - } - /** * Assert that a content block type exists. * @@ -227,7 +192,7 @@ protected function contentBlockCreateSingle(string $type, array $values): BlockC $entity = BlockContent::create($stub->getValues()); $entity->save(); - static::$contentBlockEntities[] = $entity; + $this->entityRegister($entity); return $entity; } diff --git a/src/Drupal/EckTrait.php b/src/Drupal/EckTrait.php index e81c93ab..27e5ddec 100644 --- a/src/Drupal/EckTrait.php +++ b/src/Drupal/EckTrait.php @@ -4,11 +4,10 @@ namespace DrevOps\BehatSteps\Drupal; -use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; use Behat\Step\Given; use Behat\Step\When; +use Drupal\Core\Entity\EntityInterface; use Drupal\Driver\Capability\ContentCapabilityInterface; use Drupal\Driver\Entity\EntityStub; @@ -17,51 +16,11 @@ * * - Create structured ECK entities with defined field values. * - Assert entity type registration and visit entity pages. - * - Automatically clean up created entities after scenario completion. - * - * Skip processing with tag: `@behat-steps-skip:eckAfterScenario` + * - Created entities are automatically removed at the end of the scenario. */ trait EckTrait { - /** - * Custom eck content entities organised by entity type. - * - * @var array> - */ - protected $eckEntities = []; - - /** - * Remove ECK types and entities. - */ - #[AfterScenario('@api')] - public function eckAfterScenario(AfterScenarioScope $scope): void { - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - - // @codeCoverageIgnoreStart - $entity_ids_by_type = []; - foreach ($this->eckEntities as $entity_type => $content_entities) { - foreach ($content_entities as $content_entity) { - // Handle both entity objects (with ->id() method) and stdClass (with ->id property). - if (is_object($content_entity) && method_exists($content_entity, 'id')) { - $entity_ids_by_type[$entity_type][] = $content_entity->id(); - } - elseif (is_object($content_entity) && isset($content_entity->id)) { - $entity_ids_by_type[$entity_type][] = $content_entity->id; - } - } - } - - foreach ($entity_ids_by_type as $entity_type => $entity_ids) { - $controller = \Drupal::entityTypeManager()->getStorage($entity_type); - $entities = $controller->loadMultiple($entity_ids); - $controller->delete($entities); - } - - $this->eckEntities = []; - // @codeCoverageIgnoreEnd - } + use HelperTrait; /** * Create eck entities. @@ -212,8 +171,10 @@ protected function eckCreateEntity(EntityStub $stub): void { $driver->entityCreate($stub); - // Store the saved Drupal entity for AfterScenario cleanup. - $this->eckEntities[$stub->getEntityType()][] = $stub->getSavedEntity(); + $saved = $stub->getSavedEntity(); + if ($saved instanceof EntityInterface) { + $this->entityRegister($saved); + } } } diff --git a/src/Drupal/FileTrait.php b/src/Drupal/FileTrait.php index fd773211..c52513a4 100644 --- a/src/Drupal/FileTrait.php +++ b/src/Drupal/FileTrait.php @@ -30,12 +30,7 @@ */ trait FileTrait { - /** - * Files entities. - * - * @var array - */ - protected $fileEntities = []; + use HelperTrait; /** * Unmanaged file URIs. @@ -122,7 +117,7 @@ protected function fileCreateManagedSingle(string $path, EntityStub $stub, ?stri $saved = $this->fileCreateEntity($path, $stub, $uri); - $this->fileEntities[] = $saved; + $this->entityRegister($saved); return $saved; } @@ -186,7 +181,9 @@ protected function fileCreateEntity(string $path, EntityStub $stub, ?string $uri } /** - * Clean all created managed files after scenario run. + * Remove unmanaged files created during the scenario. + * + * Managed file entities are removed by the shared entity registry cleanup. */ #[AfterScenario('@api')] public function fileAfterScenario(AfterScenarioScope $scope): void { @@ -195,15 +192,9 @@ public function fileAfterScenario(AfterScenarioScope $scope): void { return; } // @codeCoverageIgnoreEnd - foreach ($this->fileEntities as $file) { - $file->delete(); - } - foreach ($this->filesUnmanagedUris as $uri) { @unlink($uri); } - - $this->fileEntities = []; } /** diff --git a/src/Drupal/HelperTrait.php b/src/Drupal/HelperTrait.php index 4fd0108a..ad006f6c 100644 --- a/src/Drupal/HelperTrait.php +++ b/src/Drupal/HelperTrait.php @@ -4,7 +4,10 @@ namespace DrevOps\BehatSteps\Drupal; +use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Hook\AfterScenario; use DrevOps\BehatSteps\HelperTrait as CommonHelperTrait; +use Drupal\Core\Entity\EntityInterface; use Drupal\Driver\DrupalDriverInterface; use Drupal\Driver\Entity\EntityStubInterface; @@ -12,9 +15,20 @@ * Internal Drupal helper methods for Behat step definitions. * * Drupal-specific counterpart to the generic HelperTrait: fixture path - * expansion for file and image fields and managed-file lookups. Includes the - * generic helper trait so a consumer trait can rely on a single include for - * both generic and Drupal helpers. + * expansion for file and image fields, managed-file lookups, and a shared + * per-scenario entity registry. Creation steps register the entities they + * create; a separate teardown step deletes the registered entities in reverse + * creation order at scenario end. Includes the generic helper trait so a + * consumer trait can rely on a single include for both generic and Drupal + * helpers. + * + * Entity types owned by the base Drupal Extension cleanup (node, user, + * taxonomy_term, user_role) are never registered here, so there is no + * double-deletion. + * + * Skip all cleanup with tag: `@behat-steps-skip:entityCleanupAfterScenario` + * Skip cleanup for one entity type with tag: + * `@behat-steps-entity-cleanup-skip:media` * * This is an internal trait and should not be used directly in step definitions. */ @@ -22,6 +36,107 @@ trait HelperTrait { use CommonHelperTrait; + /** + * Entity types owned by the base Drupal Extension's own cleanup. + * + * Never deleted by this trait to avoid double-deletion with the base + * extension (re-deleting a user or language throws, unlike nodes/terms). + */ + const ENTITY_CLEANUP_EXCLUDED_TYPES = [ + 'node', + 'user', + 'taxonomy_term', + 'user_role', + 'language', + 'configurable_language', + ]; + + /** + * Entities registered during the scenario, in creation order. + * + * Each item is a [entity_type_id, entity_id] pair. Ids are stored as scalars + * so each entity is reloaded fresh at cleanup time and an already-deleted row + * is tolerated. Registering an entity only records it here; deleting + * registered entities is a separate concern handled at scenario teardown. + * + * @var array + */ + protected array $entityRegistry = []; + + /** + * Register a saved entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The saved entity to register. + */ + protected function entityRegister(EntityInterface $entity): void { + $id = $entity->id(); + + if ($id !== NULL) { + $this->entityRegisterId($entity->getEntityTypeId(), $id); + } + } + + /** + * Register an entity by type and id. + * + * @param string $entity_type_id + * The entity type machine name. + * @param int|string $entity_id + * The entity id. + */ + protected function entityRegisterId(string $entity_type_id, int|string $entity_id): void { + $this->entityRegistry[] = [$entity_type_id, $entity_id]; + } + + /** + * Delete registered entities in reverse creation order. + */ + #[AfterScenario('@api')] + public function entityCleanupAfterScenario(AfterScenarioScope $scope): void { + if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { + return; + } + + // @codeCoverageIgnoreStart + $skip_types = $this->entityCleanupSkippedTypes($scope->getScenario()->getTags()); + + foreach (array_reverse($this->entityRegistry) as [$entity_type_id, $entity_id]) { + if (in_array($entity_type_id, self::ENTITY_CLEANUP_EXCLUDED_TYPES, TRUE) || in_array($entity_type_id, $skip_types, TRUE)) { + continue; + } + + $entity = \Drupal::entityTypeManager()->getStorage($entity_type_id)->load($entity_id); + $entity?->delete(); + } + + $this->entityRegistry = []; + // @codeCoverageIgnoreEnd + } + + /** + * Collect entity types named in the scenario's per-type bypass tags. + * + * @param array $tags + * The scenario's tag names (without the leading '@'). + * + * @return array + * Entity type ids to skip, parsed from + * 'behat-steps-entity-cleanup-skip:' tags. + */ + protected function entityCleanupSkippedTypes(array $tags): array { + $prefix = 'behat-steps-entity-cleanup-skip:'; + $types = []; + + foreach ($tags as $tag) { + if (str_starts_with($tag, $prefix)) { + $types[] = substr($tag, strlen($prefix)); + } + } + + return $types; + } + /** * Expand fixture file paths for file/image fields on an entity stub. * diff --git a/src/Drupal/MediaTrait.php b/src/Drupal/MediaTrait.php index 9943076e..fc7f4097 100644 --- a/src/Drupal/MediaTrait.php +++ b/src/Drupal/MediaTrait.php @@ -4,9 +4,7 @@ namespace DrevOps\BehatSteps\Drupal; -use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; use Behat\Mink\Exception\ExpectationException; use Behat\Step\Given; use Behat\Step\Then; @@ -22,37 +20,12 @@ * - Create structured media items with proper file reference handling. * - Assert media browser functionality and edit media entity fields. * - Support for multiple media types with field value expansion handling. - * - Automatically clean up created entities after scenario completion. - * - * Skip processing with tag: `@behat-steps-skip:mediaAfterScenario` + * - Created entities are automatically removed at the end of the scenario. */ trait MediaTrait { use HelperTrait; - /** - * Array of created media entities. - * - * @var array - */ - protected $mediaEntities = []; - - /** - * Remove any created media items. - */ - #[AfterScenario('@api')] - public function mediaAfterScenario(AfterScenarioScope $scope): void { - // @codeCoverageIgnoreStart - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - // @codeCoverageIgnoreEnd - foreach ($this->mediaEntities as $media) { - $media->delete(); - } - $this->mediaEntities = []; - } - /** * Remove media type. * @@ -294,7 +267,7 @@ protected function mediaVisitActionPageWithName(string $media_type, string $name protected function mediaCreateSingle(EntityStub $stub): MediaInterface { $this->parseEntityFields($stub); $saved = $this->mediaCreateEntity($stub); - $this->mediaEntities[] = $saved; + $this->entityRegister($saved); return $saved; } diff --git a/src/Drupal/MenuTrait.php b/src/Drupal/MenuTrait.php index 56a42926..7ff762e6 100644 --- a/src/Drupal/MenuTrait.php +++ b/src/Drupal/MenuTrait.php @@ -5,9 +5,7 @@ namespace DrevOps\BehatSteps\Drupal; use Behat\Step\Given; -use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; use Drupal\menu_link_content\Entity\MenuLinkContent; use Drupal\system\Entity\Menu; use Drupal\system\MenuInterface; @@ -18,25 +16,11 @@ * - Assert menu items by label, path, and containment hierarchy. * - Assert menu link visibility and active states in different regions. * - Create and manage menu hierarchies with parent-child relationships. - * - Automatically clean up created menu links after scenario completion. - * - * Skip processing with tag: `@behat-steps-skip:menuAfterScenario` + * - Created menus and menu links are automatically removed at the end of the scenario. */ trait MenuTrait { - /** - * Menus. - * - * @var \Drupal\system\Entity\Menu[] - */ - protected $menus = []; - - /** - * Menu links. - * - * @var \Drupal\menu_link_content\Entity\MenuLinkContent[] - */ - protected $menuLinks = []; + use HelperTrait; /** * Remove a single menu by its label if it exists. @@ -86,7 +70,7 @@ public function menuCreate(TableNode $table): void { $menu = Menu::create($menu_hash); $menu->save(); - $this->menus[] = $menu; + $this->entityRegister($menu); } } @@ -157,29 +141,8 @@ public function menuLinksCreate(string $menu_name, TableNode $table): void { } $menu_link = MenuLinkContent::create($menu_link_hash); $menu_link->save(); - $this->menuLinks[] = $menu_link; - } - } - - /** - * Remove all menu items after scenario run. - */ - #[AfterScenario('@api')] - public function menuAfterScenario(AfterScenarioScope $scope): void { - // @codeCoverageIgnoreStart - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - // @codeCoverageIgnoreEnd - foreach ($this->menuLinks as $menu_link) { - $menu_link->delete(); - } - $this->menuLinks = []; - - foreach ($this->menus as $menu) { - $menu->delete(); + $this->entityRegister($menu_link); } - $this->menus = []; } /** diff --git a/src/Drupal/ParagraphsTrait.php b/src/Drupal/ParagraphsTrait.php index 855bf66d..0644f0c3 100644 --- a/src/Drupal/ParagraphsTrait.php +++ b/src/Drupal/ParagraphsTrait.php @@ -5,9 +5,7 @@ namespace DrevOps\BehatSteps\Drupal; use Behat\Step\Given; -use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Driver\DrupalDriverInterface; use Drupal\Driver\Entity\EntityStub; @@ -20,35 +18,11 @@ * - Create paragraph items with type-specific field values. * - Test nested paragraph structures and reference field handling. * - Attach paragraphs to various entity types with parent-child relationships. - * - Automatically clean up created paragraph items after scenario completion. - * - * Skip processing with tag: `@behat-steps-skip:paragraphsAfterScenario` + * - Created paragraph items are automatically removed at the end of the scenario. */ trait ParagraphsTrait { - /** - * Array of created paragraph entities. - * - * @var array - */ - protected static $paragraphEntities = []; - - /** - * Clean all paragraphs instances after scenario run. - */ - #[AfterScenario('@api')] - public function paragraphsAfterScenario(AfterScenarioScope $scope): void { - // @codeCoverageIgnoreStart - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - // @codeCoverageIgnoreEnd - foreach (static::$paragraphEntities as $paragraph) { - $paragraph->delete(); - } - - static::$paragraphEntities = []; - } + use HelperTrait; /** * Create a paragraph of the given type with fields within an existing entity. @@ -118,7 +92,7 @@ protected function paragraphsAttachFromStubToEntity(ContentEntityInterface $pare $parent_entity->save(); } - static::$paragraphEntities[] = $paragraph; + $this->entityRegister($paragraph); return $paragraph; } diff --git a/src/Drupal/RedirectTrait.php b/src/Drupal/RedirectTrait.php index a5092e5f..d8ae2409 100644 --- a/src/Drupal/RedirectTrait.php +++ b/src/Drupal/RedirectTrait.php @@ -4,9 +4,7 @@ namespace DrevOps\BehatSteps\Drupal; -use Behat\Behat\Hook\Scope\AfterScenarioScope; use Behat\Gherkin\Node\TableNode; -use Behat\Hook\AfterScenario; use Behat\Step\Given; use Behat\Step\Then; use Drupal\Component\Utility\UrlHelper; @@ -18,16 +16,16 @@ * - Create one or more redirects from a table of source/destination/status. * - Delete redirects by source path. * - Assert that redirects do or do not exist for given source paths. - * - Automatically clean up created redirects after scenario completion. + * - Created redirects are automatically removed at the end of the scenario. * * Requires the `redirect` contrib module to be installed and enabled in the * consumer project: add `drupal/redirect` to `composer.json` and enable the * module as part of the site's standard setup (e.g. in `core.extension.yml`). - * - * Skip processing with tag: `@behat-steps-skip:redirectAfterScenario` */ trait RedirectTrait { + use HelperTrait; + /** * Allowed HTTP status codes for redirects. * @@ -35,13 +33,6 @@ trait RedirectTrait { */ protected static $redirectAllowedStatusCodes = [301, 302, 303, 307, 308]; - /** - * Redirects created during a scenario. - * - * @var \Drupal\redirect\Entity\Redirect[] - */ - protected $redirects = []; - /** * Create one or more redirects. * @@ -90,7 +81,7 @@ public function redirectCreate(TableNode $table): void { $redirect->setRedirect($to); $redirect->save(); - $this->redirects[] = $redirect; + $this->entityRegister($redirect); } } @@ -126,14 +117,6 @@ public function redirectDelete(TableNode $table): void { $entities = $storage->loadMultiple($ids); $storage->delete($entities); - - // Drop any cleanup references for the just-deleted redirects so the - // after-scenario hook does not attempt to delete them again. - $deleted_ids = array_map(intval(...), array_keys($entities)); - $this->redirects = array_values(array_filter( - $this->redirects, - fn(Redirect $redirect): bool => !in_array((int) $redirect->id(), $deleted_ids, TRUE), - )); } } @@ -238,38 +221,6 @@ public function redirectAssertNotExist(TableNode $table): void { } } - /** - * Remove all created redirects after scenario run. - */ - #[AfterScenario('@api')] - public function redirectAfterScenario(AfterScenarioScope $scope): void { - // @codeCoverageIgnoreStart - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - return; - } - // @codeCoverageIgnoreEnd - if (empty($this->redirects)) { - return; - } - - // @codeCoverageIgnoreStart - if (!\Drupal::moduleHandler()->moduleExists('redirect')) { - $this->redirects = []; - - return; - } - // @codeCoverageIgnoreEnd - $storage = \Drupal::entityTypeManager()->getStorage('redirect'); - $ids = array_filter(array_map(fn(Redirect $redirect): int => (int) $redirect->id(), $this->redirects)); - $entities = $ids !== [] ? $storage->loadMultiple($ids) : []; - - if (!empty($entities)) { - $storage->delete($entities); - } - - $this->redirects = []; - } - /** * Throw when the `redirect` module is not enabled. */ diff --git a/src/Drupal/WebformTrait.php b/src/Drupal/WebformTrait.php index c77df30e..a57f7ec5 100644 --- a/src/Drupal/WebformTrait.php +++ b/src/Drupal/WebformTrait.php @@ -4,55 +4,20 @@ namespace DrevOps\BehatSteps\Drupal; -use Behat\Behat\Hook\Scope\AfterScenarioScope; -use Behat\Hook\AfterScenario; use Behat\Step\Given; -use Drupal\Core\Entity\EntityStorageException; /** * Manage Drupal webforms. * * - Delete webforms matching a given title for test isolation. * - Clone webform templates into new webforms for scenario setup. - * - Automatically clean up cloned webforms after scenario completion. + * - Cloned webforms are automatically removed at the end of the scenario. * * Requires `drupal/webform` module. - * - * Skip processing with tag: `@behat-steps-skip:webformAfterScenario` */ trait WebformTrait { - /** - * Array of created webform entities for cleanup. - * - * @var array - */ - protected static $webformEntities = []; - - /** - * Clean all created webform instances after scenario run. - */ - #[AfterScenario('@api')] - public function webformAfterScenario(AfterScenarioScope $scope): void { - // @codeCoverageIgnoreStart - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { - static::$webformEntities = []; - return; - } - // @codeCoverageIgnoreEnd - foreach (static::$webformEntities as $webform) { - try { - $webform->delete(); - } - // @codeCoverageIgnoreStart - catch (EntityStorageException) { - // Ignore "already deleted" errors to keep teardown resilient. - } - // @codeCoverageIgnoreEnd - } - - static::$webformEntities = []; - } + use HelperTrait; /** * Remove all webforms with a title containing the given string. @@ -107,7 +72,7 @@ public function webformCloneTemplate(string $title, string $template): void { $clone->set('template', FALSE); $clone->save(); - static::$webformEntities[] = $clone; + $this->entityRegister($clone); } /** diff --git a/tests/behat/features/drupal_block.feature b/tests/behat/features/drupal_block.feature index 23a4b4df..059f8da5 100644 --- a/tests/behat/features/drupal_block.feature +++ b/tests/behat/features/drupal_block.feature @@ -246,7 +246,7 @@ Feature: Check that BlockTrait works Could not create block with admin label "Non-existent Block Type" """ - @api @behat-steps-skip:blockAfterScenario + @api @behat-steps-entity-cleanup-skip:block Scenario: Blocks are not automatically cleaned up when skip tag is used Given the instance of "User account menu" block exists with the following configuration: | label | [TEST] Skip Cleanup Block | diff --git a/tests/behat/features/drupal_content_block.feature b/tests/behat/features/drupal_content_block.feature index e37e5fda..45262eb2 100644 --- a/tests/behat/features/drupal_content_block.feature +++ b/tests/behat/features/drupal_content_block.feature @@ -169,7 +169,7 @@ Feature: Check that ContentBlockTrait works | [TEST] Content Block That Doesn't Exist | Then I should not see the text "[TEST] Content Block That Doesn't Exist" - @api @behat-steps-skip:contentBlockAfterScenario + @api @behat-steps-entity-cleanup-skip:block_content Scenario: Content blocks are not automatically cleaned up when skip tag is used Given the following "basic" content blocks exist: | info | body | status | diff --git a/tests/behat/features/drupal_eck.feature b/tests/behat/features/drupal_eck.feature index eae5d440..94708364 100644 --- a/tests/behat/features/drupal_eck.feature +++ b/tests/behat/features/drupal_eck.feature @@ -55,7 +55,7 @@ Feature: Check that EckTrait works Unable to find "test_entity_type" page "[TEST] ECK Entity non-existing" """ - @api @behat-steps-skip:eckAfterScenario + @api @behat-steps-entity-cleanup-skip:test_entity_type Scenario: ECK entities are not automatically cleaned up when skip tag is used Given the following eck "test_bundle" "test_entity_type" entities exist: | title | field_test_text | @@ -78,4 +78,4 @@ Feature: Check that EckTrait works And I am logged in as a user with the "administrator" role When I visit eck "test_bundle" "test_entity_type" entity with the title "[TEST] Auto Cleanup ECK 1" Then I should see "[TEST] Auto Cleanup ECK 1" - # ECK entities will be auto-deleted by eckAfterScenario hook + # ECK entities will be auto-deleted by the shared entity cleanup hook diff --git a/tests/behat/features/drupal_entity_cleanup.feature b/tests/behat/features/drupal_entity_cleanup.feature new file mode 100644 index 00000000..d39c6873 --- /dev/null +++ b/tests/behat/features/drupal_entity_cleanup.feature @@ -0,0 +1,58 @@ +Feature: Check that automatic entity cleanup works + As a Behat Steps library developer + I want entities created during a scenario to be tracked and removed at teardown + So that a test database stays clean across long suites without manual cleanup + + # Entities created through the library's creation steps are registered in a + # shared registry and deleted in reverse creation order at scenario teardown. + # These paired scenarios rely on Behat running scenarios in file order: the + # first scenario of each pair creates an entity, the second asserts what the + # teardown of the first scenario did with it. + + @api + Scenario: Registered entities exist during the scenario that creates them + Given the following redirects exist: + | from | to | + | /entity-cleanup-auto | /user/login | + Then the following redirects should exist: + | from | + | /entity-cleanup-auto | + + @api + Scenario: Registered entities are deleted at teardown of the creating scenario + Then the following redirects should not exist: + | /entity-cleanup-auto | + + @api @behat-steps-skip:entityCleanupAfterScenario + Scenario: The cleanup skip tag keeps all registered entities + Given the following redirects exist: + | from | to | + | /entity-cleanup-kept-all | /user/login | + Then the following redirects should exist: + | from | + | /entity-cleanup-kept-all | + + @api + Scenario: An entity kept by the skip tag survives teardown and is removed manually + Then the following redirects should exist: + | from | + | /entity-cleanup-kept-all | + Given the following redirects do not exist: + | /entity-cleanup-kept-all | + + @api @behat-steps-entity-cleanup-skip:redirect + Scenario: The per-type skip tag keeps entities of the named type + Given the following redirects exist: + | from | to | + | /entity-cleanup-kept-type | /user/login | + Then the following redirects should exist: + | from | + | /entity-cleanup-kept-type | + + @api + Scenario: An entity kept by the per-type skip tag survives teardown and is removed manually + Then the following redirects should exist: + | from | + | /entity-cleanup-kept-type | + Given the following redirects do not exist: + | /entity-cleanup-kept-type | diff --git a/tests/phpunit/src/Drupal/HelperTraitTest.php b/tests/phpunit/src/Drupal/HelperTraitTest.php index 3db02a5d..1bec2896 100644 --- a/tests/phpunit/src/Drupal/HelperTraitTest.php +++ b/tests/phpunit/src/Drupal/HelperTraitTest.php @@ -51,6 +51,39 @@ protected function createFixtureFiles(array $basenames): void { } } + public function testEntityRegisterId(): void { + $this->testObject->callEntityRegisterId('media', 7); + $this->testObject->callEntityRegisterId('block_content', 'custom_id'); + + $this->assertSame([['media', 7], ['block_content', 'custom_id']], $this->testObject->getEntityRegistry()); + } + + /** + * Tests that per-type cleanup bypass tags are parsed into entity type ids. + * + * @param array $tags + * Scenario tag names. + * @param array $expected + * Expected skipped entity type ids. + */ + #[DataProvider('dataProviderEntityCleanupSkippedTypes')] + public function testEntityCleanupSkippedTypes(array $tags, array $expected): void { + $this->assertSame($expected, $this->testObject->callEntityCleanupSkippedTypes($tags)); + } + + public static function dataProviderEntityCleanupSkippedTypes(): array { + return [ + 'no tags' => [[], []], + 'unrelated tags ignored' => [['api', 'behat-steps-skip:entityCleanupAfterScenario'], []], + 'single type' => [['behat-steps-entity-cleanup-skip:media'], ['media']], + 'multiple types' => [ + ['api', 'behat-steps-entity-cleanup-skip:media', 'behat-steps-entity-cleanup-skip:block_content'], + ['media', 'block_content'], + ], + 'empty value' => [['behat-steps-entity-cleanup-skip:'], ['']], + ]; + } + #[DataProvider('dataProviderLooksLikeCompoundCell')] public function testLooksLikeCompoundCell(string $value, bool $expected): void { $this->assertSame($expected, $this->testObject->callHelperLooksLikeCompoundCell($value)); @@ -266,6 +299,33 @@ public function callHelperExpandEntityFieldsFixtures(string $entity_type, Entity $this->helperExpandEntityFieldsFixtures($entity_type, $stub); } + public function callEntityRegisterId(string $entity_type_id, int|string $entity_id): void { + $this->entityRegisterId($entity_type_id, $entity_id); + } + + /** + * Expose entityCleanupSkippedTypes() for testing. + * + * @param array $tags + * Scenario tag names. + * + * @return array + * Entity type ids to skip. + */ + public function callEntityCleanupSkippedTypes(array $tags): array { + return $this->entityCleanupSkippedTypes($tags); + } + + /** + * Expose the entity registry for testing. + * + * @return array + * The registered entities. + */ + public function getEntityRegistry(): array { + return $this->entityRegistry; + } + public function getMinkParameter(string $name): mixed { return $name === 'files_path' ? $this->minkFilesPath : NULL; } From db5754677d48b197b49df6f5d39f9426d20e363c Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 7 Jul 2026 21:40:29 +1000 Subject: [PATCH 2/3] Addressed code review: completed base-owned entity type list in docs. Claude-Session: https://claude.ai/code/session_01LuFNrVPiPDmpEDsxE13bn2 --- README.md | 6 +++--- src/Drupal/HelperTrait.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b1747864..af8925f3 100644 --- a/README.md +++ b/README.md @@ -172,9 +172,9 @@ Traits that create Drupal entities (content blocks, media, managed files, paragraphs, ECK entities, menus and menu links, redirects, blocks and webforms) register each created entity in a shared registry and delete them in reverse creation order at the end of the scenario, keeping the test database clean -across long suites. Nodes, users, taxonomy terms and roles are cleaned up by the -base Drupal Extension and are never registered here, so there is no -double-deletion. +across long suites. Nodes, users, taxonomy terms, roles and language entities +are cleaned up by the base Drupal Extension and are never registered here, so +there is no double-deletion. To keep **all** registered entities after a scenario, add `@behat-steps-skip:entityCleanupAfterScenario` to the scenario or feature. diff --git a/src/Drupal/HelperTrait.php b/src/Drupal/HelperTrait.php index ad006f6c..dbb4e3dc 100644 --- a/src/Drupal/HelperTrait.php +++ b/src/Drupal/HelperTrait.php @@ -23,8 +23,8 @@ * helpers. * * Entity types owned by the base Drupal Extension cleanup (node, user, - * taxonomy_term, user_role) are never registered here, so there is no - * double-deletion. + * taxonomy_term, user_role, language, configurable_language) are never + * registered here, so there is no double-deletion. * * Skip all cleanup with tag: `@behat-steps-skip:entityCleanupAfterScenario` * Skip cleanup for one entity type with tag: From 8449a933f19ebd951da6d888c70845486a535279 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 7 Jul 2026 22:34:55 +1000 Subject: [PATCH 3/3] [#677] Unit-tested entity cleanup logic and dropped its coverage-ignore. Claude-Session: https://claude.ai/code/session_01LuFNrVPiPDmpEDsxE13bn2 --- src/Drupal/HelperTrait.php | 37 +++++++++--- tests/phpunit/src/Drupal/HelperTraitTest.php | 59 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/Drupal/HelperTrait.php b/src/Drupal/HelperTrait.php index dbb4e3dc..5ef699f0 100644 --- a/src/Drupal/HelperTrait.php +++ b/src/Drupal/HelperTrait.php @@ -90,28 +90,51 @@ protected function entityRegisterId(string $entity_type_id, int|string $entity_i } /** - * Delete registered entities in reverse creation order. + * Delete registered entities in reverse creation order at scenario teardown. */ #[AfterScenario('@api')] public function entityCleanupAfterScenario(AfterScenarioScope $scope): void { - if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) { + $scenario = $scope->getScenario(); + + if ($scenario->hasTag('behat-steps-skip:' . __FUNCTION__)) { return; } - // @codeCoverageIgnoreStart - $skip_types = $this->entityCleanupSkippedTypes($scope->getScenario()->getTags()); + $this->entityCleanupRun($this->entityCleanupSkippedTypes($scenario->getTags())); + } + /** + * Delete registered entities in reverse creation order, then reset. + * + * Entity types in self::ENTITY_CLEANUP_EXCLUDED_TYPES (owned by the base + * Drupal Extension) or in $skip_types are left in place. + * + * @param array $skip_types + * Entity type ids to leave in place. + */ + protected function entityCleanupRun(array $skip_types): void { foreach (array_reverse($this->entityRegistry) as [$entity_type_id, $entity_id]) { if (in_array($entity_type_id, self::ENTITY_CLEANUP_EXCLUDED_TYPES, TRUE) || in_array($entity_type_id, $skip_types, TRUE)) { continue; } - $entity = \Drupal::entityTypeManager()->getStorage($entity_type_id)->load($entity_id); - $entity?->delete(); + $this->entityCleanupDelete($entity_type_id, $entity_id); } $this->entityRegistry = []; - // @codeCoverageIgnoreEnd + } + + /** + * Load an entity by type and id and delete it when it still exists. + * + * @param string $entity_type_id + * The entity type machine name. + * @param int|string $entity_id + * The entity id. + */ + protected function entityCleanupDelete(string $entity_type_id, int|string $entity_id): void { + $entity = \Drupal::entityTypeManager()->getStorage($entity_type_id)->load($entity_id); + $entity?->delete(); } /** diff --git a/tests/phpunit/src/Drupal/HelperTraitTest.php b/tests/phpunit/src/Drupal/HelperTraitTest.php index 1bec2896..55e33358 100644 --- a/tests/phpunit/src/Drupal/HelperTraitTest.php +++ b/tests/phpunit/src/Drupal/HelperTraitTest.php @@ -58,6 +58,39 @@ public function testEntityRegisterId(): void { $this->assertSame([['media', 7], ['block_content', 'custom_id']], $this->testObject->getEntityRegistry()); } + public function testEntityCleanupRunDeletesInReverseOrderAndResets(): void { + $this->testObject->callEntityRegisterId('redirect', 1); + $this->testObject->callEntityRegisterId('media', 2); + $this->testObject->callEntityRegisterId('block', 3); + + $this->testObject->callEntityCleanupRun([]); + + $this->assertSame([['block', 3], ['media', 2], ['redirect', 1]], $this->testObject->deleted); + $this->assertSame([], $this->testObject->getEntityRegistry()); + } + + public function testEntityCleanupRunExcludesBaseOwnedTypes(): void { + $this->testObject->callEntityRegisterId('node', 1); + $this->testObject->callEntityRegisterId('redirect', 2); + $this->testObject->callEntityRegisterId('user', 3); + $this->testObject->callEntityRegisterId('configurable_language', 'en'); + + $this->testObject->callEntityCleanupRun([]); + + $this->assertSame([['redirect', 2]], $this->testObject->deleted); + $this->assertSame([], $this->testObject->getEntityRegistry()); + } + + public function testEntityCleanupRunSkipsNamedTypes(): void { + $this->testObject->callEntityRegisterId('media', 1); + $this->testObject->callEntityRegisterId('redirect', 2); + + $this->testObject->callEntityCleanupRun(['media']); + + $this->assertSame([['redirect', 2]], $this->testObject->deleted); + $this->assertSame([], $this->testObject->getEntityRegistry()); + } + /** * Tests that per-type cleanup bypass tags are parsed into entity type ids. * @@ -287,6 +320,13 @@ class HelperTraitTestImplementation { */ public ?DrupalDriverInterface $driver = NULL; + /** + * Records [type, id] pairs passed to the overridden entityCleanupDelete(). + * + * @var array + */ + public array $deleted = []; + public function callHelperLooksLikeCompoundCell(string $value): bool { return $this->helperLooksLikeCompoundCell($value); } @@ -326,6 +366,25 @@ public function getEntityRegistry(): array { return $this->entityRegistry; } + /** + * Run the entity cleanup with the given skipped types. + * + * @param array $skip_types + * Entity type ids to leave in place. + */ + public function callEntityCleanupRun(array $skip_types): void { + $this->entityCleanupRun($skip_types); + } + + /** + * {@inheritdoc} + * + * Overridden to record deletions instead of touching Drupal. + */ + protected function entityCleanupDelete(string $entity_type_id, int|string $entity_id): void { + $this->deleted[] = [$entity_type_id, $entity_id]; + } + public function getMinkParameter(string $name): mixed { return $name === 'files_path' ? $this->minkFilesPath : NULL; }