---
title: "Accessibility testing with Playwright in C#"
url: https://recursiveai.org/onehtml/articles/playwright-accessibility-testing-csharp/
description: "Run axe-core accessibility checks in Playwright .NET tests with Deque.AxeCore.Playwright, then add WCAG 2.2 checks, layout facts and SARIF with onehtml."
date: 2026-10-26
author: onehtml team
---

# Accessibility testing with Playwright in C#

Run axe-core accessibility checks in Playwright .NET tests with Deque.AxeCore.Playwright, then add WCAG 2.2 checks, layout facts and SARIF with onehtml.

October 26, 2026 · onehtml team

For Playwright accessibility testing in C#, the standard tool is Deque’s `Deque.AxeCore.Playwright` package: add it to a Playwright test project, open the page, call `Page.RunAxe()` and assert that there are no violations. It injects the axe-core engine into the page and runs it there. onehtml’s WCAG checker fits beside it. It checks the DOM that Playwright rendered from inside your .NET process, without injecting script into the page, writes SARIF 2.1.0 for code scanning, and runs the same check in a plain unit test with no browser. With Chrome’s layout from a DevTools snapshot, it also decides rules that need layout, such as target size. Playwright’s own [accessibility testing guide](https://playwright.dev/docs/accessibility-testing) is written for Node.js and Java, and there is no .NET version, so this page covers the C# side. The tests below ran against Microsoft.Playwright.NUnit 1.64.1 with Chromium 156, Deque.AxeCore.Playwright 4.14.0 and onehtml 1.0.0 on .NET 10.

## Set up the test project

*Terminal · NuGet*

```
dotnet new nunit -o A11yTests
cd A11yTests
dotnet add package Microsoft.Playwright.NUnit --version 1.64.1
dotnet add package Deque.AxeCore.Playwright --version 4.14.0
dotnet add package RecursiveAI.OneHtml.Accessibility.Validation --version 1.0.0
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
```

