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 | |
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 | |
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 | |
Then watch the eca channel while you trigger the model:
1 | |
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 | |
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 | |
Trigger the model, then read the captured messages:
1 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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.