Summary

UiHelperTrait::drupalLogin() performs a real authenticated HTTP exchange (a one-time-login-link GET, or login-form GET+POST) every time it's called. A test class that logs the same account in per method pays that full cost — tens of seconds at class level — on every method, even though nothing about the authentication changed between calls. This is the PHPUnit/DTT analogue of Playwright's cached storageState.

Proposed design

A process-lifetime, uid-keyed session-cookie cache, added to AuthTrait as a new drupalLoginCached() method:

  • MISS (uid not cached): perform a real drupalLogin($account), read the session cookie (same name/value pair core's own login already reads), cache it by uid.
  • HIT (uid cached): restore the cookie and the loggedInUser/current_user state, then verify with a cheap drupalGet('/user') (expect 200 + a URL containing /user/{uid}). On failure (stale/expired session), evict and fall back to a real login.

The HIT-path saving is (real login cost) minus (one GET) — still a clear net win for both of DTT's login flows.

Why adoption guidance matters

A uid-keyed cache only produces a HIT when the same uid logs in twice within one PHPUnit process. Most suites create a fresh user per method (typically in setUp(), auto-cleaned by DTT in per-method tearDown()), so a literal drupalLogin → drupalLoginCached swap would produce zero hits. Surveying login-heavy ExistingSite classes on one production site confirmed essentially all follow this fresh-per-method pattern. The win only materializes with a class-stable account: created once, exempted from per-method cleanup, reused across the class's methods, and deleted in tearDownAfterClass() — and it should never be adopted in tests of the login/auth/TFA flow itself, where a cached session would mask the thing under test.

Measured impact (proof of concept)

Converting a demonstrator class from fresh-user-per-method to a class-shared fixture with cached login dropped real logins from 12 to 1 (one real login, 11 cached re-hydrations) and total class test time (junit time) from ~39.8s to ~6.95s — about an 82.5% reduction. The trait was validated with MISS, HIT, and poisoned/expired-cookie-fallback tests, all passing. To rule out a vacuous pass, the HIT condition was temporarily stubbed to force every "hit" through a real login — exactly the HIT-path test failed as expected, before the stub was reverted and the suite re-confirmed green. On a suite where every class creates a fresh user per method, the trait is a no-op — the saving is attributable to the adoption pattern, not the caching mechanism alone.

Open problem: no class-scoped fixture support today

The proof of concept has two rough edges, both stemming from DTT having no concept of a class-scoped fixture: (1) keeping the shared account alive requires splicing it back out of DTT's internal per-instance cleanup-entities list, which is fragile and depends on internals that could change; (2) there's no live service container in tearDownAfterClass() (per-method tearDown() already tears the kernel down), so cleanup requires hand-booting a throwaway kernel mirroring DTT's own bootstrap — fragile if those internals change, and not something to copy-paste into every adopting class. Both disappear if DTT grows first-class class-scoped fixture support — e.g. a markEntityForClassCleanup() helper (or createUserForClass()) backed by a DTT-owned tearDownAfterClass() that boots a kernel once to drain it. That would also make cached login safe to adopt broadly instead of as a one-off demonstrator.

Proposed API

protected function drupalLoginCached(AccountInterface $account): void

Implements the miss/hit/verify/evict behavior above, backed by a documented static cache that callers can reset between test runs.

Caveats

  • Opt-in only — default drupalLogin() semantics stay unchanged.
  • Never for auth/login/TFA flow tests — caching the session would mask the subject under test.
  • Only for stable, non-mutated accounts — fresh-user-per-method suites get zero benefit; a mutated shared account would leak state between methods.
  • Class-stable fixture lifecycle is the adopter's responsibility — created once, exempt from per-method cleanup, cleaned up in tearDownAfterClass().
  • Session invalidation is explicit — logout, password change, or expiry fail the verification GET, triggering a real-login fallback and re-cache.

Patch

No patch attached — this is a design/feature proposal soliciting feedback on the approach (particularly whether class-scoped fixture support is something maintainers would consider) before investing in a full MR.

AI-assistance disclosure

Per drupal.org's policy on AI use in contributions: AI-Assisted: Yes. An AI writing assistant helped draft this proposal under my direction. It reflects my own judgment on the approach; I take responsibility for it and will engage with feedback before any merge request is opened.

Issue fork dtt-3608417

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

grasmash created an issue. See original summary.

grasmash’s picture

Issue summary: View changes
mstrelan’s picture

The last time I investigated this kind of thing phpunit had no way of knowing if all test cases in a class were running, or just a selection of them. It may also be an issue if a single class is split across parallel test runs, but I'm not sure if that ever happens in practice. Does this MR address that? It's hard to tell from the overly verbose description the LLM gave.

grasmash’s picture

phpunit does not need to know whether it is running all of a class's test cases or just a selection. So yes, the MR addresses it. The cache is keyed by uid and re-checked on every call, so each `drupalLoginCached()` stands on its own.

Example: a class with testA/testB/testC, each calling `drupalLoginCached($user)` for the same user. Run all three and testA does the real login and caches the cookie, testB and testC reuse it (with a `GET /user` check, and a fresh login if it has gone stale). Run just testC with a single-method filter and it misses and does the real login itself. Nothing assumes testA primed anything. Whole class or a subset, same behavior.

Parallel workers are separate PHP processes with their own static cache, so splitting a class across them cannot share or corrupt state. And it is opt-in; `drupalLogin()` is unchanged.

mstrelan’s picture

My concern is probably more around the tearDown. At what point is it satisfied that there are no more test cases to run and it's time to clean up? It might be a non-issue.

grasmash’s picture

I think it's a non-issue. There's nothing to tear down at "the last test." `drupalLoginCached()` doesn't create a user or a fixture — the caller passes an existing account, and the only thing cached is the session cookie, in a process-lifetime static array. That array just gets freed when the PHP process ends; it's not holding a resource that needs releasing at a specific point.

mstrelan’s picture

Right, it wasn't clear to me that this would be for existing users only.

moshe weitzman’s picture

Status: Active » Needs work

I'm a. bit unsure. Could we use this in DTT's own tests? That would help document the feature. I'm happy to mstrelan to merge this when satisfied.

mstrelan’s picture

Version: » 2.7.1

Agree with #9, let's get one or more example tests demonstrating how to use this. Do we need to create the user before the test suite runs?