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
12 changes: 10 additions & 2 deletions .github/workflows/build-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ jobs:
with:
fetch-depth: 0

# Both SDKs are needed. global.json selects the 10.0 SDK to build with, and it can compile
# every target here — but running the net8.0 test projects needs the 8.0 runtime, which the
# 10.0 SDK does not carry. Roll-forward stops at the major version boundary by default, so a
# net8.0 test host will not start on 10.0 alone.
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
dotnet-version: |
8.0.x
10.0.x

- run: dotnet restore DependencyModules.sln

Expand Down Expand Up @@ -94,7 +100,9 @@ jobs:

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
dotnet-version: |
8.0.x
10.0.x
source-url: https://nuget.pkg.github.com/ipjohnson/index.json
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ jobs:
with:
fetch-depth: 0

# See build-package.yaml: the 10.0 SDK builds, the 8.0 runtime runs the net8.0 tests.
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
dotnet-version: |
8.0.x
10.0.x

- name: Resolve version
id: version
Expand Down
111 changes: 111 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,117 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **xUnit v3 updated from 1.0.0 to 3.2.2.** `[ModuleTest]` builds on xUnit's extensibility surface —
a custom test case and discoverer — and that surface moved across the two major versions. Module
tests now pick up the conditional-skip family the way `[Fact]` and `[Theory]` do: `SkipExceptions`
on the test case, and per-row `SkipType`/`SkipUnless`/`SkipWhen`/`Label` on a data row, each of
which previously had nowhere to go.

`DependencyModules.xUnit` also now references `xunit.v3.extensibility.core` rather than
`xunit.v3`. It ships `[ModuleTest]` for other people's test projects and is not a test project
itself, which is exactly what that package is for. It replaces three defensive settings that
existed only to stop xunit.v3's build targets forcing this library to be an executable.

**Breaking for anyone constructing `ModuleTestCase` directly:** the constructor gained a
`skipExceptions` parameter in fifth position, so positional callers past the fourth argument
need updating. Using `[ModuleTest]` is unaffected.

### Fixed

- **A module test now reports where it is declared.** `[ModuleTest]` captured no source location and
the discoverer forwarded none, so a test explorer had nowhere to navigate to and results carried
no file or line. Both halves are fixed, and a test asserts the location survives the whole way
onto the discovered test case.

One limitation, deliberate and pinned by its own test: naming *two or more* modules —
`[ModuleTest(typeof(A), typeof(B))]` — still reports no location. C# does not allow the
caller-info parameters that capture it to follow a `params` array, so the multi-module overload
cannot take them. Naming one module or none captures the location as expected.

### Added

- **.NET 10 support.** Every shipping package now multi-targets `net8.0` and `net10.0`. A .NET 10
project already worked — a `net8.0` assembly loads fine on it — but the package brought its
`Microsoft.Extensions.*` 8.x dependency along, and on .NET 10 those live in the shared framework,
so an older assembly landed in the output in place of the one the framework already supplies. Each
target framework now carries its own baseline version, so consumers roll forward from it rather
than being dragged back to it.

Nothing is dropped: `net8.0` remains a target until it leaves support in November 2026, and the
generators stay on `netstandard2.0`, which is what Roslyn analyzers must target. The test suites
and the package verification script run against both frameworks.

- **A test can ask for a `Mock<T>` directly.** With `[MoqSupport]`, a parameter typed
`Mock<ITemperatureProvider>` hands over the mock itself, so `Mock.Get` is no longer the only way to
reach it. It works exactly as `[Mock]` does: the service is replaced in the container before
anything resolves, so the service under test is built against the same mock. No attribute is needed
— the type says what it is — but `[Mock]` on such a parameter is accepted and simply redundant.

