Compono.XunitV3.Aot¶
xUnit v3 Native AOT integration — the [Compose] theory data attribute for consumers publishing their test project with PublishAot=true, using xUnit v3's own official Native AOT package family (xunit.v3.aot.mtp-v2 and friends). See ADR-0066 for the full architecture decision and RESEARCH-0031/ RESEARCH-0032 for the evidence behind it.
Compono.XunitV3 vs. Compono.XunitV3.Aot — which do I install?¶
These are two separate, mutually exclusive packages, not one package with a mode switch — never reference both in the same project.
Compono.XunitV3 | Compono.XunitV3.Aot | |
|---|---|---|
| Use when | Your test project runs under ordinary reflection-mode xUnit v3 (the default for almost every consumer today) | Your test project publishes with PublishAot=true using xUnit v3's own AOT package family |
| xUnit package it pairs with | xunit.v3.mtp-v2 | xunit.v3.aot.mtp-v2 |
| Minimum xUnit v3 version | Whatever Compono.XunitV3 already supports | 4.0.0+ — the version that introduced xUnit's Native AOT support at all |
| Minimum .NET | Compono's ordinary floor | .NET 9+ — xUnit's own stated Native AOT requirement |
[Compose] surface | Full: inline values, [Shared], [Compose<TProfile>], [Compose<TProfile, TConfig>] | Plain [Compose], [Compose<TProfile>], [Compose<TProfile, TConfig>] — no inline values or [Shared] yet |
[ComposeData] surface | Full: [ComposeData], [ComposeData<TProfile>], [ComposeData<TProfile, TConfig>], row [Shared] | Full: the same three forms and row [Shared] — composed theory rows are at parity across both engines |
Why two packages, not one: xUnit v3 ships its reflection-mode and Native AOT surfaces as genuinely separate, non-interchangeable assemblies — referencing both xunit.v3.mtp-v2 and xunit.v3.aot.mtp-v2 (or Compono.XunitV3 and Compono.XunitV3.Aot) in the same project fails to compile (CS0433, a duplicate TheoryAttribute/DataAttribute identity). This is a hard technical constraint, not a design choice — see ADR-0066 for the full account of why a single package supporting both modes isn't achievable through any supported NuGet/MSBuild mechanism.
You can, however, have both packages installed across different test projects in the same solution — one project running JIT with Compono.XunitV3, another publishing AOT with Compono.XunitV3.Aot — that's a normal, supported setup.
When to install¶
You publish your xUnit v3 test project as Native AOT (PublishAot=true) and want theory parameters composed automatically, the same way Compono.XunitV3 does for ordinary JIT tests:
dotnet add package Compono
dotnet add package Compono.XunitV3.Aot
dotnet add package xunit.v3.aot.mtp-v2
What it gives you (Phase 1)¶
[Compose]— every theory parameter is composed. Usage is textually identical toCompono.XunitV3's own[Compose]:
using Compono.XunitV3.Aot;
using Xunit;
public sealed class WidgetTests
{
[Theory]
[Compose]
public void Widget_is_composed_and_test_passes(Widget widget, string leaf)
{
Assert.False(string.IsNullOrEmpty(widget.Name));
Assert.False(string.IsNullOrEmpty(leaf));
}
}
Only the using (Compono.XunitV3.Aot instead of Compono.XunitV3) and the referenced packages change when moving a test project from JIT to Native AOT — the [Theory]/[Compose] source itself doesn't.
Profile-based composition (Phase 2)¶
[Compose<TProfile>] and [Compose<TProfile, TConfig>] — added in Phase 2 (ADR-0067/PLAN-0067), after Phase 1 shipped plain [Compose] only:
[Compose<TProfile>]— appliesTProfile(where TProfile : ICompositionProfile, new()) before composing every parameter, textually identical toCompono.XunitV3.ComposeAttribute<TProfile>:
The generated registration constructs TProfile via a direct, compile-time-closed Composer.Create(b => b.AddProfile<TProfile>()) call — no reflection, since TProfile's new() constraint is already enforced by the C# compiler at the attribute use site.
[Compose<TProfile, TConfig>]— constructsTConfigfrom this attribute's own constructor arguments, thenTProfilefrom thatTConfig, then applies it — textually identical toCompono.XunitV3.ComposeAttribute<TProfile, TConfig>:
[Theory]
[Compose<MyProfile, MyConfig>(MyConfigValue.Foo, "widget")]
public void Test_uses_configured_profile(Widget widget) { ... }
See ADR-0067 for the full design — the compile-time-verified-construction mechanism this form uses, and how it differs from Compono.XunitV3's runtime ConfigProfileBinder.
Using xUnit fixtures in profiles¶
TestContext.Current.RequireFixture<TFixture>() is available here too — the same method, textually identical to Compono.XunitV3's, in namespace Compono.XunitV3.Aot. Call it inside a Register factory to register a value xUnit already initialized (a class or collection fixture):
using Compono.XunitV3.Aot;
using Xunit;
public sealed class DatabaseFixture : IAsyncLifetime
{
public string ConnectionString { get; private set; } = "";
public async ValueTask InitializeAsync()
{
ConnectionString = "Server=localhost;Database=app";
await Task.Yield();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
public sealed record DatabaseSettings(string ConnectionString);
public sealed class DatabaseProfile : ICompositionProfile
{
public void Configure(CompositionBuilder builder) => builder.Register(
() => new DatabaseSettings(
TestContext.Current.RequireFixture<DatabaseFixture>().ConnectionString));
}
public sealed class DatabaseTests : IClassFixture<DatabaseFixture>
{
[Theory]
[Compose<DatabaseProfile>]
public void Settings_come_from_the_fixture(DatabaseSettings settings)
{
Assert.Equal("Server=localhost;Database=app", settings.ConnectionString);
}
}
The same rules apply as in Compono.XunitV3: call it inside the Register factory, matching is exact-type only (xUnit's GetFixture returns null when the requested exact fixture type isn't available; RequireFixture throws a descriptive exception instead), and it never waits for initialization (throwing immediately if the fixture isn't ready). The fixture stays xUnit's — RequireFixture never disposes it or changes its lifetime. As in Compono.XunitV3, Compono doesn't own or dispose composed values; dispose what you create. See the Compono.XunitV3 Package Guide and ADR-0069.
Known limitation: assembly fixtures¶
Class and collection fixtures (IClassFixture<T>, ICollectionFixture<T>) work fully. An assembly fixture — [assembly: AssemblyFixture(typeof(T))] — currently fails to compile under xUnit v3's Native AOT generator (4.0.1 / 4.0.2) with CS1593 in the generator-emitted AssemblyFixtureAttribute*.g.cs, before Compono is involved at all. This is an upstream xUnit issue (xunit/xunit#3664) — reported fixed upstream in xUnit 4.0.3-pre.19, but not yet validated by Compono, and no stable release carrying the fix is inside Compono's supported range yet. Until one reaches your package graph, use a class or collection fixture instead.
Composed theory rows ([ComposeData])¶
Compono.XunitV3.Aot supports the full [ComposeData] family — [ComposeData], [ComposeData<TProfile>], and [ComposeData<TProfile, TConfig>] — plus the row [Shared] behavior (ADR-0070, issue #163; Compono.XunitV3.Aot.SharedAttribute is the AOT package's own [Shared] marker, since it cannot reference the JIT package's — ADR-0070 Amendment 3). The rows come from the same public static member shapes, bind the same leading-positional way, merge the same row/attribute metadata, and follow the same seed rules as Compono.XunitV3's [ComposeData]. This is full JIT/AOT parity for ComposeData: moving a [ComposeData] theory from a JIT project to an AOT project changes only the using and the referenced packages — the same three examples live in both test/Compono.XunitV3.SampleTests/ComposeDataTests.cs and test/Compono.XunitV3.Aot.SampleTests/ComposeDataTests.cs. (This is unlike [Compose], whose inline values and [Shared] remain AOT follow-ups — see Not yet supported.)
Because the AOT package's attributes are thin markers with no runtime GetData path, Compono.Generators reads the member and validates every row shape at compile time, then emits a Xunit.v3.RegisteredEngineConfig.RegisterTheoryDataRowFactory(...) registration whose factory composes each row through real, compile-time-closed-generic CompositionRow.Resolve<T>() calls. A shape the generator can't name or call is a build error, not a silent omission — see ComposeData diagnostics on Native AOT.
ComposeData vs. Compono.XunitV3: where they differ¶
A [ComposeData] theory behaves identically across both engines except where a shape is unreachable from generated code. The differences:
- Diagnosis time. An unsupported shape is a compile-time
CMPdiagnostic on AOT, reported against the test method; the JIT path reports the equivalent condition at run time, except the AOT-only cases in ADR-0070 Amendments 2 and 4, which JIT accepts. - Inherited theory methods. JIT resolves the row member against the concrete reflected test class; AOT resolves it against the method's declaring class (ADR-0070 Amendment 2). A member that exists only on a derived class is rejected on AOT (
CMP0051). SetMemberTypeto name the holder explicitly, but note it selects one fixed holder type — it does not reproduce JIT's per-derived-class selection. - Inaccessible nested holders. A
private/protectednestedMemberTypeorSkipTypeholder may work under JIT reflection but is rejected on AOT (CMP0044) and must never be silently skipped. Make it at leastinternal. - Assembly fixtures. As with
[Compose], only class and collection fixtures work under xUnit's Native AOT generator today (xunit/xunit#3664); an assembly-fixture-backed row member won't compile. See Known limitation: assembly fixtures. - Disposal. xUnit's JIT runner disposes
IDisposabletheory arguments; its Native AOT core does not. Don't pass a fixture-owned disposable as a theory parameter on either engine — seeCompono.XunitV3's disposal note. A composed disposable you do own, such as a[Shared] IMeterFactory, must be disposed in the test body here (using var _ = meters;), because nothing else disposes it on AOT.
The register-the-row-config recipe for reading a row's TConfig in the test body, and the wrapper-profile pattern for profile constructor arguments, work unchanged on this package. See Compono.XunitV3's per-row configuration and Use Profiles.
Not yet supported¶
Compono.XunitV3.Aot.ComposeAttribute's three forms don't support inline values ([Compose(42, "widget")]) or [Shared] parameter reuse yet — real, additional generator work deferred to a later phase. There is no syntax to attempt either (a real compile error results, not silent incorrect behavior). See PLAN-0066/ PLAN-0067.
Composed theory rows ([ComposeData], issue #163) are fully supported under AOT — see Composed theory rows ([ComposeData]) above for the three forms, the row [Shared] behavior, the compile-time diagnostics, and the JIT/AOT differences. This doesn't change [Compose]'s limits above.
[Compose<TProfile, TConfig>] diagnostics differ from Compono.XunitV3 — by design¶
Compono.XunitV3.ComposeAttribute<TProfile, TConfig> validates TConfig's/ TProfile's constructor shape and the supplied profile configuration arguments at runtime, via ConfigProfileBinder reflection (the first time the attribute's GetData runs), because C#'s generic-constraint system cannot express "has a constructor accepting exactly this type." Compono.XunitV3.Aot has no runtime GetData path at all to check this through, so Compono.Generators performs the identical checks at compile time instead, against the real declared symbols and this attribute's own compile-time-constant constructor arguments:
| Diagnostic | Condition |
|---|---|
CMP0041 | TConfig does not have exactly one usable public constructor - isn't a usable named type at all, has zero or more than one public constructor, or its sole public constructor has a by-ref (ref/out/in) parameter, a dynamic-typed parameter, or is marked [RequiresDynamicCode]/[RequiresUnreferencedCode]/[RequiresAssemblyFiles] |
CMP0042 | TProfile does not have exactly one usable public constructor accepting exactly one TConfig-typed parameter - same additional exclusions as CMP0041 (by-ref parameter, or a prohibited AOT attribute) applied to the matching constructor |
CMP0043 | A supplied constructor argument's count/nullability/type doesn't match TConfig's single constructor's parameters |
CMP0044 | TProfile, TConfig, a typeof(...)/enum-typed argument's own type, or (for an array-typed argument) its declared element type or any typeof(...)/enum-typed value recursively embedded in it isn't accessible from the generated top-level registration (commonly: a profile/config type nested private inside the attributed test class) |
CMP0045 | More than one Compose-family attribute ([Compose]/[Compose<TProfile>]/[Compose<TProfile, TConfig>]) on one test method |
CMP0046 | The selected TConfig/TProfile constructor doesn't satisfy the type's required members |
CMP0047 | The selected TConfig/TProfile constructor is marked [Obsolete("...", error: true)], [Experimental("...")], or non-optional [CompilerFeatureRequired("...")], making any use of it a compiler error |
CMP0048 | An accessible sibling constructor with a higher [OverloadResolutionPriority] could silently supersede the selected TConfig/TProfile constructor at the generated call site |
CMP0049 | [Compose<TProfile>]'s TProfile has a public parameterless constructor marked [RequiresDynamicCode]/[RequiresUnreferencedCode]/[RequiresAssemblyFiles] |
This is a deliberate, documented divergence — not accidental drift between the two packages: earlier, IDE-visible feedback instead of a test-execution-time failure, and a byproduct of Compono.Generators being able to see everything it needs at compile time for this attribute family. See ADR-0067's "One accepted, explicit divergence" section. Compono.XunitV3.Aot.ComposeAttribute<TProfile, TConfig>'s generated code constructs both types via a direct new call with no reflection and no [DynamicallyAccessedMembers] annotation anywhere in the path — strictly stronger AOT-safety than the JIT-mode path it mirrors.
Unsupported method/parameter shapes (CMP0040)¶
Because ComposeAttribute is a marker only (see below) with no runtime fallback, an unsupported [Compose]-attributed method shape is rejected at compile time with CMP0040 instead:
- A generic test method — rejected before any registration is built, so without this diagnostic the method would simply never be discovered by xUnit's Native AOT pipeline, with no diagnostic anywhere.
- A
ref/out/inorparamsparameter — same silent-non-discovery failure mode as above. - A parameter type that can never be a legal
CompositionRow.Resolve<T>()type argument — an open generic parameter, aref struct, a pointer, a function pointer, or an unsupported array shape. Without this diagnostic, code generation would proceed and emit a registration containing an illegalCompositionRow.Resolve<T>()call, so the consumer would see a generated-code compiler error instead of an actionable diagnostic pointing at their own test method.
See CMP0040 for the full diagnostic reference entry.
ComposeData diagnostics on Native AOT¶
Because [ComposeData] is validated at compile time under AOT (there is no runtime member read to fail late), every unsupported shape is a CMP diagnostic against the test method:
| Diagnostic | Condition |
|---|---|
CMP0051 | The [ComposeData] member wasn't found, or more than one inherited/hidden static member matches (ambiguous) — set MemberType to disambiguate, or rename the member |
CMP0052 | The member isn't public and static, requires arguments, or is a generic method; or a selected member (or an active skip-condition property) is a static abstract/static virtual interface member the generated code cannot call |
CMP0053 | The member's return type, or a typed row's element type, can never be a supported collection/row shape; or the member's type implements IAsyncEnumerable<T> for more than one element type, where the JIT path's interface choice isn't deterministic |
CMP0054 | MemberType is an open generic type; or the selected member (or an active skip-condition property) is declared on an open generic type the generated code cannot name — set MemberType/SkipType to a non-generic or closed generic type that declares it |
CMP0055 | [ComposeData<TProfile, TConfig>]: a typed row's element 0 can never be the TConfig configuration value |
CMP0056 | A typed row supplies more values than the method has parameters, or a row value can never be assigned to its parameter |
CMP0057 | An active skip condition is invalid — both SkipUnless and SkipWhen are set, or the named property isn't a public static bool property on the holder (or a base type) |
CMP0040 | The [ComposeData]-attributed method's signature is unsupported (a generic method, a ref/out/in/params parameter, an uncomposable parameter type, or two [Shared] parameters sharing a type) |
CMP0042 | [ComposeData<TProfile, TConfig>]'s TProfile doesn't have exactly one usable public constructor accepting a single TConfig parameter |
CMP0044 | A type referenced by [ComposeData<...>] — a profile/config type or a MemberType/SkipType holder — isn't accessible from the generated top-level registration |
CMP0045 | More than one Compose-family attribute (including [ComposeData…]) on one test method |
CMP0046–CMP0048 | The same required-members / prohibited-constructor / overload-supersede conditions as the [Compose<TProfile, TConfig>] table above, applied to [ComposeData<TProfile, TConfig>]'s selected TProfile(TConfig) constructor |
CMP0049 | [ComposeData<TProfile>]'s TProfile has a public parameterless constructor marked [RequiresDynamicCode]/[RequiresUnreferencedCode]/[RequiresAssemblyFiles] |
CMP0047 (members) | A selected member (or its getter) — or an active skip property (or its getter) — is marked [Obsolete(error: true)]/[Experimental]/non-optional [CompilerFeatureRequired] |
AOT-only exclusions¶
ADR-0070 Amendments 2 and 4 (with §7a) approve eight shapes that JIT accepts but generated AOT code cannot name or call. Each is rejected with a compile-time CMP diagnostic — never a silently omitted test — and each has a workaround:
- A member that exists only on a derived class of an inherited theory method (
CMP0051) — setMemberTypeto a fixed holder, or move the member to the declaring class (Amendment 2 §1). - A
private/protectednestedMemberType/SkipTypeholder (CMP0044) — make it at leastinternal(Amendment 2 §2). - A member or active skip-condition holder that contains a type parameter, e.g. declared on a generic base (
CMP0054) — setMemberType/SkipTypeto a non-generic or closed holder (Amendment 4 §2a). - A test parameter whose type contains the generic base's type parameter, e.g.
List<T>(CMP0040) (Amendment 4 §2b). - A selected member/getter or active skip property/getter marked
[Obsolete(error: true)]/[Experimental]/non-optional[CompilerFeatureRequired](CMP0047) — remove the marking or move the data (Amendment 4 §2c). - A
static virtualinterface member with a default body selected through an interfaceMemberType(CMP0052) — move the data to a class/struct static member (Amendment 4 §2d). - A member whose type implements
IAsyncEnumerable<T>more than once with no deterministic JIT choice (CMP0053) — see the supported mixed shape below (Amendment 4 §2e). - An active skip-condition property that is a
static virtualinterface property (CMP0052) — move it to a class/struct static property or a non-virtual static interface property (Amendment 4 §7a).
A static abstract interface member — data member or skip property — gets the same CMP0052 twin but is not an exception: JIT fails it at run time too (Amendment 4 §2d, §7b).
Supported mixed async shape. A member type that implements IAsyncEnumerable<T> with one reference-type element plus any number of value-type implementations is supported on both engines — variance never applies to value types, so the reference rows are always read (Amendment 4 §2e). Only the ambiguous cases above are rejected.
Accepted residual (C-4). AOT reports an invalid active attribute-level skip condition at compile time even when no row ends up evaluating it — a known, accepted compile-time-versus-run-time difference (Amendment 2 §3, PLAN-0071 B4.9).
Generic test-class bases are supported — a [ComposeData] theory declared on a generic base is registered against the open-generic base and resolved to the closed test class at runtime, with no IL/AOT warnings. See ADR-0070 Amendments 2 and 4 for the full account and the diagnostics reference for every code.
How it actually works under the hood¶
Unlike Compono.XunitV3.ComposeAttribute, this package's ComposeAttribute carries no runtime composition logic — it's a thin marker attribute, matching the shape xUnit's own official Native AOT extensibility samples (AotCsvDataSource, AotRetryFact, AotTraitExtensibility) use. Compono.Generators discovers every [Compose]-attributed method at compile time and emits a Xunit.v3.RegisteredEngineConfig.RegisterTheoryDataRowFactory(...) registration — xUnit v3's own official Native AOT theory-data mechanism — whose factory closure calls real, compile-time-closed-generic CompositionRow.Resolve<T>() calls, exactly the same core Compono composition machinery Compono.XunitV3 uses. DataAttribute.GetData is never invoked under this path at all; xUnit's AOT pipeline calls the registered factory directly.
Native AOT publish and run¶
dotnet publish YourTestProject.csproj -c Release -f net10.0 \
-p:RuntimeIdentifier=<rid> -p:SelfContained=true -p:PublishAot=true -p:UseAppHost=true
./bin/Release/net10.0/<rid>/publish/YourTestProject
Pass RuntimeIdentifier/SelfContained as -p: MSBuild properties, not the -r <rid>/--self-contained CLI shorthand flags — the shorthand form can fail this exact publish shape with MSB3030: Could not copy ... apphost ... because it was not found in some project configurations. The resulting native binary is a real, self-hosting xUnit v3 test executable — run it directly; there is no dotnet test step for a published Native AOT binary.
Compatibility¶
- xUnit v3 4.0.0+ required — earlier versions have no Native AOT support to integrate with at all.
- .NET 9.0+ required — matches xUnit's own stated floor.
Compono.XunitV3is completely unaffected by this package's existence — same package ID, same dependency on the reflection-modexunit.v3.extensibility.core, same behavior. InstallingCompono.XunitV3.Aotin a new project never changes anything about an existingCompono.XunitV3project elsewhere in your solution.