Skip to content

PATTERN Cited by 1 source

A11y checks via Playwright fixture extension

Context

A team has a growing Playwright E2E suite and wants to add accessibility (Axe) checks without: (a) forcing test authors to learn a new framework, (b) doubling the number of tests, or (c) refactoring the framework core.

The intuitive integration — bake Axe into Playwright's Locator interaction methods (run Axe after every click, navigation, etc.) — is blocked by Locator auto-wait semantics: Locator guarantees individual-element readiness, not whole-page readiness, so embedded Axe runs at the wrong time.

The pattern

Extend Playwright's custom fixture (the team's existing per-test setup surface) with an accessibility helper method. Test authors invoke it explicitly at page-ready moments.

// Custom fixture (existing):
export const test = base.extend<{ slack: SlackFixture }>({
  slack: async ({ page }, use) => {
    const slack = new SlackFixture(page);
    await use(slack);
  },
});

// SlackFixture.utils.a11y.runAxeAndSaveViolations() is the
// new extension point:
class A11y {
  constructor(private page: Page) {
    this.defaultTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
    this.baseFileName = `${test.info().title}-violations`.replace(/\//g, '-');
  }

  async runAxeAndSaveViolations() {
    const axe = new AxeBuilder({ page: this.page }).withTags(this.defaultTags);
    constants.ACCESSIBILITY.AXE_EXCLUDED_SELECTORS.forEach(sel => {
      axe.exclude(sel);
    });
    const { violations } = await axe.analyze();
    const filtered = this.filterAndRemoveDuplicateViolations(violations);
    await this.saveViolations(filtered);
  }

  private filterAndRemoveDuplicateViolations(violations: Violation[]) {
    return violations
      .filter(v => ['critical'].includes(v.impact))
      .map(this.mapViolation)
      .filter(this.isUniqueViolation.bind(this));
  }
}

// In a test:
test('settings view', async ({ slack }) => {
  await slack.loginAs('user1');
  await slack.nav.openSettings();
  await slack.utils.a11y.runAxeAndSaveViolations();  // explicit
});

Why the fixture is the right extension point

  1. Fixtures are the per-test setup surface Playwright already provides. Teams with non-trivial Playwright suites have already built custom fixtures for login, API-seeding, UI navigation helpers. Adding an a11y utility is additive to an existing surface rather than a new one.
  2. Fixtures compose naturally. slack.utils.a11y.* sits alongside slack.api.*, slack.nav.*, slack.workflows.*. Test authors discover it via the same IDE autocomplete.
  3. Test-author control over invocation timing. Test authors know where a page is ready; the fixture's .run() call is an explicit marker that can be inspected in the test report.
  4. Custom test.step labels ("Running accessibility checks in runAxeAndSaveViolations") make every audit call visible in the Playwright HTML report.

Placement discipline

One Axe call per new view/page/flow transition in a test — typically after:

  • A button click that reveals new UI.
  • A navigation to a new page.
  • A sign-in as a second user.
  • A redirect.

The check should run after the page or view has fully loaded and all content has rendered — which is exactly what Locator auto-wait cannot guarantee but what an explicit marker after waitForURL / waitForSelector / custom readiness checks can.

Avoiding duplication

Same-view-twice audits produce:

  • Duplicate error messages in reports.
  • Duplicate screenshot artifacts.
  • Test-suite slowdown.

Slack built an audit environment flag + script that takes a screenshot of every page where Axe fires, saves to a folder, and supports manual duplication comparison. Self-flagged as manual and a candidate for AI-assisted future work.

Generalisation

The pattern generalises to any whole-X cross-cutting concern a team wants to layer onto an existing E2E suite without forcing per-test refactors:

  • Visual regression (slack.utils.visual.snapshot()).
  • Performance snapshots (slack.utils.perf.capture()).
  • Memory leak audits (slack.utils.leaks.check()).
  • Security header audits (slack.utils.sec.headers()).

Common shape: one helper class hanging off the custom fixture, explicitly invoked at page-ready moments, with test-author control and test-reporter visibility.

Seen in

Last updated · 470 distilled / 1,213 read