Unit Testing

Unit Testing

A unit test verifies a small, isolated piece of behavior — typically a single method or class — quickly and deterministically. "Isolated" means all external dependencies (database, HTTP, filesystem, clock) are replaced with test doubles so the test exercises only the logic under test. Unit tests are the foundation of a fast feedback loop: a suite of hundreds of unit tests should run in under a second, catching regressions the moment they are introduced.

The primary value of unit tests is not coverage — it is design pressure. Code that is hard to unit-test is usually poorly designed: too many dependencies, too much responsibility, or hidden coupling to global state.

Anatomy of a Unit Test (AAA Pattern)

Every unit test follows Arrange → Act → Assert:

public class DiscountServiceTests
{
    [Fact]
    public void AppliesLoyaltyDiscount_WhenCustomerHasOverTenOrders()
    {
        // Arrange
        var customer = new Customer(id: "c1", orderCount: 12);
        var service  = new DiscountService(loyaltyThreshold: 10, discountRate: 0.15m);

        // Act
        decimal price = service.Calculate(basePrice: 100m, customer);

        // Assert
        Assert.Equal(85m, price);
    }

    [Fact]
    public void NoDiscount_WhenCustomerBelowThreshold()
    {
        var customer = new Customer(id: "c2", orderCount: 3);
        var service  = new DiscountService(loyaltyThreshold: 10, discountRate: 0.15m);

        Assert.Equal(100m, service.Calculate(100m, customer));
    }
}

Naming convention: MethodName_StateUnderTest_ExpectedBehavior or a plain English description. The test name is the first thing you read when a test fails — make it diagnostic.

Test Doubles: Stubs vs Mocks

Type Purpose Example
Stub Returns canned data so the test can proceed IOrderRepository that returns a fixed list
Mock Verifies interactions — was a method called with the right arguments? Assert _emailSender.Send(...) was called once
Fake A working lightweight implementation In-memory IOrderRepository backed by a Dictionary
Spy Records calls for later assertion Rarely needed; prefer mocks
// Stub with Moq: return fixed data
var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.GetByCustomer("c1"))
    .Returns(new List<Order> { new Order("o1", 50m) });

// Mock with Moq: verify interaction
var emailSender = new Mock<IEmailSender>();
var service = new NotificationService(emailSender.Object);
service.NotifyShipped("c1");
emailSender.Verify(e => e.Send("c1", It.IsAny<string>()), Times.Once);

Rule of thumb: stub dependencies that provide data; mock dependencies that represent side effects (email, SMS, audit log). Over-mocking — mocking every dependency including internal ones — produces brittle tests that break on every refactor.

xUnit in .NET

xUnit is the standard .NET unit testing framework. Key attributes:

[Fact]                          // single test case
[Theory]                        // parameterized test
[InlineData(1, 2, 3)]           // inline parameters for Theory
[MemberData(nameof(Cases))]     // external data source
[ClassFixture<T>]               // shared setup across tests in a class
[Collection("db")]              // shared setup across test classes
[Theory]
[InlineData(0,   100m, 100m)]   // no orders → no discount
[InlineData(10,  100m, 85m)]    // exactly at threshold → discount applies
[InlineData(20,  200m, 170m)]   // well above threshold
public void DiscountCalculation(int orderCount, decimal price, decimal expected)
{
    var customer = new Customer("c1", orderCount);
    var service  = new DiscountService(loyaltyThreshold: 10, discountRate: 0.15m);
    Assert.Equal(expected, service.Calculate(price, customer));
}

Pitfalls

Testing Implementation, Not Behavior

What goes wrong: tests assert on private state or verify every internal method call. When you refactor the implementation, tests break even though behavior is unchanged.

Why it happens: writing tests after the fact often produces white-box tests that mirror the code structure.

Mitigation: test through the public interface only. Assert on return values and observable side effects. If a refactor breaks a test without changing behavior, the test was testing the wrong thing.

Shared Mutable State Between Tests

What goes wrong: tests pass individually but fail when run together because one test mutates a static field or shared object that another test reads.

Why it happens: static helpers, singleton services, or shared test fixtures with mutable state.

Mitigation: create fresh instances in each test's Arrange step. Use IClassFixture<T> only for expensive but immutable setup (e.g., starting a test server). Never share mutable state across tests.

Slow Tests from Real I/O

What goes wrong: a "unit" test hits a real database or filesystem, making the suite take minutes instead of seconds.

Why it happens: dependencies are not injected — the class creates its own SqlConnection or HttpClient internally.

Mitigation: inject all I/O dependencies as interfaces. Use fakes or in-memory implementations in unit tests. Reserve real I/O for integration tests.

Tradeoffs

Approach Strengths Weaknesses When to use
Unit tests with mocks Fast, isolated, design pressure Can miss integration bugs, mocks can diverge from real behavior Domain logic, business rules, pure functions
Unit tests with fakes More realistic than mocks, still fast Fakes require maintenance Repository layer, service layer with complex state
Integration tests only Tests real wiring Slow, harder to isolate failures Infrastructure layer, DB queries, HTTP contracts

Decision rule: write unit tests for all domain logic and anything with branching. Add integration tests for the infrastructure layer (DB, HTTP, queues). Don't try to unit-test infrastructure — mock it at the boundary and test the real thing in integration tests.

Questions

References


Whats next