Automated accessibility testing in .NET means running a WCAG rule engine over your pages inside the tests you already have, so a missing label or a low-contrast hint fails the build instead of reaching users. There are two ways to do it. Deque’s axe-core packages run the axe-core engine in a browser that Selenium or Playwright drives. onehtml’s checker, RecursiveAI.OneHtml.Accessibility.Validation, checks the HTML in your test process with no browser, so it also fits plain unit tests and ASP.NET Core integration tests. This page sets up both, explains what their results mean, and covers what automation can’t find. The samples ran on .NET 10 with onehtml 1.0.0, xUnit 2.9.3, Microsoft.AspNetCore.Mvc.Testing 10.0.14, Selenium.WebDriver 4.51.0, Deque.AxeCore.Selenium 4.14.0 and Chrome 149.
What automated accessibility testing can catch
Automated checks find the problems that can be decided from the code: an image with no text alternative, a button or link with no accessible name, a form field with no label, text whose contrast against a known background is too low, invalid ARIA, a page with no language. They can’t tell whether alt text describes the image, whether a heading describes its section, or whether a custom widget works with a keyboard. The W3C puts it plainly: “Tools cannot check all accessibility aspects automatically. Human judgement is required.” (W3C WAI, Selecting Web Accessibility Evaluation Tools).
The UK Government Digital Service measured this in 2017. It built a page with 143 accessibility barriers and ran 10 automated tools over it. Together the tools found 71% of the barriers. The best single tool found 37% counting errors and warnings, or 41% counting prompts for manual inspection. 29% were found by no tool at all (GDS accessibility blog, February 2017). Those tool versions are old, but the lesson still holds: automated tests are a fast first line that catches regressions on every commit, and they work alongside manual testing and testing with disabled users rather than replacing it.
The page under test
The samples check this sign-up page. It has four problems a machine can decide: a logo link whose only content is an image with no alt, an email field labeled only by its placeholder, a hint in light gray on white, and a submit button whose only content is a hidden icon.
<!DOCTYPE html> <html lang="en"> <head><meta charset="utf-8"><title>Create an account</title> <style>.hint { color: #aaa; background: #fff }</style></head> <body> <header><a href="/"><img src="/logo.svg"></a></header> <main> <h1>Create an account</h1> <form action="/signup" method="post"> <input type="email" name="email" placeholder="Email"> <label for="pw">Password</label> <input type="password" id="pw" name="password"> <p class="hint">At least 12 characters.</p> <button type="submit"><svg aria-hidden="true" width="16" height="16"></svg></button> </form> </main> </body> </html>
Check a page without a browser
dotnet add package RecursiveAI.OneHtml.Accessibility.Validation --version 1.0.0using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Accessibility.Validation; using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue; Document doc = Document.Parse(File.ReadAllBytes("signup.html")); CheckReport report = new Checker().Check(doc, new CheckOptions { Profile = ValidationProfile.Wcag22AA }); Console.WriteLine($"{report.RulesRun.Count} rules run"); foreach (Outcome outcome in new[] { Outcome.Failed, Outcome.NeedsReview, Outcome.NeedsRender }) { Console.WriteLine($"{outcome}: {report.Count(outcome)}"); foreach (RuleResult result in report.WithOutcome(outcome)) { string criteria = string.Join(", ", result.Rule.Descriptor.Criteria.Select(c => c.Number)); Console.WriteLine($" {result.RuleId} [{criteria}] at {result.Locator}: {result.Message}"); } }
95 rules run Failed: 4 button-name [4.1.2] at button: The button has no accessible name. color-contrast [1.4.3] at p: The text has a contrast ratio of 2.32:1; 4.50:1 is needed. image-alt [1.1.1] at img: The image has no accessible name. link-name [2.4.4, 4.1.2] at a: The link has no accessible name. NeedsReview: 6 document-title-descriptive [2.4.2] at title: Check that the title describes the topic or purpose of the page. heading-descriptive [2.4.6] at h1: Check that the heading describes the topic or purpose of its section. html-lang-matches-content [3.1.1] at html: Check that most of the page is written in "en". image-decorative [1.1.1] at svg: Check that the picture is purely decorative: it adds no information that the page does not give in text. image-of-text [1.4.5, 1.4.9] at img: Check that the image holds no text, or only text whose look is essential, or text that is a small part of the picture. label-descriptive [2.4.6] at label: Check that the label, with its visual context, describes the purpose of the field. NeedsRender: 4 target-size [2.5.8] at a: The size of a pointer target is known only after layout. target-size [2.5.8] at input[name="email"]: The size of a pointer target is known only after layout. target-size [2.5.8] at #pw: The size of a pointer target is known only after layout. target-size [2.5.8] at button: The size of a pointer target is known only after layout.
Each result has one of five outcomes, and the difference matters when you decide what fails a build:
Failedis a definite violation of the criterion in brackets. Gate the build on these. The contrast failure is computed from the cascade: the page’s<style>gives both colors, so no browser is needed.NeedsReviewis something only a person can judge, such as whether the title describes the page. It’s a worklist for review, not a failure.NeedsRenderneeds layout, which onehtml doesn’t compute. Target size (WCAG 2.5.8) depends on how big each control is drawn. In a browser test you can supply Chrome’s layout as rendered facts, as the Playwright accessibility testing article shows.PassedandInapplicablecomplete the record, so you can see which rules ran and on which elements.
The email field has no failure because its placeholder gives it an accessible name under the accessible name computation. The label-descriptive review is still worth doing, since a placeholder disappears as soon as someone starts typing.
ValidationProfile.Wcag22AA selects the rules that check a WCAG 2.2 Level A or AA success criterion. Wcag21AA and Wcag20AA are there for older targets, and All adds best practices. A profile chooses rules. It doesn’t make a statement about legal compliance.
Test ASP.NET Core pages in integration tests
If your app is ASP.NET Core, WebApplicationFactory already runs it in memory for integration tests (Microsoft Learn). Fetch each page through its client and check the HTML. Because the checker never makes requests, the test downloads the linked stylesheets through the same client and hands them to the cascade. That way color contrast is computed from your real CSS.
dotnet new xunit -o Shop.Tests cd Shop.Tests dotnet add reference ../Shop/Shop.csproj dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 10.0.14 dotnet add package RecursiveAI.OneHtml.Accessibility.Validation --version 1.0.0
Shop here is the Razor Pages template from dotnet new webapp, with one page added: the sign-up form above as Pages/Signup.cshtml, with the .hint rule in wwwroot/css/site.css.
using Microsoft.AspNetCore.Mvc.Testing; using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Accessibility; using RecursiveAI.OneHtml.Accessibility.Validation; using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue; using RecursiveAI.OneHtml.Css; namespace Shop.Tests; public class AccessibilityTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>> { static readonly Checker Checker = new(); static readonly CheckOptions Wcag22AA = new() { Profile = ValidationProfile.Wcag22AA }; [Theory] [InlineData("/")] [InlineData("/Privacy")] [InlineData("/Signup")] public async Task Page_has_no_WCAG_failures(string path) { HttpClient client = factory.CreateClient(); var pageUrl = new Uri(client.BaseAddress!, path); Document doc = Document.Parse(await client.GetByteArrayAsync(pageUrl)); // onehtml never fetches: download the linked stylesheets through the test server var sheets = new Dictionary<string, StylesheetResource>(); foreach (Node link in doc.Root.QuerySelectorAll("link[rel~=stylesheet][href]")) { var sheetUrl = new Uri(pageUrl, link.GetAttribute("href")!); sheets[sheetUrl.GetLeftPart(UriPartial.Query)] = new StylesheetResource(await client.GetByteArrayAsync(sheetUrl)); } DocumentStyles styles = DocumentStyles.Create(doc, options: new StyleSourceOptions { BaseUrl = pageUrl.ToString(), Resolver = url => sheets.GetValueOrDefault(url), }); CheckReport report = Checker.Check(AccessibilityTree.Build(doc, styles), Wcag22AA); string[] failures = report.WithOutcome(Outcome.Failed) .Select(r => $"{r.RuleId} ({r.Rule.Impact}) at {r.Locator}: {r.Message}") .ToArray(); Assert.True(failures.Length == 0, $"{path} has {failures.Length} WCAG 2.2 AA failures:\n{string.Join("\n", failures)}"); } }
Shop.Tests.AccessibilityTests.Page_has_no_WCAG_failures(path: "/Signup") [FAIL] Error Message: /Signup has 4 WCAG 2.2 AA failures: button-name (Critical) at form > button: The button has no accessible name. color-contrast (Serious) at p: The text has a contrast ratio of 2.32:1; 4.50:1 is needed. image-alt (Critical) at img: The image has no accessible name. link-name (Serious) at [role="main"] > a: The link has no accessible name. Failed! - Failed: 1, Passed: 2, Skipped: 0, Total: 3 - Shop.Tests.dll (net10.0)
- The home and privacy pages pass; the sign-up page fails with one line per problem: the rule, its impact, a CSS selector that finds the element, and what is wrong. The selector is unique in the page, so you can paste it into the browser’s DevTools.
- The contrast failure comes from
site.css, which the test downloaded and passed in throughStyleSourceOptions.Resolver. Without the resolver, linked sheets are reported as missing and contribute nothing. - The test needs no browser and no running server, so it runs wherever
dotnet testruns. - Add every route you care about to the
InlineDatalist, or build the list from your sitemap.
Scripts don’t run here. If a page builds its content with JavaScript, check it in a browser test instead (next section, or with Playwright).
Selenium accessibility testing in C#
For pages you already test with Selenium, Deque’s Deque.AxeCore.Selenium package runs axe-core in the browser that Selenium drives. Per its README, it supports .NET Standard 2.0 and later, .NET Framework 4.7.1 and later, and Selenium.WebDriver 4.4.0 and later, and its version follows the axe-core version it bundles (Deque.AxeCore.Selenium README). You can check the DOM Selenium rendered with onehtml in the same test:
dotnet add package Selenium.WebDriver --version 4.51.0 dotnet add package Deque.AxeCore.Selenium --version 4.14.0 dotnet add package RecursiveAI.OneHtml.Accessibility.Validation --version 1.0.0
using Deque.AxeCore.Commons; using Deque.AxeCore.Selenium; using OpenQA.Selenium.Chrome; using RecursiveAI.OneHtml; using RecursiveAI.OneHtml.Accessibility.Validation; using RecursiveAI.OneHtml.Accessibility.Validation.Catalogue; var options = new ChromeOptions(); options.AddArgument("--headless=new"); using var driver = new ChromeDriver(options); driver.Navigate().GoToUrl(new Uri(Path.GetFullPath("signup.html")).AbsoluteUri); // axe-core runs inside the page that Selenium drives AxeResult axe = new AxeBuilder(driver).WithTags("wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa").Analyze(); foreach (AxeResultItem violation in axe.Violations) { Console.WriteLine($"axe {violation.Id} ({violation.Impact}): {violation.Nodes.Length} node(s)"); } // onehtml checks the DOM Selenium serialized, inside the test process CheckReport report = new Checker().Check(Document.Parse(driver.PageSource), new CheckOptions { Profile = ValidationProfile.Wcag22AA }); foreach (RuleResult result in report.WithOutcome(Outcome.Failed)) { Console.WriteLine($"onehtml {result.RuleId} ({result.Rule.Impact}) at {result.Locator}"); }
axe button-name (critical): 1 node(s) axe color-contrast (serious): 1 node(s) axe image-alt (critical): 1 node(s) axe link-name (serious): 1 node(s) onehtml button-name (Critical) at button onehtml color-contrast (Serious) at p onehtml image-alt (Critical) at img onehtml link-name (Serious) at a
The tags passed to WithTags are axe-core’s names for WCAG 2.0, 2.1 and 2.2 Level A and AA (axe-core API documentation), the same WCAG versions and levels as onehtml’s Wcag22AA profile. Both report the same four violations under the same rule ids. That’s by design: onehtml’s rule catalogue takes rule ids, tags and impacts from axe-core’s published rule list, alongside the W3C ACT Rules. No ChromeDriver was installed beforehand: Selenium Manager, which comes with Selenium.WebDriver, downloaded one on the first run.
Gate CI on the results
A failing test already fails the build. For a report your code host can show on pull requests, export SARIF 2.1.0 with report.ToSarifJson() and upload it. GitHub reads SARIF for code scanning (GitHub Docs). Each result carries the line and column of the element’s start tag in your HTML, its WCAG criteria and, where a rule has exactly one correct fix, the fix as byte replacements. The WCAG checks in CI recipe is a complete console app that checks a built site and writes one SARIF file per page. report.ToAxeJson() writes axe-compatible JSON for tools that already read axe results.
On large pages, re-checking after an edit doesn’t have to start over: Checker.Recheck re-runs only what the edit reached and returns the same report a full check would.
Which approach to use
| onehtml checker 1.0.0 | Deque.AxeCore.Selenium / .Playwright 4.14.0 | |
|---|---|---|
| Needs a browser | No; checks HTML in process | Yes; axe-core runs in the page (README) |
| Pages built by JavaScript | Pass the browser’s serialized DOM | Yes, in the live page |
| Layout-dependent rules (target size, contrast over images) | NeedsRender, or decided from Chrome layout facts you supply | Run in the page, with the browser’s layout |
| Rules | All 37 approved W3C ACT rules, 0 failing ACT test cases (ACT coverage); axe-core rule ids | axe-core 4.14 |
| Output | Native JSON, axe-compatible JSON, SARIF 2.1.0 | AxeResult; axe JSON with WithOutputFile, convertible to SARIF with Microsoft’s axe-sarif-converter (README) |
| Targets | .NET 10 | Selenium: .NET Standard 2.0, .NET Framework 4.7.1. Playwright: .NET Standard 2.1 (NuGet) |
| License | MIT OR Apache-2.0 | MIT, with a dependency on Deque.AxeCore.Commons, which embeds axe-core under MPL-2.0 (license section) |
- Unit and integration tests, static sites, server-rendered pages: onehtml. No browser to install or keep in step with a driver, and a report you can export as SARIF directly.
- End-to-end tests that already drive a browser: axe-core through Deque’s packages, with onehtml on the same DOM when you want SARIF output or results that point at source lines.
- .NET Framework test projects: Deque.AxeCore.Selenium, since onehtml targets .NET 10 only.
Where next
- Accessibility testing with Playwright in C#, including Chrome’s layout as rendered facts.
- What is the accessibility tree? The structure every one of these rules reads.
- The checker reference: profiles, tags, private rules and re-checking.
- Coverage matrix: which WCAG criteria have rules, and how far each one can be automated.