The two spellings agree. `[Mock] IFoo` and `Mock<IFoo>` on one test give one mock seen two ways,
and two parameters naming the same `Mock<T>` are one mock. A `[TestExport]` naming a real
implementation still overrides both, as it already did for `[Mock]`.
- **Environment conditions on decorators.** `[IfEnvironment]` and the rest of the family now take
effect on a `[Decorator]`, so a decorator can exist only where it is wanted — request logging in
development, a circuit breaker only in production. Where the condition does not hold the decorator
is never applied, so the service resolves undecorated rather than being wrapped by something that
re-tests the environment on every call. A condition changes whether a decorator applies, never
where it sits in the nesting.
- **Environment conditions on conventions**, as `IfEnvironment(…)`, `IfNotEnvironment(…)`,
`IfEnvironmentValue(key)`, `IfEnvironmentValue(key, value)` and `IfNotEnvironmentValue(…)`. A whole
rule can be gated without repeating the attribute on every class it matches. Named after the
attributes so the two ways of saying the same thing read the same.

A condition on a convention combines with **and** against any condition on a matched class, so
neither declaration can silently discard the other. Two conventions matching one class under
different conditions keep their own guards rather than sharing the stricter one.

### Fixed

- **A condition on a `[Decorator]` was silently ignored.** The attribute compiled, read as
deliberate, and did nothing: decoration never looked at conditions, so a decorator marked
`[IfEnvironment("Development")]` wrapped the service in production too.

### Changed

- **The test extensibility hooks moved to `DependencyModules.Testing`** and no longer mention xUnit.
`ITestParameterValueProvider` and `IServiceProviderBuilderAttribute` moved across as they were but
now take an `ITestMethodContext` — a `MethodInfo` and the attributes already in scope — in place of
`IXunitTestMethod`. `ITestStartupAttribute` split along the seam it always had: registering services
is `ITestServiceSetupAttribute`, running against the built container stays `ITestStartupAttribute`,
and an attribute that only registers no longer carries a no-op `StartupAsync`.

This finishes what creating `DependencyModules.Testing` started. Only the pieces that already had
no xUnit reference moved then, which left a mocking package unable to register anything without
taking a dependency on a test framework it does not use — which is how `Mock<T>` support is
implemented without `DependencyModules.Moq` referencing xUnit at all.

Implementations need a namespace change and the new parameter type; the bodies rarely change, since
nothing in this repository read more than `.Method` off the xUnit model. An attribute that does need
the full model can downcast the context to `IXunitTestMethodContext`.
- **An environment caches what it reads from the process**, misses included, for the life of the
instance. `IModuleEnvironment` is injectable and the instance `AddModules` registers is held for
the application's lifetime, so a service reading a value per request was paying a process lookup
and a fresh string allocation every call — and a miss is the common case, since an optional
variable that is not set is exactly what a default exists for. Values supplied at the call site are
unaffected, and the cache is kept separate from them so enumerating an environment still yields
only what was supplied. The cost is that an instance no longer sees a variable changed
mid-process.
- **`ModuleEnvironment.Default` is now `ModuleEnvironment.CreateDefault()`**, returning a new
instance per call rather than one shared by the process. A shared instance that caches would let
the first read of a variable fix it for every application in the process with no way to opt out —
the same reasoning that keeps `None` a type of its own. Because each call builds a fresh one,
asking again is how a current view of the process is obtained.
- **`DecoratorRegistration.RegistryFunc` is now an `EnvironmentRegistryFunc`**, taking the
environment alongside the collection, so a decorator's condition can be evaluated where it is
applied. Constructing one is unaffected — the `RegistryFunc` overload remains and adapts — but code
reading the property and invoking it with a single argument needs the extra parameter. This is
module plumbing reached through `IDependencyModule.InternalGetDecorators`; hand-written modules
that decorate directly are unaffected.

## [1.0.0-rc9210] - 2026-08-09

Everything since `1.0.0-rc9200`. Still a release candidate: convention registration is new and
Expand Down
10 changes: 10 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
<FileVersion>1.0.0.0</FileVersion>
</PropertyGroup>

