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

TypePurposeExample
StubReturns canned data so the test can proceedIOrderRepository that returns a fixed list
MockVerifies interactions — was a method called with the right arguments?Assert _emailSender.Send(...) was called once
FakeA working lightweight implementationIn-memory IOrderRepository backed by a Dictionary
SpyRecords calls for later assertionRarely 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.

Two schools: classicist vs mockist

How much you mock isn’t just taste — it’s two named philosophies, and knowing them resolves most “should I mock this?” arguments:

  • Classicist / Detroit / “solitary-ish but sociable” — mock only at true system boundaries (DB, HTTP, clock); let a unit use its real collaborators (real value objects, real domain services). Tests assert on observable state/results. Tests survive refactors because they don’t know internal call structure, but a failure can implicate several classes.
  • Mockist / London / “outside-in” — isolate the unit by mocking all collaborators and assert on the interactions between them. Tests pinpoint the exact class and drive interface design top-down, but they couple to implementation and break when you reshuffle internals (the over-mocking brittleness above).

Most pragmatic suites lean classicist — mock the boundary, use the real thing inside — precisely to avoid that brittleness, reaching for interaction verification only for genuine side effects. The same split shows up in TDD as inside-out (Detroit) vs outside-in (London).

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

ApproachStrengthsWeaknessesWhen to use
Unit tests with mocksFast, isolated, design pressureCan miss integration bugs, mocks can diverge from real behaviorDomain logic, business rules, pure functions
Unit tests with fakesMore realistic than mocks, still fastFakes require maintenanceRepository layer, service layer with complex state
Integration tests onlyTests real wiringSlow, harder to isolate failuresInfrastructure 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