Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .github/workflows/package-validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,23 @@ jobs:
# Reuses test/Compono.XunitV3.SampleTests' own PackToLocalFeed restore
# (packs current source, not this job's Release artifacts above, into
# .local-nuget-feed and restores the four publishable packages from
# there as a real consumer would) - filtered to exclude
# FailingCompositionTests, which fails by design per ADR-0022's
# there as a real consumer would) - filtered to exclude every class
# whose name starts with "Failing" (FailingCompositionTests,
# FailingConfigProfileTests, and any future one following the same
# naming convention), each of which fails by design per ADR-0022's
# Testing Strategy and is why this project is deliberately not in
# Compono.slnx (docs/plans/0004-milestone-4-xunit-integration.md).
# Compono.slnx (docs/plans/0004-milestone-4-xunit-integration.md). A
# single wildcarded --filter-not-class covers the whole naming
# convention rather than needing a new literal class name added here
# every time a new deliberately-failing proof test is added (PR #65
# review: a non-wildcarded, single-class filter here is exactly what
# broke this job when FailingConfigProfileTests' predecessor test was
# first added inside an otherwise-green class instead). The wildcard
# must be trailing-only ("Failing*", not "Failing*Tests") - the MTP
# CLI rejects a wildcard placed in the middle of a filter expression
# ("wildcards may only be at the beginning and/or end"), confirmed
# directly against the built test host.
run: |
dotnet test test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj \
-c Release \
-- --filter-not-class "Compono.XunitV3.SampleTests.FailingCompositionTests"
-- --filter-not-class "Compono.XunitV3.SampleTests.Failing*"
530 changes: 530 additions & 0 deletions docs/adr/0036-parameterized-composition-profile-selection.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ the mechanics: numbering, status, and the index.
| [0033](0033-public-preview-samples-strategy.md) | Public Preview Samples Strategy | Accepted |
| [0034](0034-benchmark-suite-strategy-and-redesign.md) | Benchmark Suite Strategy and Redesign | Accepted |
| [0035](0035-compono-agent-skill-pack.md) | Compono Agent Skill Pack | Accepted |
| [0036](0036-parameterized-composition-profile-selection.md) | Call-Site Values Influencing Nested Composition | Accepted |
12 changes: 12 additions & 0 deletions docs/how-to/use-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ public void ComposesTheProfileConfiguredValue(NotificationSettings settings) { }
parameterless constructor — `[Compose<TProfile>]` enforces this at compile
time via a generic constraint.

