• Courses
  • TutorialsFree
  • Learning Paths
  • Blogs
  • Authors
  • Dometrain Pro
  • Shopping Basket

    Your basket is empty

  • Business Portal
  • Getting Started with Unit Testing in C#

    8/27/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

    Nick Chapsas

    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.

    View all courses by Nick Chapsas

    What's New

    What's new in C# 14
    blog

    What's new in C# 14

    This guide covers every new C# 14 feature, explains its benefits, and provides practical code examples to help you navigate how you can use them.

    Learn More
    Let's Build It: AI Chatbot with RAG in .NET Using Your Data
    course

    Let's Build It: AI Chatbot with RAG in .NET Using Your Data

    Build a Retrieval-Augmented Generation (RAG) chatbot that can answer questions using your data.

    Learn More
    Working with Large Language Models
    tutorial

    Working with Large Language Models

    Learn how to work with Large Language Models (LLMs). Understand the fundamentals of how GPT works, the transformer architecture, and master prompt engineering techniques to build AI agents.

    Learn More
    From Zero to Hero: SignalR in .NET
    course

    From Zero to Hero: SignalR in .NET

    Enable enterprise-grade real-time communication for your web apps with SignalR.

    Learn More
    Deep Dive: Solution Architecture
    course

    Deep Dive: Solution Architecture

    Master solution architecture and turn business needs into scalable, maintainable systems.

    Learn More
    Migrating: ASP.NET Web APIs to ASP.NET Core
    course

    Migrating: ASP.NET Web APIs to ASP.NET Core

    A step-by-step process to migrate ASP.NET Web APIs from .NET Framework to ASP.NET Core.

    Learn More
    Getting Started: Caching in .NET
    course

    Getting Started: Caching in .NET

    Let's make the hardest thing in programming easy for .NET software engineers.

    Learn More
    From Zero to Hero: Testing with xUnit in C#
    course

    From Zero to Hero: Testing with xUnit in C#

    Learn how to test any codebase in .NET with the latest version of xUnit, the industry-standard testing library.

    Learn More
    Create a ChatGPT Console AI Chatbot in C#
    blog

    Create a ChatGPT Console AI Chatbot in C#

    This walkthrough is your hands-on entry point to create a basic C# console application that talks to ChatGPT using the OpenAI API.

    Learn More