Our Summer Sale is Live! 🎉
Everything 30% off with code SUMMER30! (Excl. Team and VS Pro)
00
Days
00
Hrs
00
Min
00
Sec
Get 30% off anything!

Getting Started with Unit Testing in C#

27/08/2025

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.

What counts as a unit test?

Unit testing involves isolating the smallest testable parts of an application, typically methods or functions, and verifying their correctness through automated tests.

  • Pure business rules: calculations, validation, mapping, decision trees.
  • No I/O: no real database, no HTTP calls, no sleeps, no clock that you can’t control.
  • Fast: run thousands in seconds.
  • Deterministic: the same input gives the same output every run.

Everything else (HTTP pipeline, EF Core queries, container wiring, multiple services working together) belongs to integration or component tests. Different purpose, different trade-offs.

Project setup (two commands)

# 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

Example to begin with

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.


AAA done right (Arrange–Act–Assert)

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

  • The tests don’t care about how DateTimeOffset.UtcNow works, they care about rules.
  • No file system, no DB, no web server. Millisecond execution.
  • Each test communicates a rule in plain language. If a rule changes, you update the test name and expected value, and the intent remains clear.

Avoid these traps

  1. Real time, randomness, or GUIDs in tests. Abstract them (IClock, IRandom, IIdGenerator) and control them.
  2. Too many mocks. Prefer fake objects with trivial behavior. Use mocks only when collaboration behavior matters.
  3. Multiple asserts for different behaviors in one test. If it fails, you won’t know which behavior regressed.
  4. Testing private details. Test the surface and outcomes. Private methods are an implementation detail.
  5. Sleeping. If your test sleeps, you’re not unit testing anymore.

Shouldly makes assertions read naturally

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”.


Parameterized tests to cover more with less code

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);
}

A tiny builder for clarity (optional)

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);

“Can I unit test Minimal API handlers?”

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.


Naming that scales with the codebase

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.


How many tests?

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.


Running and reporting

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.


When mocks are the right tool

  • You care about interactions: e.g., “service must call IEmailSender.Send with a specific subject when an order crosses a threshold.”
  • You need to simulate an exception from a dependency to assert a specific behavior.

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!);
    }
}

A simple checklist you can keep next to your editor

  • [ ] Logic lives in small classes/functions; routes/controllers are thin.
  • [ ] Time, randomness, and IDs come from interfaces you can stub.
  • [ ] Tests read as sentences; one behavior per test.
  • [ ] Parameterize where the rule stays the same.
  • [ ] No I/O, no sleeps, no real network.
  • [ ] Fast enough that you run them all the time.

About the Author

author_img

Nick Chapsas

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