Testing registrations
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:
using DependencyModules.Runtime;
var services = new ServiceCollection();
services.AddModules(new DataModule());
var descriptor = Assert.Single(services, d => d.ServiceType == typeof(IRepository));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(SqlRepository), descriptor.ImplementationType);No test-framework integration, no attributes — an ordinary [Fact] works.
Pinning what a convention matched
This is the shape that earns its keep, because the interesting question about a convention is usually which types matched:
var registered = services
.Where(d => d.ServiceType == typeof(IRepository))
.Select(d => d.ImplementationType!.Name)
.OrderBy(name => name)
.ToArray();
Assert.Equal(["OrderRepository", "ProductRepository"], registered);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 are decided when the modules are applied, so the environment has to be supplied at that moment:
var services = new ServiceCollection();
services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule());
Assert.IsType<FakeEmailSender>(
services.BuildServiceProvider().GetRequiredService<IEmailSender>());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.
Both sides fit in one theory:
[Theory]
[InlineData("Development", typeof(FakeEmailSender))]
[InlineData("Production", typeof(SmtpEmailSender))]
public void SelectsTheSenderByEnvironment(string environment, Type expected) {
var services = new ServiceCollection();
services.AddModules(new ModuleEnvironment(environment), new ApplicationModule());
Assert.IsType(expected, services.BuildServiceProvider().GetRequiredService<IEmailSender>());
}Testing decorator order
Reflecting over a decorator chain is painful and tells you little. Have each decorator contribute to a string instead, and assert on the result:
Assert.Equal("outer(inner(core))", provider.GetRequiredService<IOrdered>().Describe());That reads as the nesting it describes, and fails with a message you can act on.
Testing interceptors
Easiest through something the interceptor writes to:
var provider = services.BuildServiceProvider();
var log = provider.GetRequiredService<InterceptLog>();
provider.GetRequiredService<IOrders>().Count("acme");
Assert.Equal(["intercepted Count"], log.Lines);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.
var provider = services.BuildServiceProvider(); // once
var service = provider.GetRequiredService<IOrders>();
var log = provider.GetRequiredService<InterceptLog>(); // same provider, same singletonTesting what a package scan found
A referenced-assembly scan is worth pinning, because a package upgrade can change what matches without anything in your code changing:
var policies = provider.GetServices<IPackagePolicy>()
.Select(policy => policy.Name)
.OrderBy(name => name)
.ToArray();
Assert.Equal(["first", "second"], policies);Remember that only public types cross an assembly boundary, so a scan finds strictly less than the same convention would in your own project.