🧪

Unit Testing Guide

Testing Intermediate 3 min read 500 words

Unit Testing in .NET

Introduction

Unit testing verifies that individual components work correctly in isolation. This guide covers testing frameworks, best practices, and patterns for writing effective unit tests in .NET.


Table of Contents


Testing Frameworks

Framework Comparison

Framework Style Parallel Community Notes
xUnit Modern, opinionated Yes Large .NET team’s choice
NUnit Attribute-based Yes Large Full-featured
MSTest Microsoft’s framework Yes Microsoft Visual Studio integration

xUnit Setup

// Install: dotnet add package xunit
//          dotnet add package xunit.runner.visualstudio
//          dotnet add package Microsoft.NET.Test.Sdk

public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.Equal(5, result);
    }

    [Theory]
    [InlineData(1, 1, 2)]
    [InlineData(5, 3, 8)]
    [InlineData(-1, 1, 0)]
    public void Add_MultipleInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var calculator = new Calculator();
        var result = calculator.Add(a, b);
        Assert.Equal(expected, result);
    }
}

NUnit Setup

// Install: dotnet add package NUnit
//          dotnet add package NUnit3TestAdapter
//          dotnet add package Microsoft.NET.Test.Sdk

[TestFixture]
public class CalculatorTests
{
    private Calculator _calculator;

    [SetUp]
    public void Setup()
    {
        _calculator = new Calculator();
    }

    [Test]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var result = _calculator.Add(2, 3);
        Assert.That(result, Is.EqualTo(5));
    }

    [TestCase(1, 1, 2)]
    [TestCase(5, 3, 8)]
    [TestCase(-1, 1, 0)]
    public void Add_MultipleInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var result = _calculator.Add(a, b);
        Assert.That(result, Is.EqualTo(expected));
    }

    [TearDown]
    public void Cleanup()
    {
        // Cleanup after each test
    }
}

Test Structure (AAA Pattern)

Arrange-Act-Assert

[Fact]
public void ProcessOrder_ValidOrder_ReturnsSuccess()
{
    // Arrange - Set up test data and dependencies
    var order = new Order
    {
        Id = Guid.NewGuid(),
        CustomerId = "CUST001",
        Items = new List<OrderItem>
        {
            new OrderItem { ProductId = "PROD001", Quantity = 2, UnitPrice = 10.00m }
        }
    };
    var orderService = new OrderService(
        Mock.Of<IOrderRepository>(),
        Mock.Of<IPaymentService>());

    // Act - Execute the method under test
    var result = orderService.Process(order);

    // Assert - Verify the expected outcome
    Assert.True(result.IsSuccess);
    Assert.Equal("CUST001", result.CustomerId);
}

Given-When-Then (BDD Style)

[Fact]
public void GivenValidOrder_WhenProcessed_ThenReturnsSuccess()
{
    // Given
    var order = CreateValidOrder();
    var sut = CreateOrderService();  // System Under Test

    // When
    var result = sut.Process(order);

    // Then
    result.Should().NotBeNull();
    result.IsSuccess.Should().BeTrue();
}

Assertions

xUnit Assertions

// Equality
Assert.Equal(expected, actual);
Assert.NotEqual(unexpected, actual);

// Boolean
Assert.True(condition);
Assert.False(condition);

// Null
Assert.Null(obj);
Assert.NotNull(obj);

// Collections
Assert.Empty(collection);
Assert.NotEmpty(collection);
Assert.Contains(item, collection);
Assert.DoesNotContain(item, collection);
Assert.Single(collection);
Assert.All(collection, item => Assert.True(item.IsValid));

// Types
Assert.IsType<ExpectedType>(obj);
Assert.IsAssignableFrom<IInterface>(obj);

// Exceptions
Assert.Throws<ArgumentException>(() => DoSomething());
await Assert.ThrowsAsync<InvalidOperationException>(async () => await DoSomethingAsync());

// Ranges
Assert.InRange(actual, low, high);

FluentAssertions (Recommended)

// Install: dotnet add package FluentAssertions

// Equality
result.Should().Be(expected);
result.Should().NotBe(unexpected);
result.Should().BeEquivalentTo(expected);  // Deep comparison

// Strings
name.Should().StartWith("John");
name.Should().Contain("Doe");
name.Should().BeNullOrEmpty();
name.Should().MatchRegex(@"^\d{3}-\d{4}$");

