@drupal/playwright

中文文档
2.0.0发布于 2026/11/06GPL-2.0+TypeScriptES Module近 30 天下载 1.4万

Fixtures and commands for testing Drupal sites with Playwright

原始使用文档
文档目录9 节
  1. Requirements
  2. Fixtures
  3. Fully Parallel
  4. Default
  5. Customising the site install
  6. Isolated per test
  7. Isolated per test, with a snapshot
  8. Isolated per file
  9. Sites in a subdirectory

Playwright

This project provides fixtures and helper objects for writing Playwright tests that interact with Drupal sites.

Requirements

  • Node.js 22.16.0 or later.

Fixtures

Two fixtures are provided for installing Drupal and then subsequently working with it. Documentation for working with the drupal object is here.

It's very important to note that by default tests can run on any available worker, which means it could be running on a Drupal install that a previous set of tests has run on. This can be altered with a combination of test configuration and which fixture you use.

Fully Parallel

This is set with either fullyParallel: true in playwright.config.ts, or test.describe.configure({ mode: 'parallel' }); in your spec file.

Tests within a describe block run in parallel. This provides faster execution since tests don't wait for each other, but requires tests to be independent with no shared state as any test could end up in any worker in any order. This means that tests running with this fixture must not do anything destructive to the site. However, they can make alterations as long as they clean up after themselves (if the test fails then Playwright will discard the entire worker process and start a new one). This also applies to anything that runs in a hook such as beforeAll - it could end up running on multiple workers and it's changes perpetuated.

