C# Testing and Testability for Maintainable Systems — Clean Code

2026/04/142 min read
bookmark this

Introduction

Testing is not separate from clean code. Code that is hard to test is usually tightly coupled or unclear.

1) Design for testability

Inject dependencies

public class TrialService(TimeProvider clock)
{
    public bool IsTrialExpired(User user)
        => clock.GetUtcNow() > user.TrialEndDate;
}

Avoid direct static/global dependencies such as DateTime.Now in business logic.

2) Follow AAA structure

  • Arrange
  • Act
  • Assert
[Fact]
public void CalculateTotal_WithDiscount_ReturnsDiscountedPrice()
{
    // Arrange
    var items = new List<OrderItem>
    {
        new("Widget", 100m, 2),
        new("Gadget", 50m, 1)
    };
    var calculator = new PriceCalculator(0.10m);

    // Act
    decimal total = calculator.CalculateTotal(items);

    // Assert
    total.Should().Be(225m);
}

3) Use descriptive test names

Pattern: MethodName_Condition_ExpectedResult

Examples:

  • GetByIdAsync_OrderNotFound_ReturnsNull
  • Validate_EmptyCustomerId_ThrowsValidationException
  • PlaceOrderAsync_ValidRequest_SavesAndNotifies

4) Test behavior, not implementation details

Avoid brittle tests tied to internal method calls.

  • Good: verify output, state change, emitted event, HTTP response
  • Bad: over-verifying private interaction chains

5) Keep tests deterministic

Sources of flakiness:

  • current time
  • random values
  • network dependencies
  • shared mutable state

Use fakes/stubs/test containers and isolate external systems.

6) Fast feedback strategy

A practical test pyramid for C# backends:

  • many unit tests (fast)
  • fewer integration tests (database/API boundaries)
  • minimal end-to-end tests (critical user flows)

Summary

Testability is a design outcome. If dependencies are explicit and methods are cohesive, your tests become simpler, faster, and more valuable.