// Numbers
count.Should().BePositive();
count.Should().BeInRange(1, 100);
price.Should().BeApproximately(10.5m, 0.01m);

// Collections
list.Should().HaveCount(5);
list.Should().Contain(x => x.Name == "John");
list.Should().BeInAscendingOrder(x => x.Date);
list.Should().OnlyContain(x => x.IsActive);

// Exceptions
action.Should().Throw<ArgumentException>()
    .WithMessage("*invalid*")
    .WithInnerException<FormatException>();

await asyncAction.Should().ThrowAsync<InvalidOperationException>();

// Object graphs
order.Should().BeEquivalentTo(expectedOrder, options =>
    options.Excluding(o => o.CreatedAt)
           .Using<DateTime>(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)))
           .WhenTypeIs<DateTime>());

// Execution time
action.ExecutionTime().Should().BeLessThan(100.Milliseconds());

Test Naming Conventions

Method Naming Patterns

// Pattern 1: MethodName_Scenario_ExpectedBehavior
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()

[Fact]
public void GetUser_WithValidId_ReturnsUser()

[Fact]
public void CreateOrder_WhenOutOfStock_ThrowsInventoryException()

// Pattern 2: Should_ExpectedBehavior_When_Scenario
[Fact]
public void Should_ThrowException_When_DividingByZero()

[Fact]
public void Should_ReturnUser_When_IdIsValid()

// Pattern 3: Given_When_Then (for BDD)
[Fact]
public void GivenValidCredentials_WhenLogin_ThenReturnsToken()

Test Class Organization

// Group by class under test
public class OrderServiceTests
{
    // Nested classes for method grouping
    public class ProcessOrder
    {
        [Fact]
        public void WithValidOrder_ReturnsSuccess() { }

        [Fact]
        public void WithEmptyItems_ThrowsException() { }
    }

    public class CancelOrder
    {
        [Fact]
        public void WithExistingOrder_SetsStatusToCancelled() { }
    }
}

Mocking with Moq

Basic Mocking

// Install: dotnet add package Moq

public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _mockRepository;
    private readonly Mock<IEmailService> _mockEmailService;
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _mockRepository = new Mock<IOrderRepository>();
        _mockEmailService = new Mock<IEmailService>();
        _sut = new OrderService(_mockRepository.Object, _mockEmailService.Object);
    }

    [Fact]
    public async Task GetOrder_WithValidId_ReturnsOrder()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var expectedOrder = new Order { Id = orderId, Total = 100 };

        _mockRepository
            .Setup(r => r.GetByIdAsync(orderId))
            .ReturnsAsync(expectedOrder);

        // Act
        var result = await _sut.GetOrderAsync(orderId);

        // Assert
        Assert.Equal(expectedOrder, result);
    }
}

Advanced Moq Scenarios

// Argument matching
_mockRepository
    .Setup(r => r.GetByIdAsync(It.IsAny<Guid>()))
    .ReturnsAsync(new Order());

_mockRepository
    .Setup(r => r.FindAsync(It.Is<string>(s => s.StartsWith("ORD"))))
    .ReturnsAsync(new List<Order>());

// Callback (inspect arguments)
Order capturedOrder = null;
_mockRepository
    .Setup(r => r.SaveAsync(It.IsAny<Order>()))
    .Callback<Order>(o => capturedOrder = o)
    .Returns(Task.CompletedTask);

// Sequence of returns
_mockRepository
    .SetupSequence(r => r.GetNextIdAsync())
    .ReturnsAsync(1)
    .ReturnsAsync(2)
    .ReturnsAsync(3);

// Throwing exceptions
_mockRepository
    .Setup(r => r.GetByIdAsync(It.IsAny<Guid>()))
    .ThrowsAsync(new NotFoundException());

// Verifying calls
_mockEmailService.Verify(
    e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()),
    Times.Once);

_mockEmailService.Verify(
    e => e.SendAsync("admin@test.com", It.Is<string>(s => s.Contains("Order"))),
    Times.Exactly(2));

// Verify no other calls
_mockRepository.VerifyNoOtherCalls();

// Mock properties
_mockRepository
    .SetupGet(r => r.ConnectionString)
    .Returns("test-connection");

_mockRepository
    .SetupProperty(r => r.Timeout, TimeSpan.FromSeconds(30));

Mocking Protected Members

