Cart
Most teams "have tests". Fewer teams have tests that you trust when you’re about to merge a risky change. This post shows the small set of habits and tools that make unit tests useful on real .NET projects—without turning every change into a refactor marathon.
We’ll use xUnit, Shouldly, and NSubstitute. Code samples are short, with a tiny Minimal API example at the end to show how to keep handlers unit-testable.
Unit testing involves isolating the smallest testable parts of an application, typically methods or functions, and verifying their correctness through automated tests.
Everything else (HTTP pipeline, EF Core queries, container wiring, multiple services working together) belongs to integration or component tests. Different purpose, different trade-offs.
# New xUnit test project
dotnet new xunit -n Shop.Tests
# Add helpful packages
dotnet add Shop.Tests package Shouldly
dotnet add Shop.Tests package NSubstitute
Folder layout should be like:
/src/Shop/PriceCalculator.cs
/tests/Shop.Tests/PriceCalculatorTests.cs
Here’s a simple calculator with a few rules: weekend adds +5% discount, December adds +10%, but total discount is capped at 80%. The only external thing is time, which we’ll control via an interface.
// src/Shop/IClock.cs
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
// src/Shop/PriceCalculator.cs
public sealed class PriceCalculator(IClock clock)
{
public decimal Calculate(decimal basePrice, decimal discount)
{
if (basePrice < 0) throw new ArgumentOutOfRangeException(nameof(basePrice));
if (discount is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(discount));
var now = clock.UtcNow;
var weekend = now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
var december = now.Month == 12;
var finalDiscount = discount;
if (weekend) finalDiscount += 0.05m;
if (december) finalDiscount += 0.10m;
finalDiscount = Math.Min(finalDiscount, 0.80m);
var price = basePrice * (1 - finalDiscount);
return Math.Round(price, 2, MidpointRounding.AwayFromZero);
}
}
Notice the constructor using the new primary constructor style in this example. Tests can still new it up easily.
Readable tests beat clever tests. Name the behavior, put the setup at the top, and keep one behavior per test. Good tests are like documentation.
// tests/Shop.Tests/PriceCalculatorTests.cs
using Shouldly;
using NSubstitute;
using Xunit;
public class PriceCalculatorTests
{
private readonly IClock _clock = Substitute.For<IClock>();
private PriceCalculator Sut => new(_clock); // System Under Test
[Fact]
public void Calculate_throws_for_negative_price()
{
// Arrange
_clock.UtcNow.Returns(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero)); // stable date
// Act + Assert
Should.Throw<ArgumentOutOfRangeException>(() => Sut.Calculate(-1, 0.1m));
}
[Theory]
[InlineData(100, 0.00, 100)] // weekday, no discount
[InlineData(100, 0.10, 90)] // weekday, 10%
public void Weekday_cases(decimal basePrice, decimal discount, decimal expected)
{
_clock.UtcNow.Returns(new DateTimeOffset(2025, 3, 5, 0, 0, 0, TimeSpan.Zero)); // Wednesday
var price = Sut.Calculate(basePrice, discount);
price.ShouldBe(expected);
}
[Fact]
public void Weekend_adds_extra_5_percent()
{
_clock.UtcNow.Returns(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero)); // Saturday
var price = Sut.Calculate(100, 0.10m);
price.ShouldBe(85); // 10% + 5%
}
[Fact]
public void December_plus_weekend_caps_at_80_percent()
{
_clock.UtcNow.Returns(new DateTimeOffset(2025, 12, 7, 0, 0, 0, TimeSpan.Zero)); // Sunday in December
var price = Sut.Calculate(100, 0.75m); // 75 + 5 + 10 = 90 -> cap 80
price.ShouldBe(20);
}
}
Why this works
DateTimeOffset.UtcNow
works, they care about rules.IClock
, IRandom
, IIdGenerator
) and control them.Instead of Assert.Equal(90, price)
, you get price.ShouldBe(90)
. It reads like English and stays short. You’ll likely use:
price.ShouldBe(90m);
text.ShouldContain("hello");
collection.ShouldBeEmpty();
Should.Throw<InvalidOperationException>(() => DoThing());
Keep it short and on point—don’t assert every field “just because”.
Use [Theory]
and [InlineData]
when the rule is the same and only input/output changes. If your setup grows, switch to [MemberData]
or a simple builder.
public static IEnumerable<object[]> WeekendDates() =>
[
[new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero)], // Sat
[new DateTimeOffset(2025, 3, 2, 0, 0, 0, TimeSpan.Zero)], // Sun
];
[Theory]
[MemberData(nameof(WeekendDates))]
public void Weekend_rule_triggers_on_both_days(DateTimeOffset when)
{
_clock.UtcNow.Returns(when);
Sut.Calculate(100, 0.10m).ShouldBe(85m);
}
When arranging complex inputs, builders keep tests readable.
public sealed record PriceRequest(decimal BasePrice, decimal Discount)
{
public static PriceRequest Default => new(100m, 0.10m);
public PriceRequest WithPrice(decimal p) => this with { BasePrice = p };
public PriceRequest WithDiscount(decimal d) => this with { Discount = d };
}
Now your arrange step reads like English:
var req = PriceRequest.Default.WithDiscount(0.75m);
Yes—if you keep logic out of the route and in a service. Then your handler is a thin adapter you can still call directly without a web server.
// src/Shop/PriceEndpoints.cs
public static class PriceEndpoints
{
public static IResult Calculate(PriceRequest req, PriceCalculator calc)
{
var amount = calc.Calculate(req.BasePrice, req.Discount);
return TypedResults.Ok(new PriceResponse(amount));
}
}
public sealed record PriceRequest(decimal BasePrice, decimal Discount);
public sealed record PriceResponse(decimal Amount);
Wire it in Program.cs
:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<PriceCalculator>();
var app = builder.Build();
app.MapPost("/price", PriceEndpoints.Calculate);
app.Run();
And test the handler without booting Kestrel:
using Shouldly;
using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using Xunit;
public class PriceEndpointsTests
{
[Fact]
public void Handler_returns_200_with_amount()
{
// Arrange
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(new DateTimeOffset(2025, 3, 5, 0, 0, 0, TimeSpan.Zero));
var calc = new PriceCalculator(clock);
// Act
var result = PriceEndpoints.Calculate(new PriceRequest(100, 0.10m), calc);
// Assert
var ok = result.ShouldBeOfType<Ok<PriceResponse>>();
ok.Value!.Amount.ShouldBe(90m);
}
}
No WebApplicationFactory
, no ports, no JSON serializers in the way. If your handler starts doing too much, extract logic back into the service.
Pick one pattern and stick with it. Two common styles:
MethodName_Should_Outcome_When_Context
Calculate_Should_CapDiscount_When_TotalExceeds80Percent
Behavior_Context_Outcome
(reads like a sentence)
PriceCalculation_WhenWeekendAndDecember_CapsAt80Percent
Keep class names plural: PriceCalculatorTests
.
Aim for one test per rule. If a rule has many input ranges, drive those with [Theory]
. Avoid “assert the whole object graph” tests; they’re brittle and slow to update. When a bug slips through, write the missing test first, then fix the code.
dotnet test -l "console;verbosity=normal"
Make sure your CI runs tests for every PR. Fast feedback is the point; if tests take minutes, developers will skip running them locally.
IEmailSender.Send
with a specific subject when an order crosses a threshold.”Keep it to one mock per test when you can. If you’re setting up four of them, the unit likely does too much.
Example with NSubstitute:
public interface IEmailSender { Task SendAsync(string to, string subject, string body); }
public sealed class OrderNotifier(IEmailSender email)
{
public async Task NotifyAsync(decimal total)
{
if (total >= 1000)
await email.SendAsync("[email protected]", "Big order", $"Total: {total}");
}
}
public class OrderNotifierTests
{
[Fact]
public async Task Sends_email_for_big_orders()
{
var email = Substitute.For<IEmailSender>();
var sut = new OrderNotifier(email);
await sut.NotifyAsync(1250);
await email.Received(1)
.SendAsync("[email protected]", "Big order", Arg.Is<string>(b => b.Contains("1250")));
}
[Fact]
public async Task Does_nothing_for_small_orders()
{
var email = Substitute.For<IEmailSender>();
var sut = new OrderNotifier(email);
await sut.NotifyAsync(99);
await email.DidNotReceiveWithAnyArgs().SendAsync(default!, default!, default!);
}
}
Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.
He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.
Nick creates free content on YouTube and is the host of the Keep Coding Podcast.
More courses by Nick Chapsas© 2025 Dometrain. All rights reserved.