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 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
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, 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
<!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
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:
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"]
PageTestcomes from Microsoft.Playwright.NUnit and gives each test aPageto drive.RunAxeworks on aPageor on aLocator, for part of a page.AxeRunOptionslimits the run to tags such aswcag2aaor switches rules off (Deque.AxeCore.Playwright README).- 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, Commons on NuGet).
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:
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); } }
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, orAll, which adds best practices. A profile selects rules; it isn’t a statement about legal compliance. - Undecided results are labeled, not guessed.
NeedsReviewmeans a person has to judge, for example whether the title describes the page.NeedsRendermeans the rule needs layout. Here all three aretarget-size, which needs the size of each control and its neighbors. The next section supplies that. signup.sarifis 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 and 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: “CDP sessions are only supported on Chromium-based browsers”), and the protocol’s DOMSnapshot.captureSnapshot returns every node’s layout. onehtml maps that snapshot onto its parse of the same DOM as rendered facts:
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); } }
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.Mapcompares the two trees and refuses a mismatch.Succeededis then false andRefusalsays 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 twooverflowaxes and the top border from it. Pass the same list toDomSnapshot.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 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:
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); } }
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.
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 and ACT rule 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 gives the method and explains each class of disagreement.
Which to use
- You already have Playwright tests: add
RunAxeto 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: parse, query, build the accessibility tree and check a page.
- The checker: rule selection, criteria, private rules and re-checking after an edit.
- Apply suggested fixes where a rule has exactly one correct fix.
- The accessibility tree: the roles and names a screen reader gets, computed without a browser.