public abstract class BaseService
{
    protected abstract Task<bool> ValidateAsync(string input);
}

// Mock protected method
var mock = new Mock<BaseService>();
mock.Protected()
    .Setup<Task<bool>>("ValidateAsync", ItExpr.IsAny<string>())
    .ReturnsAsync(true);

Mocking with NSubstitute

Basic NSubstitute

// Install: dotnet add package NSubstitute

public class OrderServiceTests
{
    private readonly IOrderRepository _repository;
    private readonly IEmailService _emailService;
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _repository = Substitute.For<IOrderRepository>();
        _emailService = Substitute.For<IEmailService>();
        _sut = new OrderService(_repository, _emailService);
    }

    [Fact]
    public async Task GetOrder_WithValidId_ReturnsOrder()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var expectedOrder = new Order { Id = orderId };

        _repository.GetByIdAsync(orderId).Returns(expectedOrder);

        // Act
        var result = await _sut.GetOrderAsync(orderId);

        // Assert
        Assert.Equal(expectedOrder, result);
    }
}

Advanced NSubstitute

// Argument matching
_repository.GetByIdAsync(Arg.Any<Guid>()).Returns(new Order());
_repository.FindAsync(Arg.Is<string>(s => s.Length > 5)).Returns(orders);

// Callbacks
_repository.SaveAsync(Arg.Do<Order>(o => capturedOrder = o));

// Throwing exceptions
_repository.GetByIdAsync(Arg.Any<Guid>()).Throws(new NotFoundException());

// Returns based on argument
_repository.GetByIdAsync(Arg.Any<Guid>())
    .Returns(callInfo => new Order { Id = callInfo.Arg<Guid>() });

// Verify received calls
_emailService.Received().SendAsync(Arg.Any<string>(), Arg.Any<string>());
_emailService.Received(2).SendAsync("admin@test.com", Arg.Any<string>());
_emailService.DidNotReceive().SendAsync("blocked@test.com", Arg.Any<string>());

// Check call order
Received.InOrder(() =>
{
    _repository.SaveAsync(Arg.Any<Order>());
    _emailService.SendAsync(Arg.Any<string>(), Arg.Any<string>());
});

Testing Async Code

Async Test Methods

