WebアクセシビリティをCI/CDで担保する ― axe DevTools × Playwright C#実践ガイド
はじめに
前回の記事「Webアクセシビリティは"もしも"に備える設計」では、アクセシビリティの考え方や設計指針について解説しました🧭
今回はその実践編として、CI/CDパイプラインでアクセシビリティを自動検査する仕組みを構築していきます🔧
本記事では、Blazor WebAssembly を Azure Static Web Apps にホストする構成を題材に、環境構築からGitHub Actionsでの自動化までを一気通貫で実装します🚀
今回のゴール
以下の流れを実現します🎯
- 📦 Playwright C# + axe-core でアクセシビリティテストを書く
- 🔄 GitHub Actions で PR ごとに自動実行する
- 📊 違反があれば GitHub Actions Summary に出力する(CI は止めない)
axe-core / axe DevTools のライセンスについて
本記事で使用する axe-core および .NET 向け NuGet パッケージのライセンスについて説明します 📜
axe-core(JavaScript エンジン)
axe-core は Mozilla Public License 2.0 (MPL-2.0) で提供されています。
Axe-core is distributed under the Mozilla Public License, version 2.0.
— axe-core GitHub リポジトリ
MPL-2.0 は弱いコピーレフトライセンスで、以下の特徴があります:
- ✅ 商用利用可能
- ✅ 修正・配布可能
- ✅ 特許権の明示的付与
- ⚠️ ライセンスファイルと著作権表示の保持が必要
- ⚠️ 変更したファイルは同じライセンスで公開が必要
Deque.AxeCore.Playwright(.NET NuGet パッケージ)
Deque.AxeCore.Playwright は MIT License で提供されています。
The
Deque.AxeCore.PlaywrightNuGet package and its source code under the packages/playwright/ directory are distributed under the terms of the MIT License.
— axe-core-nuget GitHub リポジトリ
ただし、依存パッケージ Deque.AxeCore.Commons(axe-core の JavaScript を内包)は MPL-2.0 です。
| パッケージ | ライセンス | 備考 |
|---|---|---|
Deque.AxeCore.Playwright |
MIT | Playwright 統合レイヤー |
Deque.AxeCore.Selenium |
MIT | Selenium 統合レイヤー |
Deque.AxeCore.Commons |
MPL-2.0 | axe-core エンジンを内包 |
OSS 版と有償版の違い
Deque 社は axe-core をベースに複数の製品を展開しています 💰
| 製品 | 価格 | 主な機能 |
|---|---|---|
| axe-core | 無料(OSS) | JavaScript エンジン、自動テスト API |
| axe DevTools ブラウザ拡張(Free) | 無料 | ブラウザで手動検査、自動ルール実行 |
| axe DevTools ブラウザ拡張(Pro) | 有償 | Intelligent Guided Tests™(IGT)、手動テスト支援 |
| axe DevTools Linter | 無料(VS Code)/ 有償(API) | 静的解析、CI 連携は API キーが必要 |
| axe Monitor | 有償 | 大規模サイトの継続監視、ダッシュボード |
参考:自動テストの限界について
axe-core(OSS 版)で検出できるのは WCAG 違反の 約 57%(自動検出可能な問題) とされています。残りの問題は以下のような人間の判断が必要なものです:
- 🖼️ 画像の代替テキストが内容として適切か
- ⌨️ キーボード操作の体験が自然か
- 📖 コンテンツの読み順序が論理的か
- 🎨 色の使い方が情報伝達に依存していないか
有償版の IGT はこれらの手動テストをガイド付きで効率化しますが、OSS 版でも十分に価値のある自動検査が可能です。
前提条件
- ✅ .NET 9 SDK がインストール済み
- ✅ Visual Studio 2022 または VS Code
- ✅ GitHub リポジトリがある
- ✅ Azure サブスクリプション(Static Web Apps デプロイ用)
なぜCI/CDでアクセシビリティをチェックするのか?
手動テストだけでは抜け漏れが発生しがちです 😮
| 課題 | CI/CD で解決 |
|---|---|
| ⏰ 全ページを手動でチェックする時間がない | 自動で全ページを検査 |
| 🔄 機能追加時に既存の a11y が壊れる | 回帰を即座に検出 |
| 🧠 担当者の知識に依存する | ルールベースで一貫した検査 |
ただし、自動テストで検出できるのは約 30~40% です 🧭
代替テキストの「内容」が適切か、キーボード操作の「体験」が自然か、などは人間の判断が必要です。
本記事では、自動で潰せるものを確実に潰す仕組みを構築します🎯
Step 1: プロジェクトのセットアップ
1.1 テストプロジェクトの作成
# 新しいソリューションを作成
mkdir BlazorA11yDemo
cd BlazorA11yDemo
dotnet new sln
# Blazor WebAssemblyアプリを作成(Static Web Apps対応)
dotnet new blazorwasm -n BlazorA11yDemo.Client -f net9.0
dotnet sln add BlazorA11yDemo.Client
# テストプロジェクトを作成
dotnet new xunit -n BlazorA11yDemo.Tests -f net9.0
dotnet sln add BlazorA11yDemo.Tests
# 必要なパッケージをインストール
cd BlazorA11yDemo.Tests
dotnet add package Microsoft.Playwright
dotnet add package Deque.AxeCore.Playwright
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
# ビルドしてPlaywrightブラウザをインストール
dotnet build
pwsh bin/Debug/net9.0/playwright.ps1 install chromium
1.2 プロジェクト構成
最終的なプロジェクト構成は以下のとおりです 📁
BlazorA11yDemo/
├── BlazorA11yDemo.sln
├── BlazorA11yDemo.Client/ # Blazor WebAssembly(Static Web Apps対応)
│ ├── Pages/
│ │ ├── Home.razor # / (ホーム)
│ │ ├── Counter.razor # /counter (カウンター)
│ │ └── Weather.razor # /weather (天気予報)
│ ├── wwwroot/
│ └── Program.cs
├── BlazorA11yDemo.Tests/
│ ├── BlazorA11yDemo.Tests.csproj
│ ├── GlobalUsings.cs
│ ├── AccessibilityTests.cs # テストコード
│ └── appsettings.json
├── swa-cli.config.json # SWA CLI設定
└── .github/
└── workflows/
└── azure-static-web-apps.yml
Step 2: テストコードの実装
2.1 GlobalUsings.cs
よく使う名前空間をまとめておきます 🧩
global using Xunit;
global using Microsoft.Playwright;
global using Deque.AxeCore.Playwright;
global using Deque.AxeCore.Commons;
2.2 appsettings.json
テスト対象の URL を設定ファイルで管理します 📝
ポート番号は BlazorA11yDemo.Client/Properties/launchSettings.json の applicationUrl に合わせてください。
{
"BaseUrl": "http://localhost:5212"
}
2.3 swa-cli.config.json(リポジトリルートに配置)
SWA CLI の設定ファイルを作成します 🛠️
{
"$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
"configurations": {
"blazor-a11y": {
"appLocation": "BlazorA11yDemo.Client",
"outputLocation": "bin/Release/net9.0/publish/wwwroot",
"appBuildCommand": "dotnet publish -c Release",
"run": "dotnet watch run",
"appDevserverUrl": "http://localhost:5000"
}
}
}
2.4 AccessibilityTests.cs
テストコードを 1 ファイルにまとめます 🎯
using System.Text;
using Microsoft.Extensions.Configuration;
using Deque.AxeCore.Commons;
namespace BlazorA11yDemo.Tests;
public class AccessibilityTests : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IPage _page = null!;
private readonly string _baseUrl;
public AccessibilityTests()
{
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
.Build();
_baseUrl = config["BaseUrl"] ?? "http://localhost:5000";
}
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
_page = await _browser.NewPageAsync();
}
public async Task DisposeAsync()
{
await _page.CloseAsync();
await _browser.DisposeAsync();
_playwright.Dispose();
}
/// <summary>
/// Blazor標準テンプレートのページ一覧
/// </summary>
public static TheoryData<string, string> TargetPages => new()
{
{ "/", "Home" },
{ "/counter", "Counter" },
{ "/weather", "Weather" },
};
[Theory]
[MemberData(nameof(TargetPages))]
public async Task Page_ShouldHaveNoAccessibilityViolations(string path, string pageName)
{
// 1. ページに遷移
await _page.GotoAsync($"{_baseUrl}{path}");
await _page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// 2. axe-coreでアクセシビリティ検査を実行
var options = new AxeRunOptions
{
RunOnly = new RunOnlyOptions
{
Type = "tag",
Values = ["wcag2a", "wcag2aa", "wcag21aa"]
}
};
var result = await _page.RunAxe(options);
// 3. 違反があればテストを失敗させる
if (result.Violations.Length > 0)
{
var message = FormatViolations(pageName, path, result.Violations);
Assert.Fail(message);
}
}
[Fact]
public async Task Counter_AfterInteraction_ShouldBeAccessible()
{
// 1. Counterページに遷移
await _page.GotoAsync($"{_baseUrl}/counter");
await _page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// 2. ボタンを数回クリックしてUIを変化させる
var button = _page.Locator("button", new() { HasText = "Click me" });
await button.ClickAsync();
await button.ClickAsync();
await button.ClickAsync();
// 3. 状態変化後もアクセシビリティを検査
var options = new AxeRunOptions
{
RunOnly = new RunOnlyOptions
{
Type = "tag",
Values = ["wcag2a", "wcag2aa", "wcag21aa"]
}
};
var result = await _page.RunAxe(options);
if (result.Violations.Length > 0)
{
var message = FormatViolations("Counter(操作後)", "/counter", result.Violations);
Assert.Fail(message);
}
}
private static string FormatViolations(string pageName, string path, AxeResultItem[] violations)
{
var sb = new StringBuilder();
sb.AppendLine($"♿ {pageName} ({path}) でアクセシビリティ違反が {violations.Length} 件見つかりました:");
sb.AppendLine();
foreach (var violation in violations)
{
sb.AppendLine($"【{violation.Impact}】{violation.Id}");
sb.AppendLine($" 説明: {violation.Description}");
sb.AppendLine($" ヘルプ: {violation.HelpUrl}");
foreach (var node in violation.Nodes.Take(3))
{
sb.AppendLine($" - 要素: {node.Html}");
}
if (violation.Nodes.Length > 3)
{
sb.AppendLine($" ... 他 {violation.Nodes.Length - 3} 件");
}
sb.AppendLine();
}
return sb.ToString();
}
}
2.5 ローカルでテストを実行
方法 1: dotnet run で直接起動(シンプル)
開発中はこちらが手軽です 🚀
# Blazor WASMを起動(別ターミナル)
cd BlazorA11yDemo.Client
dotnet run
# テストを実行(別ターミナル)
# ※ appsettings.json の BaseUrl を launchSettings.json のポートに合わせてください
cd BlazorA11yDemo.Tests
dotnet test
方法 2: SWA CLI でエミュレート(本番に近い環境)
認証やルーティングなど SWA の機能を確認したい場合はこちら 🔧
# Blazor WASMをビルド(リポジトリルートで実行)
cd BlazorA11yDemo.Client
dotnet publish -c Release
# SWA CLIでローカルサーバーを起動(別ターミナル、ポート4280)
cd ..
swa start BlazorA11yDemo.Client/bin/Release/net9.0/publish/wwwroot
# テストを実行(別ターミナル)
# ※ appsettings.json の BaseUrl を http://localhost:4280 に変更
cd BlazorA11yDemo.Tests
dotnet test
Step 3: GitHub Actions での自動化
Azure Static Web Apps へのデプロイと、アクセシビリティテストを同時に実行するワークフローを作成します 🛠️
3.1 ワークフローファイルの作成
.github/workflows/azure-static-web-apps.yml を作成します。
name: Azure Static Web Apps CI/CD
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
env:
DOTNET_VERSION: '9.0.x'
jobs:
# ─────────────────────────────────────────────
# ビルド&アクセシビリティテスト(PRごとに実行)
# ─────────────────────────────────────────────
build_and_test:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore & Build
run: dotnet build
- name: Publish Blazor WASM
run: dotnet publish BlazorA11yDemo.Client -c Release -o ./publish
- name: Verify publish output
run: |
echo "=== Publish output contents ==="
ls -la ./publish/wwwroot/
echo "=== Checking index.html (first 20 lines) ==="
head -20 ./publish/wwwroot/index.html
- name: Install Playwright
run: pwsh BlazorA11yDemo.Tests/bin/Debug/net9.0/playwright.ps1 install chromium --with-deps
- name: Start HTTP Server
run: |
# npx serveでBlazor WASMをサーブ(ポート4280)
echo "Starting HTTP server on port 4280..."
npx --yes serve ./publish/wwwroot -l 4280 &
SERVER_PID=$!
echo "Server PID: $SERVER_PID"
# サーバー起動を待機
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -s -o /dev/null -w "%{http_code}" http://localhost:4280 | grep -q "200"; then
echo "✅ Server is ready! (attempt $i)"
break
fi
echo "Waiting... (attempt $i)"
sleep 1
done
# 最終確認
curl -I http://localhost:4280 || echo "⚠️ Server may not be fully ready"
- name: Run Accessibility Tests
id: a11y_test
continue-on-error: true # テスト失敗でもCIを止めない
run: |
dotnet test BlazorA11yDemo.Tests --no-build \
--logger "trx;LogFileName=results.trx" \
--logger "console;verbosity=detailed" \
2>&1 | tee test-output.txt
echo "TEST_EXIT_CODE=$?" >> $GITHUB_ENV
env:
BaseUrl: 'http://localhost:4280'
- name: Generate Test Summary
if: always()
run: |
echo "## ♿ アクセシビリティテスト結果" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f test-output.txt ]; then
if grep -q "アクセシビリティ違反" test-output.txt; then
echo "### ⚠️ 違反が検出されました" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -A 50 "アクセシビリティ違反" test-output.txt | head -100 >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
elif grep -q "Passed:" test-output.txt; then
echo "### ✅ すべてのテストに合格しました" >> $GITHUB_STEP_SUMMARY
grep "Passed:" test-output.txt >> $GITHUB_STEP_SUMMARY
else
echo "### 📋 テスト出力" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -50 test-output.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: accessibility-results
path: |
BlazorA11yDemo.Tests/TestResults/
test-output.txt
- name: Upload publish output for deploy
uses: actions/upload-artifact@v4
with:
name: publish-output
path: ./publish/wwwroot/
# ─────────────────────────────────────────────
# Static Web Appsへデプロイ
# ─────────────────────────────────────────────
deploy:
if: |
(github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed'))
&& vars.ENABLE_DEPLOY == 'true'
runs-on: ubuntu-latest
needs: build_and_test
name: Deploy to SWA
steps:
- uses: actions/checkout@v4
- name: Download publish output
uses: actions/download-artifact@v4
with:
name: publish-output
path: ./publish/wwwroot/
- name: Build And Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_GRAY_MEADOW_072FA5010 }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "./publish/wwwroot"
skip_app_build: true
# ─────────────────────────────────────────────
# PRクローズ時にプレビュー環境を削除
# ─────────────────────────────────────────────
close_pull_request:
if: github.event_name == 'pull_request' && github.event.action == 'closed' && vars.ENABLE_DEPLOY == 'true'
runs-on: ubuntu-latest
name: Close Pull Request
steps:
- name: Close Pull Request
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_GRAY_MEADOW_072FA5010 }}
action: "close"
3.2 Azure Static Web Appsのセットアップ
Azure PortalでStatic Web Appsリソースを作成し、シークレットとVariableを設定します🔐
シークレットの設定
- Azure Portal → Static Web Apps → 対象のリソース
- 「デプロイトークンの管理」からトークンをコピー
- GitHub → Settings → Secrets and variables → Actions → Secrets
- シークレット名はAzureが自動生成したワークフローに合わせる(例:
AZURE_STATIC_WEB_APPS_API_TOKEN_GRAY_MEADOW_072FA5010)
デプロイの有効化
デプロイを有効にするには、GitHub Variableを設定します:
- GitHub → Settings → Secrets and variables → Actions → Variables
-
ENABLE_DEPLOY=trueを追加
| 設定 | キー | 値 |
|---|---|---|
| Secret | AZURE_STATIC_WEB_APPS_API_TOKEN_* |
Azureのデプロイトークン |
| Variable | ENABLE_DEPLOY |
true(デプロイを有効にする場合) |
Step 4: 段階的な導入戦略
いきなり全ての違反で CI を止めるのは現実的ではありません 🧭
現在の設定(可視化フェーズ)
本ワークフローでは continue-on-error: true を使用しているため、アクセシビリティ違反があってもCIは止まりません。
違反はGitHub Actions Summaryに出力され、開発者が確認できます。
段階的なロールアウト
| Phase | 期間 | 設定 |
|---|---|---|
| 📊 可視化(現在) | 最初の2週間 |
continue-on-error: true で違反を記録するがCIは落とさない |
| ⚠️ 重大のみ | 3〜4週目 | Critical/Seriousのみブロック |
| 🛡️ 全違反 | 5週目以降 |
continue-on-error: false で全ての違反でCIを止める |
Phase 3に移行する場合は、ワークフローから continue-on-error: true を削除してください:
- name: Run Accessibility Tests
id: a11y_test
# continue-on-error: true # この行を削除またはコメントアウト
run: |
dotnet test BlazorA11yDemo.Tests --no-build
よくある違反と修正方法
テストを実行すると、よく以下の違反が検出されます 🔍
color-contrast(コントラスト不足)
<!-- NG -->
<p style="color: #999;">薄いグレー</p>
<!-- OK: 4.5:1以上のコントラスト -->
<p style="color: #595959;">読みやすいグレー</p>
image-alt(代替テキスト欠落)
<!-- NG -->
<img src="product.jpg">
<!-- OK -->
<img src="product.jpg" alt="商品名: サンプル商品">
label(フォームラベル欠落)
<!-- NG -->
<input type="email" placeholder="メールアドレス">
<!-- OK -->
<label for="email">メールアドレス</label>
<input type="email" id="email">
button-name(ボタン名欠落)
<!-- NG -->
<button><svg>...</svg></button>
<!-- OK -->
<button aria-label="メニューを開く"><svg>...</svg></button>
動作結果
GitHub Actionsのサマリーとしてチェック結果が表示される。

