A unit test verifies one small behavior without crossing slow or nondeterministic process boundaries. The unit may be one method, one class, or a small group of collaborating objects. Databases, remote HTTP, the filesystem, and uncontrolled time stay outside the test. Ordinary in-memory collaborators do not need to be mocked merely to satisfy a definition.

The value is fast, local evidence and a diagnostic failure. Testability can reveal hidden time, global state, or too many responsibilities, but difficulty testing does not automatically prove a design defect. The production boundary still decides whether an abstraction is worth adding.

Anatomy of a Unit Test (AAA Pattern)

Arrange → Act → Assert is a useful structure for making setup, behavior, and observation visible:

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));
    }
}

A diagnostic name states the condition and observable result. MethodName_StateUnderTest_ExpectedBehavior is one convention. A readable behavioral sentence is equally valid when the runner supports it.

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);

Stubs commonly provide inputs. Interaction-based mocks are most useful for commands whose observable result is the call itself, such as publishing an event. That is a heuristic, not a type rule. Verifying every internal collaboration couples the test to call structure and makes behavior-preserving refactors expensive.

Two Schools: Classicist Vs Mockist

Two testing traditions frame how much of a unit remains real:

  • Classicist / Detroit tests a behavior with real in-memory collaborators and replaces awkward process boundaries. Assertions favor returned state and externally visible effects. The tests tolerate internal refactoring, although a failure may implicate several classes.
  • Mockist / London replaces collaborators and specifies their interactions while driving the design outside-in. Failures can localize collaboration changes, but the tests are coupled to those interactions.

Neither school removes the need for integration tests. A pragmatic default is to keep stable in-memory collaborators real and verify interactions only when the interaction is part of the contract. The same distinction appears in TDD as inside-out and outside-in design styles.

xUnit in .NET

xUnit is a widely used .NET test framework. [Fact], [Theory], [InlineData], [MemberData], and [Collection("name")] are attributes. Shared context for one test class is declared by implementing IClassFixture<TFixture>.

[Fact]                          // single test case
[Theory]                        // parameterized test
[InlineData(1, 2, 3)]           // inline parameters for Theory
[MemberData(nameof(Cases))]     // external data source
[Collection("db")]              // shared setup across test classes
public sealed class DatabaseTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture fixture;
 
    public DatabaseTests(DatabaseFixture fixture) => this.fixture = fixture;
}
[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

White-box tests mirror the current code structure by asserting private state or every internal call. A behavior-preserving refactor then breaks the test.

Exercise the same public or internal contract that production callers use and assert observable results. A test that fails after a behavior-preserving refactor may be observing an implementation detail. Review it before changing production code to satisfy the old call structure.

Shared Mutable State Between Tests

Tests can pass individually and fail together when a static helper, singleton, or shared fixture lets one test mutate state another test reads.

Create fresh behavior-owning objects and data per test. A fixture may share an expensive process or immutable configuration, but each test needs isolated mutable state or an explicit reset protocol.

Slow Tests from Real I/O

A test that reaches a real database or filesystem crosses a process boundary, regardless of its label. The suite becomes slower and its failures include infrastructure state.

Separate the decision from I/O when that separation improves the production design. Test the decision with values and the real adapter through an integration test. An interface is useful when it represents a genuine seam, not as a wrapper around every library call.

Tradeoffs

ApproachStrengthsWeaknessesWhen to use
State-based unit tests with real in-memory collaboratorsRefactor-tolerant and close to domain behaviorA failure may span several objectsDomain rules, value objects, deterministic workflows
Interaction-based unit testsSpecify commands and collaboration protocols preciselyCouple to call structure. Doubles can divergeSide-effect ports where the interaction is the observable contract
Integration testsProve real wiring and provider semanticsMore setup and a wider failure surfaceDatabase queries, serialization, HTTP adapters, and configuration

Choose the lowest-cost layer that can expose the failure faithfully. Deterministic domain decisions usually belong in unit tests. SQL, serialization, HTTP, queues, and configuration require integration evidence. Coverage and branch count can reveal gaps, but neither makes every line worth an independent test.

Questions

References