30% OFF - Use code HANDSON30 for 30% off any course or Dometrain Pro! Browse courses →
  • Courses
  • Learning Paths
  • Blogs
  • Authors
  • Leaderboard
  • Dometrain Pro
  • Shopping Basket

    Your basket is empty

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

    August 27, 2025
    Listen to this blog 5m 16s
    Sign in to unlock the audio version of this article
    Sign in to listen

    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

    Getting Started: Model Context Protocol (MCP)
    course

    Getting Started: Model Context Protocol (MCP)

    Learn how to get started with the Model Context Protocol (MCP) and integrate it into your applications.

    Learn more about Getting Started: Model Context Protocol (MCP)
    Hands-On: Learn TypeScript
    course

    Hands-On: Learn TypeScript

    Learn TypeScript through hands-on coding exercises. Practice what you learn with interactive challenges designed for every level.

    Learn more about Hands-On: Learn TypeScript
    Hands-On: Learn JavaScript
    course

    Hands-On: Learn JavaScript

    Learn JavaScript through hands-on coding exercises. Practice what you learn with interactive challenges designed for every level.

    Learn more about Hands-On: Learn JavaScript
    Hands-On: Data Structures & Algorithms in C#
    course

    Hands-On: Data Structures & Algorithms in C#

    Master data structures and algorithms through hands-on coding exercises in C#. Free to enroll for 7 days!

    Learn more about Hands-On: Data Structures & Algorithms in C#
    Blogsmith.ai
    feature

    Blogsmith.ai

    Turn your videos into blogs and newsletters with AI. Check out our new product at blogsmith.ai.

    Learn more about Blogsmith.ai
    Leaderboard
    feature

    Leaderboard

    See how you stack up against other learners. Track your progress, climb the ranks, and compete with the Dometrain community.

    Learn more about Leaderboard
    Hands-On: Learn PostgreSQL
    course

    Hands-On: Learn PostgreSQL

    Learn PostgreSQL through hands-on coding exercises. Practice what you learn with interactive challenges designed for every level.

    Learn more about Hands-On: Learn PostgreSQL
    Free Hands-On: C# for Beginners
    course

    Free Hands-On: C# for Beginners

    Learn C# through hands-on coding exercises. Practice what you learn with interactive challenges designed for everyone, from beginners to experts.

    Learn more about Free Hands-On: C# for Beginners
    Getting Started: AI for .NET Developers
    course

    Getting Started: AI for .NET Developers

    Get started with integrating AI into your .NET applications effectively using the latest LLM best practices.

    Learn more about Getting Started: AI for .NET Developers
    Getting Started: Building .NET Applications on AWS
    course

    Getting Started: Building .NET Applications on AWS

    Learn how to build and deploy .NET applications on AWS using CDK, Lambda, DynamoDB, S3, and more.

    Learn more about Getting Started: Building .NET Applications on AWS
    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 about What's new in C# 14
    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 about Let's Build It: AI Chatbot with RAG in .NET Using Your Data
    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 about From Zero to Hero: SignalR in .NET
    Deep Dive: Solution Architecture
    course

    Deep Dive: Solution Architecture

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

    Learn more about Deep Dive: Solution Architecture
    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 about Migrating: ASP.NET Web APIs to ASP.NET Core
    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 about Getting Started: Caching in .NET
    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 about From Zero to Hero: Testing with xUnit in C#
    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 about Create a ChatGPT Console AI Chatbot in C#