Skip to content

Testing models from the command line

A model that imports without errors is not a model that works. This page describes a repeatable shell workflow for proving that a model fires, that its conditions evaluate the way you intended, and that its actions have the effect you expect.

The commands below use plain drush. If your local environment wraps Drush, apply your own prefix.

Install and activate the model

1
2
3
4
5
6
7
8
9
# Apply the model, either as a recipe ...
drush recipe recipes/my_model
# ... or from your configuration sync directory:
drush config:import --partial

# Rebuild the list of events ECA listens to.
drush eca:subscriber:rebuild

drush cache:rebuild

Run the subscriber rebuild first when nothing happens

ECA does not listen to every Drupal event. It maintains a state entry holding only the events used by enabled, non-template models, and rebuilds that entry whenever an ECA config entity is saved or deleted. When the entry is stale, the model is present and looks correct but never executes, and there is no error anywhere to tell you so.

drush eca:subscriber:rebuild recomputes the entry from the current configuration. It is cheap and idempotent, so run it after every import rather than trying to work out whether this particular import needed it.

Confirm the model is enabled before going further:

1
drush config:get eca.eca.my_model status

A model with status: false, or with template: true, is skipped entirely.

Turn on ECA's own tracing

ECA logs a message for every successor it evaluates and for the result of every condition it asserts, naming both by ID. That tells you which condition failed, which is normally the only question you actually have.

Raise the ECA log level to debug, which is RFC 5424 severity 7:

1
drush config:set eca.settings log_level 7 -y

Then watch the eca channel while you trigger the model:

1
drush watchdog:tail --type=eca

The messages to look for, in the order ECA emits them:

Message Meaning
Check action successor <label> (<id>) ECA reached that link and is about to evaluate its condition.
Unconditional <label> (<id>) The link carries no condition, so it was followed.
Asserted condition <id> for <label> The condition returned TRUE and the successor runs.
Not asserting condition <id> for <label> The condition returned FALSE and the path stops here. This is the line that names the failing condition.
Non existent condition <id> The successor references a condition ID that is not in the conditions pool. A typo in the model, not a logic problem.
Non existent successor (<id>) The successor references an ID that is in no pool at all.
Invalid context data for condition <id> The condition received no usable entity or value. Check that the token it expects exists at that point in the flow.

Restore the log level when you are done. Debug logging is verbose enough to affect performance:

1
drush config:set eca.settings log_level 4 -y

The Database Logging (dblog) module must be enabled for drush watchdog:tail to have anything to read. For the interactive alternatives, including the modeler's visual debug mode, see Debugging.

Capture mail instead of sending it

Any model that sends email is hard to assert on and easy to break for reasons that have nothing to do with ECA. A site whose system.mail override points at a mail plugin from a module that is not enabled throws on every send, so a model whose logic is already correct still reports failure.

Switch the site to core's test mail collector. It writes each message into state instead of sending it:

1
drush config:set system.mail interface.default test_mail_collector -y

Trigger the model, then read the captured messages:

1
drush state:get system.test_mail_collector

State holds one entry per message, and each entry is the full message array: recipient, subject, body, headers, and the module and key that sent it. That makes for stronger assertions than opening a real inbox, and it needs no mail infrastructure at all.

Restore the mail interface

The collector suppresses all outgoing mail for the whole site. Put the original value back when you finish, and never leave it in place on a site that has to send real mail:

1
drush config:set system.mail interface.default php_mail -y

Read the original value with drush config:get system.mail interface.default before you change it, so you know what to restore.

Trigger the event

Entity events are straightforward to trigger with drush php:eval:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Create a published article.
drush php:eval '
$node = \Drupal\node\Entity\Node::create([
  "type" => "article",
  "title" => "Test article",
  "uid" => 1,
]);
$node->save();
print $node->id();
'

setPublished() does not take an argument

$node->setPublished(FALSE) publishes the node. It does not unpublish it. In current Drupal, EntityPublishedTrait::setPublished() accepts no arguments and unconditionally sets the published state. PHP discards the surplus argument without a warning, so the test runs against the opposite of the state you meant to set up.

Use setUnpublished() to unpublish:

1
$node->setUnpublished();

Testing a transition needs two saves

ECA detects a transition by comparing the entity against its original, the version loaded from storage before the save. Conditions such as Entity: field value changed and Entity: original has field value read that original. A single save of an already published node therefore proves nothing about a model that reacts to a node becoming published.

Create the entity in the starting state, save it, then change it and save again:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
drush php:eval '
// First save: unpublished. The model under test must not fire here.
$node = \Drupal\node\Entity\Node::create([
  "type" => "article",
  "title" => "Transition test",
  "uid" => 1,
]);
$node->setUnpublished();
$node->save();

// Second save: the transition. This is the one being tested.
$node->setPublished();
$node->save();
'

The same shape applies to any transition: two saves, with the interesting change in the second one.

Design models for testability

A model that can only be triggered by a real entity save, a real form submission, or a real cron run is a model you can only test indirectly. Move the logic you care about into a custom event and have the real-world event do nothing but dispatch it. The eca_base:eca_custom event plugin then gives you a direct entry point from the shell:

1
drush eca:trigger:custom_event my_event

This is not only a testing trick. Splitting a model at a custom event boundary also lets more than one event reuse the same logic, keeps each model small enough to read, and makes the reusable part independent of the entity type that happened to trigger it first.

The command dispatches without context

drush eca:trigger:custom_event dispatches the event with no token data. A model that expects [entity] to be present must either receive it another way or load what it needs itself. That constraint is worth designing for: a logic block that fetches its own inputs is easier to test and easier to reuse.

Do not assume article and page exist

As of Drupal 11.4 the Standard profile no longer creates the article and page content types. Separate core recipes do. A long-lived development site often still has those bundles from earlier work, which hides the problem locally and breaks the test for everyone else.

Have the recipe that installs the model provision the bundle itself. Core ships recipes for both types, and a recipes: value containing a slash is resolved relative to the Drupal root:

1
2
3
4
# recipe.yml
recipes:
  - core/recipes/article_content_type
  - core/recipes/page_content_type

Including another recipe also applies its config actions, which can rewrite existing configuration on a site that is not fresh. Use a throwaway site.

Alternatively, detect what is available and use that:

1
2
3
4
5
drush php:eval '
print implode(", ", array_keys(
  \Drupal::service("entity_type.bundle.info")->getBundleInfo("node")
));
'

The same applies to roles, fields, and vocabularies. If the model depends on them, list them in the model's dependencies and provision them in the recipe that installs it. See Authoring models as configuration for the dependency rules.