Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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`.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

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.
Expand Down
32 changes: 8 additions & 24 deletions STEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


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


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


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


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


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


<details>
Expand Down Expand Up @@ -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`).
> <br/><br/>
> Skip processing with tag: `@behat-steps-skip:redirectAfterScenario`


<details>
Expand Down Expand Up @@ -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.
> <br/><br/>
> Skip processing with tag: `@behat-steps-skip:webformAfterScenario`


<details>
Expand Down
1 change: 1 addition & 0 deletions docs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 3 additions & 45 deletions src/Drupal/BlockTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, \Drupal\block\Entity\Block>
*/
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.
Expand Down Expand Up @@ -106,7 +69,7 @@ public function blockCreateInstance(string $admin_label, TableNode $fields): voi

$this->blockConfigure($admin_label, $fields);

static::$blockInstances[] = $block;
$this->entityRegister($block);
}

/**
Expand Down Expand Up @@ -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
);
}

/**
Expand Down
39 changes: 2 additions & 37 deletions src/Drupal/ContentBlockTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,58 +7,23 @@
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;

/**
* 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.
*/
trait ContentBlockTrait {

use HelperTrait;

/**
* Array of created block_content entities.
*
* @var array<int,\Drupal\block_content\Entity\BlockContent>
*/
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.
*
Expand Down Expand Up @@ -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;
}
Expand Down
53 changes: 7 additions & 46 deletions src/Drupal/EckTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, array<int, \Drupal\eck\EckEntityInterface|object>>
*/
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.
Expand Down Expand Up @@ -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);
}
}

}
Loading