Drupal & DDEV Patterns
When to Use
Functional E2E against Drupal sites under DDEV. (DDEV plumbing —
baseURL,ignoreHTTPSErrors, storage state — is in the VR guide's Drupal section.)
Pattern: Form API Specifics
Drupal forms include hidden fields you generally don't touch (form_token, form_id, form_build_id). Submit via the actual button — Playwright fills visible inputs, hidden fields ride along.
Where you have to be careful:
Entity reference autocomplete fields — typing matches via AJAX; wait for the dropdown:
await page.getByLabel('Author').fill('admin');
await page.getByRole('option', { name: /^admin\s/ }).click();
--SELECT-- placeholder option on entity reference dropdowns and many select widgets — assert it's gone (or pick a real option) before submitting.
Machine-name fields — Drupal AJAX-fills these after typing the human label. Wait via expect(...).toHaveValue(...) rather than waitForTimeout.
Pattern: AJAX-Driven Forms
Two robust waiting patterns:
// Wait for the response that matters, not for arbitrary time
await Promise.all([
page.waitForResponse(r => r.url().includes('/system/ajax') && r.ok()),
page.getByRole('button', { name: 'Add another item' }).click(),
]);
// Wait for the throbber to disappear
await expect(page.locator('.ajax-progress, .throbber')).toHaveCount(0);
Avoid window.jQuery.active === 0 via evaluate() — fragile, long-time flake source. waitForResponse is more specific and faster.
Pattern: Big Pipe Placeholders
Big Pipe streams placeholders, then injects real markup via <script type="application/vnd.drupal-ajax"> tags.
For locators that match the placeholder and the replaced content:
// Reliable: wait for the real content, not the placeholder
await expect(page.getByRole('region', { name: 'User account menu' }))
.toContainText(/Log out/);
For elements that don't exist until Big Pipe replaces the placeholder, use toBeAttached() first, then act.
Pattern: Contextual Links and Quickedit
These render only on hover for users with access contextual links. Mitigations:
- Test as a role lacking the permission unless the test is about contextual links
- Or dismiss in setup: await page.keyboard.press('Escape'); after any hover near .contextual
Drupal Session Cookies
- Cookie name is
SESS<32-char-hash>over HTTP,SSESS<…>over HTTPS - DDEV defaults to HTTPS — expect
SSESS… secure: true, sameSite: 'Lax'are typical; if injecting cookies manually, mirror those flags or login silently fails- Don't hard-code the name — use
context.cookies()and filter by prefix
Pattern: Drush from Tests for Setup
import { execSync } from 'child_process';
test.beforeAll(() => {
execSync('ddev drush en my_module -y', { stdio: 'inherit' });
execSync('ddev drush cr', { stdio: 'inherit' });
});
Wrap in a worker-scoped fixture if the cost is significant. Couples tests to a DDEV/Drush environment — fine for project repos, problematic for portable test catalogs.
Decision: Entity CRUD via UI vs JSON:API + UI Verification
The recommended pattern (Section 7's "killer pattern" applied to Drupal):
- Create the node via JSON:API or
drush content:exportrecipe (fast, deterministic) - Drive UI for the thing under test (publishing, paragraph editing, moderation transition)
- Verify via JSON:API
UI-creating a node touches dozens of fields, alters, and behaviors irrelevant to most tests.
Drupal-Specific Waits
| Scenario | Approach |
|---|---|
| Cache rebuild after config change | await execSync('ddev drush cr') then re-navigate; assert with expect(...).toContainText(...) for post-cr slow first request |
| Deferred fields / lazy builders | Same as Big Pipe — assert against final content |
| Search index reindex | expect.toPass({ timeout: 60_000 }) polling search results |
Common Mistakes
- Hardcoded
waitForTimeoutafter AJAX — usewaitForResponseor assertion auto-retry - Not handling Big Pipe — first assertion fails on placeholder content
ddev drush crbetween every test — kills suite time; use sparingly
See Also
- API Testing — the JSON:API + UI hybrid pattern in detail
- Authentication — Drupal session cookies and
storageState - ATK Integration — Drush-backed DB reset with Testor
- Reference: Lullabot/playwright-drupal — parallel SQLite per worker for full DB isolation