.NET Testing Standards
Before writing tests: Read .planning/CONVENTIONS.md for repository-specific test patterns (framework, naming, mocking library). If it doesn't exist, ask to run the repo-analyzer skill first.
Hard Rules
Must
- •Write failing test first (TDD) — Test drives the implementation, not vice versa
- •One assertion concept per test — Test one behaviour, multiple asserts on same object is OK
- •Use repo's existing test framework — Don't mix xUnit and NUnit in same solution
- •Tests must be deterministic — No dependency on time, random, or external state
- •Follow repo's naming convention — Check CONVENTIONS.md for pattern
- •Test behaviour, not implementation — Tests should survive refactoring
Must Not
- •Test private methods directly — Test through public API; if you need to test private, extract a class
- •Use Thread.Sleep in tests — Use async/await, polling with timeout, or test doubles
- •Share mutable state between tests — Each test gets fresh state
- •Mock what you don't own — Wrap third-party APIs, mock the wrapper
- •Write tests for trivial code — Auto-properties, simple DTOs don't need tests
- •Depend on test execution order — Tests must run independently and in parallel
TDD Workflow
Phase 1: RED — Write Failing Test
code
1. Identify the behaviour to implement 2. Write a test that expects that behaviour 3. Run the test — it MUST fail 4. If it passes, either the test is wrong or the feature exists
Command:
bash
dotnet test --filter "FullyQualifiedName~{TestClassName}.{TestMethodName}"
Phase 2: GREEN — Minimal Implementation
code
1. Write the MINIMUM code to make the test pass 2. No extra features, no "nice to haves" 3. Hard-coding is acceptable if it makes the test pass 4. Run the test — it MUST pass
Phase 3: REFACTOR — Clean Up
code
1. Remove duplication 2. Improve naming 3. Extract methods/classes if needed 4. Run ALL tests — they MUST still pass
Command:
bash
dotnet test --no-build --verbosity minimal
Golden Examples
Test Class Structure (xUnit)
csharp
public sealed class OrderServiceTests
{
private readonly Mock<IOrderRepository> _repositoryMock;
private readonly Mock<IDateTimeProvider> _dateTimeMock;
private readonly OrderService _sut;
public OrderServiceTests()
{
_repositoryMock = new Mock<IOrderRepository>();
_dateTimeMock = new Mock<IDateTimeProvider>();
_sut = new OrderService(_repositoryMock.Object, _dateTimeMock.Object);
}
[Fact]
public async Task GetOrder_WithValidId_ReturnsOrder()
{
// Arrange
var orderId = OrderId.New();
var expectedOrder = new Order(orderId, "Test Order");
_repositoryMock
.Setup(r => r.FindByIdAsync(orderId, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedOrder);
// Act
var result = await _sut.GetOrderAsync(orderId, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(orderId);
}
[Fact]
public async Task GetOrder_WithNonExistentId_ReturnsNotFoundError()
{
// Arrange
var orderId = OrderId.New();
_repositoryMock
.Setup(r => r.FindByIdAsync(orderId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Order?)null);
// Act
var result = await _sut.GetOrderAsync(orderId, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Order.NotFound");
}
}
Test Naming Patterns
Choose the pattern used in your repo (check CONVENTIONS.md):
csharp
// Pattern 1: MethodName_Condition_ExpectedResult public void GetOrder_WithValidId_ReturnsOrder() // Pattern 2: Should_ExpectedResult_When_Condition public void Should_ReturnOrder_When_IdIsValid() // Pattern 3: Given_When_Then public void GivenValidOrderId_WhenGetOrderCalled_ThenReturnsOrder()
Parameterised Tests (xUnit Theory)
csharp
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public void Withdraw_WithInvalidAmount_ThrowsArgumentException(decimal amount)
{
// Arrange
var account = new BankAccount(initialBalance: 100m);
// Act
var act = () => account.Withdraw(amount);
// Assert
act.Should().Throw<ArgumentOutOfRangeException>();
}
Testing Exceptions
csharp
[Fact]
public async Task ProcessPayment_WhenGatewayFails_ThrowsPaymentException()
{
// Arrange
_gatewayMock
.Setup(g => g.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new GatewayTimeoutException());
// Act
var act = () => _sut.ProcessPaymentAsync(ValidPayment, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<PaymentProcessingException>()
.WithMessage("*gateway*");
}
Anti-Patterns (Don't Do This)
❌ Testing Implementation Details
csharp
// BAD: Verifying internal method calls _repositoryMock.Verify(r => r.FindByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Once); // This breaks if implementation changes to use caching
Why it's bad: Tests break when refactoring even if behaviour is unchanged.
❌ Testing Multiple Behaviours
csharp
// BAD: Testing too much in one test
[Fact]
public void OrderService_WorksCorrectly()
{
// Tests create, update, delete, and query all in one test
}
Why it's bad: When it fails, you don't know which behaviour broke.
❌ Non-Deterministic Tests
csharp
// BAD: Depends on current time
[Fact]
public void Order_IsExpired_WhenPastExpiryDate()
{
var order = new Order { ExpiryDate = DateTime.Now.AddDays(-1) };
order.IsExpired.Should().BeTrue(); // May fail at midnight!
}
Fix: Inject IDateTimeProvider and control time in tests.
❌ Over-Mocking
csharp
// BAD: Mocking the system under test var sutMock = new Mock<OrderService>(); sutMock.Setup(s => s.Calculate()).Returns(100); // You're not testing anything real!
Why it's bad: You're testing your mocks, not your code.
Test Organisation
code
tests/ ├── MyProject.UnitTests/ │ ├── Services/ │ │ └── OrderServiceTests.cs # Mirrors src structure │ └── Domain/ │ └── OrderTests.cs ├── MyProject.IntegrationTests/ │ ├── Api/ │ │ └── OrdersEndpointTests.cs │ └── Fixtures/ │ └── DatabaseFixture.cs
Commands Reference
bash
# Run all tests dotnet test # Run specific test project dotnet test ./tests/MyProject.UnitTests/ # Run tests matching filter dotnet test --filter "FullyQualifiedName~OrderServiceTests" # Run tests by trait/category dotnet test --filter "Category=Unit" # List tests without running dotnet test --list-tests # Run with coverage (requires coverlet) dotnet test --collect:"XPlat Code Coverage"
Verification Checklist
Before considering tests complete:
- • Test fails before implementation (TDD RED)
- • Test passes after implementation (TDD GREEN)
- • Refactoring done with tests still passing
- • Follows naming convention from CONVENTIONS.md
- • One behaviour per test
- • No flaky/non-deterministic elements
- • Mocks are for external dependencies only
- • Test class has same structure as others in repo