**A profile that needs a value known only at a specific test's call
site** — not a fixed, default-constructed one — can't use
`[Compose<TProfile>]` at all, since it has no way to receive that value.
`[Compose<TProfile, TConfig>]` covers this: `TConfig` is a small,
strongly-typed configuration object, bound positionally from the
attribute's own constructor arguments and passed to `TProfile`'s
constructor. See
[`Compono.XunitV3`'s Package Guide](../packages/compono-xunitv3.md#profile-configuration-arguments)
for the full shape and
[Migrating from AutoFixture](../migrating-from-autofixture.md#migrate-a-parameterized-custom-autodataattribute)
for the AutoFixture pattern this replaces.

## Combining more than one profile

```csharp
Expand Down
143 changes: 142 additions & 1 deletion docs/migrating-from-autofixture.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ each row is expanded into its own section below.
| `fixture.Create<T>()` | `composer.Create<T>()` |
| `[AutoData]` | `[Compose]` |
| Custom `AutoDataAttribute` subclass | `[Compose<TProfile>]` |
| **Parameterized** custom `AutoDataAttribute` subclass (constructor args driving customization logic) | `[Compose<TProfile, TConfig>]` |
| `ICustomization` | `ICompositionProfile` |
| Exact-type specimen customization | `Register<T>()` |
| Exact-type `ISpecimenBuilder` | `Register<T>()` |
Expand Down Expand Up @@ -180,6 +181,93 @@ cover the rest with a separate `[Theory]`/`[InlineData]` method instead.
See [`Compono.XunitV3`'s Package Guide](packages/compono-xunitv3.md#what-it-deliberately-doesnt-do)
for the full mechanics of why stacking isn't supported.

## Migrate a parameterized custom `AutoDataAttribute`

A common, larger pattern than the previous section's simple wrapper: a
custom `AutoDataAttribute` subclass whose own **constructor** takes
arguments that change what the underlying fixture customization produces
— not just which type gets composed, but a value read *inside* the
customization logic itself. Real, frequent examples found migrating a
much larger AutoFixture test suite than this guide's other examples are
drawn from (`ncipollina/trivia-platform`'s `PersistenceAutoData(repositoryName)` —
around 45 call sites, each a different repository name driving a
different persistence setup — and an 8-parameter
`AnnouncementsAutoData(validConfig, gameOverEnabled, ...)`):

```csharp
// Before
public sealed class PersistenceAutoDataAttribute(string repositoryName)
: AutoDataAttribute(() => CreateFixture(repositoryName))
{
private static IFixture CreateFixture(string repositoryName)
{
var fixture = new Fixture();
fixture.Customize(new PersistenceCustomization(repositoryName));
return fixture;
}
}

[Theory]
[PersistenceAutoData("PlayerRepository")]
public void Repository_Works(PlayerRepository sut) { }
```

Neither of the migration paths the previous sections cover fits cleanly
here: a plain `[Compose<TProfile>]` has no way to receive
`"PlayerRepository"` at all, and writing one profile subclass per
repository name doesn't scale to `AnnouncementsAutoData`'s combinatorial
8-flag argument space — nor does falling back to a hand-built
`Composer.Create(...)` per test, which reintroduces exactly the per-test
setup code the attribute-based idiom exists to eliminate. This is what
`[Compose<TProfile, TConfig>]` ([ADR-0036](adr/0036-parameterized-composition-profile-selection.md))
exists for — a **typed configuration object** paired with the profile,
bound from this attribute's own constructor arguments:

```csharp
// After
public enum RepositoryKind
{
Player,
Leaderboard,
}

public sealed record PersistenceConfig(RepositoryKind Repository);

public sealed class PersistenceProfile : ICompositionProfile
{
public PersistenceProfile(PersistenceConfig config) => Config = config;

public PersistenceConfig Config { get; }

public void Configure(CompositionBuilder builder) =>
builder.Register<IRepositoryOptions>(_ => RepositoryOptionsFactory.Create(Config.Repository));
}

[Theory]
[Compose<PersistenceProfile, PersistenceConfig>(RepositoryKind.Player)]
public void Repository_Works(PlayerRepository sut) { }
```

Note the enum, not a string — the original AutoFixture attribute took a
raw `string repositoryName`, but that string only ever had a handful of
valid values in practice (a finite, named choice). `params object?[]` is
a binding mechanism forced by C#'s attribute-argument-must-be-a-
compile-time-constant rule, not a license to carry the original
stringly-typed shape forward — see
[`Compono.XunitV3`'s Package Guide](packages/compono-xunitv3.md#profile-configuration-arguments)
for the full "prefer the strongest attribute-legal type" guidance
(`typeof(...)` for a CLR type, `bool`/numeric values where those already
carry the real meaning).

**Don't reach for `[Compose<TProfile, TConfig>]` for every parameterized
attribute, though.** If the "parameter" is really just a `[Frozen]`-style
substitute or a single fixed value that's the same for every call site in
practice, the simpler existing forms (`[Compose<TProfile>]`, an inline
value, a member rule) already cover it — reserve this form for the case
this section actually describes: a value that's genuinely different per
call site and needs to reach configuration logic running *inside* the
profile, not at the test method's own parameter list.

## Migrate `ICustomization`

```csharp
Expand Down Expand Up @@ -346,6 +434,57 @@ case. Reach for a custom `ICompositionValueProvider` only for the rarer
case that genuinely needs to match on request shape rather than a fixed
type — see [Providers](concepts/providers.md).

**A specimen builder that dispatches on the requesting *parameter/member
name*, not just its type** — several distinct values of the same
declared type, chosen by which parameter is asking — is the other real
case a custom `ICompositionValueProvider` covers cleanly.
Comment thread
ncipollina marked this conversation as resolved.
`CompositionProviderRequest.Name` carries the requesting constructor
parameter/required member/test-method-parameter's own name for exactly
this:

```csharp
// Before
public sealed class UpsellPayloadSpecimenBuilder : ISpecimenBuilder
{
public object Create(object request, ISpecimenContext context) => request switch
{
ParameterInfo { Name: "newGamePayload" } => new UpsellPayload("new-game"),
ParameterInfo { Name: "lockedPackPayload" } => new UpsellPayload("locked-pack"),
_ => new NoSpecimen(),
};
}
```

```csharp
// After
public sealed class UpsellPayloadProvider : ICompositionValueProvider
{
public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context)
{
if (request.RequestedType != typeof(UpsellPayload))
return CompositionProviderResult.NotHandled;

return request.Name switch
{
"newGamePayload" => CompositionProviderResult.Handled(new UpsellPayload("new-game")),
"lockedPackPayload" => CompositionProviderResult.Handled(new UpsellPayload("locked-pack")),
_ => CompositionProviderResult.NotHandled,
};
}
}
```

Registered via `builder.AddSemanticProvider(new UpsellPayloadProvider())`
(or `AddTestDoubleProvider`, depending on what it's producing — see
[Providers](concepts/providers.md)). This is a different question from
[Profile configuration arguments](packages/compono-xunitv3.md#profile-configuration-arguments) —
a `Name`-based provider is a **global rule** ("whenever anything asks for
`UpsellPayload` named `newGamePayload`, produce this"), evaluated for
every matching request across every test; a profile configuration
argument is a **per-invocation value** known only at one specific test's
`[Compose<TProfile, TConfig>(...)]` call site. Don't reach for one to
solve the other.

## Handle recursion behavior

**Intentional difference:** AutoFixture's default `ThrowingRecursionBehavior`
Expand Down Expand Up @@ -469,7 +608,9 @@ in [Troubleshooting](troubleshooting/index.md#known-limitations).

- [ ] Remove AutoFixture package references.
- [ ] Add the required Compono packages at matching versions.
- [ ] Replace custom AutoData attributes with `[Compose]` or `[Compose<TProfile>]`.
- [ ] Replace custom AutoData attributes with `[Compose]` or
`[Compose<TProfile>]` — or, for one whose constructor arguments
drive customization logic, `[Compose<TProfile, TConfig>]`.
- [ ] Convert real customizations into profiles.
- [ ] Delete empty or obsolete fixture abstractions.
- [ ] Audit every `[Frozen]` usage to determine whether identity is
Expand Down
69 changes: 69 additions & 0 deletions docs/packages/compono-xunitv3.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ composer's own `Create<T>()`.
[Your First Composed Theory](../getting-started/first-test.md).
- **`[Compose<TProfile>]`** — same, with a specific
[`ICompositionProfile`](../concepts/profiles.md) applied.
- **`[Compose<TProfile, TConfig>]`** — same, with a profile built from
call-site-known profile configuration arguments — see
[Profile configuration arguments](#profile-configuration-arguments)
below.
- **Inline + composed mixing** — `[Compose(42, "widget")]` binds inline
values left-to-right; anything left over is composed. See
[How Do I Write a Composed Theory?](../how-to/write-a-composed-theory.md).
Expand All @@ -40,6 +44,71 @@ composer's own `Create<T>()`.
instead. See
[Determinism and Seeding](../concepts/determinism-and-seeding.md).

## Profile configuration arguments

`[Compose<TProfile>]` selects a fixed, default-constructed profile type —
the same profile, configured the same way, for every caller. When a
profile needs to be built differently per test call site (drawn from real
migration evidence — see
[Migrating from AutoFixture](../migrating-from-autofixture.md#migrate-a-parameterized-custom-autodataattribute)),
`[Compose<TProfile, TConfig>]` binds this attribute's own constructor
arguments — **profile configuration arguments**, a distinct concept from
this package's inline values above — positionally to `TConfig`'s single
public constructor, then constructs `TProfile` from that `TConfig`:

```csharp
public enum RepositoryKind
{
Player,
Game,
}

public sealed record RepositoryConfig(RepositoryKind Repository);

public sealed class RepositoryProfile : ICompositionProfile
{
public RepositoryProfile(RepositoryConfig config) => Config = config;

public RepositoryConfig Config { get; }

public void Configure(CompositionBuilder builder) =>
builder.Register<IRepository>(_ => RepositoryFactory.Create(Config.Repository));
}

[Theory]
[Compose<RepositoryProfile, RepositoryConfig>(RepositoryKind.Player)]
public void Handles_PlayerRepository(IRepository repository) { }
```

**Inline values vs. profile configuration arguments — never the same
thing.** Inline values (`[Compose(42, "widget")]`) bind to the **test
method's own parameters**. Profile configuration arguments
(`[Compose<TProfile, TConfig>(...)]`) bind to **`TConfig`'s
constructor**, which builds the profile applied to the whole row — they
never bind to the test method's parameters, all of which are composed in
full under this attribute form.

**Prefer the strongest attribute-legal type for each argument.**
`params object?[]` is a binding mechanism forced by C#'s
attribute-argument-must-be-a-compile-time-constant rule, not a license to
design `TConfig` around magic strings — use an `enum` for a finite choice
(`RepositoryKind.Player`, not `"Player"`), `typeof(...)` for a CLR type, a
`bool`/numeric value where that's already the real meaning.

**Constructor contracts are narrow and deterministic, not "best match."**
`TConfig` must have exactly one public constructor; `TProfile` must have
exactly one public constructor accepting exactly one `TConfig`-typed
parameter. Either shape being missing or ambiguous is a clear, cached
`CompositionException` — computed once per attribute instance, never on
the per-row path. See
[Troubleshooting: Common Errors](../troubleshooting/common-errors.md) for
each specific message. This is a deliberate tradeoff:
`[Compose<TProfile>]`'s `TProfile : ICompositionProfile, new()` constraint
rejects an invalid profile type at **compile time**; this form's
constructor-shape checks can only happen at runtime, since "has a
constructor accepting exactly this type" isn't expressible as a C# generic
constraint.

## What it deliberately doesn't do

- **No stacking distinct Compose-family attributes on one method.** A test
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ need the first two.
| Package | What it adds | Install if... |
|---|---|---|
| [`Compono`](compono.md) | The core composition engine: `Composer`, the resolution pipeline, and the source generator (embedded, no separate install). | Always — every other package depends on it. |
| [`Compono.XunitV3`](compono-xunitv3.md) | `[Compose]`/`[Compose<TProfile>]` theory data attributes and `[Shared]` parameter sharing for xUnit v3. | You write xUnit v3 tests and want composed theory parameters instead of hand-built test data. |
| [`Compono.XunitV3`](compono-xunitv3.md) | `[Compose]`/`[Compose<TProfile>]`/`[Compose<TProfile, TConfig>]` theory data attributes and `[Shared]` parameter sharing for xUnit v3. | You write xUnit v3 tests and want composed theory parameters instead of hand-built test data. |
| [`Compono.NSubstitute`](compono-nsubstitute.md) | Automatic substitute composition for interface, delegate, and (optionally) abstract-class parameters. | Your composed types depend on interfaces you'd otherwise stub by hand with NSubstitute. |
| [`Compono.Bogus`](compono-bogus.md) | Realistic fake data — member-name-convention matching plus explicit `Faker<T>` sugar. | You want `FullName`/`Email`/`StreetAddress`-shaped fields to look like real data instead of anonymous strings. |

Expand Down
Loading