diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bf50207..46f8e2b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -82,8 +82,11 @@ jobs: src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj \ src/DependencyModules.SourceGenerator.Impl/DependencyModules.SourceGenerator.Impl.csproj \ src/DependencyModules.Conventions/DependencyModules.Conventions.csproj \ + src/DependencyModules.Testing/DependencyModules.Testing.csproj \ src/DependencyModules.xUnit/DependencyModules.xUnit.csproj \ - src/DependencyModules.xUnit.NSubstitute/DependencyModules.xUnit.NSubstitute.csproj; do + src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj \ + src/DependencyModules.Moq/DependencyModules.Moq.csproj \ + src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj; do dotnet pack "$project" --configuration Release --output ./artifacts \ "/p:PackageVersion=${{ steps.version.outputs.version }}" done diff --git a/CHANGELOG.md b/CHANGELOG.md index baa71c0..2269067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,131 @@ 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). -## [1.0.0] - 2026-08-06 +## [1.0.0-rc9210] - 2026-08-09 -First stable release. The public API is unchanged from the `1.0.0-rc*` line; this release -fixes packaging and generated-code defects and commits to the API surface going forward. +Everything since `1.0.0-rc9200`. Still a release candidate: convention registration is new and +large, and the environment API changed shape late, so the surface is not committed to yet. + +### Added + +- **Moq and FakeItEasy mocking support**, in new `DependencyModules.Moq` and + `DependencyModules.FakeItEasy` packages. Apply `[MoqSupport]` or `[FakeItEasySupport]` where you + would have applied `[NSubstituteSupport]`; `[Mock]` then works the same way. With NSubstitute and + FakeItEasy the injected instance is also what you configure, while Moq separates the two, so the + container receives `Mock.Object` and the mock is reached with `Mock.Get(instance)`. +- **`DependencyModules.Testing`**, a test-framework-neutral package holding the pieces the mocking + packages need — `IMockSupportAttribute`, `IOrderedAttribute`, `IInjectValueAttribute`, + `InjectValuesAttribute` and `AttributeUtility`. None of them referenced xUnit, but living in + `DependencyModules.xUnit` meant every mocking package had to depend on a test framework it does not + use. +- **Convention registration**, in a new `DependencyModules.Conventions` package. A module + implements `IConventionModule` and declares what to register; the generator resolves the matches + at compile time and emits ordinary registrations. Selection by assignability, namespace, + attribute or name glob; shapes `AsSelf`, `AlsoAsSelf`, `AsSelfWithInterfaces`, + `AsMatchingInterface` and `As`; `Using` and `WithKey` pass through to the registration. The + declaration body is read rather than executed, so anything that cannot be evaluated at build time + is reported as DM0009 instead of ignored. Ships as its own analyzer, so a project that does not + use conventions never loads the class-scanning providers. +- **Scanning a referenced assembly.** `InAssemblyOf()` points a convention at a package instead + of the project being built. Types are read as symbols during the build and emitted as literal + `typeof()`, so this survives trimming where a reflection-based scan does not. One named assembly + at a time; only `public` types are visible. +- **Interception.** `[Intercept]` wraps a service in a generated type that routes every member + through an interceptor. `IInterceptor`, `IAsyncInterceptor` and `IAsyncEnumerableInterceptor` are + chosen per member, and a member no interceptor can serve is forwarded untouched. Properties, + indexers and events are supported; shapes that cannot be wrapped are refused with DM0008. +- **Environment-conditional registration.** `[IfEnvironment]`, `[IfNotEnvironment]`, + `[IfEnvironmentValue]` and `[IfNotEnvironmentValue]` gate a registration on the environment. + Conditions of different kinds combine with and. `ModuleEnvironment.Default` reads + `ASPNETCORE_ENVIRONMENT`, then `DOTNET_ENVIRONMENT`, then `"Production"`. A `ModuleEnvironment` is + a collection of its values, so they can be written inline — + `new ModuleEnvironment("Development") { { "REGION", "eu" } }` — and enumerated back out. A key not + written there falls back to an environment variable of that name; a key written as `null` hides + one. Lead with `false` — `new ModuleEnvironment(false, "Development")` — to read only what is at + the call site, which is what a test asserting registrations wants. Conditional registrations are + emitted after unconditional ones so they can override a default; across modules, module order + decides. +- **A documentation site** at , covering conventions, + decorators, interception, environments, testing, trimming and AOT, and a DM diagnostics reference — + all of which were previously undocumented. +- DM0004 through DM0012, covering convention ambiguity, a convention matching nothing, an + unconstructable match, an unreadable declaration, provenance for convention registrations, and + environment conditions. + +### Changed + +- **`DependencyModules.xUnit.NSubstitute` is now `DependencyModules.NSubstitute`.** The mocking + packages do not touch xUnit, and naming one of them after it would have been misleading next to + `DependencyModules.Moq` and `DependencyModules.FakeItEasy`. Update the `PackageReference` and the + `using` — the attribute itself is unchanged. Types that moved to `DependencyModules.Testing` + changed namespace to match, so `DependencyModules.xUnit.Attributes.InjectValuesAttribute` is now + `DependencyModules.Testing.Attributes.InjectValuesAttribute`, and the extension methods on + `MethodInfo`/`ParameterInfo` moved from `DependencyModules.xUnit.Impl` to + `DependencyModules.Testing.Impl`. The xUnit-bound interfaces — `ITestStartupAttribute`, + `ITestParameterValueProvider` and `IServiceProviderBuilderAttribute`, all of which take an + `IXunitTestMethod` — stay where they were. +- **`IEnvironmentServiceCollectionConfiguration.ConfigureServices` takes a non-nullable + `IModuleEnvironment`.** There is now always an environment, so an implementation that branched on + `null` takes the other branch. Existing implementations still compile. +- **`AddModules` registers the environment it used.** Previously nothing was registered when none + was supplied, so `GetRequiredService()` threw while conditions had been decided + against the process default. An environment passed to `AddModules` now replaces one already in the + collection rather than joining it. +- **An `IModuleEnvironment` registered by type or factory is refused.** It cannot be constructed + while the collection is still being populated, and was previously ignored in favour of the process + default — so a service gated on `"Development"` quietly took its production branch. +- Generated modules implement both overloads of `InternalApplyServices`. The generator package + declares no dependency on the runtime package, so a new generator paired with an older runtime + would otherwise register nothing at all. +- Attribute providers use `ForAttributeWithMetadataName`, roughly halving generator time on a + 2,000-class compilation. This also fixed selection of namespace-qualified attribute usages, which + were silently not matched. + +### Fixed + +- **A capability interface could win the default service type.** The service type is inferred from + the first interface a class declares, so `class ConnectionPool : IDisposable, IPool` registered as + `IDisposable` and was unresolvable as `IPool` — and a class whose only interface was `IDisposable` + registered as `IDisposable` rather than as itself. Interfaces describing what a class *can do* + rather than what it *is* are now passed over: `IDisposable`, `IAsyncDisposable`, `IEquatable`, + `IComparable`, `ICloneable`, `IConvertible`, `IFormattable`, `IParsable`, `ISerializable`, + `IEnumerable`/`IEnumerable` and the `INotify*` family. Interfaces that are genuine service roles + are untouched, including framework ones such as `IEqualityComparer`, `IJsonTypeInfoResolver` and + `IHttpClientFactory`, as is any type named with `As`. Previously only `INotifyPropertyChanged` was + skipped. +- **A handler implementing several closings of one interface registered only the first**, silently. + The MediatR notification shape — one class handling two events — lost every event but one. +- **A `[Decorator]` was matched by conventions as though it were a service.** A decorator implements + the interface it decorates, so a convention scanning that interface matched the decorator; being + generic and closing nothing it registered as an open generic, and decoration then refused + everything with an error blaming the open generic limitation. One open generic decorator over + convention-registered handlers — the ordinary MediatR shape — could not work. +- **A partial class was two convention candidates**, so a type whose parts each reached the scanned + interface was reported as ambiguous and registered nothing. +- **A nested type's constructor was used as the outer type's.** Constructor discovery walked the + whole subtree rather than the type's own members, so a service containing a nested class with a + parameterised constructor was registered against that constructor. Only visible with + `DependencyModules_GenerateFactories`. +- **A decorator or interceptor file declared a record module a class**, failing the build with + CS0261. Two of the four writers contributing to a module's partial carried the record rewrite and + two did not. +- **`AsSelfWithInterfaces` cross-wired BCL interfaces**, so any type whose base implemented + `IDisposable` became resolvable as `IDisposable`. Interfaces in `System` and below are no longer + expanded into. +- **A metadata scan re-registered a package's own services**, ignoring the service attributes that + exclude a type in the project being built. +- Constructor discovery no longer walks every method body of every candidate, which was the dominant + cost of the generator on ordinary code: 73 ms to 12 ms on a 2,000-class compilation, measured on + the run after an edit. +- The README had a code fence opened at *Unit testing* and closed at *Implementation*, so the whole + *Reporting a problem* section rendered as a C# block on GitHub and on every NuGet package page. + +--- + +## Earlier, in the 1.0.0-rc line + +The entries below were written for a 1.0.0 that was not cut. They describe the state reached at +`1.0.0-rc9200` and the decorator work that followed it, and are kept here rather than restated. ### Fixed @@ -120,4 +241,4 @@ fixes packaging and generated-code defects and commits to the API surface going Enable it with ``. - A tag-driven release workflow publishing to nuget.org and GitHub Packages. -[1.0.0]: https://github.com/ipjohnson/DependencyModules/releases/tag/v1.0.0 +[1.0.0-rc9210]: https://github.com/ipjohnson/DependencyModules/releases/tag/v1.0.0-rc9210 diff --git a/DependencyModules.sln b/DependencyModules.sln index fba67eb..926d7d6 100644 --- a/DependencyModules.sln +++ b/DependencyModules.sln @@ -19,8 +19,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SutProject.Tests", "integ-t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.xUnit", "src\DependencyModules.xUnit\DependencyModules.xUnit.csproj", "{0960CFB0-87F7-418F-9673-FAE5A6C9BB74}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.xUnit.NSubstitute", "src\DependencyModules.xUnit.NSubstitute\DependencyModules.xUnit.NSubstitute.csproj", "{444349D1-F2D1-4E4B-87C2-F3DBCF0D1D19}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleTestProject", "integ-tests\ConsoleTestProject\ConsoleTestProject.csproj", "{9A4D50DE-3995-494F-85CF-68F803051644}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "web", "web", "{D6D764B2-B906-4386-BC37-7EE29D7821DF}" @@ -39,6 +37,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Benchmarks", "benchmarks\DependencyModules.Benchmarks\DependencyModules.Benchmarks.csproj", "{F72AFF5C-C9FB-406E-B65A-192933E697D2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Testing", "src\DependencyModules.Testing\DependencyModules.Testing.csproj", "{6091F3CE-7C70-464D-AD37-E0F78BC95F2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.NSubstitute", "src\DependencyModules.NSubstitute\DependencyModules.NSubstitute.csproj", "{5430B97D-5012-4CEB-BBCA-219C0A53D3C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.Moq", "src\DependencyModules.Moq\DependencyModules.Moq.csproj", "{F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyModules.FakeItEasy", "src\DependencyModules.FakeItEasy\DependencyModules.FakeItEasy.csproj", "{DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,10 +79,6 @@ Global {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Debug|Any CPU.Build.0 = Debug|Any CPU {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|Any CPU.ActiveCfg = Release|Any CPU {0960CFB0-87F7-418F-9673-FAE5A6C9BB74}.Release|Any CPU.Build.0 = Release|Any CPU - {444349D1-F2D1-4E4B-87C2-F3DBCF0D1D19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {444349D1-F2D1-4E4B-87C2-F3DBCF0D1D19}.Debug|Any CPU.Build.0 = Debug|Any CPU - {444349D1-F2D1-4E4B-87C2-F3DBCF0D1D19}.Release|Any CPU.ActiveCfg = Release|Any CPU - {444349D1-F2D1-4E4B-87C2-F3DBCF0D1D19}.Release|Any CPU.Build.0 = Release|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Debug|Any CPU.Build.0 = Debug|Any CPU {9A4D50DE-3995-494F-85CF-68F803051644}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -101,6 +103,22 @@ Global {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Debug|Any CPU.Build.0 = Debug|Any CPU {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|Any CPU.ActiveCfg = Release|Any CPU {F72AFF5C-C9FB-406E-B65A-192933E697D2}.Release|Any CPU.Build.0 = Release|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C}.Release|Any CPU.Build.0 = Release|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1}.Release|Any CPU.Build.0 = Release|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D}.Release|Any CPU.Build.0 = Release|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1E7E9023-435E-499B-9A6F-6C3E20A9A4F1} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} @@ -110,7 +128,6 @@ Global {01878BB5-D308-4EF3-8144-C35C58188E1A} = {DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE} {93187389-37C0-4DA0-8D25-E672ED858052} = {DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE} {0960CFB0-87F7-418F-9673-FAE5A6C9BB74} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} - {444349D1-F2D1-4E4B-87C2-F3DBCF0D1D19} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {9A4D50DE-3995-494F-85CF-68F803051644} = {DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE} {D6D764B2-B906-4386-BC37-7EE29D7821DF} = {DC7C95FE-F58F-4F16-8DFA-87E1B67DCEAE} {3946D986-65E0-4103-AFD6-1C1D9549163F} = {D6D764B2-B906-4386-BC37-7EE29D7821DF} @@ -118,5 +135,9 @@ Global {1AFFBCCF-FD6E-4232-9DC7-40FE2784ECD1} = {F4DC9AA2-7F61-4DEE-A7D1-2BCDBEEFD1C5} {B39ACDFB-85D2-4764-B06E-74744E683268} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} {F72AFF5C-C9FB-406E-B65A-192933E697D2} = {2E4BAA8C-195F-490B-B9F9-57194B08CD12} + {6091F3CE-7C70-464D-AD37-E0F78BC95F2C} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} + {5430B97D-5012-4CEB-BBCA-219C0A53D3C1} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} + {F453C574-0EC6-44D2-BFBF-F5BB9097EC4D} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} + {DB6B8603-CBA7-443D-AEA9-E37BF61AD1B7} = {FB161494-E422-4D4E-BA04-3473C2EB13B0} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 85f2cdd..9e8cdd6 100644 --- a/README.md +++ b/README.md @@ -271,9 +271,13 @@ It handles the population and construction of a service provider using specified ```shell dotnet add package DependencyModules.xUnit -dotnet add package DependencyModules.xUnit.NSubstitute +dotnet add package DependencyModules.NSubstitute ``` +Mocking is supplied by a separate package, so use whichever library you already have — +`DependencyModules.NSubstitute`, `DependencyModules.Moq` or `DependencyModules.FakeItEasy` — and +apply its `[NSubstituteSupport]`, `[MoqSupport]` or `[FakeItEasySupport]` attribute. + ```csharp // applies module & nsubstitute support to all tests. // test attributes can be applied at the assembly, class, and test method level diff --git a/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs b/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs new file mode 100644 index 0000000..fe46e4c --- /dev/null +++ b/integ-tests/SutProject.Tests/FakeItEasy/FakeItEasyAttributeTests.cs @@ -0,0 +1,33 @@ +using DependencyModules.FakeItEasy; +using DependencyModules.xUnit.Attributes; +using FakeItEasy; +using Xunit; + +namespace SutProject.Tests.FakeItEasy; + +/// +/// The same scenario as the NSubstitute and Moq tests, so the three can be read against each other. +/// +[FakeItEasySupport] +public class FakeItEasyAttributeTests { + + [ModuleTest] + [SutModule] + public void MockTest([Mock] IDependencyOne dependencyOne, + [Mock] IScopedService scopedService, ISingletonService singletonService) { + A.CallTo(() => dependencyOne.SingletonService).Returns(singletonService); + A.CallTo(() => dependencyOne.ScopedService).Returns(scopedService); + + Assert.Same(dependencyOne.SingletonService, singletonService); + Assert.Same(dependencyOne.ScopedService, scopedService); + } + + /// + /// The injected fake is the thing you configure, unlike Moq — no unwrapping step. + /// + [ModuleTest] + [SutModule] + public void TheInjectedInstanceIsTheFake([Mock] IDependencyOne dependencyOne) { + Assert.True(Fake.GetFakeManager(dependencyOne) is not null); + } +} diff --git a/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs b/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs new file mode 100644 index 0000000..4b84f56 --- /dev/null +++ b/integ-tests/SutProject.Tests/Moq/MoqAttributeTests.cs @@ -0,0 +1,35 @@ +using DependencyModules.Moq; +using DependencyModules.xUnit.Attributes; +using Moq; +using Xunit; + +namespace SutProject.Tests.Moq; + +/// +/// The same scenario as the NSubstitute and FakeItEasy tests, so the three can be read against each +/// other. Moq is the one that separates the mock from the object, and this is what that costs at the +/// call site: Mock.Get to reach the setup. +/// +[MoqSupport] +public class MoqAttributeTests { + + [ModuleTest] + [SutModule] + public void MockTest([Mock] IDependencyOne dependencyOne, + [Mock] IScopedService scopedService, ISingletonService singletonService) { + Mock.Get(dependencyOne).Setup(x => x.SingletonService).Returns(singletonService); + Mock.Get(dependencyOne).Setup(x => x.ScopedService).Returns(scopedService); + + Assert.Same(dependencyOne.SingletonService, singletonService); + Assert.Same(dependencyOne.ScopedService, scopedService); + } + + /// + /// An unconfigured member returns default rather than throwing, matching Moq's own loose default. + /// + [ModuleTest] + [SutModule] + public void UnconfiguredMembersAreLoose([Mock] IDependencyOne dependencyOne) { + Assert.Null(dependencyOne.ScopedService); + } +} diff --git a/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs b/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs index 9170b74..1652682 100644 --- a/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs +++ b/integ-tests/SutProject.Tests/NSubstitute/NSubstituteAttributeTests.cs @@ -1,5 +1,5 @@ using DependencyModules.xUnit.Attributes; -using DependencyModules.xUnit.NSubstitute; +using DependencyModules.NSubstitute; using NSubstitute; using Xunit; diff --git a/integ-tests/SutProject.Tests/SutProject.Tests.csproj b/integ-tests/SutProject.Tests/SutProject.Tests.csproj index 31dad39..6ea0dc8 100644 --- a/integ-tests/SutProject.Tests/SutProject.Tests.csproj +++ b/integ-tests/SutProject.Tests/SutProject.Tests.csproj @@ -9,7 +9,9 @@ - + + + diff --git a/integ-tests/SutProject.Tests/TestFramework/InjectValueTests.cs b/integ-tests/SutProject.Tests/TestFramework/InjectValueTests.cs index 2848674..ea8a58c 100644 --- a/integ-tests/SutProject.Tests/TestFramework/InjectValueTests.cs +++ b/integ-tests/SutProject.Tests/TestFramework/InjectValueTests.cs @@ -1,3 +1,4 @@ +using DependencyModules.Testing.Attributes; using DependencyModules.xUnit.Attributes; using Xunit; diff --git a/integ-tests/web/WebApiApp.Tests/Bootstrap.cs b/integ-tests/web/WebApiApp.Tests/Bootstrap.cs index 26d3752..ba4dff6 100644 --- a/integ-tests/web/WebApiApp.Tests/Bootstrap.cs +++ b/integ-tests/web/WebApiApp.Tests/Bootstrap.cs @@ -1,4 +1,4 @@ -using DependencyModules.xUnit.NSubstitute; +using DependencyModules.NSubstitute; using WebApiApp; [assembly: NSubstituteSupport] diff --git a/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj b/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj index 717ec17..90e213d 100644 --- a/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj +++ b/integ-tests/web/WebApiApp.Tests/WebApiApp.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/scripts/verify-packages.sh b/scripts/verify-packages.sh index cabed69..66439f9 100755 --- a/scripts/verify-packages.sh +++ b/scripts/verify-packages.sh @@ -33,8 +33,11 @@ for proj in \ src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj \ src/DependencyModules.SourceGenerator.Impl/DependencyModules.SourceGenerator.Impl.csproj \ src/DependencyModules.Conventions/DependencyModules.Conventions.csproj \ + src/DependencyModules.Testing/DependencyModules.Testing.csproj \ src/DependencyModules.xUnit/DependencyModules.xUnit.csproj \ - src/DependencyModules.xUnit.NSubstitute/DependencyModules.xUnit.NSubstitute.csproj; do + src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj \ + src/DependencyModules.Moq/DependencyModules.Moq.csproj \ + src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj; do dotnet pack "${REPO_ROOT}/${proj}" -c Release -o "${FEED}" \ "/p:PackageVersion=${VERSION}" --nologo -v quiet done diff --git a/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj b/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj new file mode 100644 index 0000000..962c562 --- /dev/null +++ b/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + True + DependencyModules.FakeItEasy + FakeItEasy mocking support for DependencyModules test integrations. Add [FakeItEasySupport] to resolve any unregistered dependency as a FakeItEasy fake, and use [Mock] on a test parameter to inject and configure it. Pair with a test framework integration such as DependencyModules.xUnit. + true + + + + + + + + + + + diff --git a/src/DependencyModules.FakeItEasy/FakeItEasySupportAttribute.cs b/src/DependencyModules.FakeItEasy/FakeItEasySupportAttribute.cs new file mode 100644 index 0000000..70d466f --- /dev/null +++ b/src/DependencyModules.FakeItEasy/FakeItEasySupportAttribute.cs @@ -0,0 +1,39 @@ +using DependencyModules.Testing.Attributes.Interfaces; + +namespace DependencyModules.FakeItEasy; + +/// +/// Resolves any dependency that is not registered as a FakeItEasy fake. +/// +/// +/// Applies to a method, a class, or a whole assembly, so a test project can switch fakes on once in +/// an AssemblyInfo rather than per test. +/// +/// The fake is both what gets injected and what you configure, so a parameter marked [Mock] +/// can be set up with A.CallTo directly. +/// +/// +/// +/// [ModuleTest] +/// [FakeItEasySupport] +/// public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) { +/// A.CallTo(() => log.Write(A<string>._)).MustHaveHappened(); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] +public class FakeItEasySupportAttribute : Attribute, IMockSupportAttribute { + + /// + /// Provides a fake of the specified type. + /// + /// + /// Built through FakeItEasy.Sdk.Create rather than A.Fake<T>(), which is the + /// form that takes a — the type is not known until the test asks for it. + /// + /// The type to fake. + /// A fake implementing . + public object ProvideMock(Type type) { + return global::FakeItEasy.Sdk.Create.Fake(type); + } +} diff --git a/src/DependencyModules.Moq/DependencyModules.Moq.csproj b/src/DependencyModules.Moq/DependencyModules.Moq.csproj new file mode 100644 index 0000000..ffa4967 --- /dev/null +++ b/src/DependencyModules.Moq/DependencyModules.Moq.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + True + DependencyModules.Moq + Moq mocking support for DependencyModules test integrations. Add [MoqSupport] to resolve any unregistered dependency as a Moq mock, and use [Mock] on a test parameter to inject it. Reach the underlying Mock<T> with Mock.Get(instance). Pair with a test framework integration such as DependencyModules.xUnit. + true + + + + + + + + + + + diff --git a/src/DependencyModules.Moq/MoqSupportAttribute.cs b/src/DependencyModules.Moq/MoqSupportAttribute.cs new file mode 100644 index 0000000..35fefd1 --- /dev/null +++ b/src/DependencyModules.Moq/MoqSupportAttribute.cs @@ -0,0 +1,49 @@ +using DependencyModules.Testing.Attributes.Interfaces; +using MoqLib = Moq; + +namespace DependencyModules.Moq; + +/// +/// Resolves any dependency that is not registered as a Moq mock. +/// +/// +/// Applies to a method, a class, or a whole assembly, so a test project can switch mocks on once in +/// an AssemblyInfo rather than per test. +/// +/// Moq separates the mock from the object it produces, and the container needs the object, so that +/// is what is injected. Reach the Mock<T> to configure or verify it with +/// Mock.Get(instance). This is the one place Moq reads differently from NSubstitute and +/// FakeItEasy, where the injected instance is itself the thing you configure. +/// +/// Mocks are loose, matching Moq's own default: an unconfigured member returns default rather than +/// throwing. +/// +/// +/// +/// [ModuleTest] +/// [MoqSupport] +/// public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) { +/// Mock.Get(log).Verify(x => x.Write(It.IsAny<string>())); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] +public class MoqSupportAttribute : Attribute, IMockSupportAttribute { + + /// + /// Provides a mocked instance of the specified type. + /// + /// + /// Constructed through the closed generic because Mock<T> is the only form Moq + /// offers, and the type is not known until the test asks for it. + /// + /// The type to mock. + /// + /// The mocked instance — Mock<T>.Object, not the Mock<T> itself. + /// + public object ProvideMock(Type type) { + var mock = (MoqLib.Mock)Activator.CreateInstance(typeof(MoqLib.Mock<>).MakeGenericType(type))!; + + return mock.Object; + } +} diff --git a/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj b/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj new file mode 100644 index 0000000..3bd80d5 --- /dev/null +++ b/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + True + DependencyModules.NSubstitute + NSubstitute mocking support for DependencyModules test integrations. Add [NSubstituteSupport] to resolve any unregistered dependency as an NSubstitute substitute, and use [Mock] on a test parameter to inject and configure it. Pair with a test framework integration such as DependencyModules.xUnit. + true + + + + + + + + + + + diff --git a/src/DependencyModules.NSubstitute/NSubstituteSupportAttribute.cs b/src/DependencyModules.NSubstitute/NSubstituteSupportAttribute.cs new file mode 100644 index 0000000..a3fea15 --- /dev/null +++ b/src/DependencyModules.NSubstitute/NSubstituteSupportAttribute.cs @@ -0,0 +1,37 @@ +using DependencyModules.Testing.Attributes.Interfaces; +using NSub = NSubstitute; + +namespace DependencyModules.NSubstitute; + +/// +/// Resolves any dependency that is not registered as an NSubstitute substitute. +/// +/// +/// Applies to a method, a class, or a whole assembly, so a test project can switch substitutes on +/// once in an AssemblyInfo rather than per test. +/// +/// The substitute is both what gets injected and what you configure, so a parameter marked +/// [Mock] can be set up directly. +/// +/// +/// +/// [ModuleTest] +/// [NSubstituteSupport] +/// public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) { +/// log.Received().Write(Arg.Any<string>()); +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] +public class NSubstituteSupportAttribute : Attribute, IMockSupportAttribute { + + /// + /// Provides a substitute for the specified type. + /// + /// The type to substitute for. + /// A substitute implementing . + public object ProvideMock(Type type) { + return NSub.Substitute.For([type], []); + } +} diff --git a/src/DependencyModules.Runtime/ModuleEnvironment.cs b/src/DependencyModules.Runtime/ModuleEnvironment.cs index b09e8f9..ceaab26 100644 --- a/src/DependencyModules.Runtime/ModuleEnvironment.cs +++ b/src/DependencyModules.Runtime/ModuleEnvironment.cs @@ -1,3 +1,4 @@ +using System.Collections; using DependencyModules.Runtime.Interfaces; namespace DependencyModules.Runtime; @@ -10,31 +11,113 @@ namespace DependencyModules.Runtime; /// to be known before the provider exists. That is why this is a plain object handed to /// AddModules rather than something resolved from the container. /// -public class ModuleEnvironment : IModuleEnvironment { - // Declared before None, which constructs an instance during static initialization and would - // otherwise capture this field before it is assigned. Static field initializers run in - // declaration order, so the order here is load-bearing. - private static readonly IReadOnlyDictionary EmptyValues = - new Dictionary(0); +/// +/// Values can be supplied inline, since this is a collection of them: +/// +/// services.AddModules( +/// new ModuleEnvironment("Development") { +/// { "FEATURE_PROFILING", "on" }, +/// { "REGION", "eu" } +/// }, +/// new ApplicationModule()); +/// +/// +public class ModuleEnvironment : IModuleEnvironment, IEnumerable> { + private readonly Dictionary _values; + private readonly bool _fallBackToEnvironmentVariables; - private readonly IReadOnlyDictionary _values; + /// + /// Creates an environment with a fixed name and an optional set of values, falling back to + /// environment variables for anything not supplied here. + /// + /// The environment name conditions compare against. + /// Values reachable through ; null means none. + public ModuleEnvironment(string environmentName, IReadOnlyDictionary? values = null) + : this(true, environmentName, values) { } /// - /// Creates an environment with a fixed name and an optional set of values. + /// Creates an environment that reads only the values supplied here, with no fall back to + /// environment variables. /// + /// + /// The flag leads so that it is read before the values it governs, and so that turning it off + /// cannot be mistaken for one more optional argument on the end. + /// + /// Pass false to pin an environment to exactly what is written at the call site. A test asserting + /// which services a given environment registers wants this, since a variable set on the machine + /// running it would otherwise reach a key the test never mentioned. + /// + /// + /// False to read only . True behaves as the other constructor. + /// /// The environment name conditions compare against. /// Values reachable through ; null means none. - public ModuleEnvironment(string environmentName, IReadOnlyDictionary? values = null) { + public ModuleEnvironment( + bool fallBackToEnvironmentVariables, + string environmentName, + IReadOnlyDictionary? values = null) { EnvironmentName = environmentName ?? throw new ArgumentNullException(nameof(environmentName)); - _values = values ?? EmptyValues; + _fallBackToEnvironmentVariables = fallBackToEnvironmentVariables; + + // Copied rather than held by reference. Add writes to this dictionary, and writing into one + // the caller still holds would be a side effect they did not ask for. A caller who supplied + // a comparer picked it deliberately — most often OrdinalIgnoreCase, matching how Windows + // treats variable names — so it is carried over instead of being reset to ordinal. + _values = values switch { + Dictionary dictionary => + new Dictionary(dictionary, dictionary.Comparer), + not null => new Dictionary(values), + null => new Dictionary() + }; } /// public string EnvironmentName { get; } /// + /// + /// A key written here wins, including one written as null — saying a key has no value is how you + /// hide an environment variable of the same name. Only a key that was never mentioned falls + /// through to the process. + /// + /// The variable is read on each call rather than captured, matching + /// . Registration reads these once at startup, so nothing repeats the cost. + /// public string? Value(string name) => - _values.TryGetValue(name, out var value) ? value : null; + _values.TryGetValue(name, out var value) + ? value + : _fallBackToEnvironmentVariables + ? Environment.GetEnvironmentVariable(name) + : null; + + /// + /// Adds a value, replacing any already present for . + /// + /// + /// Present so that values can be written inline in a collection initializer, which is what this + /// exists for. Replacing rather than throwing on a repeated key lets an initializer override a + /// value seeded through the constructor, so the two can be combined. + /// + /// Nothing captures the environment, so a value added after AddModules has run is visible + /// to whatever reads it next — but the registrations are already decided by then, and adding one + /// late will not change them. + /// + /// The key to write. + /// The value, which may be null. + public void Add(string key, string? value) => _values[key] = value; + + /// + /// Enumerates the values supplied to this environment. + /// + /// + /// answers lookups and nothing more, which leaves no way to + /// combine two environments. Enumeration is on this class rather than on the interface so that + /// a hand-written environment is not required to produce a list of everything it knows, which + /// some cannot. + /// + public IEnumerator> GetEnumerator() => _values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// /// Reads the environment from the process: ASPNETCORE_ENVIRONMENT, then @@ -59,8 +142,12 @@ public ModuleEnvironment(string environmentName, IReadOnlyDictionary /// Pass this to AddModules to state that this application has no environment, rather /// than leaving it unset and picking up . + /// + /// Its own type rather than an empty : this instance is shared by + /// every application in the process, and would let a cast reach in and give + /// "no environment" some values. /// - public static IModuleEnvironment None { get; } = new ModuleEnvironment(""); + public static IModuleEnvironment None { get; } = new EmptyModuleEnvironment(); private sealed class ProcessModuleEnvironment : IModuleEnvironment { public string EnvironmentName => @@ -70,4 +157,10 @@ private sealed class ProcessModuleEnvironment : IModuleEnvironment { public string? Value(string name) => Environment.GetEnvironmentVariable(name); } + + private sealed class EmptyModuleEnvironment : IModuleEnvironment { + public string EnvironmentName => ""; + + public string? Value(string name) => null; + } } diff --git a/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs b/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs index b1635c8..e0badb7 100644 --- a/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs +++ b/src/DependencyModules.SourceGenerator.Impl/Utilities/ServiceModelUtility.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.ComponentModel; using CSharpAuthor; using DependencyModules.SourceGenerator.Impl.Models; using Microsoft.CodeAnalysis; @@ -9,8 +8,56 @@ namespace DependencyModules.SourceGenerator.Impl.Utilities; public class ServiceModelUtility { - private static ITypeDefinition[] _skipTypes = new[] { - TypeDefinition.Get(typeof(INotifyPropertyChanged)) + /// + /// Interfaces that describe a capability rather than a role, keyed by namespace and name. + /// + /// + /// + /// Passed over when choosing a service type nobody named. Writing : IDisposable says the + /// class cleans up after itself; it does not say IDisposable is what callers ask for. + /// Without this, class OrderedPool : IDisposable, IPool registers as IDisposable, + /// because the first interface in the declaration wins. + /// + /// + /// A list rather than the namespace rule AsSelfWithInterfaces uses, because the two are + /// not the same problem. That expansion is additive — excluding too much costs a bonus + /// registration. This is an exclusive choice, so excluding too much means the interface the + /// developer wanted is not registered at all. System holds plenty of interfaces that are + /// genuinely service roles: IEqualityComparer<T>, IJsonTypeInfoResolver, + /// IHttpClientFactory. Precision matters more here than a rule stated in one sentence. + /// + /// + /// The list is short and stays short. These are the BCL's language and framework integration + /// points, and the set has barely moved in twenty years — unlike the open-ended set of + /// interfaces a type happens to reach, which is what makes the namespace rule right over there. + /// + /// + /// IEnumerable earns its place twice over: registering a service as + /// IEnumerable<T> collides with how the container represents "every registration of + /// T". + /// + /// + /// A service type the developer names is untouched — [SingletonService(As = + /// typeof(IDisposable))] still registers IDisposable. This governs inference only. + /// + /// + private static readonly HashSet _capabilityInterfaces = new() { + "System.IDisposable", + "System.IAsyncDisposable", + "System.ICloneable", + "System.IComparable", // covers IComparable, same name + "System.IEquatable", + "System.IConvertible", + "System.IFormattable", + "System.ISpanFormattable", + "System.IParsable", + "System.ISpanParsable", + "System.Collections.IEnumerable", + "System.Collections.Generic.IEnumerable", + "System.Runtime.Serialization.ISerializable", + "System.ComponentModel.INotifyPropertyChanged", + "System.ComponentModel.INotifyPropertyChanging", + "System.Collections.Specialized.INotifyCollectionChanged" }; private static readonly ITypeDefinition _crossWireService = @@ -439,9 +486,14 @@ private static ITypeDefinition GetServiceTypeFromClass( return GetBaseTypeRegistration(context) ?? classDefinition; } + /// + /// The service type to register a class as when the developer did not name one: the first + /// declared interface that is not a capability, else the + /// first one a base class provides. + /// private static ITypeDefinition? GetBaseTypeRegistration(SyntaxTransformContext context) { if (context.Node is TypeDeclarationSyntax { BaseList: not null } typeDeclarationSyntax) { - INamedTypeSymbol? baseTypeSymbol = null; + INamedTypeSymbol? baseClassSymbol = null; foreach (var baseTypeSyntax in typeDeclarationSyntax.BaseList.Types) { var symbolInfo = ModelExtensions.GetSymbolInfo(context.SemanticModel, baseTypeSyntax.Type); @@ -451,8 +503,13 @@ private static ITypeDefinition GetServiceTypeFromClass( namedTypeSymbol.GetTypeDefinitionFromNamedSymbol(); // only auto register interfaces - if (baseTypeDefinition is { TypeDefinitionEnum: TypeDefinitionEnum.InterfaceDefinition } && - !SkipInterface(baseTypeDefinition)) { + if (baseTypeDefinition is { TypeDefinitionEnum: TypeDefinitionEnum.InterfaceDefinition }) { + // Passed over rather than remembered: a skipped interface must not become the + // symbol walked below, or IEnumerable would hand back IEnumerable. + if (SkipInterface(baseTypeDefinition)) { + continue; + } + if (baseTypeDefinition is GenericTypeDefinition) { baseTypeDefinition = ReplaceGenericParametersForRegistration(baseTypeDefinition); } @@ -460,12 +517,12 @@ private static ITypeDefinition GetServiceTypeFromClass( return baseTypeDefinition; } - baseTypeSymbol = namedTypeSymbol; + baseClassSymbol = namedTypeSymbol; } } - if (baseTypeSymbol != null) { - return GetBaseInterface(context, baseTypeSymbol); + if (baseClassSymbol != null) { + return GetBaseInterface(context, baseClassSymbol); } } @@ -498,9 +555,16 @@ private static ITypeDefinition GetServiceTypeFromClass( return GetBaseInterface(context, baseTypeSymbol.BaseType); } - private static bool SkipInterface(ITypeDefinition interfaceType) { - return _skipTypes.Any(type => type.Equals(interfaceType)); - } + /// + /// Whether an interface is passed over when choosing a service type nobody named. + /// + /// + /// Matched on namespace and name so one entry covers a generic and its closings — + /// IEquatable<Money> and IEquatable<T> both render as + /// System.IEquatable. + /// + private static bool SkipInterface(ITypeDefinition interfaceType) => + _capabilityInterfaces.Contains($"{interfaceType.Namespace}.{interfaceType.Name}"); private static ITypeDefinition ReplaceGenericParametersForRegistration(ITypeDefinition registration) { var argumentTypes = diff --git a/src/DependencyModules.xUnit/Attributes/InjectValuesAttribute.cs b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs similarity index 94% rename from src/DependencyModules.xUnit/Attributes/InjectValuesAttribute.cs rename to src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs index 033c3ef..43ff8d5 100644 --- a/src/DependencyModules.xUnit/Attributes/InjectValuesAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs @@ -1,7 +1,7 @@ using System.Reflection; -using DependencyModules.xUnit.Attributes.Interfaces; +using DependencyModules.Testing.Attributes.Interfaces; -namespace DependencyModules.xUnit.Attributes; +namespace DependencyModules.Testing.Attributes; /// /// Specifies a custom attribute used for injecting specified values into diff --git a/src/DependencyModules.xUnit/Attributes/Interfaces/IInjectValueAttribute.cs b/src/DependencyModules.Testing/Attributes/Interfaces/IInjectValueAttribute.cs similarity index 95% rename from src/DependencyModules.xUnit/Attributes/Interfaces/IInjectValueAttribute.cs rename to src/DependencyModules.Testing/Attributes/Interfaces/IInjectValueAttribute.cs index 127c66c..ae4d711 100644 --- a/src/DependencyModules.xUnit/Attributes/Interfaces/IInjectValueAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/Interfaces/IInjectValueAttribute.cs @@ -1,6 +1,6 @@ using System.Reflection; -namespace DependencyModules.xUnit.Attributes.Interfaces; +namespace DependencyModules.Testing.Attributes.Interfaces; /// /// Attribute interface that can be used when resolving concrete types under test diff --git a/src/DependencyModules.xUnit/Attributes/Interfaces/IMockSupportAttribute.cs b/src/DependencyModules.Testing/Attributes/Interfaces/IMockSupportAttribute.cs similarity index 92% rename from src/DependencyModules.xUnit/Attributes/Interfaces/IMockSupportAttribute.cs rename to src/DependencyModules.Testing/Attributes/Interfaces/IMockSupportAttribute.cs index 6ed04df..39ba75a 100644 --- a/src/DependencyModules.xUnit/Attributes/Interfaces/IMockSupportAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/Interfaces/IMockSupportAttribute.cs @@ -1,4 +1,4 @@ -namespace DependencyModules.xUnit.Attributes.Interfaces; +namespace DependencyModules.Testing.Attributes.Interfaces; /// /// Defines an interface that provides support for creating mock objects within test contexts. diff --git a/src/DependencyModules.xUnit/Attributes/Interfaces/IOrderedAttribute.cs b/src/DependencyModules.Testing/Attributes/Interfaces/IOrderedAttribute.cs similarity index 94% rename from src/DependencyModules.xUnit/Attributes/Interfaces/IOrderedAttribute.cs rename to src/DependencyModules.Testing/Attributes/Interfaces/IOrderedAttribute.cs index ee1ebc6..260d3bd 100644 --- a/src/DependencyModules.xUnit/Attributes/Interfaces/IOrderedAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/Interfaces/IOrderedAttribute.cs @@ -1,4 +1,4 @@ -namespace DependencyModules.xUnit.Attributes.Interfaces; +namespace DependencyModules.Testing.Attributes.Interfaces; /// /// Represents an interface for defining a specific order of execution or processing diff --git a/src/DependencyModules.Testing/DependencyModules.Testing.csproj b/src/DependencyModules.Testing/DependencyModules.Testing.csproj new file mode 100644 index 0000000..ba25fcb --- /dev/null +++ b/src/DependencyModules.Testing/DependencyModules.Testing.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + True + DependencyModules.Testing + Test-framework-neutral building blocks for DependencyModules test integrations. Contains the mocking seam (IMockSupportAttribute) that the DependencyModules.NSubstitute, DependencyModules.Moq and DependencyModules.FakeItEasy packages implement, plus attribute discovery helpers. Reference a test framework integration such as DependencyModules.xUnit rather than this package directly. + true + + + diff --git a/src/DependencyModules.xUnit/Impl/AttributeUtility.cs b/src/DependencyModules.Testing/Impl/AttributeUtility.cs similarity index 99% rename from src/DependencyModules.xUnit/Impl/AttributeUtility.cs rename to src/DependencyModules.Testing/Impl/AttributeUtility.cs index 0e68bb5..c2253ec 100644 --- a/src/DependencyModules.xUnit/Impl/AttributeUtility.cs +++ b/src/DependencyModules.Testing/Impl/AttributeUtility.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using System.Reflection; -namespace DependencyModules.xUnit.Impl; +namespace DependencyModules.Testing.Impl; /// /// Provides utility methods for retrieving attributes from methods, parameters, classes, or assemblies. diff --git a/src/DependencyModules.xUnit.NSubstitute/DependencyModules.xUnit.NSubstitute.csproj b/src/DependencyModules.xUnit.NSubstitute/DependencyModules.xUnit.NSubstitute.csproj deleted file mode 100644 index b872f6b..0000000 --- a/src/DependencyModules.xUnit.NSubstitute/DependencyModules.xUnit.NSubstitute.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net8.0 - enable - enable - True - DependencyModules.xUnit.NSubstitute - NSubstitute mocking support for DependencyModules.xUnit. Add [NSubstituteSupport] to resolve any unregistered dependency as an NSubstitute mock, and use [Mock] on a test parameter to inject and configure it. - true - - - - - - - - - - - - - - - diff --git a/src/DependencyModules.xUnit.NSubstitute/NSubstituteSupportAttribute.cs b/src/DependencyModules.xUnit.NSubstitute/NSubstituteSupportAttribute.cs deleted file mode 100644 index d5e82e2..0000000 --- a/src/DependencyModules.xUnit.NSubstitute/NSubstituteSupportAttribute.cs +++ /dev/null @@ -1,34 +0,0 @@ -using DependencyModules.xUnit.Attributes.Interfaces; -using NSubstitute; -using NSub = NSubstitute; - -namespace DependencyModules.xUnit.NSubstitute; - -/// -/// An attribute that enables support for creating mock objects using NSubstitute -/// in xUnit test contexts. -/// -/// -/// The NSubstituteSupportAttribute provides integration with the NSubstitute -/// library for generating mock instances. This attribute can be applied to classes, -/// methods, or assemblies to enable mock creation for dependency injection during -/// testing scenarios. It implements the IMockSupportAttribute interface -/// to provide mock instances of specified types. -/// -/// -/// This attribute is typically used in conjunction with other testing utilities -/// to inject mocked dependencies into test methods or classes. -/// -/// -[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] -public class NSubstituteSupportAttribute : Attribute, IMockSupportAttribute { - - /// - /// Provides a mock instance of the specified type using NSubstitute. - /// - /// The type for which a mock instance is to be created. - /// A mock object of the specified type. - public object ProvideMock(Type type) { - return NSub.Substitute.For([type], []); - } -} \ No newline at end of file diff --git a/src/DependencyModules.xUnit/Attributes/MockAttribute.cs b/src/DependencyModules.xUnit/Attributes/MockAttribute.cs index 476d3c5..4d53056 100644 --- a/src/DependencyModules.xUnit/Attributes/MockAttribute.cs +++ b/src/DependencyModules.xUnit/Attributes/MockAttribute.cs @@ -1,4 +1,6 @@ using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; using DependencyModules.xUnit.Attributes.Interfaces; using DependencyModules.xUnit.Impl; using Microsoft.Extensions.DependencyInjection; diff --git a/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj b/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj index 80ddb57..df39ed7 100644 --- a/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj +++ b/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj @@ -5,6 +5,15 @@ enable enable True + + Library + false DependencyModules.xUnit xUnit v3 integration for DependencyModules. Provides the [ModuleTest] attribute, which builds a service provider from your modules and injects the services a test method asks for, plus attributes for per-test service overrides and value injection. true @@ -12,12 +21,20 @@ - + + + diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index a3a0f3a..020aafe 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -2,6 +2,8 @@ using DependencyModules.Runtime.Helpers; using DependencyModules.Runtime.Interfaces; using DependencyModules.xUnit.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; using DependencyModules.xUnit.Attributes.Interfaces; using Microsoft.Extensions.DependencyInjection; using Xunit.Internal; diff --git a/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs b/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs index ebb040d..1e7a335 100644 --- a/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs +++ b/tests/DependencyModules.Tests/ApiTests/PublicApiTests.cs @@ -31,9 +31,28 @@ public void XUnitApi() { Snapshot.Match(ApiOf(typeof(ModuleTestAttribute))); } + /// + /// The seam every mocking package implements, and the only assembly they share. It carries no + /// test framework dependency, which is the point of it — a change here reaches all of them. + /// + [Fact] + public void TestingApi() { + Snapshot.Match(ApiOf(typeof(Testing.Attributes.Interfaces.IMockSupportAttribute))); + } + + [Fact] + public void NSubstituteApi() { + Snapshot.Match(ApiOf(typeof(global::DependencyModules.NSubstitute.NSubstituteSupportAttribute))); + } + + [Fact] + public void MoqApi() { + Snapshot.Match(ApiOf(typeof(global::DependencyModules.Moq.MoqSupportAttribute))); + } + [Fact] - public void XUnitNSubstituteApi() { - Snapshot.Match(ApiOf(typeof(xUnit.NSubstitute.NSubstituteSupportAttribute))); + public void FakeItEasyApi() { + Snapshot.Match(ApiOf(typeof(global::DependencyModules.FakeItEasy.FakeItEasySupportAttribute))); } /// diff --git a/tests/DependencyModules.Tests/DependencyModules.Tests.csproj b/tests/DependencyModules.Tests/DependencyModules.Tests.csproj index 109ced7..8b7edc0 100644 --- a/tests/DependencyModules.Tests/DependencyModules.Tests.csproj +++ b/tests/DependencyModules.Tests/DependencyModules.Tests.csproj @@ -25,7 +25,9 @@ OutputItemType="Analyzer" ReferenceOutputAssembly="true" Aliases="ConventionsGen"/> - + + + diff --git a/tests/DependencyModules.Tests/GeneratorTests/EnvironmentConditionTests.cs b/tests/DependencyModules.Tests/GeneratorTests/EnvironmentConditionTests.cs index 999d334..fd51982 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/EnvironmentConditionTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/EnvironmentConditionTests.cs @@ -27,10 +27,15 @@ namespace TestNamespace; private static GeneratedAssembly Compile(string source, IModuleEnvironment? environment) => GeneratedAssembly.Create(Preamble + source, environment: environment); - private static IModuleEnvironment Env(string name) => new ModuleEnvironment(name); + /// + /// Pinned to the values written here. These assert which registrations a given set of values + /// produces, so a variable set on the machine running them must not reach a key they never name. + /// + private static IModuleEnvironment Env(string name) => Env(name, []); + /// private static IModuleEnvironment Env(string name, params (string Key, string? Value)[] values) => - new ModuleEnvironment(name, values.ToDictionary(v => v.Key, v => v.Value)); + new ModuleEnvironment(false, name, values.ToDictionary(v => v.Key, v => v.Value)); private const string NameGated = """ diff --git a/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs b/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs index f42357d..39de7e3 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/ServiceRegistrationTests.cs @@ -238,6 +238,101 @@ public partial record TestModule; Assert.Contains("AddSingleton", result.SourceContaining("Dependencies")); } + /// + /// The first interface in the declaration used to win outright, so a class that cleaned up after + /// itself registered as IDisposable and was unreachable through the interface it existed for. + /// + [Fact] + public void CapabilityInterface_DoesNotWinOverTheServiceInterface() { + var result = GeneratorTestHarness.Run(Module( + """ + [SingletonService] + public class Thing : System.IDisposable, IThing { + public void Dispose() { } + } + """)); + + result.AssertNoErrors(); + var generated = result.SourceContaining("Dependencies"); + + Assert.Contains("global::TestNamespace.IThing", generated); + Assert.DoesNotContain("System.IDisposable", generated); + } + + [Fact] + public void CapabilityInterfaceAlone_RegistersAsSelf() { + var result = GeneratorTestHarness.Run(Module( + """ + [SingletonService] + public class Thing : System.IDisposable { + public void Dispose() { } + } + """)); + + result.AssertNoErrors(); + var generated = result.SourceContaining("Dependencies"); + + Assert.Contains("global::TestNamespace.Thing", generated); + Assert.DoesNotContain("System.IDisposable", generated); + } + + [Fact] + public void CapabilityInterfaceThroughABaseClass_RegistersAsSelf() { + var result = GeneratorTestHarness.Run(Module( + """ + public abstract class DisposableBase : System.IDisposable { + public void Dispose() { } + } + + [SingletonService] + public class Thing : DisposableBase; + """)); + + result.AssertNoErrors(); + var generated = result.SourceContaining("Dependencies"); + + Assert.Contains("global::TestNamespace.Thing", generated); + Assert.DoesNotContain("System.IDisposable", generated); + } + + /// + /// Guards the boundary of the capability list: System is full of interfaces that are + /// genuine service roles, so this must not become a namespace rule. IJsonTypeInfoResolver and + /// IHttpClientFactory are the same shape. + /// + [Fact] + public void FrameworkRoleInterface_IsStillTheServiceType() { + var result = GeneratorTestHarness.Run(Module( + """ + [SingletonService] + public class Thing : System.Collections.Generic.IEqualityComparer { + public bool Equals(IThing? a, IThing? b) => false; + public int GetHashCode(IThing o) => 0; + } + """)); + + result.AssertNoErrors(); + var generated = result.SourceContaining("Dependencies"); + + Assert.Contains("IEqualityComparer", generated); + } + + [Fact] + public void CapabilityInterface_IsHonouredWhenNamedExplicitly() { + var result = GeneratorTestHarness.Run(Module( + """ + [SingletonService(As = typeof(System.IDisposable))] + public class Thing : System.IDisposable, IThing { + public void Dispose() { } + } + """)); + + result.AssertNoErrors(); + var generated = result.SourceContaining("Dependencies"); + + Assert.Contains("System.IDisposable", generated); + } + private static string Module(string body) => $$""" using DependencyModules.Runtime.Attributes; diff --git a/tests/DependencyModules.Tests/RuntimeTests/ModuleEnvironmentTests.cs b/tests/DependencyModules.Tests/RuntimeTests/ModuleEnvironmentTests.cs index 88de342..292e6d0 100644 --- a/tests/DependencyModules.Tests/RuntimeTests/ModuleEnvironmentTests.cs +++ b/tests/DependencyModules.Tests/RuntimeTests/ModuleEnvironmentTests.cs @@ -27,6 +27,183 @@ public void ValuesAreOptional() { Assert.Null(environment.Value("Anything")); } + [Fact] + public void ValuesCanBeWrittenInAnInitializer() { + var environment = new ModuleEnvironment("Development") { + { "A", "1" }, + { "Null", null } + }; + + Assert.Equal("1", environment.Value("A")); + Assert.Null(environment.Value("Null")); + Assert.Null(environment.Value("Missing")); + } + + /// + /// So a fixed set can be seeded and then adjusted, rather than the two forms being exclusive. + /// + [Fact] + public void AnInitializerOverridesAValueFromTheConstructor() { + var environment = new ModuleEnvironment( + "Development", + new Dictionary { ["Seed"] = "original", ["Kept"] = "kept" }) { + { "Seed", "replaced" } + }; + + Assert.Equal("replaced", environment.Value("Seed")); + Assert.Equal("kept", environment.Value("Kept")); + } + + /// + /// The values are copied, so the dictionary the caller still holds is not written to. + /// + [Fact] + public void AddDoesNotWriteToTheCallersDictionary() { + var values = new Dictionary { ["A"] = "1" }; + var environment = new ModuleEnvironment("Development", values) { { "B", "2" } }; + + Assert.Equal("2", environment.Value("B")); + Assert.DoesNotContain("B", values.Keys); + } + + /// + /// A comparer is a deliberate choice — most often to match how Windows treats variable names — + /// so copying the values must not quietly reset it to ordinal. + /// + [Fact] + public void ACallersComparerSurvivesTheCopy() { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["Key"] = "value" + }; + + var environment = new ModuleEnvironment("Development", values); + + Assert.Equal("value", environment.Value("KEY")); + } + + [Fact] + public void ValuesEnumerate() { + var environment = new ModuleEnvironment("Development") { + { "A", "1" }, + { "B", "2" } + }; + + Assert.Equal( + new Dictionary { ["A"] = "1", ["B"] = "2" }, + environment.ToDictionary(pair => pair.Key, pair => pair.Value)); + } + + /// + /// Shared by every application in the process, so it cannot be one of the mutable ones. + /// + [Fact] + public void NoneCannotBeGivenValues() { + Assert.IsNotType(ModuleEnvironment.None); + } + + /// + /// Uniquely named so nothing else in the suite, and nothing on the machine, can be looking at it. + /// + private static string UniqueKey() => "DM_TEST_" + Guid.NewGuid().ToString("N"); + + [Fact] + public void AKeyNotSuppliedFallsBackToAnEnvironmentVariable() { + var key = UniqueKey(); + var environment = new ModuleEnvironment("Development") { { "Supplied", "value" } }; + + Assert.Null(environment.Value(key)); + + try { + Environment.SetEnvironmentVariable(key, "from-process"); + + // Read on each call rather than captured, matching ModuleEnvironment.Default. + Assert.Equal("from-process", environment.Value(key)); + } finally { + Environment.SetEnvironmentVariable(key, null); + } + } + + [Fact] + public void ASuppliedValueWinsOverAnEnvironmentVariable() { + var key = UniqueKey(); + + try { + Environment.SetEnvironmentVariable(key, "from-process"); + + var environment = new ModuleEnvironment("Development") { { key, "supplied" } }; + + Assert.Equal("supplied", environment.Value(key)); + } finally { + Environment.SetEnvironmentVariable(key, null); + } + } + + /// + /// Saying a key has no value is how an environment variable of the same name is hidden. + /// + [Fact] + public void ASuppliedNullHidesAnEnvironmentVariable() { + var key = UniqueKey(); + + try { + Environment.SetEnvironmentVariable(key, "from-process"); + + var environment = new ModuleEnvironment("Development") { { key, null } }; + + Assert.Null(environment.Value(key)); + Assert.False(EnvironmentConditions.HasValue(environment, key)); + } finally { + Environment.SetEnvironmentVariable(key, null); + } + } + + [Fact] + public void FallBackCanBeTurnedOff() { + var key = UniqueKey(); + + try { + Environment.SetEnvironmentVariable(key, "from-process"); + + var environment = new ModuleEnvironment(false, "Development") { { "Supplied", "value" } }; + + Assert.Null(environment.Value(key)); + Assert.Equal("value", environment.Value("Supplied")); + } finally { + Environment.SetEnvironmentVariable(key, null); + } + } + + /// + /// The values still travel with it, so turning fall back off does not mean giving up the + /// constructor that takes a dictionary. + /// + [Fact] + public void FallBackCanBeTurnedOffWithValuesSuppliedUpFront() { + var environment = new ModuleEnvironment( + false, + "Development", + new Dictionary { ["A"] = "1" }); + + Assert.Equal("Development", environment.EnvironmentName); + Assert.Equal("1", environment.Value("A")); + } + + /// + /// An empty name and no values, whatever the machine running this has set. + /// + [Fact] + public void NoneDoesNotFallBack() { + var key = UniqueKey(); + + try { + Environment.SetEnvironmentVariable(key, "from-process"); + + Assert.Null(ModuleEnvironment.None.Value(key)); + } finally { + Environment.SetEnvironmentVariable(key, null); + } + } + [Fact] public void NoneHasNoNameAndNoValues() { Assert.Equal("", ModuleEnvironment.None.EnvironmentName); @@ -172,8 +349,12 @@ private class StubByType : IModuleEnvironment { public class EnvironmentConditionsTests { + /// + /// Pinned to the values written here. These assert what a condition does with a given set of + /// values, so a variable set on the machine running them must not reach a key they never name. + /// private static IModuleEnvironment Env(string name, params (string Key, string? Value)[] values) => - new ModuleEnvironment(name, values.ToDictionary(v => v.Key, v => v.Value)); + new ModuleEnvironment(false, name, values.ToDictionary(v => v.Key, v => v.Value)); [Theory] [InlineData("Development", true)] diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.FakeItEasyApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.FakeItEasyApi.verified.txt new file mode 100644 index 0000000..8e2a4b3 --- /dev/null +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.FakeItEasyApi.verified.txt @@ -0,0 +1,9 @@ +namespace DependencyModules.FakeItEasy +{ + [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method)] + public class FakeItEasySupportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IMockSupportAttribute + { + public FakeItEasySupportAttribute() { } + public object ProvideMock(System.Type type) { } + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.MoqApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.MoqApi.verified.txt new file mode 100644 index 0000000..ff656e9 --- /dev/null +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.MoqApi.verified.txt @@ -0,0 +1,9 @@ +namespace DependencyModules.Moq +{ + [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method)] + public class MoqSupportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IMockSupportAttribute + { + public MoqSupportAttribute() { } + public object ProvideMock(System.Type type) { } + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.NSubstituteApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.NSubstituteApi.verified.txt new file mode 100644 index 0000000..d3f842c --- /dev/null +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.NSubstituteApi.verified.txt @@ -0,0 +1,9 @@ +namespace DependencyModules.NSubstitute +{ + [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method)] + public class NSubstituteSupportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IMockSupportAttribute + { + public NSubstituteSupportAttribute() { } + public object ProvideMock(System.Type type) { } + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt index 5007060..057c4ff 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt @@ -292,12 +292,15 @@ namespace DependencyModules.Runtime.Interfaces } namespace DependencyModules.Runtime { - public class ModuleEnvironment : DependencyModules.Runtime.Interfaces.IModuleEnvironment + public class ModuleEnvironment : DependencyModules.Runtime.Interfaces.IModuleEnvironment, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable { public ModuleEnvironment(string environmentName, System.Collections.Generic.IReadOnlyDictionary? values = null) { } + public ModuleEnvironment(bool fallBackToEnvironmentVariables, string environmentName, System.Collections.Generic.IReadOnlyDictionary? values = null) { } public string EnvironmentName { get; } public static DependencyModules.Runtime.Interfaces.IModuleEnvironment Default { get; } public static DependencyModules.Runtime.Interfaces.IModuleEnvironment None { get; } + public void Add(string key, string? value) { } + public System.Collections.Generic.IEnumerator> GetEnumerator() { } public string? Value(string name) { } } public static class ServiceCollectionExtensions diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt new file mode 100644 index 0000000..0e82f7e --- /dev/null +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt @@ -0,0 +1,37 @@ +namespace DependencyModules.Testing.Attributes +{ + public class InjectValuesAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IInjectValueAttribute + { + public InjectValuesAttribute(params object[] value) { } + public object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } + } +} +namespace DependencyModules.Testing.Attributes.Interfaces +{ + public interface IInjectValueAttribute + { + object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter); + } + public interface IMockSupportAttribute + { + object ProvideMock(System.Type type); + } + public interface IOrderedAttribute + { + int Order { get; } + } +} +namespace DependencyModules.Testing.Impl +{ + public static class AttributeUtility + { + public static T? GetTestAttribute(this System.Reflection.MethodInfo methodInfo) + where T : class { } + public static T? GetTestAttribute(this System.Reflection.ParameterInfo parameterInfo) + where T : class { } + public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.MethodInfo methodInfo) + where T : class { } + public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.ParameterInfo parameterInfo) + where T : class { } + } +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt index 57a8447..310cf98 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitApi.verified.txt @@ -1,18 +1,22 @@ -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.AppVeyorReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.DefaultRunnerReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.JsonReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.QuietReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.SilentReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.TeamCityReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.VerboseReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.VstsReporter))] -namespace DependencyModules.xUnit.Attributes +namespace DependencyModules.xUnit.Attributes.Interfaces { - public class InjectValuesAttribute : System.Attribute, DependencyModules.xUnit.Attributes.Interfaces.IInjectValueAttribute + public interface IServiceProviderBuilderAttribute { - public InjectValuesAttribute(params object[] value) { } - public object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } + System.IServiceProvider BuildServiceProvider(Xunit.v3.IXunitTestMethod testCaseContext, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection); } + public interface ITestParameterValueProvider + { + System.Threading.Tasks.Task GetParameterValueAsync(Xunit.v3.IXunitTestMethod context, System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter); + void SetupServiceCollection(Xunit.v3.IXunitTestMethod testCaseContext, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Reflection.ParameterInfo parameter); + } + public interface ITestStartupAttribute + { + void SetupServiceCollection(Xunit.v3.IXunitTestMethod testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection); + System.Threading.Tasks.Task StartupAsync(Xunit.v3.IXunitTestMethod testMethod, System.IServiceProvider serviceProvider); + } +} +namespace DependencyModules.xUnit.Attributes +{ [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=true)] public class MockAttribute : System.Attribute, DependencyModules.xUnit.Attributes.Interfaces.ITestParameterValueProvider { @@ -38,48 +42,8 @@ namespace DependencyModules.xUnit.Attributes public System.Threading.Tasks.Task StartupAsync(Xunit.v3.IXunitTestMethod testMethod, System.IServiceProvider serviceProvider) { } } } -namespace DependencyModules.xUnit.Attributes.Interfaces -{ - public interface IInjectValueAttribute - { - object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter); - } - public interface IMockSupportAttribute - { - object ProvideMock(System.Type type); - } - public interface IOrderedAttribute - { - int Order { get; } - } - public interface IServiceProviderBuilderAttribute - { - System.IServiceProvider BuildServiceProvider(Xunit.v3.IXunitTestMethod testCaseContext, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection); - } - public interface ITestParameterValueProvider - { - System.Threading.Tasks.Task GetParameterValueAsync(Xunit.v3.IXunitTestMethod context, System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter); - void SetupServiceCollection(Xunit.v3.IXunitTestMethod testCaseContext, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Reflection.ParameterInfo parameter); - } - public interface ITestStartupAttribute - { - void SetupServiceCollection(Xunit.v3.IXunitTestMethod testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection); - System.Threading.Tasks.Task StartupAsync(Xunit.v3.IXunitTestMethod testMethod, System.IServiceProvider serviceProvider); - } -} namespace DependencyModules.xUnit.Impl { - public static class AttributeUtility - { - public static T? GetTestAttribute(this System.Reflection.MethodInfo methodInfo) - where T : class { } - public static T? GetTestAttribute(this System.Reflection.ParameterInfo parameterInfo) - where T : class { } - public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.MethodInfo methodInfo) - where T : class { } - public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.ParameterInfo parameterInfo) - where T : class { } - } public interface ITestCaseInfo { Xunit.v3.IXunitTestMethod TestMethod { get; } diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitNSubstituteApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitNSubstituteApi.verified.txt deleted file mode 100644 index edfc0f5..0000000 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.XUnitNSubstituteApi.verified.txt +++ /dev/null @@ -1,17 +0,0 @@ -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.AppVeyorReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.DefaultRunnerReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.JsonReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.QuietReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.SilentReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.TeamCityReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.VerboseReporter))] -[assembly: Xunit.Runner.Common.RegisterRunnerReporter(typeof(Xunit.Runner.Common.VstsReporter))] -namespace DependencyModules.xUnit.NSubstitute -{ - [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method)] - public class NSubstituteSupportAttribute : System.Attribute, DependencyModules.xUnit.Attributes.Interfaces.IMockSupportAttribute - { - public NSubstituteSupportAttribute() { } - public object ProvideMock(System.Type type) { } - } -} diff --git a/tests/DependencyModules.Tests/xUnitTests/AttributeUtilityTests.cs b/tests/DependencyModules.Tests/xUnitTests/AttributeUtilityTests.cs index 2e6f871..6b9b160 100644 --- a/tests/DependencyModules.Tests/xUnitTests/AttributeUtilityTests.cs +++ b/tests/DependencyModules.Tests/xUnitTests/AttributeUtilityTests.cs @@ -1,5 +1,5 @@ using System.Reflection; -using DependencyModules.xUnit.Impl; +using DependencyModules.Testing.Impl; using Xunit; namespace DependencyModules.Tests.xUnitTests; diff --git a/website/guide/aot.md b/website/guide/aot.md index 223a565..882afe3 100644 --- a/website/guide/aot.md +++ b/website/guide/aot.md @@ -1,53 +1,72 @@ # Trimming and Native AOT -What survives trimming, and what does not. +## The problem -## The problem with scanning at run time +You publish trimmed, or as Native AOT, and the application dies at startup: + +``` +System.InvalidOperationException: Unable to resolve service for type 'MyApp.IHandler' +``` + +Nothing changed in your code, and it works perfectly in development. This is the classic failure of +runtime assembly scanning, and it is worth understanding why it happens rather than which flag +suppresses it. A reflection-based scanner enumerates an assembly's types when the application starts. The trimmer -cannot follow that: it has no way to know those types are needed, so it removes them, and the scan -finds nothing. The failure appears at startup in a published build and never in development. +runs long before that, and its job is to remove any type nothing references. It has no way to know +your scanner will go looking for `CreateOrderHandler`, because nothing in your code mentions +`CreateOrderHandler` — that is the whole appeal of scanning. So the trimmer removes it, the scan +finds nothing, and the container has no registration. -## What happens here instead +The failure only appears in a published build, which is the worst place to discover it. -The same work happens during the build, and each match is emitted as a literal `typeof()` into your -assembly: +## How DependencyModules helps + +The same work happens during the build instead, and each match is emitted as a literal `typeof()` +into your assembly: ```csharp services.AddScoped(typeof(IHandler), typeof(CreateOrderHandler)); ``` -Two things follow. +Two things follow from that one line, and together they are the whole story. -**The trimmer roots the type**, because a `typeof()` in your code is an ordinary static reference. +**The trimmer roots the type.** A `typeof()` in your code is an ordinary static reference — exactly +the thing the trimmer is looking for. There is nothing dynamic to see through. **The constructor survives too.** `ServiceDescriptor`'s implementation-type parameter carries `[DynamicallyAccessedMembers(PublicConstructors)]`, and that annotation can only flow to a type the -compiler knows about. +compiler knows about. Because the type is named literally, it does. -Both hold for [types in a referenced package](/guide/scanning) as well. +Both hold for [types in a referenced package](/guide/scanning) as well, which is the case runtime +scanners handle worst. ## What this covers - Attribute registration - Conventions, including open generics and referenced-assembly scanning -- Decorators and interception — the wrapper is generated code in your assembly +- Decorators and interception — the wrapper is generated code in your own assembly ## What it does not cover **Environment conditions decide behaviour, not size.** The test runs at run time, so both branches -are compiled and every conditionally registered type stays referenced. Removing a service from a -build is a compile-time decision and belongs to `#if`. +compile and every conditionally registered type stays referenced. Removing a service from a build is +a compile-time decision, and belongs to `#if`. See +[what conditions cost](/guide/environments#what-conditions-cost). **Open generic registration is the least AOT-friendly part of the container itself**, independent of -this library. If you are targeting Native AOT aggressively, prefer closed registrations. +this library — the container has to construct a closed type at run time. If you are targeting Native +AOT aggressively, prefer closed registrations. -**Runtime assembly discovery is not supported**, since there is nothing to resolve at build time. See -[Scanning a package](/guide/scanning). +**Runtime assembly discovery is not supported**, because there would be nothing to resolve at build +time. See [Scanning a package](/guide/scanning). ## The generator never ships -The analyzer packages contain no `lib/` folder, so they cannot reach your output, and -`DevelopmentDependency=true` stops them flowing transitively to anything that references your -library. Only `DependencyModules.Runtime` is a run-time dependency, and it holds interfaces, -attributes and a small registry — no Roslyn, no reflection over your types. +Worth stating plainly, since "source generator" sometimes reads as "extra thing in my output". + +The analyzer packages contain no `lib/` folder, so they cannot reach your build output at all, and +`DevelopmentDependency=true` stops them flowing transitively to anything referencing your library. + +Only `DependencyModules.Runtime` is a run-time dependency, and it holds interfaces, attributes and a +small registry — no Roslyn, and no reflection over your types. diff --git a/website/guide/conventions.md b/website/guide/conventions.md index 05d8a4e..8abf7a3 100644 --- a/website/guide/conventions.md +++ b/website/guide/conventions.md @@ -1,39 +1,69 @@ # Conventions -Attributes are explicit, and explicit stops being a virtue somewhere around the fortieth handler. -Conventions let a module say *what* to register once, and the generator works out which types fit -while it builds. +## The problem + +Attributes are explicit, which is a virtue right up until you have forty of them saying the same +thing: + +```csharp +[TransientService] public class CreateOrderHandler : IRequestHandler { } +[TransientService] public class RenameOrderHandler : IRequestHandler { } +[TransientService] public class ShipOrderHandler : IRequestHandler { } +// … thirty-seven more +``` + +Nothing here is a decision. Every handler is transient because every handler is transient, and the +only real event is the day someone writes the forty-first and forgets the attribute. You are back to +the hand-maintained list, just spread across forty files instead of gathered in one. + +## How DependencyModules helps + +State the rule once, and let the generator find the types that fit **while it builds**: ```csharp [DependencyModule] public partial class DataModule : IConventionModule { void IConventionModule.Conventions(IConventionDefinitions conventions) { - conventions.RegisterAll().AsScoped(); conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient(); } } ``` -Implement `Conventions` **explicitly**, as above. An implicit `public void Conventions(…)` does not -compile. - -::: tip Install -Conventions ship in their own analyzer package, so a project that does not use them never loads the -class-scanning providers. +Forty registrations, one declaration, and the forty-first handler registers itself by existing. ```shell dotnet add package DependencyModules.Conventions ``` + +Conventions ship in their own analyzer package, so a project that does not use them never loads the +class-scanning providers. + +::: warning Implement the interface explicitly +`void IConventionModule.Conventions(…)`, as above. An implicit `public void Conventions(…)` does not +compile. ::: ## The body never runs -`Conventions` is **read** at compile time, not executed. That means only the calls documented on this -page may appear in it — a loop, a conditional, a local variable or a call to your own helper is -reported as [DM0009](/reference/diagnostics#dm0009). +This is the one genuinely surprising thing on this page, and everything else follows from it. + +`Conventions` is **read at compile time, not executed**. The generator parses that method as source +and works out what you asked for. It is a declaration that happens to be written in C# syntax. + +Two consequences: + +**Only the calls documented on this page may appear in it.** A loop, an `if`, a local variable or a +call to your own helper method cannot be read, and is reported as +[DM0009](/reference/diagnostics#dm0009) rather than silently ignored. -What comes out is ordinary registration code, one `services.AddScoped(…)` per match. Turn on -`EmitCompilerGeneratedFiles` to read it. +**What comes out is ordinary registration code** — one `services.AddTransient(…)` per match, sitting +in your assembly. Turn on `EmitCompilerGeneratedFiles` and read it: + +```csharp +// generated +services.AddTransient(typeof(IRequestHandler), typeof(CreateOrderHandler)); +services.AddTransient(typeof(IRequestHandler), typeof(RenameOrderHandler)); +``` ## What matches @@ -46,10 +76,10 @@ public class OrderRepository : IRepository { } // matches public class AuditedOrders : IAuditedRepository { } // matches — IAuditedRepository extends IRepository ``` -An interface saying it extends another is a deliberate statement that it is substitutable for it, -so it counts. +An interface declaring that it extends another is a deliberate statement that it is substitutable for +it, so it counts. -Reaching the service type through a **base class** does not, unless you ask: +Reaching the service type through a **base class** does not count, unless you ask for it: ```csharp public abstract class RepositoryBase : IRepository { } @@ -58,19 +88,23 @@ public class ProductRepository : RepositoryBase { } // no match by default conventions.RegisterAll().IncludeBaseClasses().AsScoped(); // now it matches ``` -Turn it on for the common `CreateOrderValidator : AbstractValidator` shape. Bear in mind -that every future subclass of that base joins the convention too. +Turn it on for the common `CreateOrderValidator : AbstractValidator` shape, where the +interface only ever arrives through a framework base class. Bear in mind that every future subclass +of that base joins the convention too. ::: info Attributes always win A type carrying `[SingletonService]`, `[ScopedService]`, `[TransientService]` or `[CrossWireService]` -is never a convention candidate. Neither is a `[Decorator]` — a decorator implements the interface -it decorates, and it is not a service. +is never a convention candidate, so an attribute is how you exempt one type from a rule that would +otherwise catch it. + +Neither is a `[Decorator]` — a decorator implements the interface it decorates, and it is not a +service in its own right. ::: ## Open generics -An open generic cannot be written as a type argument, so use the `Type` overload. Each match -registers against the **closed** construction it implements: +An open generic cannot be written as a type argument, so use the `Type` overload. Each match is +registered against the **closed** construction it actually implements: ```csharp public class CreateOrderHandler : IRequestHandler { } @@ -85,15 +119,15 @@ services.AddTransient(typeof(IRequestHandler), typeof(Crea services.AddTransient(typeof(IRequestHandler), typeof(RenameOrderHandler)); ``` -A type implementing **several** closings registers against all of them: +A type implementing **several** closings is registered against all of them: ```csharp public class OrderEvents : INotificationHandler, INotificationHandler { } ``` -Both are registered. They are different service types, so this is not the same implementation -appearing twice. +Both are registered. They are different service types, so this is not one implementation registered +twice. A generic implementation that closes nothing registers as the open generic, and the container closes it per request: @@ -104,13 +138,14 @@ public class PassThroughCache : ICache { } // registers ICache<> itself ## Narrowing what matches -Filters chain, and combine with **and**. Alternatives go inside a single call. +A service type is often too broad on its own. Filters chain, and combine with **and**; alternatives +go inside a single call: ```csharp conventions.RegisterAll() - .InNamespaceOf() // and in this namespace or below it - .WithoutName("*Legacy") // and not named like this - .WithAttribute() // and carrying this attribute + .InNamespaceOf() // and: in this namespace or below it + .WithoutName("*Legacy") // and: not named like this + .WithAttribute() // and: carrying this attribute .AsScoped(); ``` @@ -123,12 +158,12 @@ conventions.RegisterAll() | `WithAttribute()`, `WithoutAttribute()` | the attribute type, resolved rather than name-matched | | `WithName(params string[])`, `WithoutName(…)` | name globs — see below | -Namespace and name inclusions of the same kind combine with **or**; exclusions are applied afterwards +Namespace and name inclusions of the same kind combine with **or**. Exclusions are applied afterwards, and any one of them removes a match. ### Name globs -Two wildcards and no regular expressions: +Two wildcards, and no regular expressions: | Token | Matches | |---|---| @@ -142,13 +177,13 @@ bare type name. Matching is ordinal and case-sensitive, like C# identifiers. conventions.RegisterAll().WithName("*Repository", "*Store").AsScoped(); ``` -Prefer a service type, an attribute or a namespace where you can. A name pattern will happily match -a class somebody adds next year. +Prefer a service type, an attribute or a namespace wherever you can. A name pattern will cheerfully +match a class somebody adds next year — and `*Handler` matches `LoggingHandler` too. ## Registering types that implement nothing -`RegisterAll()` with no service type selects by filter alone. It is how a concrete class that -implements no interface gets registered by convention: +Some things worth registering implement no interface at all. `RegisterAll()` with no service type +selects by filter alone: ```csharp conventions.RegisterAll() @@ -158,8 +193,8 @@ conventions.RegisterAll() .AsScoped(); ``` -It requires a shape and at least one filter. Both are reported as -[DM0009](/reference/diagnostics#dm0009) if missing. +Because there is no interface to constrain it, this form **requires** a shape and at least one +filter. Missing either is [DM0009](/reference/diagnostics#dm0009). ## What each match is registered as @@ -174,15 +209,16 @@ It requires a shape and at least one filter. Both are reported as ### One instance or several -This is the distinction that catches people out with every scanning library. +This is the distinction that catches people out with every scanning library, so it is worth being +explicit about. ```csharp conventions.RegisterAll().AsSingleton(); // one registration conventions.RegisterAll().AsSingleton(); // another, same class ``` -A class matched through two different interfaces gets **two registrations and two instances**, which -is what Scrutor and MediatR both produce and is usually what you want for handlers. +A class matched through two different interfaces gets **two registrations and two instances**. That +is what Scrutor and MediatR both produce, and for handlers it is usually what you want. When you want one instance reachable through several service types, say so: @@ -190,22 +226,22 @@ When you want one instance reachable through several service types, say so: conventions.RegisterAll(typeof(IValidator<>)).IncludeBaseClasses().AlsoAsSelf().AsScoped(); ``` -`AlsoAsSelf()` and `AsSelfWithInterfaces()` both cross-wire: resolving any of the registered service -types gives the same instance. The difference is reach — `AlsoAsSelf()` registers only the interfaces -the convention matched, `AsSelfWithInterfaces()` registers everything the type implements. +`AlsoAsSelf()` and `AsSelfWithInterfaces()` both cross-wire — resolving any of the registered service +types gives the same instance. The difference is reach: `AlsoAsSelf()` registers only the interfaces +the convention matched, while `AsSelfWithInterfaces()` registers everything the type implements. ::: warning AsSelfWithInterfaces skips System interfaces Interfaces in `System` or a namespace beginning `System.` are not expanded into, so a type whose base implements `IDisposable` does not become resolvable as `IDisposable`. -This applies only to the expansion. A service type you name yourself is always honoured, so +This applies only to the automatic expansion. A service type you name yourself is always honoured, so `RegisterAll()` still registers `IDisposable`. ::: ## Lifetime, keys and registration strategy -A lifetime is required; there is no default. Omitting one is -[DM0009](/reference/diagnostics#dm0009). +A lifetime is **required**; there is no default. Omitting one is +[DM0009](/reference/diagnostics#dm0009) rather than a silent transient. ```csharp conventions.RegisterAll() @@ -217,7 +253,7 @@ conventions.RegisterAll() ## When two conventions collide Two conventions in one module registering the same implementation under the **same service type** is -[DM0004](/reference/diagnostics#dm0004), an error — the lifetime would be ambiguous. +[DM0004](/reference/diagnostics#dm0004), an error — the lifetime would be ambiguous: ```csharp conventions.RegisterAll().AsScoped(); @@ -233,18 +269,24 @@ conventions.RegisterAll(typeof(INotificationHandler<>)).AsTransient(); conventions.RegisterAll(typeof(IRequestPreProcessor<>)).AsTransient(); // fine ``` -Conventions in *different* modules never collide — each registers into its own realm. +Conventions in *different* modules never collide, because each registers into its own +[realm](/guide/modules#realms-keeping-a-registration-out-of-the-default-module). ## What conventions will not do -Anything that would need a lambda over the matched types — a predicate, or a lifetime chosen per type -— cannot be expressed, because the declaration is read at compile time rather than run. +Anything needing a lambda over the matched types — a predicate, or a lifetime chosen per type — +cannot be expressed, because the declaration is read rather than run. There is no way to evaluate +your code at compile time. Use `IServiceCollectionConfiguration` for those, alongside your conventions: ```csharp [DependencyModule] -public partial class DataModule : IServiceCollectionConfiguration { +public partial class DataModule : IConventionModule, IServiceCollectionConfiguration { + void IConventionModule.Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll().AsScoped(); + } + public void ConfigureServices(IServiceCollection services) { // unrestricted access to IServiceCollection, at run time } @@ -253,6 +295,6 @@ public partial class DataModule : IServiceCollectionConfiguration { ## Next -- [Scanning a package](/guide/scanning) — matching types in a referenced assembly +- [Scanning a package](/guide/scanning) — matching types in an assembly you do not own - [Convention API reference](/reference/conventions-api) — every call in one table - [Diagnostics](/reference/diagnostics) — what each DM code means diff --git a/website/guide/decorators.md b/website/guide/decorators.md index 0961f97..297fd1d 100644 --- a/website/guide/decorators.md +++ b/website/guide/decorators.md @@ -1,8 +1,25 @@ # Decorators -A decorator wraps a registered service with a type you write. You get real signatures, real -parameter names, and no generics gymnastics — which is what makes it the right tool when you want to -do something specific to one member. +## The problem + +You want to cache the results of a repository: + +```csharp +[SingletonService] +public class SqlRepository : IRepository { + public Item Get(int id) => /* a database round trip */; +} +``` + +Putting the cache inside `SqlRepository` gives that class a second job and makes it harder to test. +Putting it in every caller is worse. What you want is something that sits **between** the callers and +the repository, without either side knowing. + +Microsoft's container has no built-in way to express that. + +## How DependencyModules helps + +Write the wrapper as an ordinary class, mark it `[Decorator]`, and it takes over the registration: ```csharp public interface IRepository { Item Get(int id); } @@ -18,22 +35,23 @@ public class CachingRepository(IRepository inner, IMemoryCache cache) : IReposit } ``` -Resolving `IRepository` now gives you `CachingRepository` wrapping `SqlRepository`. +Resolving `IRepository` now gives you `CachingRepository` wrapping `SqlRepository`. Neither the +callers nor `SqlRepository` changed. ## How it is wired The **first constructor parameter is the wrapped instance**; every other parameter is resolved from -the container. You never register the decorator yourself — `[Decorator]` is enough, and the -decorator is not registered as a service in its own right. +the container normally. That is the whole convention. -This matters with [conventions](/guide/conventions): a decorator implements the interface it -decorates, and `[Decorator]` keeps it out of convention matching so it is not registered as a service -in its own right. +You never register the decorator yourself — `[Decorator]` is enough, and the decorator is not +registered as a service in its own right. This also keeps it out of +[convention](/guide/conventions) matching, which matters because a decorator implements the very +interface a convention over that interface would be looking for. ## Ordering -Decorators are sorted across **every module** in an `AddModule(s)` call, not just within the module -that declared them. Lower orders sit closer to the implementation; higher ones wrap them. +With more than one decorator, `Order` decides the nesting. **Lower orders sit closer to the +implementation**; higher ones wrap them: ```csharp [Decorator(Order = 10)] public class Retrying(IRepository inner) : IRepository { } @@ -42,15 +60,21 @@ that declared them. Lower orders sit closer to the implementation; higher ones w // resolves as Logging(Retrying(SqlRepository)) ``` -By convention framework packages use 0–999 and application code 1000 and above, so an application's -decorators wrap those contributed by the libraries it consumes. +So a logged call reports the whole retry sequence as one operation, which is usually what you want. + +Ordering is global — decorators are sorted across **every module** in an `AddModule(s)` call, not +just within the module that declared them. By convention framework packages use 0–999 and application +code 1000 and above, so an application's decorators wrap the ones contributed by libraries it +consumes. -Two decorators of one service sharing an order is [DM0007](/reference/diagnostics#dm0007). +Two decorators of one service sharing an order is [DM0007](/reference/diagnostics#dm0007), since +their nesting would be ambiguous. -## Open generics +## One decorator over every closed generic -One decorator can wrap every closed registration of an open generic. This is the shape that makes -cross-cutting behaviour over MediatR handlers or FluentValidation validators a single declaration: +This is where decorators earn their keep. A single declaration can wrap **every** closed registration +of an open generic — cross-cutting behaviour over all your MediatR handlers or FluentValidation +validators, written once: ```csharp [Decorator] @@ -65,15 +89,15 @@ public class LoggingHandler( } ``` -Combined with a convention, that is the whole setup: +Combined with a convention, that is the entire setup: ```csharp conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped(); ``` -Every handler is registered and every handler is wrapped. +Every handler registered, every handler wrapped, and a new handler joins both by existing. -## Decorating from the module +## Decorating a type you do not own When the service, the decorator, or both come from an assembly you do not control, there is nowhere to put `[Decorator]`. Declare it on the module instead: @@ -84,18 +108,19 @@ to put `[Decorator]`. Declare it on the module instead: public partial class DataModule; ``` -## Ordering relative to services +## When decoration happens Decoration runs as a distinct phase **after** every module's registrations, so a decorator sees -everything registered by every module in the call and you do not have to sequence anything. +everything registered by every module in the call, regardless of the order they were added in. You do +not have to sequence anything. -A decorator sees the services registered by the modules in its `AddModule(s)` call. Anything you -register afterwards is outside that scope. +The boundary is the `AddModule(s)` call: anything you register afterwards is outside that scope and +will not be decorated. ## One limitation -A service **registered as an open generic** — a single generic implementation serving every closing -— cannot be decorated: +A service **registered as an open generic** — one generic implementation serving every closing — +cannot be decorated: ```csharp [SingletonService] @@ -112,14 +137,17 @@ decorated by 'CachingRepository`1'. … Register closed constructions instead. -This is about the *registration*, not the decorator. An open generic decorator over closed -registrations — the example further up — works, and is the common case. +Note that this is about the **registration**, not the decorator. An open generic decorator over +closed registrations — the example further up — works, and is the common case. ## Decorator or interceptor? | | Decorator | [Interception](/guide/interception) | |---|---|---| -| Who writes the wrapper | you | the generator, every member | +| Who writes the wrapper | you | the generator, for every member | | Applies to | one interface | many unrelated services | -| Member access | real signatures | uniform, `TResult` and `IArguments` | +| Member access | real signatures and parameter names | uniform, `TResult` and `IArguments` | | Reach for it when | caching *this* method, validating *that* one | logging, timing, retry, tracing | + +If you need to do something specific to one member, write a decorator. If you need to do the same +thing to every member of thirty services, read on. diff --git a/website/guide/environments.md b/website/guide/environments.md index 2cb05d8..80d78c4 100644 --- a/website/guide/environments.md +++ b/website/guide/environments.md @@ -1,6 +1,26 @@ # Environments -A registration can depend on the environment the application is running in. +## The problem + +You do not want your development machine sending real email. So the registration becomes conditional: + +```csharp +// Program.cs +if (builder.Environment.IsDevelopment()) { + services.AddSingleton(); +} else { + services.AddSingleton(); +} +``` + +This works, and it has a habit of multiplying. The decision lives in `Program.cs`, a long way from +either class, so reading `FakeEmailSender` tells you nothing about when it is used. After a few of +these the composition root is a pile of branches, and the only way to know what runs in staging is to +trace all of them. + +## How DependencyModules helps + +Put the condition on the class, next to the registration it qualifies: ```csharp [SingletonService] @@ -12,9 +32,10 @@ public class FakeEmailSender : IEmailSender { } public class SmtpEmailSender : IEmailSender { } ``` -Resolve `IEmailSender` and you get whichever one the environment selected. +Resolve `IEmailSender` and you get whichever one the environment selected. `Program.cs` has no branch +in it, and each class states its own applicability where you will actually read it. -## The attributes +## The conditions | Attribute | Registers when | |---|---| @@ -24,8 +45,8 @@ Resolve `IEmailSender` and you get whichever one the environment selected. | `[IfEnvironmentValue(key, value)]` | the value equals exactly | | `[IfNotEnvironmentValue(…)]` | the inverse of either form | -Conditions of **different kinds** combine with **and**. Alternatives go inside one attribute, as -`params`. +Conditions of **different kinds** combine with **and**; alternatives go inside one attribute as +`params`. So this registers only outside production, and only when the feature is switched on: ```csharp [SingletonService] @@ -41,31 +62,85 @@ Environment **names** compare case-insensitively, matching `IHostEnvironment.IsD There is always one, and it is never null. +The simplest case needs no wiring at all. Supply nothing and you get `ModuleEnvironment.Default`, +which reads the process — `ASPNETCORE_ENVIRONMENT`, then `DOTNET_ENVIRONMENT`, then falling back to +`"Production"`. Values come from environment variables, read on each call rather than captured once. + +So `[IfEnvironment("Development")]` already works against the variable your tooling sets for you. + +To decide explicitly — and a test should — pass one to `AddModules`: + ```csharp services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule()); ``` -Supply nothing and you get `ModuleEnvironment.Default`, which reads the process: -`ASPNETCORE_ENVIRONMENT`, then `DOTNET_ENVIRONMENT`, then `"Production"`. Values come from -environment variables, read on each call rather than captured. +Values go inline, since a `ModuleEnvironment` is a collection of them: + +```csharp +services.AddModules( + new ModuleEnvironment("Development") { + { "FEATURE_PROFILING", "on" }, + { "REGION", "eu" } + }, + new ApplicationModule()); +``` + +A dictionary still works, and the two combine — an entry written inline replaces one of the same key +that came from the dictionary. -So `[IfEnvironment("Development")]` works with nothing wired up beyond the variable you already set. +### What happens to keys you did not write -`ModuleEnvironment.None` says this application has no environment — an empty name and no values. +Anything **not** written there falls back to an environment variable of that name, so supplying a +couple of values does not mean giving up the rest. -Whatever is used is **registered**, so `GetRequiredService()` returns the same -environment that decided the registrations. +A key you did write wins — including one written as `null`, which is how you hide a variable of the +same name: + +```csharp +new ModuleEnvironment("Development") { + { "REGION", "eu" }, // wins over any REGION variable + { "FEATURE_PROFILING", null } // hides a FEATURE_PROFILING variable +} +``` + +To pin an environment to exactly what is at the call site and read nothing else, lead with `false`: + +```csharp +new ModuleEnvironment(false, "Development") { { "REGION", "eu" } } // reads nothing else +``` + +A test asserting which services an environment registers wants this. Otherwise a variable set on the +machine running it can reach a key the test never mentioned, and the test passes or fails depending +on whose machine it runs on. + +The flag leads rather than trailing, so it is read before the values it governs. Both forms still +take a dictionary, so turning fallback off does not mean giving up the constructor you were using: + +```csharp +new ModuleEnvironment(false, "Development", new Dictionary { ["A"] = "1" }) +``` + +A comparer you supplied on that dictionary is carried over rather than reset — useful for +`OrdinalIgnoreCase`, matching how Windows treats variable names. + +### Stating that there is no environment + +`ModuleEnvironment.None` has an empty name and no values, so every condition evaluates false. Prefer +it to leaving the environment unset, which silently picks up the process instead. + +Whatever is used is **registered**, so `GetRequiredService()` afterwards returns +the same environment that decided the registrations. ::: warning Register an instance, not a type -The environment is read while the collection is still being populated, before any provider exists, -so only a singleton **instance** can be used. +The environment is read while the collection is still being populated — before any provider exists to +resolve anything — so only a singleton **instance** can work. ```csharp services.AddSingleton(new ModuleEnvironment("Staging")); // works services.AddSingleton(); // throws ``` -Registering by type or factory throws, with a message naming the fix. +Registering by type or by factory throws, with a message naming the fix. ::: An environment passed to `AddModules` **replaces** one already in the collection. To layer one on @@ -78,49 +153,52 @@ var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IModuleEnvir services.AddModules(Combine(existing ?? ModuleEnvironment.Default, overlay), modules); ``` -## Ordering +## Overriding a default -A conditional registration is emitted **after** the unconditional ones in its module, so it can -override a default: +A conditional registration is emitted **after** the unconditional ones in its module, which is what +makes the override pattern work — register the normal implementation unconditionally and the special +one conditionally: ```csharp -[SingletonService] public class SmtpEmailSender : IEmailSender { } +[SingletonService] public class SmtpEmailSender : IEmailSender { } [SingletonService] [IfEnvironment("Development")] public class FakeEmailSender : IEmailSender { } ``` -In Development the fake wins, since the container resolves a single service from the last matching -descriptor. +In Development the fake wins, because the container resolves a single service from the **last** +matching descriptor. Across modules, **module order decides** — a referenced module's conditional registration does not override the module that references it. ::: info Try is first-wins -A conditional `Using(RegistrationType.Try)` cannot override an unconditional registration. Use `Add`, -the default, for the override pattern. +A conditional `Using(RegistrationType.Try)` cannot override an unconditional registration, since +`Try` declines when the service type is already present. Use `Add`, the default, for this pattern. ::: ## Conditions and conventions A class matched by a [convention](/guide/conventions) honours its conditions too, so a condition -works whether the class is registered by attribute or by convention. +behaves the same whether the class was registered by attribute or by rule. ## What conditions cost -The test runs at run time, so **both branches are compiled and every conditionally registered type -stays referenced**. Conditions change what is registered, not what ships. To remove a service from a -build, use `#if`. +The test runs at **run time**, which means both branches are compiled and every conditionally +registered type stays referenced in the output. + +Conditions change what is *registered*, not what *ships*. To keep a service out of a build entirely, +you want `#if`. ## Seeing it at build time -[DM0011](/reference/diagnostics#dm0011) reports what each conditional registration depends on, in the -IDE at the class. +[DM0011](/reference/diagnostics#dm0011) reports what each conditional registration depends on, inline +in the IDE at the class — so the applicability is visible without running anything. A condition that names nothing to test — `[IfEnvironment()]`, `[IfEnvironmentValue("")]` — is -[DM0012](/reference/diagnostics#dm0012). +[DM0012](/reference/diagnostics#dm0012). Both compile, and both are almost certainly a mistake. ## Programmatic access -For registration that needs the environment but is not a simple condition: +For registration that depends on the environment but is not a simple condition: ```csharp [DependencyModule] diff --git a/website/guide/extending.md b/website/guide/extending.md index 2cce63b..2badb6c 100644 --- a/website/guide/extending.md +++ b/website/guide/extending.md @@ -1,14 +1,26 @@ # Writing your own generator -DependencyModules is built out of parts you can reuse. Module discovery, configuration reading, -diagnostics, logging and emission all live in a shared assembly, so a package can add its own -registration mechanism without re-implementing any of it. +## The problem -`DependencyModules.Conventions` is exactly that — a separate analyzer package that plugs into the -same pipeline. This page describes how, using it as the worked example. +You want a registration mechanism this library does not have — your own attribute, a DSL that suits +your domain, registrations derived from something only your codebase knows about. -::: warning Not a supported public API yet -These are the extension points the conventions package uses, and they are public. They are not +Writing that as a standalone source generator means rebuilding a lot of unglamorous machinery first: +finding the modules, parsing the MSBuild configuration, producing diagnostics, keeping the +incremental cache honest, and emitting registration code that composes with everything else. None of +that is the part you actually wanted to write. + +## How DependencyModules helps + +All of it lives in a shared assembly you can compile into your own analyzer. Your mechanism produces +the same `ServiceModel`s the attribute path produces, so emission needs no special case and your +registrations compose with `[SingletonService]` and conventions as if they had always been there. + +`DependencyModules.Conventions` is exactly this — a separate analyzer package plugged into the same +pipeline — and it is the worked example throughout this page. + +::: warning Not a stable public API yet +These are the extension points the conventions package uses, and they are public. They are **not** versioned as a stable API, so a minor release may move them. If you build on this, pin the generator package version. ::: @@ -24,9 +36,6 @@ package version. | Diagnostics | the `DM####` descriptors and their release tracking | | Model equality helpers | what keeps the incremental cache working | -Producing `ServiceModel`s that look like the attribute path's means emission needs no special case — -your mechanism and `[SingletonService]` come out the same way. - ## The shape Two interfaces. `BaseSourceGenerator` is the Roslyn entry point, and it asks you for the generators @@ -65,8 +74,8 @@ public class MyGenerator : IDependencyModuleSourceGenerator { } ``` -For an attribute-driven mechanism, `BaseAttributeSourceGenerator` does more of the work — -you supply the attribute types, a transform, a comparer and an ignored sentinel: +For an attribute-driven mechanism, `BaseAttributeSourceGenerator` does more of the work — you +supply the attribute types, a transform, a comparer and an ignored sentinel: ```csharp public class MyGenerator : BaseAttributeSourceGenerator { @@ -102,8 +111,8 @@ apply it per member. Pass `true` unless you are the first. ## Packaging -The project is an analyzer, and the packaging is unforgiving in ways that only show up once someone -installs it. Copy the conventions project's csproj rather than working it out again. +The project is an analyzer, and analyzer packaging is unforgiving in ways that only surface once +someone installs the package. Copy the conventions project's csproj rather than working it out again. ```xml @@ -143,7 +152,8 @@ That works because **`Impl` declares no `[Generator]` of its own**. Compiling it analyzer assembly adds no second registration of the service, decorator or interceptor generators, so a project referencing both packages does not generate everything twice. -If you carry the `DM####` descriptors, you need their release tracking too or the build fails RS2008: +If you carry the `DM####` descriptors, you need their release tracking too, or the build fails +RS2008: ```xml @@ -154,8 +164,8 @@ If you carry the `DM####` descriptors, you need their release tracking too or th ## Three rules that will cost you a day each -**Never put a symbol in a model.** `ISymbol` is not equatable and holds its `SyntaxTree` alive. -A model containing one never compares equal across runs, so the incremental cache misses on every +**Never put a symbol in a model.** `ISymbol` is not equatable and holds its `SyntaxTree` alive. A +model containing one never compares equal across runs, so the incremental cache misses on every keystroke and pins memory. Render what you need to strings or `ITypeDefinition` during the transform. **Give every model structural equality.** A positional record compares `IReadOnlyList` members by @@ -163,7 +173,7 @@ reference, so two structurally identical models built on consecutive runs are un downstream recomputes. `ModelEquality.ListEquals` and `ListHashCode` exist for this. **Keep the predicate syntax-only and cheap.** It runs on a great many nodes. Reject on node type -first, and do not touch the semantic model — resolve in the transform, which runs only for what the +first, and never touch the semantic model — resolve in the transform, which runs only for what the predicate accepted. ## Refuse rather than guess @@ -173,7 +183,7 @@ The failure mode should be "this library does not support X", never a `CS` error code, and never a silent absence. Silent failure is the recurring bug class here. When you add something, ask what happens when it does -not work — and if the answer is "nothing is registered and the build is green", add a diagnostic. +*not* work — and if the answer is "nothing is registered and the build is green", add a diagnostic. ## Testing it @@ -181,8 +191,8 @@ Drive the generator in memory and then **execute what it produced**. Asserting o passes happily while the wrong service type is registered. The pattern used throughout this repository is: compile the source with the generator, emit a real -assembly, load it, build a provider, and resolve. See [Testing](/guide/testing) for the consumer-facing -equivalent. +assembly, load it, build a provider, and resolve. See [Testing modules](/guide/testing) for the +consumer-facing equivalent. One caveat if you drive two analyzers from one test project: both compile in the shared `Impl` sources, so referencing both as libraries puts two copies of every `Impl` type in scope and every use diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 10ad5ac..f9e5738 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -1,8 +1,42 @@ # Getting started -DependencyModules turns attributes and conventions into `IServiceCollection` registration code -during the build. There is no container of its own — what comes out is `services.AddScoped(…)` calls -in a file you can read. +## The problem + +Every .NET application wires its services in one place, and that place grows: + +```csharp +// Program.cs, eventually +services.AddScoped(); +services.AddScoped(); +services.AddSingleton(); +services.AddScoped(); +// … and another two hundred lines +``` + +Nothing checks that this list is complete. Write a new class, forget to add its line, and the failure +shows up at run time: + +``` +System.InvalidOperationException: Unable to resolve service for type +'MyApp.IPricingRules' while attempting to activate 'MyApp.OrderService'. +``` + +Usually in the environment you deployed to, rather than the one you tested in. + +The common escape is a runtime scanner such as Scrutor: describe the types once, and let reflection +find them when the application starts. That does remove the list, but it costs you three things. You +can no longer read what was registered. The scan runs on every start. And the trimmer cannot see +through reflection, so a published, trimmed or Native AOT build registers nothing and fails at +startup — a failure that never reproduces in development. + +## How DependencyModules helps + +You declare registration next to the class it belongs to, and a source generator writes the +`services.AddScoped(…)` calls into your assembly **while the project builds**. + +The hand-written list comes back, except you did not write it and cannot forget a line. Because it is +ordinary C# in your own assembly, there is nothing to reflect over at startup and nothing for the +trimmer to lose. ## Install @@ -11,16 +45,17 @@ dotnet add package DependencyModules.Runtime dotnet add package DependencyModules.SourceGenerator ``` -Requires .NET 8.0 or later. Two more packages are optional: +Requires .NET 8.0 or later. Two more packages are optional, and this guide will tell you when you +want them: | Package | For | |---|---| -| `DependencyModules.Conventions` | [registering by convention](/guide/conventions) rather than per class | -| `DependencyModules.xUnit` | [building a provider in tests](/guide/testing) from the modules a test names | +| `DependencyModules.Conventions` | [registering by rule](/guide/conventions) instead of per class | +| `DependencyModules.xUnit` | [building a provider in tests](/guide/testing) from your real modules | -## A first module +## Your first module -A module is a `partial` class. The generator completes it. +Two pieces. First, mark the class you want registered: ```csharp using DependencyModules.Runtime.Attributes; @@ -33,12 +68,20 @@ public interface IEmailSender { void Send(string to); } public class SmtpEmailSender : IEmailSender { public void Send(string to) { } } +``` +Second, declare a **module** — a `partial` class the generator fills in. It collects every marked +class in the project: + +```csharp [DependencyModule] public partial class ApplicationModule; ``` -Then load it: +`partial` is required. The generator completes the class you declared; without `partial` there is +nothing to complete, and you get [DM0003](/reference/diagnostics#dm0003). + +Now load it at your composition root: ```csharp using DependencyModules.Runtime; @@ -48,17 +91,21 @@ var services = new ServiceCollection(); services.AddModule(); var provider = services.BuildServiceProvider(); -var sender = provider.GetRequiredService(); +var sender = provider.GetRequiredService(); // SmtpEmailSender ``` +That is the whole loop: mark the class, declare the module once, load the module once. + ::: tip Call AddModule once -Modules compose through attributes rather than by calling `AddModule` inside each other. Calling it -once at the composition root keeps the registration order predictable. +Modules pull in other modules through attributes rather than by calling `AddModule` inside each +other — see [Modules](/guide/modules#composing-modules). Calling it once at the composition root +keeps the registration order predictable and avoids registering anything twice. ::: -## See what was generated +## Proving to yourself that nothing is hiding -The generated code is the ground truth, and it is worth looking at once. +The generated code is the ground truth, and it is worth looking at once so the rest of this guide +reads as concrete rather than magic. Turn it on: ```xml @@ -66,7 +113,7 @@ The generated code is the ground truth, and it is worth looking at once. ``` -The files appear under `obj/`, and `ApplicationModule.Dependencies.g.cs` will contain something like: +Build, then open `obj/…/ApplicationModule.Dependencies.g.cs`. Inside it: ```csharp private static void ModuleDependencies(IServiceCollection services) { @@ -74,18 +121,18 @@ private static void ModuleDependencies(IServiceCollection services) { } ``` -That is all there is. No reflection, no startup scan, and a literal `typeof()` the trimmer can -follow. +One line, and it is the line you would have written by hand. No reflection, no startup scan, and a +literal `typeof()` the trimmer can follow. -::: warning Delete generated/ between runs -If you point `CompilerGeneratedFilesOutputPath` at a folder inside your project, stale files from a -previous build compile alongside fresh ones and produce a wall of `CS0111`/`CS0579`. Clear it when -you change module names. +::: warning If you redirect the output, clear it between builds +`CompilerGeneratedFilesOutputPath` pointing at a folder inside your project means stale files from a +previous build compile alongside fresh ones, producing a wall of `CS0111`/`CS0579`. Delete the folder +when you rename a module. ::: ## Where to go next -- [Modules](/guide/modules) — composition, realms, parameters and features +- [Modules](/guide/modules) — grouping registrations and composing them across projects - [Registering services](/guide/services) — lifetimes, keys, factories, `As`, `Try`/`Replace` -- [Conventions](/guide/conventions) — declare a rule instead of attributing each class -- [Trimming and AOT](/guide/aot) — why this survives what reflection-based scanners do not +- [Conventions](/guide/conventions) — when attributing each class stops scaling +- [Testing modules](/guide/testing) — building a provider from your real modules in a test diff --git a/website/guide/interception.md b/website/guide/interception.md index 978bfe5..49b14d7 100644 --- a/website/guide/interception.md +++ b/website/guide/interception.md @@ -1,8 +1,23 @@ # Interception -An interceptor runs around every call to a service. Unlike a [decorator](/guide/decorators) you do -not write the wrapper — the generator emits a type implementing the service interface and routes -every member through your interceptor. +## The problem + +A [decorator](/guide/decorators) works well when you want to do something to one member. It scales +badly in two directions. + +**Wide interfaces.** To time one method on an interface with twenty members, you write a decorator +with twenty methods — nineteen of which are pass-throughs that exist only to compile, and which +someone has to remember to update when a twenty-first member appears. + +**Many services.** To time thirty unrelated services, you write thirty decorators. The behaviour is +identical in all of them; only the interface differs. + +In both cases you are writing forwarding code by hand, and the actual logic is four lines. + +## How DependencyModules helps + +Write the behaviour once, as an interceptor. The generator emits a type implementing the service +interface and routes **every member** through it: ```csharp public class TimingInterceptor(ILogger log) : IInterceptor { @@ -16,23 +31,28 @@ public class TimingInterceptor(ILogger log) : IInterceptor { } } } +``` + +Apply it to any service, however many members it has: +```csharp [SingletonService] [Intercept(typeof(TimingInterceptor))] public class Repository : IRepository { } ``` -The return type comes from the generated call site, so nothing is boxed and nothing is inspected at -run time. +The return type comes from the generated call site rather than from reflection, so nothing is boxed +and nothing is inspected at run time. -::: info Only calls through the interface -A call the implementation makes to itself does not pass through the wrapper. +::: info Only calls through the interface are intercepted +A call the implementation makes to *itself* does not pass through the wrapper — it is an ordinary +method call inside one object. ::: ## Three interfaces, chosen per member A synchronous interceptor cannot serve a `Task`-returning member, because it has nowhere to await. -Implement whichever you need: +Implement whichever kinds your services actually have: | Interface | For members returning | |---|---| @@ -40,8 +60,7 @@ Implement whichever you need: | `IAsyncInterceptor` | `Task`, `Task`, `ValueTask`, `ValueTask` | | `IAsyncEnumerableInterceptor` | `IAsyncEnumerable` | -A type may implement any combination. **The generator picks per member**, and a member no -interceptor can serve is forwarded untouched with no allocation. +One type may implement any combination, and **the generator picks per member**: ```csharp public class TracingInterceptor : IInterceptor, IAsyncInterceptor { @@ -55,14 +74,15 @@ public class TracingInterceptor : IInterceptor, IAsyncInterceptor { } ``` -An interceptor implementing only `IAsyncInterceptor`, applied to a service with both synchronous and -asynchronous members, intercepts the asynchronous ones and passes the rest straight through. +A member that no interceptor can serve is forwarded untouched, with no allocation. So an interceptor +implementing only `IAsyncInterceptor`, applied to a service with both synchronous and asynchronous +members, intercepts the asynchronous ones and leaves the rest alone. ## Awaiting is yours -The generated wrapper awaits nothing on your behalf. An interceptor awaits `ProceedAsync()` itself, -so anything after the await happens once the work has finished — and because the call is held in a -single method body, state spanning it is an ordinary local: +The generated wrapper awaits nothing on your behalf. Your interceptor awaits `ProceedAsync()` itself, +which means anything after the await runs once the work has genuinely finished — and because the +whole call sits in one method body, state that spans it is an ordinary local: ```csharp public async ValueTask InterceptAsync(AsyncInvocationContext context) { @@ -72,12 +92,12 @@ public async ValueTask InterceptAsync(AsyncInvocationContext` member hands its stream back immediately. A stream interceptor enumerates -it, so it observes each item as it is produced: +An `IAsyncEnumerable` member returns its stream immediately, before any item exists. A stream +interceptor enumerates it, so it observes each item as it is produced: ```csharp public async IAsyncEnumerable InterceptStream(StreamInvocationContext context) { @@ -109,12 +129,13 @@ Arguments cost nothing until you read one. public class Repository : IRepository { } ``` -They nest in declaration order. Each is resolved from the container, so an interceptor may take its -own dependencies. +They nest in declaration order. Each is resolved from the container, so an interceptor can take +dependencies of its own — as `TimingInterceptor` does with its `ILogger`. ## What cannot be intercepted -These are reported as [DM0008](/reference/diagnostics#dm0008) and left unwrapped: +The generator has to emit a real override, so some shapes are impossible. These are reported as +[DM0008](/reference/diagnostics#dm0008) and left unwrapped rather than failing the build: - `ref`, `in` and `out` parameters, and `ref struct` parameters - by-reference returns @@ -122,4 +143,4 @@ These are reported as [DM0008](/reference/diagnostics#dm0008) and left unwrapped - static members - generic implementations, which register as an open generic -Custom decorators remain the answer for those. +Write a [decorator](/guide/decorators) for those. diff --git a/website/guide/modules.md b/website/guide/modules.md index df703ec..d6a179f 100644 --- a/website/guide/modules.md +++ b/website/guide/modules.md @@ -1,38 +1,70 @@ # Modules -A module is a `partial` class carrying `[DependencyModule]`. The generator completes the partial -with the plumbing that applies its registrations. +## The problem + +A single project registering everything is fine until it is not. Two things push back: + +**Your own application grows areas.** Data access, messaging, and diagnostics each have their own +services, and you would like to reason about them — and switch them out — as units rather than as one +undifferentiated pile of registrations. + +**A library cannot register itself.** If you ship a package, its services have to end up in the +consumer's container somehow. The usual answer is to export an `AddMyLibrary(this IServiceCollection)` +extension method and hope everybody remembers to call it, in the right order, once. + +## How DependencyModules helps + +A **module** is a unit of registration you can name, and modules pull each other in. A library +declares its own module; an application references it and gets everything the library registers +without knowing what any of it is. + +## Declaring one + +A module is a `partial` class carrying `[DependencyModule]`. The generator completes the partial with +the code that applies its registrations: ```csharp [DependencyModule] public partial class ApplicationModule; ``` +By default it collects every attributed service in its project. That is the whole declaration — the +body stays empty unless you want something from the rest of this page. + ::: warning Two rules -A module **must** be `partial` — otherwise the generator cannot complete it, and reports +A module **must** be `partial`, or the generator has nothing to complete — [DM0003](/reference/diagnostics#dm0003). -A module must be declared **directly in a namespace**, not nested inside another type. A nested -module generates a separate, detached class rather than completing the partial, so its registrations -never run. Services may be nested freely; only the module itself is restricted. +A module must be declared **directly in a namespace**, never nested inside another type. A nested +module quietly generates a separate, detached class instead of completing your partial, so its +registrations never run. Services can be nested freely; the restriction is only on modules. ::: ## Composing modules -Every module generates an attribute of the same name. Applying it to another module makes it a -dependency. +Every module generates **an attribute with the same name**. Applying that attribute to another module +makes it a dependency: ```csharp [DependencyModule] public partial class DataModule; [DependencyModule] -[DataModule] // DataModule's registrations come along +[DataModule] // everything DataModule registers comes along public partial class ApplicationModule; ``` -Dependencies are expanded before the module that declares them, so a module's own registrations are -applied last and win where the container is last-wins. +Loading `ApplicationModule` now also applies `DataModule`. This is what replaces the +`AddMyLibrary(services)` extension method: a package ships a module, and consuming it is one +attribute rather than a call somebody has to remember. + +```csharp +services.AddModule(); // DataModule comes too +``` + +Dependencies are expanded **before** the module that declares them, so a module's own registrations +are applied last and win wherever the container is last-wins. An application can therefore override +something a library registered simply by registering it itself. ## Loading modules @@ -43,67 +75,77 @@ services.AddModule(); services.AddModules(new ApplicationModule(), new DiagnosticsModule()); ``` -`AddModules` also takes an [environment](/guide/environments), which is what conditional -registrations are evaluated against: +`AddModules` also accepts an [environment](/guide/environments), which is what conditional +registrations get evaluated against: ```csharp services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule()); ``` -## Auto-generated application module +## You may not need to declare one -For [top-level statement](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements) -applications, an `ApplicationModule` is generated for a file named `Program.cs`, so you do not need -to declare one. +For applications using [top-level statements](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements), +an `ApplicationModule` is generated for you from `Program.cs`: ```csharp -[assembly: SomeOtherModule] +[assembly: SomeOtherModule] // compose other modules at the assembly level var services = new ServiceCollection(); -// SomeOtherModule, plus every registration in this project +// SomeOtherModule, plus every attributed service in this project services.AddModule(); ``` -## Realms +This is why the ASP.NET sample in this repository never declares a module — the web project's +`Program.cs` gets one automatically, and the test project composes it by name. -A realm scopes a registration to one module rather than to every module in the compilation. +## Realms: keeping a registration out of the default module + +By default an attributed service joins every module in its compilation. Occasionally that is wrong — +a profiler you only want when the diagnostics module is loaded, say. A **realm** scopes a +registration to one named module: ```csharp [SingletonService(Realm = typeof(DiagnosticsModule))] public class Profiler : IProfiler { } ``` -A module declared with `OnlyRealm = true` takes **nothing** that did not name it: +`Profiler` is now registered only by `DiagnosticsModule`, and an application that does not compose +that module never sees it. + +The reverse restriction is on the module itself. `OnlyRealm = true` means the module takes **nothing** +that did not name it: ```csharp [DependencyModule(OnlyRealm = true)] public partial class DiagnosticsModule; ``` -Convention registrations always name their declaring module as their realm, so two modules scanning -the same interface do not leak into each other. +Convention registrations always name their declaring module as their realm, which is why two modules +running conventions over the same interface do not leak into each other. ## Parameters -A module can take constructor parameters and expose them as properties, which its generated -attribute mirrors. +A module can take values from whoever loads it — a connection string, a base URL. Declare them as +properties, and the generated attribute mirrors them: ```csharp [DependencyModule] public partial class ApplicationModule { public string? ConnectionString { get; set; } } +``` -[ApplicationModule(ConnectionString = "…")] +```csharp +[ApplicationModule(ConnectionString = "Server=…")] public partial class TestModule; ``` -## Programmatic registration +## When attributes are not enough -For anything the attributes and conventions cannot express, implement -`IServiceCollectionConfiguration`. It runs after the module's own registrations, with unrestricted -access to the collection. +Some registration cannot be expressed as an attribute on a class — `AddHttpClient()`, options +binding, anything from a third-party library with its own extension method. Implement +`IServiceCollectionConfiguration` on the module and you get the collection directly: ```csharp [DependencyModule] @@ -114,6 +156,12 @@ public partial class ApplicationModule : IServiceCollectionConfiguration { } ``` -There is a matching `ConfigureDecorators` that runs after every module's decorators, and an -`IEnvironmentServiceCollectionConfiguration` that also receives the -[environment](/guide/environments). +It runs **after** the module's own registrations, with unrestricted access. There is a matching +`ConfigureDecorators` that runs after every module's decorators, and an +`IEnvironmentServiceCollectionConfiguration` that also hands you the +[environment](/guide/environments#programmatic-access). + +## Next + +- [Registering services](/guide/services) — what each attribute emits +- [Conventions](/guide/conventions) — registering by rule instead of per class diff --git a/website/guide/scanning.md b/website/guide/scanning.md index 674363e..3892e6b 100644 --- a/website/guide/scanning.md +++ b/website/guide/scanning.md @@ -1,8 +1,18 @@ # Scanning a package -A convention normally matches types in the project being built. `InAssemblyOf()` points it at a -**referenced assembly** instead — for registering handlers, validators or policies out of a package -you do not control. +## The problem + +A [convention](/guide/conventions) matches types in the project being built. That covers your own +code, but not this: + +You depend on a package that ships a dozen `IHandler<,>` implementations and no +`AddThePackage(services)` extension method. You cannot put `[TransientService]` on those classes — +they are not yours — and a convention declared in your project does not look inside them. + +## How DependencyModules helps + +`InAssemblyOf()` points a convention at a **referenced assembly**, using any type from it as the +marker: ```csharp conventions.RegisterAll(typeof(IHandler<,>)) @@ -10,31 +20,20 @@ conventions.RegisterAll(typeof(IHandler<,>)) .AsScoped(); ``` -The types are read during the build, and each match is emitted as a literal `typeof()` into your -assembly: +The package's types are read during **your** build, and each match is emitted as a literal `typeof()` +into **your** assembly: ```csharp +// generated, in your project services.AddScoped(typeof(IHandler), typeof(ThePackage.CreateOrderHandler)); ``` -Nothing is loaded or reflected over at run time, so this survives trimming — see -[Trimming and AOT](/guide/aot). - -## Name one assembly at a time - -There is no "scan everything I depend on". Point each convention at the assembly you want, using any -type from it as the marker. +Nothing is loaded or reflected over at run time, so this survives trimming exactly as your own +registrations do — see [Trimming and AOT](/guide/aot). -## What is visible +## Filters and shapes work the same -Only `public` types cross an assembly boundary, where a scan of your own project also sees `internal` -ones. Nothing warns about this — the generator cannot see what it cannot see. - -Types carrying `[SingletonService]` and friends are skipped, as they are in your own project. An -assembly whose types carry those attributes has its own module; compose that module rather than -scanning it. - -## Filters and shapes still apply +Everything from [Conventions](/guide/conventions#narrowing-what-matches) applies: ```csharp conventions.RegisterAll() @@ -44,18 +43,32 @@ conventions.RegisterAll() .AsSingleton(); ``` -## When to reach for it +## What you can and cannot see + +**Only `public` types cross an assembly boundary.** A scan of your own project also sees `internal` +types; a scan of a package does not. Nothing warns about this — the generator cannot report on what +it cannot see — so a convention that matches less than you expected is usually this. + +**One assembly at a time.** There is no "scan everything I depend on". Point each convention at the +assembly you mean. + +**Attributed types are skipped**, just as they are in your own project. An assembly whose types carry +`[SingletonService]` and friends already has its own module — compose that module instead of scanning +it, and you get the author's intended lifetimes rather than your guess at them. + +## When not to reach for it -Scanning is for assemblies you **do not own** — a package whose handlers or validators you want -registered. +Scanning is for assemblies you **do not own**. -For a project you own, give it its own module with its own conventions and compose through module -attributes. That works across assemblies already, and keeps each project in charge of its own -registrations. +For a project you do own, give it its own module with its own conventions and compose through +[module attributes](/guide/modules#composing-modules). That already works across assemblies, and it +keeps each project in charge of its own registrations rather than making the consumer guess at them. -Discovering assemblies at run time is not supported, since there is nothing to resolve at build time. +Discovering assemblies at run time is not supported at all, since there would be nothing to resolve +at build time. ## Diagnostics -A match from a referenced assembly has no source to point at, so -[DM0010](/reference/diagnostics#dm0010) and friends report at the `RegisterAll` line. +A match from a referenced assembly has no source location to point at, so +[DM0010](/reference/diagnostics#dm0010) and friends report at the `RegisterAll` line instead of at +the class. diff --git a/website/guide/services.md b/website/guide/services.md index e99a9c8..4c17414 100644 --- a/website/guide/services.md +++ b/website/guide/services.md @@ -1,7 +1,12 @@ # Registering services -Four attributes cover most registration. Each maps onto the `IServiceCollection` call you would -otherwise write. +Each registration line you would have written by hand answers three questions: how long the instance +lives, what type callers ask for, and how the registration is added to the collection. This page +covers how to answer each one with an attribute. + +## Lifetime + +The attribute name is the lifetime, and it maps directly onto the call it replaces: | Attribute | Emits | |---|---| @@ -15,45 +20,96 @@ otherwise write. public class SmtpEmailSender : IEmailSender { } ``` -A class with no interface registers as itself. A class with interfaces registers as the first one it -declares, unless you say otherwise with `As`. +```csharp +// generated +services.AddSingleton(typeof(IEmailSender), typeof(SmtpEmailSender)); +``` + +## What callers ask for -## Choosing the service type +By default, a class with interfaces registers as **the first interface it declares**, and a class +with no interface registers as itself. + +That default is wrong as soon as a class implements two interfaces for different reasons: + +```csharp +[SingletonService] +public class SmtpEmailSender : IEmailSender, IDiagnosticSource { } +``` + +Here `IEmailSender` happens to be first, but nothing about the code says that was deliberate — and +reordering the base list would silently change the registration. Say which one you meant: ```csharp [SingletonService(As = typeof(IEmailSender))] public class SmtpEmailSender : IEmailSender, IDiagnosticSource { } ``` -## Cross wiring +Two details of the default worth knowing, since neither is guessable: -`[CrossWireService]` is the "one instance, several front doors" registration. Resolving the concrete -type or any of its interfaces gives the same instance. +**Capability interfaces are passed over.** `IDisposable`, `IEquatable`, `IComparable`, +`INotifyPropertyChanged`, `IEnumerable` and their relatives describe something a class *can do*, +not what callers ask for. They are skipped when picking the default, so this registers as `IPool` +rather than `IDisposable`: + +```csharp +[SingletonService] +public class ConnectionPool : IDisposable, IPool { } +``` + +If a capability interface is the only one, the class registers as itself. This is inference only — +`[SingletonService(As = typeof(IDisposable))]` is always honoured. + +Note that framework interfaces which *are* genuine service roles stay eligible, so +`IEqualityComparer`, `IJsonTypeInfoResolver` and `IHttpClientFactory` all work as you would +expect. + +**A class declaring no interface of its own inherits the search.** The generator walks up the base +classes looking for one, which is what makes `class OrderRepository : RepositoryBase` register as +`IRepository`, and `class Worker : BackgroundService` register as `IHostedService`. If it finds +nothing but capability interfaces, the class registers as itself. + +### One instance behind several interfaces + +Sometimes both interfaces are the point. A cache with a read side and a write side wants **one +instance** reachable through either: ```csharp [CrossWireService] public class Cache : IReadCache, IWriteCache { } ``` -Registering the interfaces separately would give you one instance per service type instead. +```csharp +provider.GetRequiredService(); // same instance +provider.GetRequiredService(); // as this one +``` + +Registering the two interfaces separately would give you one instance per service type instead, which +for a cache means two caches and a bug that takes a while to find. + +## Several implementations of one interface -## Keys +When more than one implementation is registered, a key says which one you want: ```csharp [SingletonService(Key = "primary")] public class PrimaryConnection : IConnection { } + +[SingletonService(Key = "reporting")] +public class ReportingConnection : IConnection { } ``` ```csharp provider.GetRequiredKeyedService("primary"); ``` -The key is written into the registration as you wrote it, so a literal, a `const` or an enum member -all work. +The key is written into the registration exactly as you wrote it, so a string literal, a `const` or +an enum member all work. -## Registration strategy +## How the registration is added -`Using` chooses how the registration is added. +By default every attribute adds unconditionally, so registering the same service type twice leaves +two descriptors and the last one wins. `Using` changes that: | Value | Behaviour | |---|---| @@ -62,15 +118,20 @@ all work. | `TryEnumerable` | adds unless this exact service/implementation pair is present | | `Replace` | replaces an existing registration of the service type | +`Try` is the one a library wants for a default the application should be able to override: + ```csharp [SingletonService(Using = RegistrationType.Try)] public class DefaultClock : IClock { } ``` -## Factories +The application registers its own `IClock` and wins; if it does not, `DefaultClock` is there. -When a type cannot be constructed by the container, register a static factory method instead. The -attribute goes on the method. +## When the container cannot construct the type + +Some classes need something the container has no way to supply — a timestamp, a value from +configuration, an object built by a factory somewhere else. Put the attribute on a **static factory +method** instead of on the class: ```csharp public class SomeClass : ISomeInterface { @@ -82,22 +143,23 @@ public class SomeClass : ISomeInterface { } ``` -Every parameter of the factory is resolved from the container. +Every parameter of the factory method is resolved from the container; everything else is yours to +supply. -## Constructor selection +## Choosing a constructor -The greediest accessible constructor is used, matching `ActivatorUtilities`. To pick a specific one, -mark it: +With several constructors, the greediest accessible one is used — the same rule `ActivatorUtilities` +follows. To pin a specific one: ```csharp [ActivatorUtilitiesConstructor] public SomeClass(IDep one) { } ``` -## Generated factories +## Removing the container's reflection -By default the generator emits `typeof(Implementation)` and the container constructs it. Factory -generation emits a `new` expression instead, removing the container's reflection from the hot path: +By default the generator emits `typeof(Implementation)` and lets the container construct it, which it +does by reflection. Turning on factory generation emits a `new` expression instead: ```xml @@ -105,4 +167,19 @@ generation emits a `new` expression instead, removing the container's reflection ``` -See [MSBuild properties](/reference/msbuild) for the rest. +```csharp +// generated, with the property set +services.AddSingleton( + typeof(ISummaryProvider), + provider => new SummaryProvider(provider.GetRequiredService()) +); +``` + +Every constructor dependency becomes an explicit `GetRequiredService` call, so the container never +reflects over the constructor. Worth it when you are chasing startup time or targeting Native AOT +aggressively. See [MSBuild properties](/reference/msbuild) for the rest. + +## Next + +- [Conventions](/guide/conventions) — when one attribute per class stops scaling +- [Environments](/guide/environments) — registering a different implementation per environment diff --git a/website/guide/testing-mocks.md b/website/guide/testing-mocks.md index 37f4922..ce2ddc2 100644 --- a/website/guide/testing-mocks.md +++ b/website/guide/testing-mocks.md @@ -1,89 +1,140 @@ # Mocks and values -Not every parameter should come from the real container. Two attributes cover the rest, and a third -lets a single test override a registration. +## The problem -## Mocking - -`[Mock]` on a parameter substitutes that service. The substitute is **registered in the container**, -so anything resolved afterwards depends on the mock rather than the real implementation. +A provider built from your real modules gives you real services, which is usually the point — and +occasionally the problem. Two of the services behind `Weather` are non-deterministic: ```csharp -[assembly: NSubstituteSupport] +[SingletonService] +public class TemperatureProvider : ITemperatureProvider { + public int GetTemperature() => Random.Shared.Next(-20, 55); +} ``` +You cannot assert on a forecast built out of random numbers. But you do not want to abandon the +container either — `Weather` and `SummaryProvider` should still be the real ones, wired the real way. +You want to replace exactly two leaves of the graph and leave the rest alone. + +## How DependencyModules helps + +Mark the parameter `[Mock]` and that service is **replaced in the container** before anything is +resolved. Everything constructed afterwards gets the substitute: + ```csharp [ModuleTest] -[ApplicationModule] -public void SendsToTheCustomerAddress(OrderService service, [Mock] IEmailSender sender) { - service.Place(new Order { Email = "a@b.com" }); +public void GetStaticForecast( + Weather weather, + [Mock] ITemperatureProvider temperatureProvider, + [Mock] IAiSummaryProvider aiSummaryProvider) { + + temperatureProvider.GetTemperature().Returns(38); + aiSummaryProvider.GetSummary().Returns("Sunny"); - sender.Received().Send("a@b.com"); + var forecast = weather.GetWeatherForecast().ToArray(); + + Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); + Assert.All(forecast, day => Assert.Equal("Sunny", day.Summary)); } ``` -`OrderService` is constructed by the container and receives the same substitute the test holds. You -do not wire anything together. +`Weather` is still constructed by the container, and it receives the same substitutes the test is +holding. You wire nothing together yourself. -`[NSubstituteSupport]` supplies the mocking library. Without it `[Mock]` fails with a message saying -so. Like the module attributes it applies at assembly, class or method level; assembly is usually -right. +Note what stayed real: `SummaryProvider` was not mocked, so the call still travels +`Weather` → `SummaryProvider` → `IAiSummaryProvider`. Only the leaf was swapped. -## Supplying values +::: tip Register the mocking library once +`[Mock]` needs a mocking framework, supplied by a separate package. Pick the one you already use: -Some parameters are not services at all. `[InjectValues]` provides them, and it mixes with -resolution: a record can take both a service and a literal. +| Package | Attribute | +|---|---| +| `DependencyModules.NSubstitute` | `[NSubstituteSupport]` | +| `DependencyModules.Moq` | `[MoqSupport]` | +| `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` | + +```shell +dotnet add package DependencyModules.NSubstitute +``` ```csharp -public record InjectModel(IDependencyOne DependencyOne, string StringValue); +[assembly: NSubstituteSupport] +``` -[ModuleTest] -[SutModule] -public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) { - Assert.NotNull(model.DependencyOne); // from the container - Assert.Equal("Hello World!", model.StringValue); // from the attribute -} +Without one, `[Mock]` fails with a message telling you so. Like the module attributes it works at +assembly, class or method level; assembly is almost always right. + +With NSubstitute and FakeItEasy the injected instance is also what you configure. Moq separates the +two, so the container gets `Mock.Object` and you reach the mock with `Mock.Get(instance)`: + +```csharp +Mock.Get(summaryProvider).Setup(x => x.Summarize(It.IsAny())).Returns("mild"); ``` +::: -The values are matched to the constructor parameters the container cannot supply, so you only list -the ones it could not work out for itself. +## When you want a real object, not a mock -## Registering something for one test +A mock is right when you intend to **assert on the interaction** — what was called, with which +arguments. When you instead want a working implementation that simply behaves differently, a mock +makes you stub out every member you touch. -`[TestExport]` adds a registration to the test's container without touching the module. Useful for a -stub you want the real container to construct, or for overriding one service in one place. +`[TestExport]` registers a real type into the test's container without touching the module: ```csharp +public class FixedClock : IClock { + public DateTime UtcNow => new(2026, 1, 1); +} + [ModuleTest] -[ApplicationModule] [TestExport(typeof(IClock), Implementation = typeof(FixedClock), Lifetime = ServiceLifetime.Singleton)] -public void UsesTheFixedClock(IOrderService service) { } +public void OrdersAreStampedWithTheCurrentTime(IOrderService service) { } ``` +`FixedClock` is constructed by the container, so it can have dependencies of its own. + | Property | | |---|---| | *(constructor)* | the service type | | `Implementation` | defaults to the service type when omitted | | `Lifetime` | defaults to `Transient` | -It applies at assembly, class or method level, so a stub every test needs can sit at the top of the -file once. +It also applies at assembly, class or method level, so a stub every test needs can sit in your +bootstrap file once. + +## When the parameter is not a service at all + +Sometimes a test parameter is data — a string, an id, a record combining both. `[InjectValues]` +supplies the parts the container cannot: + +```csharp +public record InjectModel(IDependencyOne DependencyOne, string StringValue); + +[ModuleTest] +public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) { + Assert.NotNull(model.DependencyOne); // resolved from the container + Assert.Equal("Hello World!", model.StringValue); // supplied by the attribute +} +``` + +The values are matched against the constructor parameters the container **cannot** supply, so you +list only what it could not work out for itself. -## Which one to reach for +## Choosing between the three -| | | +| | Reach for it when | |---|---| | `[Mock]` | you want to assert on the interaction — what was called, with what | | `[TestExport]` | you want a real object with different behaviour, constructed by the container | | `[InjectValues]` | the parameter is data, not a service | -## A trap worth knowing +## A trap worth knowing about -An [intercepted](/guide/interception) service resolves as a **generated wrapper**, so this fails: +An [intercepted](/guide/interception) service resolves as a **generated wrapper**, not as your class. +So this fails, confusingly: ```csharp Assert.IsType(provider.GetRequiredService()); // it is Orders_Intercepted ``` -Assert on the interface or on behaviour instead. The same applies to a -[decorated](/guide/decorators) service, where the outermost decorator is what resolves. +Assert on the interface, or on behaviour. The same applies to a [decorated](/guide/decorators) +service, where what resolves is the outermost decorator. diff --git a/website/guide/testing-registrations.md b/website/guide/testing-registrations.md index 72685ce..f63421b 100644 --- a/website/guide/testing-registrations.md +++ b/website/guide/testing-registrations.md @@ -1,8 +1,19 @@ # Testing registrations -The xUnit package tests behaviour *through* the container. For assertions about the registrations -themselves — lifetimes, keys, how many types matched — build the collection directly and look at it. -No test framework integration is involved. +## The problem + +`[ModuleTest]` answers questions about **behaviour**: resolve a service, call it, assert on what it +did. Some questions are not about behaviour at all. + +"Did my convention match exactly the three repositories I meant, and not the test double someone +added last week?" You cannot answer that by resolving one service — resolving works fine whether the +convention matched three types or thirty. The thing you want to inspect is the registration list +itself. + +## How DependencyModules helps + +There is nothing to learn here, and that is the point. Modules apply to a plain `IServiceCollection`, +so you build one and read it: ```csharp using DependencyModules.Runtime; @@ -17,10 +28,12 @@ Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); Assert.Equal(typeof(SqlRepository), descriptor.ImplementationType); ``` -## Testing a convention +No test-framework integration, no attributes — an ordinary `[Fact]` works. -This is the shape that matters most, because the interesting question about a -[convention](/guide/conventions) is usually *which types matched* rather than what one of them does. +## Pinning what a convention matched + +This is the shape that earns its keep, because the interesting question about a +[convention](/guide/conventions) is usually *which types matched*: ```csharp var registered = services @@ -32,13 +45,14 @@ var registered = services Assert.Equal(["OrderRepository", "ProductRepository"], registered); ``` -Assert on the **whole set** rather than using `Assert.Contains` — that is what catches a convention -quietly picking up an extra type. +Assert on the **whole set**, not with `Assert.Contains`. A containment check passes happily while +your convention quietly picks up a fourth type that someone adds next year — which is precisely the +failure mode conventions have. ## Testing conditional registrations -Registrations gated on the [environment](/guide/environments) are decided when the modules are -applied, so the environment has to be supplied at that point: +Registrations gated on the [environment](/guide/environments) are decided **when the modules are +applied**, so the environment has to be supplied at that moment: ```csharp var services = new ServiceCollection(); @@ -49,12 +63,13 @@ Assert.IsType( services.BuildServiceProvider().GetRequiredService()); ``` -::: warning Name the environment -Supply nothing and the process environment is used, which defaults to `"Production"`. A test for a -development-only service that forgets this tests the wrong branch. +::: warning Always name the environment +Supply nothing and the **process** environment is used, which defaults to `"Production"`. A test for +a development-only service that forgets this quietly tests the other branch and passes for the wrong +reason. ::: -A theory covers both sides in one place: +Both sides fit in one theory: ```csharp [Theory] @@ -69,18 +84,20 @@ public void SelectsTheSenderByEnvironment(string environment, Type expected) { } ``` -## Testing decorators +## Testing decorator order -Assert on the resolved type, and on the effect. For [ordering](/guide/decorators#ordering), have each -decorator contribute to a string — far clearer than reflecting over the chain: +Reflecting over a decorator chain is painful and tells you little. Have each decorator contribute to +a string instead, and assert on the result: ```csharp Assert.Equal("outer(inner(core))", provider.GetRequiredService().Describe()); ``` +That reads as the nesting it describes, and fails with a message you can act on. + ## Testing interceptors -Easiest through something the interceptor records: +Easiest through something the interceptor writes to: ```csharp var provider = services.BuildServiceProvider(); @@ -91,10 +108,10 @@ provider.GetRequiredService().Count("acme"); Assert.Equal(["intercepted Count"], log.Lines); ``` -::: danger Build one provider -Building a provider twice gives you two independent sets of singletons. Resolving a service from one -and asserting on a singleton captured from another compares two different instances, and the failure -looks like the registration is broken. +::: danger Build the provider once +`BuildServiceProvider()` called twice gives you two providers with two independent sets of +singletons. Resolve the service from one and the log from the other and you are comparing two +different instances — the assertion fails and the registration looks broken when it is not. ```csharp var provider = services.BuildServiceProvider(); // once @@ -102,13 +119,12 @@ var provider = services.BuildServiceProvider(); // once var service = provider.GetRequiredService(); var log = provider.GetRequiredService(); // same provider, same singleton ``` - ::: ## Testing what a package scan found -A [referenced-assembly scan](/guide/scanning) is worth pinning: a package upgrade can change what -matches. +A [referenced-assembly scan](/guide/scanning) is worth pinning, because a package upgrade can change +what matches without anything in your code changing: ```csharp var policies = provider.GetServices() @@ -119,5 +135,5 @@ var policies = provider.GetServices() Assert.Equal(["first", "second"], policies); ``` -Remember only `public` types cross an assembly boundary, so a scan finds strictly less than the same -convention would in your own project. +Remember that only `public` types cross an assembly boundary, so a scan finds strictly less than the +same convention would in your own project. diff --git a/website/guide/testing.md b/website/guide/testing.md index 42fd682..5d32e4f 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -1,50 +1,107 @@ # Testing modules -Testing a module means building a provider from it and asking what came out. The xUnit package does -that for you: a test names the modules it wants, declares the services it needs as parameters, and -gets them injected. +## The problem + +Here is a service with two dependencies, one of which has a dependency of its own: + +```csharp +[SingletonService] +public class Weather(ISummaryProvider summaryProvider, ITemperatureProvider temperatureProvider) { + public IEnumerable GetWeatherForecast() { /* … */ } +} +``` + +To test it, you have two options and neither is good. + +**Construct it by hand.** You end up rebuilding the object graph in the test: + +```csharp +[Fact] +public void GetForecast() { + var weather = new Weather( + new SummaryProvider(new AiSummaryProvider()), + new TemperatureProvider()); + // … +} +``` + +Every constructor change breaks every test that touches the type, and the wiring you are testing is +the wiring you just wrote — not the wiring your application actually uses. + +**Build a provider in each test.** Correct, but it is four lines of ceremony before you get to the +part you care about, repeated in every test, and now you have a provider to dispose: + +```csharp +[Fact] +public void GetForecast() { + var services = new ServiceCollection(); + services.AddModule(); + using var provider = services.BuildServiceProvider(); + + var weather = provider.GetRequiredService(); + // … +} +``` + +## How DependencyModules helps + +`DependencyModules.xUnit` does the second thing for you. You say which modules to load, and the +services your test needs arrive as **method parameters**, resolved from a provider built out of your +real modules: ```shell dotnet add package DependencyModules.xUnit -dotnet add package DependencyModules.xUnit.NSubstitute +dotnet add package DependencyModules.NSubstitute ``` -## A first test - ```csharp using DependencyModules.xUnit.Attributes; -public class OrderServiceTests { +public class WeatherTests { [ModuleTest] [ApplicationModule] - public void PlacingAnOrderSendsConfirmation(OrderService service) { - service.Place(new Order()); + public void GetForecast(Weather weather) { + var forecast = weather.GetWeatherForecast().ToArray(); + + Assert.Equal(5, forecast.Length); } } ``` -`[ModuleTest]` replaces `[Fact]`. The module attribute says which modules to load — it is the -attribute the generator produced for `ApplicationModule`. Every parameter is resolved from the -provider that results. +Three things are happening in that test: + +- **`[ModuleTest]`** replaces `[Fact]`. It builds a service provider and runs your method against it. +- **`[ApplicationModule]`** says which modules to load. It is the attribute the generator produced + for your module — see [composing modules](/guide/modules#composing-modules). +- **`Weather weather`** is resolved from the resulting provider, along with its whole dependency + graph. -## Where module attributes go +Change `Weather`'s constructor and the test keeps compiling, because the test never mentioned the +constructor. -They apply at assembly, class or method level, and they accumulate. Put the ones every test needs at -the assembly level and stop repeating them: +## Stop repeating the module list + +Module attributes apply at **assembly, class or method level**, and they accumulate. Put the ones +every test needs in one file at the assembly level: ```csharp +// Bootstrap.cs +using DependencyModules.NSubstitute; + [assembly: ApplicationModule] [assembly: NSubstituteSupport] ``` +Every test in the project now gets `ApplicationModule` without saying so: + ```csharp -public class OrderServiceTests { +public class WeatherTests { [ModuleTest] - public void UsesTheAssemblyModules(OrderService service) { } + public void UsesTheAssemblyModules(Weather weather) { } [ModuleTest] - [DiagnosticsModule] // this test also gets DiagnosticsModule - public void AddsOneMore(OrderService service, IProfiler profiler) { } + [DiagnosticsModule] // this test gets DiagnosticsModule as well + public void AddsOneMore(Weather weather, IProfiler profiler) { } } ``` @@ -57,21 +114,19 @@ after: [ModuleTest] [InlineData("one")] [InlineData("two")] -[SutModule] -public void MultipleRows(string value, IDependencyOne one) { - Assert.NotNull(value); - Assert.NotNull(one); +public void MultipleRows(string value, ITemperatureProvider provider) { + Assert.NotNull(value); // from [InlineData] + Assert.NotNull(provider); // from the container } ``` -## Scopes +## Scopes and isolation -A `[ModuleTest]` gets its own provider, so singletons do not leak between tests. Within one test you -can create scopes as usual: +Each `[ModuleTest]` gets **its own provider**, so a singleton mutated in one test cannot leak into +another. Within a test, ask for `IServiceProvider` and create scopes as usual: ```csharp [ModuleTest] -[ApplicationModule] public void ScopedServicesAreScoped(IServiceProvider provider) { using var first = provider.CreateScope(); using var second = provider.CreateScope(); @@ -83,22 +138,22 @@ public void ScopedServicesAreScoped(IServiceProvider provider) { } ``` -## What to test, and what not to +## What is worth testing -Testing that `[SingletonService]` produced `AddSingleton` is testing this library. Assert instead on -the things the compiler cannot check for you: +Asserting that `[SingletonService]` produced an `AddSingleton` call is testing this library, and this +library has its own tests. Spend your assertions on the things the compiler cannot check: -- a convention matched the set of types you meant — and, more usefully, did **not** match the ones - you did not -- a conditional registration selects the right implementation in each environment -- decorators nest in the order you intended +- a [convention](/guide/conventions) matched the types you meant — and, more usefully, did **not** + match the ones you did not +- a [conditional registration](/guide/environments) picks the right implementation per environment +- [decorators](/guide/decorators) nest in the order you intended - a service resolves at all, which catches a missing registration in a module you compose -The build covers the rest — a convention that matches nothing is -[DM0005](/reference/diagnostics#dm0005) and a service that cannot be constructed is -[DM0002](/reference/diagnostics#dm0002), both before a test runs. +The build already covers a good deal of the rest. A convention that matches nothing is +[DM0005](/reference/diagnostics#dm0005), and a service that cannot be constructed is +[DM0002](/reference/diagnostics#dm0002) — both before a test runs. ## Next -- [Mocks and values](/guide/testing-mocks) — substituting services and supplying literals +- [Mocks and values](/guide/testing-mocks) — faking one service while the rest stays real - [Testing registrations](/guide/testing-registrations) — asserting on what a module registered diff --git a/website/guide/troubleshooting.md b/website/guide/troubleshooting.md index 3416794..e5da103 100644 --- a/website/guide/troubleshooting.md +++ b/website/guide/troubleshooting.md @@ -1,11 +1,13 @@ # Troubleshooting -If services are not registered the way you expect, three steps produce almost everything needed to -diagnose it. +Something is not registered the way you expected. Because every registration is generated code sitting +in your own assembly, you can go and look at it rather than guessing — which makes this a short page. + +Three steps produce almost everything needed to diagnose a problem, in the order worth doing them. ## 1. Read the generated code -The registrations the generator produced are the ground truth. +This answers "was it registered, and as what" definitively, and it is usually the only step you need. ```xml @@ -13,19 +15,29 @@ The registrations the generator produced are the ground truth. ``` -The files appear under `obj/`. `YourModule.Dependencies.g.cs` holds the registrations, -`YourModule.Module.g.cs` the module plumbing, and there are separate files for decorators and -interceptors. +The files appear under `obj/`: + +| File | Holds | +|---|---| +| `YourModule.Dependencies.g.cs` | the registrations | +| `YourModule.Module.g.cs` | the module plumbing | +| *(separate files)* | decorators and interceptors | + +A service missing from `Dependencies.g.cs` was never discovered — jump to the common causes below. A +service present but registered as the wrong service type is a question about `As` and matching, and +[Registering services](/guide/services#what-callers-ask-for) covers it. ::: warning Stale files If you redirect `CompilerGeneratedFilesOutputPath` into your project, delete the folder between runs. -Stale files compile alongside fresh ones and produce a wall of `CS0111`/`CS0579`. +Stale files compile alongside fresh ones and produce a wall of `CS0111`/`CS0579` that has nothing to +do with your actual problem. ::: ## 2. Turn on the generator log -It records the configuration in effect, every module and service discovered, and anything skipped -along with the reason. +When the generated file does not explain it, the log says what the generator saw and what it decided +— the configuration in effect, every module and service discovered, and anything skipped **along with +the reason**. ```xml @@ -35,34 +47,43 @@ along with the reason. ## 3. Check for DM diagnostics -The generator reports what it can detect at build time. See the -[diagnostics reference](/reference/diagnostics) for what each one means and what to do about it. +The generator reports what it can detect at build time, and a good deal of what goes wrong here is +already a warning you have not read yet. See the [diagnostics reference](/reference/diagnostics). ## Common causes -**The module is not `partial`.** [DM0003](/reference/diagnostics#dm0003). The generator cannot -complete a class it cannot extend. +**The module is not `partial`.** [DM0003](/reference/diagnostics#dm0003). The generator completes +your class; without `partial` there is nothing to complete. **The module is nested inside another type.** A nested module generates a separate, detached class -rather than completing the partial, so its registrations never run. Declare it directly in a +instead of completing your partial, so its registrations never run. Declare modules directly in a namespace. **A convention matched nothing.** [DM0005](/reference/diagnostics#dm0005) — usually a renamed interface or a typo in a filter. -**A service was registered but resolves to the wrong implementation.** The container takes the last -matching descriptor for a single resolve. Check the order in the generated file — conditional -registrations are emitted after unconditional ones. - **A convention picked up something unexpected.** Narrow it with a -[filter](/guide/conventions#narrowing-what-matches). Watch name patterns in particular — `*Handler` +[filter](/guide/conventions#narrowing-what-matches). Watch name patterns in particular: `*Handler` matches `LoggingHandler` too. -**`AddModule` called more than once.** Modules compose through attributes; calling `AddModule` inside -a module or several times at the root duplicates registrations. +**The wrong implementation resolves.** The container takes the **last** matching descriptor for a +single resolve. Check the order in the generated file, remembering that conditional registrations are +emitted after unconditional ones — see [overriding a default](/guide/environments#overriding-a-default). + +**A test resolves the wrong environment branch.** Supply nothing and the *process* environment is +used, defaulting to `"Production"`. See +[testing conditional registrations](/guide/testing-registrations#testing-conditional-registrations). + +**An assertion on the concrete type fails.** An [intercepted](/guide/interception) or +[decorated](/guide/decorators) service resolves as the wrapper, not your class. + +**`AddModule` called more than once.** Modules compose through +[attributes](/guide/modules#composing-modules); calling `AddModule` inside a module, or several times +at the root, duplicates registrations. ## Reporting a problem -Please include the generator log and the generated file in any +Please include **the generator log and the generated file** in any [issue](https://github.com/ipjohnson/DependencyModules/issues). Between them they show whether a -service was discovered, which realm it landed in, and what configuration was in effect. +service was discovered, which realm it landed in, and what configuration was in effect — which is +most of the way to a diagnosis before anyone has to reproduce it. diff --git a/website/index.md b/website/index.md index 42624da..47171ec 100644 --- a/website/index.md +++ b/website/index.md @@ -5,8 +5,9 @@ hero: name: DependencyModules text: Dependency injection, decided at compile time tagline: >- - Attributes and conventions become ordinary registration code during the build. Nothing reflects, - nothing scans an assembly at startup, and the trimmer can follow every registration you declared. + Declare registration next to the class it belongs to, and a source generator writes the + IServiceCollection calls during the build. Nothing reflects, nothing scans at startup, and the + trimmer can follow every registration you declared. image: src: /hero.svg alt: Declarations on the left becoming generated registration code on the right @@ -32,16 +33,16 @@ features: - title: Conventions without reflection details: >- - Declare what to register and the generator resolves the matches during the build. Assignability, - namespaces, attributes and name globs — including types in a referenced package. + Declare what to register once and the generator resolves the matches during the build. + Assignability, namespaces, attributes and name globs — including types in a referenced package. link: /guide/conventions linkText: How conventions work - title: Trimming and Native AOT safe details: >- Each match is emitted as a literal typeof(), which the trimmer roots and which carries the - constructor along with it. The capability that breaks reflection-based scanners is the one - that works here. + constructor along with it. The capability that breaks reflection-based scanners is the one that + works here. link: /guide/aot linkText: Why it survives trimming @@ -70,9 +71,24 @@ features:
-## What it looks like +## The problem -Mark a class, and the registration is written for you. +Every .NET application keeps a list like this, and nothing checks that it is complete: + +```csharp +services.AddScoped(); +services.AddSingleton(); +// … another two hundred lines +``` + +Forget a line and you find out at run time, in the environment you deployed to. Reach for a runtime +scanner instead and you trade that for three new problems: you can no longer read what was +registered, the scan runs on every start, and the trimmer cannot see through reflection — so a +published, trimmed build registers nothing at all. + +## What it looks like instead + +Mark the class, and the registration is written for you during the build. ```csharp [SingletonService] @@ -88,7 +104,8 @@ var services = new ServiceCollection(); services.AddModule(); ``` -Or declare a rule once, and let it cover everything that fits. +Or declare a rule once, and let it cover everything that fits — including the handler somebody adds +next year. ```csharp [DependencyModule] @@ -101,7 +118,12 @@ public partial class HandlerModule : IConventionModule { ``` That body never runs. It is read during the build, and what comes out the other side is the same -registration code you would have written by hand. +registration code you would have written by hand: + +```csharp +services.AddScoped(typeof(IRequestHandler), typeof(CreateOrderHandler)); +services.AddScoped(typeof(IRequestHandler), typeof(RenameOrderHandler)); +```
diff --git a/website/reference/attributes.md b/website/reference/attributes.md index a490cc9..a17db27 100644 --- a/website/reference/attributes.md +++ b/website/reference/attributes.md @@ -1,6 +1,11 @@ # Attributes -Every attribute lives in `DependencyModules.Runtime.Attributes`. +Every attribute this library defines, with its properties — for looking one up once you know what you +are after. If you are working out *which* attribute you want, the guide covers that: +[registering services](/guide/services), [modules](/guide/modules), +[decorators](/guide/decorators) and [environments](/guide/environments). + +All of them live in `DependencyModules.Runtime.Attributes`. ## Modules diff --git a/website/reference/diagnostics.md b/website/reference/diagnostics.md index 4f27d89..5a78bb2 100644 --- a/website/reference/diagnostics.md +++ b/website/reference/diagnostics.md @@ -1,11 +1,19 @@ # Diagnostics -Each code can be tuned or silenced through `.editorconfig`: +The generator reports what it can work out at build time as `DM####` codes, so a registration mistake +shows up in the IDE rather than as a resolution failure at startup. This page says what each one +means and what to do about it. + +They behave like any other analyzer diagnostic, so each can be tuned or silenced through +`.editorconfig`: ```ini dotnet_diagnostic.DM0010.severity = none ``` +`DM0010` and `DM0011` are informational and exist to make registration visible at the class. Silence +them if the IDE gets noisy; the rest are worth reading. + | Code | Severity | Meaning | |---|---|---| | [DM0001](#dm0001) | Error | The generator failed | diff --git a/website/reference/msbuild.md b/website/reference/msbuild.md index 201b07f..fe2bde2 100644 --- a/website/reference/msbuild.md +++ b/website/reference/msbuild.md @@ -1,7 +1,8 @@ # MSBuild properties -Set these in a `PropertyGroup` in the consuming project. They reach the generator through the -package's `build/*.targets`. +Project-wide settings that change what the generator emits. Set them in a `PropertyGroup` in the +consuming project; they reach the generator through the package's `build/*.targets`, so they work +when the packages are installed from NuGet. | Property | Default | | |---|---|---|