E2E testing on a Magento 2 project always starts the same way. Clone a suite, fix the selectors, fix the slugs, add the checkout step your modules introduced. Now you maintain a branch per client, and a fix on one never reaches the others.
Most of what differs between two stores is not logic. It is a selector, a slug, a postcode that passes validation, a feature switched off. That is data, so I built a suite that treats it as data.
A core suite you extend, rather than a fork you maintain
cd <magento root>
npx @samjuk/create-magento-e2e
Nine packages on npm, MIT, currently v0.1.x. A core that I maintain, two theme packages, five module packages, and the scaffolder above. Luma, Hyvä and Mage-OS are supported out of the box, against Magento 2.4.7 to 2.4.9.
95 tests today, with 55 tracked gaps. Coverage is published as a matrix in docs/COVERAGE.md and enforced in CI, so the headline number cannot drift away from the tables.
The shared suite covers what every store has. Everything past that is yours, and the next two sections are how you express it.
Configuration first
A store’s setup lives in five files: config/selectors.json, slugs.json, inputs.json, fixtures.json and features.json. They merge in order, core defaults, then the theme package, then yours. You restate only what you actually moved.
Unrecognised keys pass straight through. That is how a module package namespaces its own block inside those same five files, rather than earning a sixth.
Write the test once, opt in everywhere
There is no projects array to keep in sync. Install a package and its tests turn up on the next run.
| Source | Where | Enabled by |
|---|---|---|
| Theme suite | @samjuk/e2e-m2-theme-* | config.theme |
| Module packages | @<scope>/e2e-m2-module-* | any dependency declaring e2eModule.testDir |
| Your own tests | dev/tests/e2e/tests/ | always |
app/code modules | app/code/{Vendor}/{Module}/Test/E2E/ | the directory existing |
| Composer packages | vendor/<name>/Test/E2E/ | opt in via config.vendorModules |
Which gives you somewhere sensible to put every kind of test you write:
- One off store behaviour goes in
dev/tests/e2e/tests/. - A client specific module gets its tests in
app/code/{Vendor}/{Module}/Test/E2E/, next to the code they cover, picked up by the directory existing and nothing else. - A module you reuse across clients ships its tests inside the Composer package. Install the module on the next project, name it in
config.vendorModules, and the tests come with it. - A suite for somebody else’s module goes in an npm package declaring
e2eModule.testDir. That is all the five bundled module packages are, and yours sit alongside them as equals.
Write and maintain the tests once, in the same place as the thing they test, then opt in per project.
Why vendor/ is opt in
Composer packages are the one source that has to be named one at a time. That is deliberate, and it is a security decision rather than a technical limit.
Test code runs with your developer’s credentials, your CI runner’s network access and, in many setups, a live database connection. A suite that scanned vendor/ and executed whatever specs it found would give every package in your dependency tree, and anybody who compromises one, arbitrary code execution in your pipeline. Supply chain attacks in this ecosystem are not hypothetical.
So config.vendorModules lists packages explicitly. Adding one is a reviewable line in a pull request.
A skipped test is not a passing one
Exclusions carry a reason code, printed at the start of every run:
not-applicable, the store genuinely lacks the featureplatform-gap, real customers can do this, my page objects cannotclient-bug, it is actually broken
Without that split, my own debt hides inside a store’s legitimate limitations and never gets paid.
Two things follow from the same instinct. theme-luma and theme-hyva expose identical test titles and tags, so one config behaves the same under either, and CI fails if they drift. And assertions read state back rather than trusting a success message, because a success message only proves a controller did not throw. An address is saved then re-read from the form. A product rename is checked in the form, in the grid and on the storefront. An order confirmation is matched by increment ID.
Seeding uses a dump and restore strategy, with a flag row written once the dump exists and cleared by a completed restore. A run killed before teardown leaves the flag set, and the next run refuses to start rather than dumping an already polluted database as its only clean restore point.
When to use something else
If you have one store and you want a suite that grows with it, elgentos/magento2-playwright and ProxiBlue/m2-hyva-playwright are both good, and involve considerably less machinery than this. The configuration layers here only start paying for themselves once the same suite runs against stores you did not build together.
This is an early release at v0.1.x and it will have edges. I want feedback of every kind: bug reports, what you had to override, what you could not express in config at all, where the docs are wrong, which module packages are worth adding next. Telling me the whole approach is wrong counts as well.
Check it out: github.com/SamJUK/m2-e2e-magento-testing-platform