[Fact]
public async Task ProcessOrderAsync_ValidOrder_SavesAndNotifies()
{
    // Arrange
    var order = new Order { Id = Guid.NewGuid() };
    _mockRepository.Setup(r => r.SaveAsync(It.IsAny<Order>()))
        .Returns(Task.CompletedTask);

    // Act
    await _sut.ProcessOrderAsync(order);

    // Assert
    _mockRepository.Verify(r => r.SaveAsync(order), Times.Once);
    _mockEmailService.Verify(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}

// Testing exceptions in async methods
[Fact]
public async Task ProcessOrderAsync_InvalidOrder_ThrowsValidationException()
{
    var invalidOrder = new Order(); // Empty order

    await Assert.ThrowsAsync<ValidationException>(
        () => _sut.ProcessOrderAsync(invalidOrder));
}

// With FluentAssertions
[Fact]
public async Task ProcessOrderAsync_InvalidOrder_ThrowsWithMessage()
{
    var invalidOrder = new Order();

    Func<Task> act = () => _sut.ProcessOrderAsync(invalidOrder);

    await act.Should().ThrowAsync<ValidationException>()
        .WithMessage("*Items cannot be empty*");
}

Testing Cancellation

[Fact]
public async Task LongRunningOperation_WhenCancelled_ThrowsOperationCancelled()
{
    var cts = new CancellationTokenSource();
    cts.CancelAfter(TimeSpan.FromMilliseconds(100));

    await Assert.ThrowsAsync<OperationCanceledException>(
        () => _sut.LongRunningOperationAsync(cts.Token));
}

Test Data Builders

Builder Pattern for Test Data

public class OrderBuilder
{
    private Guid _id = Guid.NewGuid();
    private string _customerId = "CUST001";
    private List<OrderItem> _items = new();
    private OrderStatus _status = OrderStatus.Pending;
    private DateTime _createdAt = DateTime.UtcNow;

    public OrderBuilder WithId(Guid id)
    {
        _id = id;
        return this;
    }

    public OrderBuilder WithCustomer(string customerId)
    {
        _customerId = customerId;
        return this;
    }

    public OrderBuilder WithItem(string productId, int quantity, decimal price)
    {
        _items.Add(new OrderItem
        {
            ProductId = productId,
            Quantity = quantity,
            UnitPrice = price
        });
        return this;
    }

    public OrderBuilder WithStatus(OrderStatus status)
    {
        _status = status;
        return this;
    }

    public OrderBuilder CreatedDaysAgo(int days)
    {
        _createdAt = DateTime.UtcNow.AddDays(-days);
        return this;
    }

    public Order Build()
    {
        return new Order
        {
            Id = _id,
            CustomerId = _customerId,
            Items = _items,
            Status = _status,
            CreatedAt = _createdAt,
            Total = _items.Sum(i => i.Quantity * i.UnitPrice)
        };
    }
}

// Usage in tests
[Fact]
public void ProcessOrder_WithMultipleItems_CalculatesTotal()
{
    var order = new OrderBuilder()
        .WithCustomer("CUST001")
        .WithItem("PROD001", 2, 10.00m)
        .WithItem("PROD002", 1, 25.00m)
        .Build();

    var result = _sut.Process(order);

    result.Total.Should().Be(45.00m);
}

// Preset builders for common scenarios
public static class OrderBuilders
{
    public static OrderBuilder ValidOrder() =>
        new OrderBuilder()
            .WithItem("PROD001", 1, 10.00m);

    public static OrderBuilder ExpiredOrder() =>
        new OrderBuilder()
            .CreatedDaysAgo(30)
            .WithStatus(OrderStatus.Expired);
}

AutoFixture for Automatic Test Data

// Install: dotnet add package AutoFixture
//          dotnet add package AutoFixture.AutoMoq
//          dotnet add package AutoFixture.Xunit2

public class OrderServiceTests
{
    private readonly IFixture _fixture;

    public OrderServiceTests()
    {
        _fixture = new Fixture()
            .Customize(new AutoMoqCustomization());
    }

    [Fact]
    public void ProcessOrder_ValidOrder_Succeeds()
    {
        // Auto-generate test data
        var order = _fixture.Create<Order>();
        var sut = _fixture.Create<OrderService>();

        var result = sut.Process(order);

        result.Should().NotBeNull();
    }

    // With AutoData attribute
    [Theory, AutoData]
    public void ProcessOrder_AutoGenerated_Succeeds(Order order)
    {
        var sut = _fixture.Create<OrderService>();
        var result = sut.Process(order);
        result.Should().NotBeNull();
    }
}

Parameterized Tests

xUnit Theory with InlineData

[Theory]
[InlineData("", false)]
[InlineData("a", false)]
[InlineData("ab", false)]
[InlineData("abc", true)]
[InlineData("abcdefghijk", false)]  // Too long
public void ValidateUsername_VariousInputs_ReturnsExpectedResult(
    string username, bool expected)
{
    var result = _sut.ValidateUsername(username);
    Assert.Equal(expected, result);
}

Class Data for Complex Objects

public class OrderTestData : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[]
        {
            new Order { Items = new List<OrderItem>() },
            false,
            "Empty order should be invalid"
        };
        yield return new object[]
        {
            new Order { Items = new List<OrderItem> { new OrderItem { Quantity = 1 } } },
            true,
            "Order with items should be valid"
        };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

[Theory]
[ClassData(typeof(OrderTestData))]
public void ValidateOrder_VariousOrders_ReturnsExpected(
    Order order, bool expected, string because)
{
    var result = _sut.Validate(order);
    result.Should().Be(expected, because);
}

Member Data

public class CalculatorTests
{
    public static IEnumerable<object[]> AdditionData =>
        new List<object[]>
        {
            new object[] { 1, 1, 2 },
            new object[] { -1, 1, 0 },
            new object[] { 100, 200, 300 },
        };

    [Theory]
    [MemberData(nameof(AdditionData))]
    public void Add_ReturnsCorrectSum(int a, int b, int expected)
    {
        var result = _calculator.Add(a, b);
        Assert.Equal(expected, result);
    }
}

Testing Best Practices

DO

// ✅ Test one thing per test
[Fact]
public void ProcessOrder_ValidOrder_SavesOrder() { }

[Fact]
public void ProcessOrder_ValidOrder_SendsNotification() { }

// ✅ Use descriptive names
[Fact]
public void GetDiscountedPrice_WhenCustomerIsPremium_Returns20PercentDiscount() { }

// ✅ Arrange test data clearly
var order = new OrderBuilder()
    .WithCustomer("premium-customer")
    .WithItem("expensive-item", 1, 100m)
    .Build();

// ✅ Test edge cases
[Theory]
[InlineData(int.MinValue)]
[InlineData(-1)]
[InlineData(0)]
[InlineData(int.MaxValue)]
public void ProcessQuantity_EdgeCases_HandledCorrectly(int quantity) { }

// ✅ Use strict mocking (verify no unexpected calls)
_mockRepository.VerifyNoOtherCalls();

DON’T

// ❌ Testing implementation details
Assert.Equal("SELECT * FROM Orders", capturedQuery);  // Fragile!

// ❌ Testing multiple things
[Fact]
public void ProcessOrder_DoesEverything()  // Too broad
{
    // Tests save, email, logging, validation...
}

// ❌ Using Thread.Sleep in tests
Thread.Sleep(1000);  // Use async/await properly

// ❌ Testing private methods directly
// Test through public API instead

// ❌ Shared mutable state between tests
private static int _counter = 0;  // Tests affect each other!

// ❌ Ignoring test failures
[Fact(Skip = "Fix later")]  // Technical debt

Code Coverage

Running Coverage

# Using dotnet CLI
dotnet test --collect:"XPlat Code Coverage"

# Using Coverlet
dotnet add package coverlet.msbuild
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura

# Generate HTML report
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:coverage.cobertura.xml -targetdir:coveragereport

Coverage Configuration

<!-- .csproj -->
<PropertyGroup>
  <CollectCoverage>true</CollectCoverage>
  <CoverletOutputFormat>cobertura</CoverletOutputFormat>
  <Threshold>80</Threshold>
  <ThresholdType>line,branch,method</ThresholdType>
  <ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</PropertyGroup>

Coverage Guidelines

Metric Target Notes
Line coverage 70-80% Core business logic
Branch coverage 60-70% Decision paths
Method coverage 80-90% Public API

Interview Questions

1. What is the difference between a mock and a stub?

Answer:

  • Stub: Provides canned answers to calls. Used to replace dependencies that return data. No verification of how it’s used.
  • Mock: Records calls made to it for verification. Used to verify behavior and interactions.
// Stub - just returns data
var stubRepo = Substitute.For<IRepository>();
stubRepo.GetById(1).Returns(new User());

// Mock - verify interactions
var mockEmail = Substitute.For<IEmailService>();
await sut.ProcessOrder(order);
mockEmail.Received().SendAsync(Arg.Any<string>());

2. What does the Arrange-Act-Assert pattern mean?

Answer: It’s a structure for organizing unit tests:

  • Arrange: Set up test data, mocks, and the system under test
  • Act: Execute the method being tested
  • Assert: Verify the expected outcome

This separation makes tests readable and maintainable.


3. When should you use [Fact] vs [Theory] in xUnit?

Answer:

  • [Fact]: Single test case with fixed input. Use when testing specific scenario.
  • [Theory]: Parameterized test running multiple times with different data. Use when testing same logic with various inputs.
[Fact]
public void Specific_Scenario_Test() { }

[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, 1, 0)]
public void Multiple_Inputs_SameBehavior(int a, int b, int expected) { }

4. What is code coverage and what’s a good target?

Answer: Code coverage measures the percentage of code executed by tests. Types include:

  • Line coverage: Lines executed
  • Branch coverage: Decision paths taken
  • Method coverage: Methods called

Good targets: 70-80% line coverage for business logic. 100% isn’t practical or necessary—focus on critical paths. High coverage doesn’t guarantee quality tests.


5. How do you test methods that depend on DateTime.Now?

Answer: Abstract time behind an interface:

public interface ITimeProvider
{
    DateTime Now { get; }
}

public class OrderService
{
    private readonly ITimeProvider _time;

    public bool IsExpired(Order order) =>
        _time.Now > order.ExpirationDate;
}

// Test
[Fact]
public void IsExpired_WhenPastExpiration_ReturnsTrue()
{
    var timeProvider = Substitute.For<ITimeProvider>();
    timeProvider.Now.Returns(new DateTime(2024, 12, 25));

    var order = new Order { ExpirationDate = new DateTime(2024, 12, 24) };
    var sut = new OrderService(timeProvider);

    Assert.True(sut.IsExpired(order));
}

Sources

📚 Related Articles