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_ReturnsNullValidate_EmptyCustomerId_ThrowsValidationExceptionPlaceOrderAsync_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.