The last line installs the browser, as described in [Playwright’s .NET guide](https://playwright.dev/dotnet/docs/intro), which also covers MSTest and xUnit if you don’t use NUnit. In a real suite, the tests navigate to your running app. Here they load a local file, `signup.html`, copied to the output folder with `<None Include="signup.html" CopyToOutputDirectory="PreserveNewest" />` in the project file.

## The page under test

*signup.html · HTML*

```
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Get a library card</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem; background: #fff; color: #222; }
    .hint { color: #999; }
  </style>
</head>
<body>
  <header><img src="logo.png" width="120" height="40"></header>
  <main>
    <h1>Get a library card</h1>
    <form action="/signup" method="post">
      <label for="name">Full name</label>
      <input id="name" name="name" autocomplete="name">
      <p>Email</p>
      <input type="email" name="email">
      <p class="hint">We never share your email address.</p>
      <button type="submit"><svg aria-hidden="true" width="16" height="16"><path d="M2 8h12"/></svg></button>
    </form>
  </main>
</body>
</html>
```

It has four problems that automated checks can find: the logo has no `alt`, the email field has no label (the “Email” paragraph isn’t associated with it), the hint text is light gray on white, and the submit button holds only a hidden icon.

## Run axe-core in a Playwright test

*AxeTests.cs · Deque.AxeCore.Playwright 4.14.0*

```
using Deque.AxeCore.Commons;
using Deque.AxeCore.Playwright;
using Microsoft.Playwright.NUnit;
 
namespace A11yTests;
 
public class AxeTests : PageTest
{
    [Test]
    public async Task SignupPageHasNoAxeViolations()
    {
        // In your suite this is your app's URL; here it is a local file
        await Page.GotoAsync(new Uri(Path.Combine(AppContext.BaseDirectory, "signup.html")).AbsoluteUri);
 
        AxeResult results = await Page.RunAxe();
 
        foreach (AxeResultItem violation in results.Violations)
        {
            TestContext.Out.WriteLine($"{violation.Id} ({violation.Impact}): {violation.Help}");
            foreach (AxeResultNode node in violation.Nodes)
            {
                TestContext.Out.WriteLine($"  {node.Target}");
            }
        }
        Assert.That(results.Violations, Is.Empty);
    }
}
```

Running `dotnet test` fails the test, and it prints:

*Test output · axe-core 4.14*

```
button-name (critical): Buttons must have discernible text
  button
color-contrast (serious): Elements must meet minimum color contrast ratio thresholds
  .hint
image-alt (critical): Images must have alternative text
  img
label (critical): Form elements must have labels
  input[type="email"]
```

- `PageTest` comes from Microsoft.Playwright.NUnit and gives each test a `Page` to drive.
- `RunAxe` works on a `Page` or on a `Locator`, for part of a page. `AxeRunOptions` limits the run to tags such as `wcag2aa` or switches rules off ([Deque.AxeCore.Playwright README](https://github.com/dequelabs/axe-core-nuget/blob/develop/packages/playwright/README.md)).
- The package version follows axe-core: 4.14.x runs axe-core 4.14. It targets .NET Standard 2.1 and is MIT-licensed, and it depends on `Deque.AxeCore.Commons`, which is licensed MPL-2.0 ([NuGet](https://www.nuget.org/packages/Deque.AxeCore.Playwright/4.14.0), [Commons on NuGet](https://www.nuget.org/packages/Deque.AxeCore.Commons/4.14.0)).

## Check the same page with onehtml

The onehtml checker reads HTML, not a live page. `Page.ContentAsync()` gives it the DOM as the browser holds it, after any script has run:

*OneHtmlTests.cs · onehtml 1.0.0*

```
using Microsoft.Playwright.NUnit;
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Accessibility.Validation;
using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue;
 
namespace A11yTests;
 
public class OneHtmlTests : PageTest
{
    [Test]
    public async Task SignupPageMeetsWcag22AA()
    {
        await Page.GotoAsync(new Uri(Path.Combine(AppContext.BaseDirectory, "signup.html")).AbsoluteUri);
 
        // The DOM as the browser has it now, after any script ran
        Document doc = Document.Parse(await Page.ContentAsync());
        CheckReport report = new Checker().Check(doc, new CheckOptions { Profile = ValidationProfile.Wcag22AA });
 
        foreach (RuleResult failure in report.WithOutcome(Outcome.Failed))
        {
            TestContext.Out.WriteLine($"{failure.RuleId} at {failure.Locator}: {failure.Message}");
        }
        TestContext.Out.WriteLine($"Needs review: {report.Count(Outcome.NeedsReview)}, needs render: {report.Count(Outcome.NeedsRender)}");
 
        // SARIF 2.1.0 for code scanning
        File.WriteAllText("signup.sarif", report.ToSarifJson(new ExportOptions { Url = "signup.html", Indented = true }));
 
        Assert.That(report.Count(Outcome.Failed), Is.Zero);
    }
}
```

*Test output · onehtml 1.0.0*

```
button-name at button: The button has no accessible name.
color-contrast at form > p:nth-of-type(2): The text has a contrast ratio of 2.84:1; 4.50:1 is needed.
image-alt at img: The image has no accessible name.
label at input[name="email"]: The form field has no accessible name.
Needs review: 6, needs render: 3
```

- **The same four failures,** each with a CSS locator that is unique in the page and a message. The contrast ratio is computed from the cascade here, because nothing in the page can move the text off its plain background.
- **Nothing runs inside the page.** The check happens in your test process, on the serialized DOM, so it can’t interfere with the app under test.
- **Profiles** select rules for a standard: `Wcag20AA`, `Wcag21AA`, `Wcag22AA`, or `All`, which adds best practices. A profile selects rules; it isn’t a statement about legal compliance.
- **Undecided results are labeled, not guessed.** `NeedsReview` means a person has to judge, for example whether the title describes the page. `NeedsRender` means the rule needs layout. Here all three are `target-size`, which needs the size of each control and its neighbors. The next section supplies that.
- **`signup.sarif`** is SARIF 2.1.0, ready for GitHub code scanning or any SARIF viewer. Its line numbers refer to the DOM Playwright serialized, not to your source file. `report.ToAxeJson()` gives axe-compatible JSON if your tooling already reads axe results. [SARIF export](https://recursiveai.org/onehtml/docs/sarif/) and [Results](https://recursiveai.org/onehtml/docs/results/) describe both.

## Add Chrome’s layout with a DOMSnapshot

Rules such as target size need the boxes the browser laid out. Playwright can open a Chrome DevTools Protocol session on Chromium ([BrowserContext.NewCDPSessionAsync](https://playwright.dev/dotnet/docs/api/class-browsercontext): “CDP sessions are only supported on Chromium-based browsers”), and the protocol’s [`DOMSnapshot.captureSnapshot`](https://chromedevtools.github.io/devtools-protocol/tot/DOMSnapshot/) returns every node’s layout. onehtml maps that snapshot onto its parse of the same DOM as rendered facts:

*RenderedFactsTests.cs · onehtml 1.0.0 · Chromium*

```
using System.Text.Json;
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Accessibility.Rendered;
using RecursiveAI.OneHtml.Accessibility.Validation;
using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue;
 
namespace A11yTests;
 
public class RenderedFactsTests : PageTest
{
    // Computed styles Chrome adds to each layout box in the snapshot
    private static readonly string[] Styles =
        { "display", "visibility", "position", "float", "overflow-x", "overflow-y", "border-top-style", "border-top-width" };
 
    [Test]
    public async Task SignupPageMeetsWcag22AAWithLayout()
    {
        await Page.SetViewportSizeAsync(1280, 800);
        await Page.GotoAsync(new Uri(Path.Combine(AppContext.BaseDirectory, "signup.html")).AbsoluteUri);
 
        // Serialize the DOM and capture Chrome's layout of that same DOM (Chromium only: CDP)
        Document doc = Document.Parse(await Page.ContentAsync());
        ICDPSession cdp = await Page.Context.NewCDPSessionAsync(Page);
        JsonElement? capture = await cdp.SendAsync("DOMSnapshot.captureSnapshot", new Dictionary<string, object>
        {
            ["computedStyles"] = Styles,
            ["includePaintOrder"] = true,
            ["includeDOMRects"] = true,
            ["includeBlendedBackgroundColors"] = true,
            ["includeTextColorOpacities"] = true,
        });
 
        DomSnapshot snapshot = DomSnapshot.Parse(JsonSerializer.SerializeToUtf8Bytes(capture!.Value), Styles);
        DomSnapshotMapResult mapped = DomSnapshotAdapter.Map(snapshot, doc,
            new DomSnapshotMapOptions { ViewportWidth = 1280, ViewportHeight = 800 });
        Assert.That(mapped.Succeeded, Is.True, $"{mapped.Refusal}");
 
        CheckReport report = new Checker().Check(doc,
            new CheckOptions { Profile = ValidationProfile.Wcag22AA, Facts = mapped.Facts });
 
        foreach (RuleResult failure in report.WithOutcome(Outcome.Failed))
        {
            TestContext.Out.WriteLine($"{failure.RuleId} at {failure.Locator}: {failure.Message}");
        }
        TestContext.Out.WriteLine($"Needs review: {report.Count(Outcome.NeedsReview)}, needs render: {report.Count(Outcome.NeedsRender)}");
 
        Assert.That(report.Count(Outcome.Failed), Is.Zero);
    }
}
```

*Test output · onehtml 1.0.0*

```
button-name at button: The button has no accessible name.
color-contrast at form > p:nth-of-type(2): The text has a contrast ratio of 2.84:1; 4.50:1 is needed.
image-alt at img: The image has no accessible name.
label at input[name="email"]: The form field has no accessible name.
Needs review: 6, needs render: 0
```

With layout, the three `target-size` results are decided: “needs render” drops from 3 to 0. A few things to know:

- **The snapshot must describe the same DOM.** `DomSnapshotAdapter.Map` compares the two trees and refuses a mismatch. `Succeeded` is then false and `Refusal` says why. If page script keeps changing the DOM, capture with script paused or the page settled.
- **The style list** names the computed styles Chrome adds to each box. onehtml 1.0.0 reads `display`, `visibility`, `position`, `float`, the two `overflow` axes and the top border from it. Pass the same list to `DomSnapshot.Parse`.
- **Facts are one capture,** under one viewport and state. Hover and focus states that script drives aren’t visible to the checker.
- **The snapshot format isn’t a stable Chrome API.** Keep the browser version fixed in CI. [Rendered facts](https://recursiveai.org/onehtml/docs/rendered-facts/) documents the accepted format.

## The same check without a browser

For server-rendered templates, static sites and HTML email, you can check the HTML in an ordinary unit test with no browser at all:

*StaticHtmlTests.cs · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Accessibility.Validation;
using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue;
 
namespace A11yTests;
 
public class StaticHtmlTests
{
    [Test]
    public void SignupTemplateMeetsWcag22AA()
    {
        // No browser: parse the file's bytes and check them
        Document doc = Document.Parse(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "signup.html")));
        CheckReport report = new Checker().Check(doc, new CheckOptions { Profile = ValidationProfile.Wcag22AA });
 
        foreach (RuleResult failure in report.WithOutcome(Outcome.Failed))
        {
            TestContext.Out.WriteLine($"{failure.RuleId} at {failure.Locator}: {failure.Message}");
        }
        Assert.That(report.Count(Outcome.Failed), Is.Zero);
    }
}
```

*Test output · onehtml 1.0.0*

```
button-name at button: The button has no accessible name.
color-contrast at form > p:nth-of-type(2): The text has a contrast ratio of 2.84:1; 4.50:1 is needed.
image-alt at img: The image has no accessible name.
label at input[name="email"]: The form field has no accessible name.
```

The results are the same as in the browser, apart from `target-size`, which stays `NeedsRender` without layout. This is the cheapest gate to put in front of a pull request: it needs no browser install and no running app. To check every page of a built site and upload the SARIF, see [WCAG checks in CI with SARIF output](https://recursiveai.org/onehtml/cookbook/wcag-checks-ci-sarif/).

## axe-core and onehtml side by side

|  | Deque.AxeCore.Playwright 4.14.0 | onehtml 1.0.0 |
|---|---|---|
| Where the check runs | Inside the page: axe-core is injected and run in the browser | In your .NET process, on HTML you pass in |
| Browser needed | Yes | No; Chromium only to capture optional layout facts |
| Rules | axe-core’s rules | One catalogue covering axe-core’s WCAG and best-practice rules (two left out on purpose) and all 37 approved ACT rules |
| Output | `AxeResult` | Native JSON, axe-compatible JSON, SARIF 2.1.0 |
| Targets | .NET Standard 2.1 | .NET 10 |
| License | MIT; depends on Deque.AxeCore.Commons, MPL-2.0 | MIT OR Apache-2.0 |

The onehtml column comes from its [conformance table](https://github.com/recursive-ai-oss/onehtml-csharp/blob/v1.0.0/README.md#conformance) and [ACT rule coverage](https://recursiveai.org/onehtml/docs/act-coverage/). The two axe rules without a counterpart are `aria-roledescription`, which axe lists as deprecated, and `frame-tested`, which reports whether axe could run inside each frame. The project compares the two engines element by element: on 12,807 crawled pages, onehtml agreed with axe-core 4.14.0 running in Chrome for Testing 156 on 97.51% of compared nodes without layout facts, and on 96.72% with them (12,778 pages). The [axe-core differential](https://recursiveai.org/onehtml/docs/axe-differential/) gives the method and explains each class of disagreement.

## Which to use

- **You already have Playwright tests:** add `RunAxe` to them. It runs inside a real browser, against the live page.
- **You want results in code scanning, or no script injected into the app under test:** add the onehtml check on `Page.ContentAsync()` and upload its SARIF.
- **You render HTML on the server or build a static site:** check the HTML in unit tests with onehtml and keep the browser for end-to-end runs.

Neither replaces a person. Playwright’s guide says it plainly: “Automated accessibility tests can detect some common accessibility problems such as missing or invalid properties. But many accessibility problems can only be discovered through manual testing.” onehtml marks those cases as `NeedsReview` instead of passing them.

## Where next

- [Getting started with onehtml](https://recursiveai.org/onehtml/docs/getting-started/): parse, query, build the accessibility tree and check a page.
- [The checker](https://recursiveai.org/onehtml/docs/checker/): rule selection, criteria, private rules and re-checking after an edit.
- [Apply suggested fixes](https://recursiveai.org/onehtml/cookbook/apply-suggested-fixes/) where a rule has exactly one correct fix.
- [The accessibility tree](https://recursiveai.org/onehtml/cookbook/accessibility-tree/): the roles and names a screen reader gets, computed without a browser.
