A demonstration project showcasing how to use dependency injection with TUnit β a modern, source-generated .NET testing framework. The system under test is a simple Gravity Calculator API that computes gravitational force on different planets.
This repository exists to answer one question: how do you wire up dependency injection in TUnit tests?
Along the way it shows how to:
- Create a custom
DependencyInjectionDataSourceAttributeimplementation - Reuse the exact same DI registrations your application uses
- Write test classes that receive services through constructor injection β no
newkeywords, no service locators
The core insight of this demo is that TUnit lets your test classes consume dependencies the same way your production code does. The glue is a single extension method β AddGravity() β called from two places:
Production β Program.cs:
builder.Services.AddGravity();Tests β GravityDIAttribute.cs:
var serviceCollection = new ServiceCollection();
serviceCollection.AddGravity();That single shared call means the object graph is identical. Now look at how similar the consumers become:
| Production controller | Test class | |
|---|---|---|
| File | GravityController.cs |
GravityCalculatorTests.cs |
| DI trigger | ASP.NET Core's built-in DI | [GravityDI] attribute |
| Injection style | Primary constructor parameter | Primary constructor parameter |
The controller:
[ApiController]
[Route("api/[controller]")]
public class GravityController(IGravityCalculator calculator) : ControllerBase
{
[HttpGet("calculate")]
public ActionResult GetGravity([FromQuery] double weightKg, [FromQuery] Planet planet)
{
double result = calculator.CalculateForce(weightKg, planet);
// ...
}
}The test class:
[GravityDI]
public class GravityCalculatorTests(IGravityCalculator calculator)
{
[Test]
[Arguments(100.0, Planet.Earth, 980.7)]
public async Task CalculateForce_ReturnsCorrectForce(double mass, Planet planet, double expected)
{
var result = calculator.CalculateForce(mass, planet);
await Assert.That(result).IsEqualTo(expected);
}
}Notice the symmetry: both classes declare IGravityCalculator calculator as a primary constructor parameter and simply use it. Neither class knows how the calculator was built, what its dependencies are, or how it was registered. The framework β ASP.NET Core in production, TUnit in tests β resolves it from the same AddGravity() registrations.
Most testing frameworks treat DI as an afterthought. You either new up your dependencies by hand, or you bolt on a third-party container with ceremony. TUnit's DependencyInjectionDataSourceAttribute makes DI a first-class concept at the test-class level:
- The pattern is identical to production code. A controller receives
IGravityCalculatorvia its constructor; a test class receives it the same way. There is no mental context switch. - The registrations are shared. You call
AddGravity()in bothProgram.csandGravityDIAttribute. If you add a new service to the app, the tests get it automatically. - Swapping is trivial. Need a mock? Create a
TestGravityDIAttributethat registers fakes instead β the test class itself doesn't change at all. - Cleanup is built-in. The
IAsyncDisposablescope means theServiceProvider(and all its disposable services) is torn down automatically after each test class.
In short: TUnit lets you write test classes that look, feel, and behave like production consumers of your DI container. That is the elegance.
The Gravity API registers all of its services through the AddGravity() extension method:
public static class DependencySetup
{
public static IServiceCollection AddGravity(this IServiceCollection services)
{
services.AddSingleton<IGravityConfiguration, GravityConfiguration>();
services.AddScoped<IGravityCalculator, GravityCalculator>();
return services;
}
}Both the production API (Program.cs) and the test project call this same method β a single source of truth for your object graph.
TUnit provides the abstract base class DependencyInjectionDataSourceAttribute<TScope>. You subclass it to tell TUnit how to build a DI container and how to resolve services from it.
The full implementation lives in GravityDIAttribute.cs:
public class GravityDIAttribute : DependencyInjectionDataSourceAttribute<GravityDIAttribute.Scope>
{
// Called once per test class instance β build the container here
public override Scope CreateScope(DataGeneratorMetadata dataGeneratorMetadata)
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddGravity(); // β reuse app registrations
var serviceProvider = serviceCollection.BuildServiceProvider();
return new Scope(serviceProvider);
}
// Called to resolve each constructor parameter
public override object Create(Scope scope, Type type)
{
return scope.ServiceProvider.GetRequiredService(type);
}
// Disposable wrapper so the container is cleaned up after tests
public class Scope(IServiceProvider serviceProvider) : IAsyncDisposable
{
public IServiceProvider ServiceProvider { get; } = serviceProvider;
public ValueTask DisposeAsync()
{
if (serviceProvider is IDisposable disposable)
{
disposable.Dispose();
}
return ValueTask.CompletedTask;
}
}
}| Override | Responsibility |
|---|---|
CreateScope() |
Builds a fresh ServiceProvider using the app's DI registrations |
Create() |
Resolves a service from the container for constructor injection |
Scope |
Implements IAsyncDisposable so the ServiceProvider is disposed after the test class is done |
Decorate the class with [GravityDI] and declare the dependencies you need as constructor parameters. TUnit takes care of the rest.
The full test class lives in GravityCalculatorTests.cs:
[GravityDI] // β activates DI
public class GravityCalculatorTests(IGravityCalculator calculator) // β constructor injection
{
[Test]
[Arguments(100.0, Planet.Earth, 980.7)]
[Arguments(100.0, Planet.Moon, 162.0)]
[Arguments(100.0, Planet.Mars, 371.0)]
[Arguments(50.0, Planet.Jupiter, 1239.5)]
public async Task CalculateForce_ReturnsCorrectForce(
double mass, Planet planet, double expected)
{
// Act
var result = calculator.CalculateForce(mass, planet);
// Assert
await Assert.That(result).IsEqualTo(expected);
}
}What happens at runtime:
- TUnit sees
[GravityDI]and callsCreateScope()β a newServiceProvideris built. - TUnit calls
Create(scope, typeof(IGravityCalculator))β theGravityCalculatorinstance is resolved (along with its own dependency onIGravityConfiguration). - The resolved service is passed into the constructor.
- After the tests complete,
Scope.DisposeAsync()tears down the container.
βββ Gravity/ # ASP.NET Core Web API
β βββ Controllers/
β β βββ GravityController.cs # API endpoint (receives IGravityCalculator via DI)
β βββ Interfaces/
β β βββ IGravityCalculator.cs # Calculator contract
β β βββ IGravityConfiguration.cs # Configuration contract
β βββ Models/
β β βββ Planet.cs # Planet enum
β βββ Services/
β β βββ GravityCalculator.cs # Calculator implementation
β β βββ GravityConfiguration.cs # Planet gravity factors
β βββ DependencySetup.cs # AddGravity() β shared by app and tests β
β βββ Program.cs # Calls AddGravity() for production
β
βββ Gravity.Test/ # TUnit test project
β βββ Data/
β β βββ GravityDIAttribute.cs # Calls AddGravity() for tests β
β βββ GravityCalculatorTests.cs # Receives IGravityCalculator via DI β
β
βββ Gravity.slnx # Solution file
git clone https://github.com/your-username/TUnitPublicDemo.git
cd TUnitPublicDemo
dotnet buildcd Gravity.Test
dotnet runOr with detailed output:
dotnet run -- --report-trx --output ./test-resultsNote
Why dotnet run instead of dotnet test?
TUnit is architecturally different from traditional .NET test frameworks. It uses source generators to discover and wire up tests at compile time rather than relying on runtime reflection. A TUnit test project compiles into a standalone executable with its own entry point β it is not a class library that needs a separate test host to load it.
Because of this, dotnet run simply executes the compiled binary directly. While dotnet test still works (TUnit ships a VSTest adapter for IDE compatibility), dotnet run is the recommended approach because:
dotnet run |
dotnet test |
|
|---|---|---|
| Speed | Runs the executable directly β no hosting overhead | Spins up the VSTest engine first |
| Architecture fit | TUnit projects are executables; running them as such is natural | Treats the project as a test library loaded by an external host |
| CLI output | TUnit's built-in console reporter with rich, real-time output | Standard VSTest output |
| Argument passing | Pass TUnit flags directly after -- |
Must conform to dotnet test argument format |
cd Gravity
dotnet runThe API will be available at http://localhost:5073.
GET /api/gravity/calculate?weightKg=70&planet=Earth| Parameter | Type | Description |
|---|---|---|
weightKg |
double |
Mass in kilograms |
planet |
string |
Planet name (Earth, Mars, Jupiter, β¦) |
curl "http://localhost:5073/api/gravity/calculate?weightKg=70&planet=Earth"| Project | Package | Version |
|---|---|---|
| Gravity | Microsoft.AspNetCore.OpenApi | 10.0.0 |
| Gravity.Test | TUnit | 1.13.8 |
This project is licensed under the GNU General Public License v3.0 β see the LICENSE file for details.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request