<!--
The shipping libraries' target frameworks, in one place. net8.0 leaves support in November
2026, and dropping it should then be a one-line edit here rather than a sweep through every
csproj. The generator projects are deliberately not covered: a Roslyn analyzer has to target
netstandard2.0 and pins its own TFM.
-->
<PropertyGroup>
<LibraryTargetFrameworks>net8.0;net10.0</LibraryTargetFrameworks>
</PropertyGroup>

<PropertyGroup>
<Authors>Ian Johnson</Authors>
<Company>Ian Johnson</Company>
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ dotnet add package DependencyModules.Runtime
dotnet add package DependencyModules.SourceGenerator
```

Requires .NET 8.0 or later. See [CHANGELOG.md](CHANGELOG.md) for release notes.
Requires .NET 8.0 or later. The packages ship both `net8.0` and `net10.0` assemblies, so a project on
either LTS release gets one built against its own framework. See [CHANGELOG.md](CHANGELOG.md) for
release notes.

## Service Attributes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>$(LibraryTargetFrameworks)</TargetFrameworks>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
Expand All @@ -12,9 +12,17 @@

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0"/>
</ItemGroup>

<!-- Both TFMs, so registration cost can be compared across runtimes rather than assumed. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1"/>
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\DependencyModules.Conventions\DependencyModules.Conventions.csproj"/>
<ProjectReference Include="..\..\src\DependencyModules.Runtime\DependencyModules.Runtime.csproj"/>
Expand Down
7 changes: 7 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.302",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}
8 changes: 6 additions & 2 deletions integ-tests/ConsoleTestProject/ConsoleTestProject.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>$(LibraryTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
Expand All @@ -13,7 +13,11 @@
<ProjectReference Include="..\SutProject\SutProject.csproj"/>
</ItemGroup>

<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion integ-tests/SecondarySutProject/SecondarySutProject.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>$(LibraryTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>False</IsPackable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
using DependencyModules.xUnit.Attributes.Interfaces;
using DependencyModules.Testing.Attributes.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Xunit.v3;

namespace SutProject.Tests.Customization;

public class CustomServiceProviderAttribute : Attribute, IServiceProviderBuilderAttribute {

public IServiceProvider BuildServiceProvider(IXunitTestMethod testCaseContext, IServiceCollection serviceCollection) {

public IServiceProvider BuildServiceProvider(
ITestMethodContext testMethod, IServiceCollection serviceCollection) {
serviceCollection.AddSingleton<ICustomTestDependency, CustomTestDependency>();
return serviceCollection.BuildServiceProvider();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public void ProcessEnvironment_WhenNotRegistered() {
var serviceProvider = serviceCollection.BuildServiceProvider();
var dependency = serviceProvider.GetRequiredService<IEnvironmentDependency>();

Assert.Equal(ModuleEnvironment.Default.EnvironmentName, dependency.EnvironmentName);
Assert.Equal(ModuleEnvironment.CreateDefault().EnvironmentName, dependency.EnvironmentName);
}

/// <summary>
Expand Down Expand Up @@ -155,9 +155,19 @@ public void NullEnvironmentParameter_RegistersTheProcessDefault() {

serviceCollection.AddModules((IModuleEnvironment?)null, new EnvironmentAwareModule());

// The registered instance is the one that decided the registrations. CreateDefault builds a
// fresh environment per call, so the invariant is that these are the same object — not that
// either matches something asked for later.
var registered = Assert.Single(
serviceCollection, descriptor => descriptor.ServiceType == typeof(IModuleEnvironment));

var serviceProvider = serviceCollection.BuildServiceProvider();

Assert.Same(ModuleEnvironment.Default, serviceProvider.GetRequiredService<IModuleEnvironment>());
Assert.Same(
registered.ImplementationInstance, serviceProvider.GetRequiredService<IModuleEnvironment>());
Assert.Equal(
ModuleEnvironment.CreateDefault().EnvironmentName,
serviceProvider.GetRequiredService<IModuleEnvironment>().EnvironmentName);
}

/// <summary>
Expand Down
Loading
Loading