要件によってPRにつなげたり、Teamsなどに通知するなどさまざまな方法が考えられます。
自動チェックと手動チェックをうまく組み合わせてアクセシビリティを担保していきましょう。
まとめ
本記事では、Blazor WebAssembly + Azure Static Web Apps を題材に、Playwright C# + axe-core + GitHub Actions でアクセシビリティを自動検査する仕組みを構築しました 🎯
実装したこと
- ✅ Blazor WebAssembly を Static Web Apps にホスト
- ✅
npx serveでCI環境でのHTTPサーバー起動 - ✅ Playwright C# + axe-core で WCAG 2.1 AA 検査
- ✅
continue-on-error: trueでテスト失敗でもCIを止めない - ✅ GitHub Actions Summaryにテスト結果を出力
- ✅
ENABLE_DEPLOY変数でデプロイを制御
次のステップ
- 🔧 認証が必要なページのテスト追加
- 📊 テスト結果のダッシュボード化
- 🧪 手動テストとの組み合わせ
- 🛡️ 段階的に CI を厳格化(Phase 2, 3 への移行)
「自動で潰せるものは自動で潰し、人間の判断が必要なものに集中する」 🤝
これがCI/CDでアクセシビリティを担保する意義です♿
参考リンク
Azure Static Web Apps
- Azure Static Web Apps ドキュメント
- Deploy a Blazor app on Azure Static Web Apps
- Set up local development for Azure Static Web Apps
- SWA CLI - npm
Blazor
アクセシビリティテスト
- Deque.AxeCore.Playwright - NuGet
- axe-core NuGet packages - GitHub
- axe-core Rules
- Playwright for .NET
Discussion