flowchart TD
 subgraph W1["Worker 1"]
  W1si["Site install"]
  W1g1["group1 beforeAll"]
  W1g1t1["group1 test1"]
  W1g2["group2 beforeAll"]
  W1g2t2["group2 test3 - FAIL, end worker
  flaky test example
  (retries: 1)"]
 end
 subgraph W2["Worker 2"]
  W2si["Site install"]
  W2g1["group1 beforeAll"]
  W2g1t2["group1 test2"]
  W2g2["group2 beforeAll"]
  W2g2t2["group2 test2"]
  W2g3["group3 beforeAll"]
  W2g3t2["group3 test2"]
  W2g4["group4 beforeAll"]
  W2g4t1["group4 test1"]
  W2g42["group4 beforeAll"]
  W2g4t2["group4 test2"]
  W2g4t3["group4 test3"]
 end
 subgraph W3["Worker 3"]
  W3si["Site install"]
  W3g1["group1 beforeAll"]
  W3g1t3["group1 test3 - FAIL, end worker
  (retries: 0)"]
 end
 subgraph W4["Worker 4"]
  W4si["Site install"]
  W4g2["group2 beforeAll"]
  W4g2t1["group2 test1"]
  W4g3["group3 beforeAll"]
  W4g3t1["group3 test1"]
  W4g32["group3 beforeAll"]
  W4g3t3["group3 test3"]
 end
 subgraph W5["Worker 5"]
    W5si["Site install"]
    W5g4["group4 beforeAll"]
    W5g4t3["group4 test3"]
 end
 subgraph W6["Worker 6"]
  W6si["Site install"]
  W6g2["group2 beforeAll"]
  W6g2t3["group2 test3"]
 end

W1si --> W1g1
W1g1 --> W1g1t1
W1g1t1 --> W1g2
W1g2 --> W1g2t2

 W2si --> W2g1
 W2g1 --> W2g1t2
 W2g1t2 --> W2g2
 W2g2 --> W2g2t2
 W2g2t2 --> W2g3
 W2g3 --> W2g3t2
 W2g3t2 --> W2g4
 W2g4 --> W2g4t1
 W2g4t1 --> W2g42
 W2g42 --> W2g4t2
 W2g4t2 --> W2g4t3

W3si --> W3g1
W3g1 --> W3g1t3

W4si --> W4g2
W4g2 --> W4g2t1
W4g2t1 --> W4g3
W4g3 --> W4g3t1
W4g3t1 --> W4g32
W4g32 --> W4g3t3

W5si --> W5g4
W5g4 --> W5g4t3

W6si --> W6g2
W6g2 --> W6g2t3

W3 --> W5
W3g1t3 -.-> W5si
W1 --> W6
W1g2t2 -.-> W6si

 A["fullyParallel: true"] --> W1si
 A --> W2si
 A --> W3si
 A --> W4si
import { expect } from '@playwright/test';
import { parallelWorker as test } from '@drupal/playwright';

test.describe('Drupal', () => {
  test('Can create a role, add permissions, and create a user', async ({
    drupal,
    page,
  }) => {
    await drupal.loginAsAdmin();
    await drupal.createRole({ name: 'content editor' });
    await drupal.addPermissions({
      role: 'content editor',
      permissions: ['access content overview', 'administer blocks'],
    });
    await drupal.createUser({
      username: 'catbro',
      password: 'meow',
      email: '[email protected]',
      roles: ['content editor'],
    });
    await drupal.logout();
    await drupal.login({ username: 'catbro', password: 'meow' });
    await page.goto('./admin/content');
    await expect(
      page.getByText('There are no content items yet.'),
    ).toBeVisible();
    await page.goto('./admin/structure/block');
    await expect(page.locator('h1')).toHaveText('Block layout');
    await expect(
      page.locator('table[data-drupal-selector="edit-blocks"]'),
    ).toBeVisible();
  });
});

Default

This is set with either fullyParallel: false in playwright.config.ts, or test.describe.configure({ mode: 'default' }); in your spec file, but you would still import the parallelWorker fixture.

Tests within the describe block run in order in the same worker process, with test files running in parallel. However, tests may still be distributed across workers and workers may still be reused.

flowchart TD
 subgraph W1["Worker 1"]
  W1si["Site install"]
  W1g1["group1 beforeAll"]
  W1g1t1["group1 test1"]
  W1g1t2["group1 test2"]
  W1g1t3["group1 test3"]
  W1g3["group3 beforeAll"]
  W1g3t1["group3 test1"]
  W1g3t2["group3 test2 - FAIL, end worker
  (retries: 0)"]
 end
 subgraph W2["Worker 2"]
  W2si["Site install"]
  W2g2["group2 beforeAll"]
  W2g2t1["group2 test1"]
  W2g2t2["group2 test2"]
  W2g2t3["group2 test3"]
  W2g4["group4 beforeAll"]
  W2g4t1["group4 test1"]
  W2g4t2["group4 test2"]
  W2g4t3["group4 test3"]
 end
 subgraph W3["Worker 3"]
  W3si["Site install"]
  W3g3["group3 beforeAll"]
  W3g3t3["group3 test3"]
 end

W1 --> W3

W1si --> W1g1
W1g1 --> W1g1t1
W1g1t1 --> W1g1t2
W1g1t2 --> W1g1t3
W1g1t3 --> W1g3
W1g3 --> W1g3t1
W1g3t1 --> W1g3t2

W2si --> W2g2
W2g2 --> W2g2t1
W2g2t1 --> W2g2t2
W2g2t2 --> W2g2t3
W2g2t3 --> W2g4
W2g4 --> W2g4t1
W2g4t1 --> W2g4t2
W2g4t2 --> W2g4t3

W3si --> W3g3
W3g3 --> W3g3t3

W1g3t2 -.-> W3si

 A["fullyParallel: false"] --> W1si
 A --> W2si

Customising the site install

The parallelWorker site install is configured with a worker-scoped option. Because it is worker-scoped, set it once at the top of a file, in playwright.config.ts, or on a re-exported pre-configured test (not inside an individual test()). Tests declaring different values run in separate workers, each with its own site install.

Playwright guarantees this: a worker is only reused for tests whose worker-scoped options match the ones it started with, so a file using standard will never run against a worker's minimal site. If it would otherwise be scheduled on such a worker, Playwright stops that worker and starts a new one, which installs the site with the requested profile. Files that declare the same profile can still share a worker. Setting installProfile inside a test.describe() block is a load-time error, since it would require a new worker partway through a file.

Each distinct profile used in a run therefore costs at least one extra site install, so prefer a single profile per suite where possible.

OptionDefaultDescription
installProfile'minimal'Install profile passed to Drupal.install().
import { parallelWorker as test } from '@drupal/playwright';

test.use({ installProfile: 'standard' });

Isolated per test

You can have an isolated Drupal install per test by using the isolatedPerTest fixture.

flowchart TD
 subgraph W1["Worker 1"]
  W1g1["group1 beforeAll"]
  W1si1["Site install"]
  W1g1t1["group1 test1"]
  W1g3["group3 beforeAll"]
  W1si2["Site install"]
  W1g3t1["group3 test1"]
  W1si3["Site install"]
  W1g3t2["group3 test2"]
  W1si4["Site install"]
  W1g3t3["group3 test3"]
 end
 subgraph W2["Worker 2"]
  W2g1["group2 beforeAll"]
  W2si1["Site install"]
  W2g2t1["group2 test1"]
  W2si2["Site install"]
  W2g2t2["group2 test2"]
  W2si3["Site install"]
  W2g2t3["group2 test3"]
  W2g4["group4 beforeAll"]
  W2si4["Site install"]
  W2g4t1["group4 test1"]
  W2si5["Site install"]
  W2g4t2["group4 test2"]
  W2si6["Site install"]
  W2g4t3["group4 test3"]
 end

W1g1 --> W1si1
W1si1 --> W1g1t1
W1g1t1 --> W1g3
W1g3 --> W1si2
W1si2 --> W1g3t1
W1g3t1 --> W1si3
W1si3 --> W1g3t2
W1g3t2 --> W1si4
W1si4 --> W1g3t3

W2g1 --> W2si1
W2si1 --> W2g2t1
W2g2t1 --> W2si2
W2si2 --> W2g2t2
W2g2t2 --> W2si3
W2si3 --> W2g2t3
W2g2t3 --> W2g4
W2g4 --> W2si4
W2si4 --> W2g4t1
W2g4t1 --> W2si5
W2si5 --> W2g4t2
W2g4t2 --> W2si6
W2si6 --> W2g4t3

 A["isolatedPerTest"] --> W1g1
 A --> W2g1
import { expect } from '@playwright/test';
import { isolatedPerTest as test } from '@drupal/playwright';

test.describe('Drupal', () => {
  test('Install a module', async ({ drupal, page }) => {
    await drupal.loginAsAdmin();
    const response = await page.goto('./admin/structure/views');
    expect(response).not.toBeNull();
    expect(response!.status()).toBe(404);
    await drupal.installModules(['views', 'views_ui']);
    await page.goto('./admin/structure/views');
    await expect(page.locator('h1')).toHaveText('Views');
  });

  test('Verify views is not installed', async ({ drupal, page }) => {
    await drupal.loginAsAdmin();
    const response = await page.goto('./admin/structure/views');
    expect(response).not.toBeNull();
    expect(response!.status()).toBe(404);
  });
});

Options for the whole file can be passed through via test.use:

import { expect } from '@playwright/test';
import { isolatedPerTest as test } from '@drupal/playwright';

test.use({ installProfile: 'standard', modules: ['views', 'views_ui'], enableTestExtensions: true });

test.describe('Drupal', () => {
    test('Do something', async ({ drupal, page }) => {

Isolated per test, with a snapshot

The isolatedPerTestSnapshot fixture gives each test the same per-test isolation as isolatedPerTest, but installs the site once per worker and restores a snapshot before each test instead of reinstalling. This is useful for sites with heavy install profiles or recipes, making tests run faster whilst still guaranteeing no data leaks between tests.

Requires sqlite. Snapshot/restore works by copying the standalone sqlite database (and the managed file directories). Set DRUPAL_TEST_DB_URL to a sqlite:// URL. With any other database the fixture throws immediately — use isolatedPerTest or parallelWorker instead.

import { expect } from '@playwright/test';
import { isolatedPerTestSnapshot as test } from '@drupal/playwright';

test.describe('Drupal', () => {
  test('Install a module', async ({ drupal, page }) => {
    await drupal.loginAsAdmin();
    await drupal.installModules(['views', 'views_ui']);
    await page.goto('./admin/structure/views');
    await expect(page.locator('h1')).toHaveText('Views');
  });

  test('Verify views is not installed', async ({ drupal, page }) => {
    // The previous test's module install was rolled back by the restore.
    await drupal.loginAsAdmin();
    const response = await page.goto('./admin/structure/views');
    expect(response!.status()).toBe(404);
  });
});

What the restore covers

Before each test the fixture restores the database and the site's files and private directories. Other site files, such as settings.php, are not restored. The database is copied with sqlite's online backup API, so it is safe even if requests from the previous test are still running.

No cache clear is needed after a restore. The database cache tables are restored with the database, and the snapshot is taken without the last_write_timestamp_* cache entries, so Drupal ignores anything the previous test left in APCu.

Customising the snapshot

The setup baked into the snapshot is configured with worker-scoped options. Because they are worker-scoped, set them once at the top of a file, in playwright.config.ts, or on a re-exported pre-configured test (not inside an individual test()). Tests declaring different values run in separate workers, each with its own snapshot.

OptionDefaultDescription
installProfile'minimal'Install profile passed to Drupal.install().
modules[]Modules installed declaratively before the snapshot.
enableTestExtensionsfalseMake Drupal's test-only modules discoverable.
snapshotCachefalseReuse a worker's snapshot across runs while the code is unchanged.
test.use({ installProfile: 'standard', modules: ['views', 'views_ui'] });

The recommended pattern for a consuming project is to re-export a pre-configured test so individual specs need no boilerplate:

// tests/fixtures/test.ts
import { isolatedPerTestSnapshot } from '@drupal/playwright';

export const test = isolatedPerTestSnapshot;
test.use({
  installProfile: 'my_distribution',
  enableTestExtensions: true,
  modules: ['views', 'views_ui'],
});
// tests/my-feature.spec.ts
import { expect } from '@playwright/test';
import { test } from './fixtures/test.js';

test('starts from the pre-configured snapshot', async ({ drupal, page }) => {
  await drupal.loginAsAdmin();
  // ...
});

Reusing the snapshot across runs

By default the snapshot is rebuilt from scratch on every run. Enabling snapshotCache persists each worker's post-setup site so the next run restores it instead of reinstalling, skipping install and setup entirely on a cache hit. This is very useful when you're only editing test files.

test.use({ snapshotCache: true });

The cache is keyed by a fingerprint of everything that can change what ends up in the snapshot: the installProfile, modules and enableTestExtensions options, the resolved database / base URL, and via git the state of the Drupal root's working tree. Test files are excluded from that git fingerprint, so editing a *.spec.ts reuses the cached snapshot, while changing a module, config or other source file rebuilds it. Only *.spec.ts is excluded by default, set testFileGlobs to match a different layout. Only the current fingerprint is kept on disk; older snapshots are pruned automatically.

Requires git (or a key). The working-tree fingerprint relies on the Drupal root being a git checkout. If it is not, and you do not supply a key, caching disables itself with a warning rather than risk reusing a stale snapshot.

Pass an object to tune it:

test.use({
  snapshotCache: {
    // Provide a custom cache key
    key: process.env.MY_CACHE_KEY,
    // Where snapshots are stored
    // defaults to a directory under the OS temp
    // Also configurable with DRUPAL_TEST_SNAPSHOT_CACHE_DIR
    dir: '.cache/playwright-snapshots',
    // Paths excluded from the git fingerprint (defaults to ['**/*.spec.ts'])
    testFileGlobs: ['**/*.spec.ts', 'tests/**'],
  },
});

Caching can also be forced on or off with the DRUPAL_TEST_SNAPSHOT_CACHE environment variable, which overrides the option — handy for keeping it on locally but off in CI:

DRUPAL_TEST_SNAPSHOT_CACHE=0 npx playwright test   # force off (e.g. in CI)
DRUPAL_TEST_SNAPSHOT_CACHE=1 npx playwright test   # force on

Each worker only ever reuses the slot it wrote itself, so parallel runs stay isolated and the restored database keeps the prefix it was installed with. A run that uses more workers than a cached run simply rebuilds the extra workers' snapshots and caches them too.

Isolated per file

You can also have an isolated Drupal install per test file, but this requires some manual work. However, it will allow you to ensure test data does not leak outside a test file, and utilise the default mode if needed.

If you'd like to keep a set of tests together in one file, you could combine these strategies to have some tests run in parallel on the shared worker installation, and some in isolation.

flowchart TD
 subgraph W1["Worker 1"]
  W1g1["group1 beforeAll (Site install)"]
  W1g1t1["group1 test1"]
  W1g1t2["group1 test2"]
  W1g1t3["group1 test3"]
  W1g3["group3 beforeAll (Site install)"]
  W1g3t1["group3 test1"]
  W1g3t2["group3 test2"]
  W1g3t3["group3 test3"]
 end
 subgraph W2["Worker 2"]
  W2g2["group2 beforeAll (Site install)"]
  W2g2t1["group2 test1"]
  W2g2t2["group2 test2"]
  W2g2t3["group2 test3"]
  W2g4["group4 beforeAll (Site install)"]
  W2g4t1["group4 test1"]
  W2g4t2["group4 test2"]
  W2g4t3["group4 test3"]
 end

 W1g1 --> W1g1t1
 W1g1t1 --> W1g1t2
 W1g1t2 --> W1g1t3
 W1g1t3 --> W1g3
 W1g3 --> W1g3t1
 W1g3t1 --> W1g3t2
 W1g3t2 --> W1g3t3

 W2g2 --> W2g2t1
 W2g2t1 --> W2g2t2
 W2g2t2 --> W2g2t3
 W2g2t3 --> W2g4
 W2g4 --> W2g4t1
 W2g4t1 --> W2g4t2
 W2g4t2 --> W2g4t3


 A["isolatedPerTest"] --> W1g1
 A --> W2g2
import { Drupal, type DrupalSite } from '@drupal/playwright';
import { test, expect } from '@playwright/test';

test.describe('Isolated', () => {
  let drupalSite: DrupalSite;
  let drupal: Drupal;

  test.beforeAll(async ({ browser }) => {
    drupalSite = await Drupal.install();
    const page = await browser.newPage();
    drupal = new Drupal({ page, drupalSite });
    await drupal.setTestCookie();
    await drupal.loginAsAdmin();
    await drupal.setPreprocessing({ css: true, javascript: true });
    await drupal.logout();
    await page.close();
  });
  test.beforeEach(async ({ page }) => {
    drupal = new Drupal({ page, drupalSite });
    await drupal.setTestCookie();
  });
  test.afterAll(async ({}) => {
    await drupalSite.teardown();
  });

  test('Verify CSS and JS aggregation are on', async ({ page }) => {
    await drupal.loginAsAdmin();
    await page.goto('./admin/config/development/performance');
    await expect(page.getByLabel('Aggregate CSS files')).toBeChecked();
    await expect(page.getByLabel('Aggregate JavaScript files')).toBeChecked();
  });
});
import { Drupal, type DrupalSite } from '@drupal/playwright';
import { test, expect } from '@playwright/test';

test.describe.configure({ mode: 'default' });

test.describe('Isolated (Serial)', () => {
  let drupalSite: DrupalSite;
  let drupal: Drupal;

  test.beforeAll(async ({ browser }) => {
    drupalSite = await Drupal.install();
  });
  test.beforeEach(async ({ page }) => {
    drupal = new Drupal({ page, drupalSite });
    await drupal.setTestCookie();
  });
  test.afterAll(async ({}) => {
    await drupalSite.teardown();
  });

  test('Turn CSS and JS aggregation on', async ({ page }) => {
    await drupal.loginAsAdmin();
    await drupal.setPreprocessing({ css: true, javascript: true });
  });

  test('Verify CSS and JS aggregation are on', async ({ page }) => {
    await drupal.loginAsAdmin();
    await page.goto('./admin/config/development/performance');
    await expect(page.getByLabel('Aggregate CSS files')).toBeChecked();
    await expect(page.getByLabel('Aggregate JavaScript files')).toBeChecked();
  });
});

Sites in a subdirectory

If your tests may run against a Drupal site installed in a subdirectory, e.g. http://localhost/web/ on Drupal.org CI, navigate with paths relative to baseURL, starting with ./:

await page.goto('./user/login'); // ✓
await page.goto('/user/login'); // ✗ ignores any path in baseURL

A leading / drops the subdirectory, so the test 404s. This also applies to waitForURL(), toHaveURL() and page.request. The fixtures add the trailing slash baseURL needs for this to work. If your site is always served from the root, either form works.

To catch root-relative paths, and fix them with eslint --fix, add the ESLint plugin to eslint.config.mjs:

import drupalPlaywright from '@drupal/playwright/eslint';

export default [
  // ...
  drupalPlaywright.configs.recommended,
];

版本与发布记录

@drupal/playwright 共发布 8 个版本,首次发布于 2025/08/27;过去 12 个月发布了 5 个版本。

版本发布日期距今
2.0.0latest2026/11/067 天前
1.3.02026/10/3014 天前
1.2.02026/07/024 个月前
1.1.22026/06/184 个月前
1.1.12026/03/257 个月前
1.1.02025/09/251.1 年前

关于 @drupal/playwright 的常见问题

@drupal/playwright 是什么?

Fixtures and commands for testing Drupal sites with Playwright

如何安装 @drupal/playwright?

在项目目录运行 npm install @drupal/playwright,或使用 pnpm add / yarn add / bun add。

@drupal/playwright 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@drupal/playwright 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@drupal/playwright 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.16.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@drupal/playwright 可以商用吗?

@drupal/playwright 使用 GPL-2.0+ 许可。强 Copyleft:分发包含该代码的软件时,整个作品需以兼容的 GPL 许可开源。仅在内部使用、不对外分发时不触发此义务。以上为概括,不构成法律意见,请以许可原文为准。

@drupal/playwright 的下载量有多少?

根据 npm 官方下载统计,@drupal/playwright 在 2026/10/13 – 2026/11/11 这 30 天内被下载 14,387 次,日均约 480 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@drupal/playwright 还在维护吗?

最新版本 2.0.0 发布于 2026/11/06(7 天前);至今共发布 8 个版本,其中过去 12 个月发布了 5 个;首次发布于 2025/08/27。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。