Architecture Decision Record
ADR-103: bUnit Component-Test Tier as a Shipped Package
Status
Accepted (2026-08-31). Revised 2026-09-03: one trade-off overstated how far the AngleSharp advisory
pin travels. See Revision (2026-09-03) at the end.
Context
Three test tiers in this workspace are decided in writing and one is not. ADR-015 gates structure
with NetArchTest. ADR-058 ships the runtime conformance suites as abstract bases in
MMCA.Common.Testing and scopes itself explicitly to contracts that only a booted host can prove
(058-runtime-conformance-suites-as-a-package.md:22-25). ADR-063 and ADR-092 ship the browser
tier as Playwright contracts and deploy gates (WCAG 2.1 AA scans, Core Web Vitals budgets). Between a
plain unit test over a handler and a Playwright run against a live stack sits the component tier:
render one Blazor page or component in process, drive it, assert on its markup. That tier exists in
all three code repos with a UI and was recorded nowhere. ADR-101 names the Testing.* packages only
to keep them out of the metapackage (101-common-metapackage.md:55), which decides their packaging
and nothing about the tier itself.
A component test is cheap to write and expensive to set up, and the expensive part is not per test.
It is a fixed set of choices that is the same answer in every repo: which bUnit line, which component
vendor's services, whether JSInterop is strict or loose, how a principal reaches both
<AuthorizeView> and a page that injects AuthenticationStateProvider directly, how
IStringLocalizer<T> resolves for ADR-027 markup, and when the renderer info is set. Getting one of
them wrong does not fail as a setup error: it fails as what looks like a bug in the page under test.
An unresolvable IToastService, a viewport that no browser answers so the card/grid choice comes down
to timing, or a test double silently replaced by the framework default because its registration ran
after the bUnit provider was frozen.
Decision
Ship the component-test tier as a package. MMCA.Common.Testing.UI
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/MMCA.Common.Testing.UI.csproj:3-4) is one of the
17 packages released in lockstep (MMCA.Common/FACTS.md:19,37), and its BunitComponentTestBase
fixes every choice above once, in one file.
- bUnit v2, with the version-specific symbols isolated to this base. The base derives from bUnit
v2's
BunitContext(MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Infrastructure/BunitComponentTestBase.cs:37), and its remarks state why: v2 is the line compatible with xUnit v3 and Microsoft Testing Platform, and derived test classes callRenderUnderTest/RenderAsand never touch the version-specific symbols, so a move off that line changes this file and no other (BunitComponentTestBase.cs:29-34). The line is pinned atbunit2.9.0 in each repo's central package file (MMCA.Common/Directory.Packages.props:205-206,MMCA.ADC/Directory.Packages.props:30-31,MMCA.Store/Directory.Packages.props:50-51), and the package carries a directAngleSharppin because central package management does not pin transitives (MMCA.Common.Testing.UI.csproj:13-15). - MudBlazor services plus the ADR-067 facades, registered once. The constructor calls
Services.AddMudServices()(BunitComponentTestBase.cs:46) and thenServices.AddCommonUiFacades()(:53), which is the same call the production shell makes fromAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:110). That call registersIToastService->MudToastServiceandIAppDialogService->MudAppDialogServicewithTryAdd(DependencyInjection.cs:162-167), so a component test resolves the vendor-neutral facades and exercises the real Mud-backed path, and a test that wants a recording double registers one afterwards (last registration wins,BunitComponentTestBase.cs:48-52). - Loose JSInterop.
JSInterop.Mode = JSRuntimeMode.Loose(:55) so MudBlazor components that probe JS during render return default values instead of throwing (:17-19). - A mutable
AuthenticationStateProviderthat serves both consumption paths. OneMutableAuthenticationStateProviderinstance is held by the base (:42), registered as theAuthenticationStateProvidersingleton (:58), and implemented over a settable principal that notifies listeners (:162-174).RenderAssets the principal and also adds the cascadingAuthenticationState, so<AuthorizeView>and a directly injecting page agree (:130-141);SetUserchanges it mid-test without a new render root (:120-121); the default is anonymous (:39-40,:123-127). Authorization is permissive but real:IsAuthenticatedAuthorizationServicesucceeds for an authenticated identity and fails otherwise (:57,:176-186). Principals come from the shippedTestPrincipalfactory, which writes the user id under bothsubandClaimTypes.NameIdentifierbecause a real principal reaches a page under either name (Infrastructure/TestPrincipal.cs:7,22-32), plus anOrganizershorthand (TestPrincipal.cs:35-36). - Open-generic
IStringLocalizerfor ADR-027 markup.Services.AddLogging()andServices.AddLocalization()(BunitComponentTestBase.cs:63-64) let every component test render localized markup against the neutral resources in the component's own assembly with no per-test setup (:60-62). SetRendererInfobehind one helper, because its call ordering is load-bearing.ConfigureDataGridListPageHost(:100-118) registers the list-page state services (:105-106), substitutes MudBlazor'sIBrowserViewportServicewith an inert double soIsMobilestays deterministically false (:110, which is whyMoqis a package dependency rather than a hand-written stub,MMCA.Common.Testing.UI.csproj:17-21), adds bUnit's persistent component state for the prerender boundary (:114), and callsSetRendererInfolast (:117). The rule is written where the helper is:SetRendererInfobuilds and freezes the bUnit service provider, so any registration made after it is silently ignored and the page resolves the framework default instead of the test's double (:78-82). Nineteen test files across MMCA.Common, MMCA.ADC and MMCA.Store call the helper today; its comment records the fifteen hand-rolled copies of the block that the extraction replaced (:81-82).- The rest of the harness ships with it.
RenderMudProvidersrenders the popover, dialog and snackbar providers into the test's render root and returns handles (:148-154,:157-160);BunitInteractionExtensionsexpresses clicks and text reads over accessible text rather than CSS paths (Infrastructure/BunitInteractionExtensions.cs:12-34);MarkupSnapshotis a dependency-free golden-markup comparison that normalizes MudBlazor's per-render GUIDs (Infrastructure/MarkupSnapshot.cs:21); and the HTTP-facing doubles cover the UI service layer (Infrastructure/UiHttpServiceHarness.cs:12,Infrastructure/CapturingHttpMessageHandler.cs:19,Infrastructure/StubTokenStorageService.cs:13,Infrastructure/HttpTestDoubles.cs:12,Infrastructure/ErrorSummaryExtensions.cs:10). - Adoption is one thin repo-local subclass per test project. Six consumer test projects take the
package reference: ADC Conference, Identity and Engagement
(
MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/MMCA.ADC.Conference.UI.Tests.csproj:12,.../Identity/MMCA.ADC.Identity.UI.Tests/MMCA.ADC.Identity.UI.Tests.csproj:12,.../Engagement/MMCA.ADC.Engagement.UI.Tests/MMCA.ADC.Engagement.UI.Tests.csproj:11) and Store Catalog, Sales and Identity (MMCA.Store/Tests/Modules/Catalog/MMCA.Store.Catalog.UI.Tests/MMCA.Store.Catalog.UI.Tests.csproj:12,.../Sales/MMCA.Store.Sales.UI.Tests/MMCA.Store.Sales.UI.Tests.csproj:12,.../Identity/MMCA.Store.Identity.UI.Tests/MMCA.Store.Identity.UI.Tests.csproj:12); MMCA.Common's own UI tests take it by project reference (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/MMCA.Common.UI.Tests.csproj:25). Each repo's subclass carries only what its head owns and nothing shared: Store Catalog's is an empty declaration (MMCA.Store/Tests/Modules/Catalog/MMCA.Store.Catalog.UI.Tests/BunitTestBase.cs:11), ADC Conference's adds the ADR-042 device-capability defaults and inert configuration (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/BunitTestBase.cs:19-46), and MMCA.Common's adds the layout-chrome services only its own tests render (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/BunitTestBase.cs:25-42).
All six consumer projects sit in the gating CI subset, so the tier runs on every pull request rather
than on a schedule (MMCA.ADC/MMCA.ADC.CI.slnf:43,49,55,
MMCA.Store/MMCA.Store.CI.slnf:40,46,52). MMCA.Helpdesk has no UI test project (its three test
projects are Tickets domain, Tickets application, and architecture), so the tier is adopted exactly
where a Blazor UI exists.
Rationale
- The setup is what a component test gets wrong, so the setup is what the framework should own. Every item in the Decision is a choice with one correct answer per repo and a failure mode that reads as a defect in the page under test. Shipping them as a base class turns "remember the six rules" into "inherit the base", the same invariant-over-discipline posture ADR-015 takes for structure and ADR-058 takes for runtime contracts.
- One freeze rule, one call site. The
SetRendererInfoordering constraint cannot be enforced by the compiler, so the next best thing is to have exactly one place that gets it right and a helper name that says when to call it (BunitComponentTestBase.cs:74-82). - The version boundary is a single file. Isolating
BunitContextandRender<T>behindRenderUnderTest/RenderAsmeans a bUnit line change is a framework edit, not a sweep across every UI test class in three repos (:29-34). - Test-time and run-time resolve the same facades. Because the base calls the production
AddCommonUiFacadesrather than registering its own doubles (:53,MMCA.Common.UI/DependencyInjection.cs:110,162-167), a component test asserts against the real toast and dialog implementations ADR-067 put behind those interfaces, and a test that wants to assert on a toast opts into a double explicitly. - A package matches how every other shipped test tier is delivered. Runtime conformance
(ADR-058), accessibility (ADR-063) and web vitals (ADR-092) all ship as consumable contracts rather
than as copied snippets, and a package inherits the lockstep release policy (ADR-016) so the tier
moves with the framework it tests (
FACTS.md:15-17).
Trade-offs
- Loose JSInterop proves nothing about JS. A component test can render a component that calls a
JS module which does not exist, because the loose mode answers with defaults
(
BunitComponentTestBase.cs:55). Only the browser tier (ADR-063, ADR-092) catches that. - Authorization in this tier answers on authentication, not on policy. The shipped double succeeds
for any authenticated principal (
:176-186), so a component test cannot assert a permission denial; permission behavior belongs to the handler and API tiers. - The frozen-provider rule is a convention, not a compiler error. Nothing fails a test that
registers a service after
SetRendererInfo; the symptom is the framework default resolving quietly in place of the double (:78-82), which is exactly the failure the helper exists to prevent and cannot prevent for a test that bypasses it. - Nothing enforces adoption. No fitness rule requires a UI test project to subclass the shared base, so a new project can still re-derive the block; the only inventory is a search.
- The base pulls MudBlazor and Moq into every consuming test project
(
MMCA.Common.Testing.UI.csproj:16,21), so a repo that wanted a different mocking library in its UI tests still takes Moq transitively, and a non-MudBlazor UI could not use this base at all. - The bUnit version is pinned per repo, not by the package. The package references
bunitwithout a version (MMCA.Common.Testing.UI.csproj:13), and every consuming test project referencesbunitdirectly as well (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/MMCA.ADC.Conference.UI.Tests.csproj:10), so each repo's central package file names the number (MMCA.Common/Directory.Packages.props:206,MMCA.ADC/Directory.Packages.props:31,MMCA.Store/Directory.Packages.props:51) and three files have to agree. TheAngleSharpadvisory pin does not spread that way: it is named once, inMMCA.Common/Directory.Packages.props:210, and reaches consumers transitively through the package's own direct reference (MMCA.Common.Testing.UI.csproj:15).
Related
ADR-058 (the runtime conformance tier this sits
below: same delivery shape, different question, and its bases need a booted host where these need
only a renderer), ADR-063 and
ADR-092 (the browser tier above, which owns everything loose
JSInterop and a stubbed viewport cannot see), ADR-067 (the shell
and the IToastService / IAppDialogService facades this base registers through the production
AddCommonUiFacades call), ADR-027 (the localized markup the open-generic
IStringLocalizer registration lets a component test render),
ADR-042 (the capability defaults a consumer subclass adds on
top), ADR-101 (why this package stays outside the MMCA.Common
metapackage), ADR-015 (the structural tier below) and
ADR-016 (the lockstep release the package rides).
Revision (2026-09-03)
The decision and the mechanism are unchanged. One trade-off's premise was wrong. "The bUnit
version is pinned per repo" said the AngleSharp advisory pin has to be repeated across the three
central package files the way the bunit version is. It does not. bunit is named in all three
(MMCA.Common/Directory.Packages.props:206, MMCA.ADC/Directory.Packages.props:31,
MMCA.Store/Directory.Packages.props:51) because every consuming UI test project takes a direct
bunit reference of its own
(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/MMCA.ADC.Conference.UI.Tests.csproj:10).
AngleSharp is named only in MMCA.Common (MMCA.Common/Directory.Packages.props:210), where it
versions the package's own direct reference (MMCA.Common.Testing.UI.csproj:15); no consumer repo
pins it in a central package file or a project file, so consumers inherit the patched version with
the package. The trade-off now says so. Citation anchors elsewhere in the record were refreshed at
the same time with no change of substance.