--- name: qa-tester description: Create and maintain automated tests in Microsoft-native/.NET projects with a minimal stack — MSTest runner, `System.Windows.Automation` for Windows desktop, Playwright for real browser smoke. Code-first, single-source-of-truth. Use whenever the user wants to write, review, structure, migrate, or plan tests for any .NET project (unit, API, database, WPF, WinUI, MAUI, Blazor, Razor) — even when the request says "test" without naming a framework. --- # QA Tester > **Core philosophy: one test runner, minimum tools, executable code is the spec.** > The fewer different tools a codebase has, the lower the cognitive load on every engineer and AI that touches it. Prefer in-box Microsoft APIs; every extra dependency is a cost, not a feature. ## 1. First principles Each principle has a *why* so you can judge edge cases instead of pattern-matching on the rule. 1. **Minimum tool surface.** Two tools that do the job beat five that do it elegantly. *Why:* every extra framework is cognitive debt owed by the next person who opens the repo. 2. **In-box first.** Prefer APIs that ship with .NET or Windows. *Why:* a test that runs with `dotnet test` on a clean checkout is the test that actually gets run. 3. **Code-first.** A test is the spec. Do not duplicate intent into Gherkin + bindings + helpers. *Why:* every layer of indirection is another place the next AI has to look. 4. **Readable beats clever.** A new engineer should understand a test in under 30 seconds. 5. **Lightest abstraction that still reads well.** Inline → helper → screen object → fixture. *Why:* page objects are a symptom of test duplication, not a solution. 6. **Stable selectors.** Roles, accessible names, `AutomationId`. Never CSS tied to layout internals. *Why:* a fragile selector is a test that will break for reasons unrelated to what it's checking. If a stable selector doesn't exist, fix the product first. 7. **Deterministic waits.** Use framework auto-waits or in-box event subscriptions. *Why:* `Thread.Sleep` hides real race conditions and taxes everyone else's suite time. 8. **Test the lowest layer that gives confidence.** Unit → API/integration → UI. *Why:* UI tests are slow, flaky, and tell you less than the integration test that already covers the same rule. 9. **Isolation.** No shared mutable state. Each test sets up and tears down its own world. *Why:* ordered tests and shared fixtures produce heisenbugs whose failure mode depends on run order — impossible to reproduce locally and demoralising for everyone on call. 10. **Minimal change.** Edit small. Don't rewrite suites unless asked. *Why:* a "while I'm in here" refactor turns a 5-line fix into a 500-line review and blocks the actual bug shipping. 11. **Test project must be simpler than the app.** One `{Project}.Tests` project by default. Mirror the product folder layout 1:1. Split into more projects only when a real constraint (headless CI, massive scale, different TFM) forces it. *Why:* a test project fractured into more pieces than the app it tests is absurd — every extra csproj duplicates infrastructure, slows the loop, and makes newcomers wonder which project to open. 12. **Performance data is a byproduct of functional tests.** If a code path already runs in CI under a unit / integration / UI test, attaching a lightweight in-box collector gives you perf coverage equal to your functional coverage at near-zero marginal cost. *Why:* a parallel "perf suite" that re-implements the same sign-in / order / search scenarios is pure duplication, and it creates coverage gaps wherever the two suites drift. Dedicated load/stress/soak tests exist only for what functional tests genuinely cannot express: concurrency, saturation, sustained throughput, and memory growth over time. See §6a. ## 2. The two-tool stack | Tool | Ships from | Used for | |---|---|---| | **MSTest** (`Microsoft.NET.Test.Sdk` + `MSTest.TestFramework` + `MSTest.TestAdapter`) | NuGet / Microsoft | *Everything .NET*: unit, service, DB integration, API integration, **Windows desktop UI via `System.Windows.Automation`**, view-model tests for WPF/WinUI/MAUI-desktop. | | **Playwright** (`Microsoft.Playwright` + `Microsoft.Playwright.MSTest`) | NuGet / Microsoft | Real-browser web UI smoke. Runs under the same MSTest runner as everything else — one test explorer, one `dotnet test` command. | That's the whole stack. **`System.Windows.Automation`** is in-box (no NuGet), so it doesn't appear in the package list but is the default for any Windows desktop test. **Performance collection (§6a)** uses only in-box instrumentation — `EventPipe` / `dotnet-trace`, `dotnet-gcdump`, `dotnet-counters`, SQL Server Extended Events, Playwright tracing, ETW via `wpr.exe` — plus one Microsoft-owned load generator (`Microsoft.Crank`). Collectors are runtime/OS facilities, not "tools", so the two-tool rule is unchanged. **Quick exclusion list** (see [`references/legacy-bdd.md`](references/legacy-bdd.md) for inherited-project support): | Excluded | Why | |---|---| | Appium / WinAppDriver | Driver process, HTTP wire protocol, installer step. `System.Windows.Automation` does the same job in-box. Exception: MAUI mobile (Android/iOS) — keep it in a dedicated `Tests/UI.Mobile/` project. | | FlaUI | Thin wrapper around `System.Windows.Automation`. Use the in-box API directly. | | Selenium | Playwright replaces it. | | Cypress / TestCafe / WebDriverIO | Playwright replaces all of them. | | NUnit / xUnit | One runner per repo — MSTest for Microsoft-aligned codebases. | | SpecFlow / Reqnroll | Inherited-project support only. See [`references/legacy-bdd.md`](references/legacy-bdd.md). | | Testcontainers for SQL Server | Use `SqlLocalDB` — no Docker dependency. | | bunit | Use `WebApplicationFactory` + HTTP first. | Before adding any third tool, write one sentence justifying it. If the sentence is "it would be more convenient", reject it. ## 3. Layer → test type (the whole decision matrix) | You want to test … | Use | Perf dimension it already measures (piggyback, §6a) | |---|---|---| | A pure business rule | **MSTest** unit test — direct instantiation, no framework | Method CPU, allocations, hot-path micro-regressions | | A rule that needs HTTP / auth / DI / EF | **MSTest + `WebApplicationFactory`** — in-process HTTP against the real middleware pipeline | End-to-end latency, middleware cost, per-request allocations, SQL count + text + shape (N+1 detector) | | Real SQL Server behaviour (constraints, triggers, migrations) | **MSTest + `SqlLocalDB`** — `sqllocaldb create` + a connection string, no Docker | Query duration, logical reads, CPU time, actual plan, missing-index hints | | A Razor / MVC page that renders mostly server-side | **MSTest + `WebApplicationFactory` + `HttpClient`** — HTTP test, not a browser test | Same as integration-api | | Genuine browser behaviour a server-side test can't express | **MSTest + Playwright** — keep the suite small, one or two happy-path tests per area | Navigation + resource timings, LCP/CLS/INP, long tasks, JS heap, network waterfall | | A WPF / WinUI **view-model** rule | **MSTest** — instantiate the VM directly. Under MVVM this is 90% of desktop test coverage. | VM method CPU + allocations | | A WPF / WinUI **launch / binding / wiring** check | **MSTest + `System.Windows.Automation`** — `Process.Start(exe)` + `AutomationElement.FromHandle` + `InvokePattern` / `ValuePattern` | Startup time, frame time, UI-thread stalls, managed allocs during interaction | | A MAUI view-model rule | **MSTest** — direct VM instantiation | VM CPU + allocations | | MAUI on the Windows target | **MSTest + `System.Windows.Automation`** — MAUI/Windows is a UIA tree | Same as WPF | | MAUI on Android / iOS | Appium, isolated in `Tests/UI.Mobile/` — the single intentional exception | — (mobile perf is its own discipline) | | Concurrency, throughput ceiling, saturation, soak, memory growth under load | **MSTest + `Microsoft.Crank`** scenario in `{Project}.Tests.Perf/` — the only place dedicated load/stress/soak lives. See §6a. | Thread-pool starvation, GC pressure under load, leak detection via bracketed `gcdump` | **Default rule of thumb:** MSTest + `System.Windows.Automation` covers every .NET desktop scenario; MSTest + Playwright covers every web scenario. If you reach for a third tool, re-read §2. ## 4. Writing style — code-first, business-readable second When the user says: > Given a valid user, when they sign in, then they should see the dashboard Write this: ```csharp // @under-test: src/Foo.Web/Pages/SignIn.razor // @area: auth @layer: ui-web [TestClass] public class SignInTests : PageTest { [TestMethod] [TestCategory("auth"), TestCategory("smoke")] [Description("Valid user signs in and lands on dashboard")] public async Task Valid_user_signs_in_and_lands_on_dashboard() { // Given: a valid user on the sign-in page await Page.GotoAsync("/sign-in"); // When: they sign in with valid credentials await Page.GetByLabel("Email").FillAsync("valid.user@example.com"); await Page.GetByLabel("Password").FillAsync(Environment.GetEnvironmentVariable("TEST_USER_PASSWORD")!); await Page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync(); // Then: the dashboard is visible await Expect(Page).ToHaveURLAsync(new Regex("/dashboard$")); await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Dashboard" })).ToBeVisibleAsync(); } } ``` The Given/When/Then phrasing lives in **test name + comments + `[TestCategory]` tags + `[Description]`**. No `.feature` file, no bindings, no regex maintenance, no indirection. A reporter that lifts `[Description]` + `[TestCategory]` into Markdown covers every "but stakeholders need to read it" objection without introducing BDD tooling. ## 5. Discoverability — finding tests from a code path (and vice versa) > For Code ↔ UI mapping (web routes, desktop menu breadcrumbs), see the [`solution-patterns`](../solution-patterns/SKILL.md) skill. This section covers Code ↔ Test only. This section is load-bearing. Renaming any convention here breaks every future locate-or-create query. ### 5.1 Test project naming **Default: one test project per product project** — `{Project}.Tests`, sibling to `{Project}`. *Why:* a test project more complex than the app it tests is absurd; the whole point of §1.1 (minimum tool surface) applies to the test tree too. One csproj, one namespace root, one `dotnet test` command. Split into multiple projects **only** when one of these is actually true: - **Headless CI constraint.** UI tests need a graphical session; unit and integration tests must pass on a headless build server. Split into `{Project}.Tests` (headless-safe) and `{Project}.Tests.UI` (requires a desktop session). Tag the UI csproj so CI can skip it in headless runs, e.g. `true` plus a `[TestCategory("ui-interactive")]` filter. - **Large codebase.** Hundreds of product files and thousands of tests where a single csproj's compile + test-discovery time noticeably slows the loop. This is rare in practice. - **Fundamentally different runtime.** e.g. product targets `net8.0` but one test layer needs `net48` for COM interop. - **Dedicated perf/load/soak suite.** `{Project}.Tests.Perf/` houses `Microsoft.Crank` scenarios and long-running soak tests. Its runtime constraints are genuinely different: long durations, non-parallel with the functional suite, its own CI job, statistical gating instead of pass/fail per run. See §6a.5. Do not put piggyback perf collectors here — those belong next to the functional tests whose coverage they inherit. **Do not split** because "separation of concerns" or "unit vs integration is cleaner" — those are solved by folder structure and `[TestCategory]` tags, not by csproj boundaries. Extra csprojs bring: duplicated `[AssemblyInitialize]` code, duplicated `TestSandbox`/`TestHelpers`, multiple `InternalsVisibleTo` grants, separate `Directory.Packages.props` entries, and separate test hosts that all have to be kept in sync. **Pay that cost only when a real constraint forces it.** Naming, whichever variant you pick: `{Project}.Tests` or `{Project}.Tests.UI`. One spelling forever. Never `{Project}Tests`, never `Test.{Project}`, never `{Project}.UnitTests`. ### 5.2 File-to-file mapping (deterministic) Folders inside the test project mirror folders inside the product project **1:1**. Never flatten — reversibility is the whole point. The product sub-path becomes the test sub-path verbatim, with `Tests` appended to the class-file name. **Default (single `{Project}.Tests` project):** | Product file | Test file | |---|---| | `{Project}/{Sub}/{Name}.cs` | `{Project}.Tests/{Sub}/{Name}Tests.cs` | | `{Project}/Controllers/{Name}Controller.cs` | `{Project}.Tests/Controllers/{Name}ControllerTests.cs` | | `{Project}/ViewModels/{Name}ViewModel.cs` | `{Project}.Tests/ViewModels/{Name}ViewModelTests.cs` | | `{Project}/Views/{Name}View.xaml` (WPF) | `{Project}.Tests/Views/{Name}ViewTests.cs` | | `{Project}/Pages/{Name}.razor` (or `.cshtml`) | `{Project}.Tests/Pages/{Name}Tests.cs` | **Split form (only when §5.1's split criteria apply):** the UI-requiring tests move to `{Project}.Tests.UI/` using the same sub-path mirror; everything else stays in `{Project}.Tests/`. No other splits. **Namespace rule:** every test file uses the single root namespace `{Project}.Tests` (or `{Project}.Tests.UI`), regardless of which subfolder it lives in. *Why:* sub-namespacing per folder forces a cross-file `using` hunt every time a test helper moves, with no discoverability benefit — the folder is right there in the solution explorer. The `@under-test` header (§5.3) is the authoritative coverage link, not the namespace. *Locating a test file:* given `{Project}/Foo/Bar/Baz.cs`, the test is at `{Project}.Tests/Foo/Bar/BazTests.cs`. No lookup, no search — pure string transform. That is the whole point of the mirror. ### 5.3 Mandatory test-file header Every test file declares the product file it covers: ```csharp // @under-test: src/Foo.Bar/Services/OrderService.cs // @area: orders @layer: unit @ticket: JIRA-1234 ``` Rules: `@under-test` is required, repo-relative, forward slashes, no globs. Multi-file tests list at most three paths comma-separated; more means the test is too broad — split it. `@layer` is one of `unit | integration-api | integration-db | ui-web | ui-wpf | ui-maui | perf`. Optional: `@perf-capture: always` forces piggyback collection even when `QA_PERF_CAPTURE` is unset — use only for known hot paths whose perf you always want a baseline for. *Why this matters:* reverse coverage queries become one ripgrep (`rg "@under-test:\s*src/Foo.Bar/Services/OrderService.cs" Tests/`) instead of a semantic search. ### 5.4 Locate-or-create algorithm Adding/modifying a test for product file `P`: 1. Compute expected path `T` from §5.2. 2. If `T` exists → edit it. 3. Else `rg "@under-test:.*P"` across `Tests/` — if a hit exists at a non-canonical path, edit it and leave `// TODO: relocate to {T}`. 4. Else search by symbol name (`{Name}Tests`). Same rule. 5. Else create `T` with the `@under-test` header. ### 5.5 Coverage verification + impact-scoped test selection [`scripts/test_map.py`](scripts/test_map.py) automates the §5.2 mirror check and the §5.3 `@under-test` header lookup. Zero required arguments — auto-discovers the `{Project}` + `{Project}.Tests` pair from the current directory. Cross-platform (Windows, Linux, macOS — runs in containers, CI, Codex). ```bash # Coverage gap report (default — run from repo root, no arguments needed) python test_map.py # Output: table of OK / MISSING / ORPHAN / WRONG_HEADER per product file # Impact analysis: which tests cover my recent changes? python test_map.py --affected HEAD~1 # Output: direct changes + transitive dependents (2-level type-name grep) + filter string # JSON for AI consumption python test_map.py --affected HEAD~3 --json ``` The `--affected` mode produces a `dotnet test --filter` string that includes every mirror-path test + every crosscutting test whose `@under-test` header mentions any affected file + `TestCategory=critical`. The AI supplements this list by using whatever reference-finding capability its IDE/platform provides — e.g. `find_references` in VS Code (Claude Code, GitHub Copilot), `find_usages` in JetBrains (Roo Code), or `Go to References` in Visual Studio — to chase shared interfaces/base classes where string-grep may miss semantic dependents. See [`references/test-selection.md`](references/test-selection.md) for the detailed workflow. **Standard severity categories** for filtering (§7 naming convention applies): | Category | Meaning | When to run | |---|---|---| | `critical` | Fast unit/service tests that catch the most important regressions. See criteria below. | Every build, every commit (~10 s) | | *(no tag)* | Normal importance — the default. | PR check | | `stress` | Concurrency, soak, memory growth. Slow (>10 s per test). | Pre-release, nightly | | `ui-interactive` | Needs a desktop session (`System.Windows.Automation`). | Developer machine | | `requires-elevation` | Triggers UAC / needs admin. Locked-down VMs cannot run these. | Developer machine with admin rights | | `projfs` | Needs Client-ProjFS Windows feature. | Developer machine with ProjFS | **What to tag `critical`** — a test earns this tag when ALL of these are true: 1. **Fast.** Runs in under 1 second. If it needs ProjFS, a database, a browser, an exe launch, or a network call, it is not critical — it belongs in a slower category. The whole point of `critical` is a 5–10 second feedback loop. 2. **Covers a code path where a bug causes data loss, crashes, or silent corruption.** Examples: config load/save (losing projections), manifest read/write (losing the file list), hash comparison (false "in sync"), single-instance mutex (app refuses to start), provider state machine (delete propagates to Source instead of just manifest). 3. **Is a unit or service test, not an integration or UI test.** `critical` tests must run on any machine — headless CI, a locked-down VM, a container, a developer laptop — with zero external dependencies. If it needs the Client-ProjFS feature, a desktop session, admin elevation, or a specific folder on disk, tag it with the appropriate category instead. *Why this matters:* `dotnet test --filter "TestCategory=critical"` is the developer's 10-second sanity check between edits. If critical tests include slow ProjFS integration tests, the filter becomes useless and developers stop using it. If critical tests miss the config-persistence path, a developer ships a bug that loses every user's projection list. The tag is the contract between "this is cheap enough to run constantly" and "this catches the bugs that matter most." ```bash dotnet test --filter "TestCategory=critical" # fast dev loop dotnet test --filter "TestCategory!=stress&TestCategory!=ui-interactive&TestCategory!=requires-elevation" # PR / CI (headless, no admin) dotnet test --filter "TestCategory!=requires-elevation" # developer machine (has desktop, no admin) dotnet test # release gate (full, interactive, admin) ``` ## 6. Examples Four examples, one per major layer. Each is runnable; no pseudo-code. ### 6.1 Unit test ```csharp // @under-test: src/Foo.Bar/Services/OrderTotals.cs // @area: orders @layer: unit [TestClass] public class OrderTotalsTests { [TestMethod] public void Total_sums_line_items_with_tax() { var items = new[] { new LineItem(10m), new LineItem(20m) }; Assert.AreEqual(33m, new OrderTotals(taxRate: 0.10m).Compute(items)); } } ``` ### 6.2 API integration — replaces most "page renders" UI tests `WebApplicationFactory` needs the product's `Program.cs` to expose `Program` as a type. In a top-level-statements project add this at the bottom of `Program.cs`: ```csharp public partial class Program { } ``` Then the test (needs `using System.Net.Http.Headers;`, `using System.Text;`, `using Microsoft.AspNetCore.Mvc.Testing;`): ```csharp // @under-test: src/Foo.Web/Controllers/OrdersController.cs // @area: orders @layer: integration-api [TestClass] public class OrdersApiTests { private static WebApplicationFactory _factory = null!; private HttpClient _client = null!; [ClassInitialize] public static void Setup(TestContext _) => _factory = new WebApplicationFactory(); [ClassCleanup] public static void Teardown() => _factory.Dispose(); [TestInitialize] public void SetupClient() => _client = _factory.CreateClient(); [TestMethod, TestCategory("orders"), TestCategory("smoke")] public async Task Get_orders_returns_empty_array_for_new_user() { var creds = Convert.ToBase64String( Encoding.UTF8.GetBytes("new.user@example.com:" + Environment.GetEnvironmentVariable("TEST_USER_PASSWORD"))); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", creds); var response = await _client.GetAsync("/api/orders"); response.EnsureSuccessStatusCode(); Assert.AreEqual("[]", await response.Content.ReadAsStringAsync()); } } ``` Any test that was reaching for Playwright just to assert "page loads, contains X" belongs here instead. 10× faster, no browser dependency. ### 6.3 Playwright web smoke — same MSTest runner ```csharp // @under-test: src/Foo.Web/Pages/SignIn.razor // @area: auth @layer: ui-web [TestClass] public class SignInSmokeTests : PageTest { [TestMethod, TestCategory("auth"), TestCategory("smoke")] public async Task Valid_user_signs_in_and_lands_on_dashboard() { await Page.GotoAsync("/sign-in"); await Page.GetByLabel("Email").FillAsync("valid.user@example.com"); await Page.GetByLabel("Password").FillAsync(Environment.GetEnvironmentVariable("TEST_USER_PASSWORD")!); await Page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync(); await Expect(Page).ToHaveURLAsync(new Regex("/dashboard$")); await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Dashboard" })).ToBeVisibleAsync(); } } ``` ### 6.4 WPF / WinUI smoke via `System.Windows.Automation` (in-box) Every interactive control gets `AutomationProperties.AutomationId` in the format `Screen.Element`. No AutomationId → fix the XAML first, don't write the test. ```xml