C# Testing In-Depth
2025/05/058 min read
bookmark this
C# Testing In-Depth
xUnit — Test Framework
-
[Fact]— a test with no parameters-
[Fact] public void Add_TwoPositiveNumbers_ReturnsSum() { // Arrange var calculator = new Calculator(); // Act int result = calculator.Add(3, 4); // Assert Assert.Equal(7, result); }
-
-
[Theory]+[InlineData]— parameterized tests-
[Theory] [InlineData(1, 2, 3)] [InlineData(0, 0, 0)] [InlineData(-1, 1, 0)] [InlineData(int.MaxValue, 0, int.MaxValue)] public void Add_VariousInputs_ReturnsExpectedSum(int a, int b, int expected) { var calculator = new Calculator(); int result = calculator.Add(a, b); Assert.Equal(expected, result); }
-
-
[Theory]+[MemberData]— complex test data-
public class DiscountCalculatorTests { public static IEnumerable<object[]> DiscountTestData => [ [new Customer { OrderCount = 0, IsActive = true }, 0.00m], [new Customer { OrderCount = 5, IsActive = true }, 0.05m], [new Customer { OrderCount = 15, IsActive = true }, 0.15m], [new Customer { OrderCount = 15, IsActive = false }, 0.00m], ]; [Theory] [MemberData(nameof(DiscountTestData))] public void GetDiscount_VariousCustomers_ReturnsExpectedRate( Customer customer, decimal expectedDiscount) { var calculator = new DiscountCalculator(); decimal discount = calculator.GetDiscount(customer); Assert.Equal(expectedDiscount, discount); } }
-
-
[Theory]+[ClassData]— reusable test data across test classes-
public class ValidEmailTestData : IEnumerable<object[]> { public IEnumerator<object[]> GetEnumerator() { yield return ["user@example.com", true]; yield return ["user@sub.domain.com", true]; yield return ["invalid", false]; yield return ["@no-local.com", false]; yield return ["", false]; } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } [Theory] [ClassData(typeof(ValidEmailTestData))] public void IsValidEmail_VariousInputs_ReturnsExpected(string email, bool expected) { bool result = EmailValidator.IsValid(email); Assert.Equal(expected, result); }
-
AAA Pattern — Arrange, Act, Assert
- Every test should follow this structure — it makes tests readable and consistent
-
[Fact] public async Task PlaceOrderAsync_ValidRequest_CreatesOrderAndNotifies() { // Arrange — set up dependencies, inputs, and expected values var request = new CreateOrderRequest("cust-1", [new OrderItemRequest("SKU-1", 2, 50m)]); var mockRepo = new Mock<IOrderRepository>(); var mockNotifier = new Mock<IOrderNotifier>(); var service = new OrderService(mockRepo.Object, mockNotifier.Object); // Act — call the method under test var result = await service.PlaceOrderAsync(request, CancellationToken.None); // Assert — verify the outcome result.Should().NotBeNull(); result.Total.Should().Be(100m); mockRepo.Verify(r => r.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once); mockNotifier.Verify(n => n.NotifyAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once); }
Test Naming Convention
- Pattern:
MethodName_Condition_ExpectedResult -
// ✓ Clear and descriptive GetByIdAsync_OrderExists_ReturnsOrder() GetByIdAsync_OrderNotFound_ReturnsNull() GetByIdAsync_NegativeId_ThrowsArgumentException() PlaceOrderAsync_EmptyItems_ThrowsValidationException() CalculateDiscount_PremiumCustomer_Returns15Percent() IsValid_EmptyEmail_ReturnsFalse() // ✗ Bad names — unclear what's being tested Test1() TestOrder() ShouldWork() OrderServiceTest()
Mocking with Moq
-
Setup return values
-
var mockRepo = new Mock<IOrderRepository>(); // Setup a specific return value mockRepo .Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>())) .ReturnsAsync(new Order { Id = 1, Total = 99.99m }); // Setup for any argument mockRepo .Setup(r => r.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>())) .ReturnsAsync((Order?)null); // Setup with argument matching mockRepo .Setup(r => r.GetByIdAsync(It.Is<int>(id => id > 0), It.IsAny<CancellationToken>())) .ReturnsAsync(new Order { Id = 1 });
-
-
Setup to throw exceptions
-
mockRepo .Setup(r => r.GetByIdAsync(-1, It.IsAny<CancellationToken>())) .ThrowsAsync(new ArgumentException("Invalid ID"));
-
-
Verify method calls
-
// Verify a method was called exactly once mockRepo.Verify( r => r.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once); // Verify never called mockNotifier.Verify( n => n.NotifyAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Never); // Verify with specific argument mockRepo.Verify( r => r.SaveAsync( It.Is<Order>(o => o.Total == 100m), It.IsAny<CancellationToken>()), Times.Once);
-
-
Callback — capture arguments for inspection
-
Order? savedOrder = null; mockRepo .Setup(r => r.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>())) .Callback<Order, CancellationToken>((order, ct) => savedOrder = order) .Returns(Task.CompletedTask); await service.PlaceOrderAsync(request, CancellationToken.None); savedOrder.Should().NotBeNull(); savedOrder!.CustomerName.Should().Be("Alice");
-
-
Mock.Of<T>()— quick mock with no setup needed-
// When you just need a mock that does nothing var service = new OrderService( mockRepo.Object, Mock.Of<ILogger<OrderService>>());
-
Mocking with NSubstitute (Alternative)
-
// Create a substitute var repo = Substitute.For<IOrderRepository>(); // Setup return value repo.GetByIdAsync(1, Arg.Any<CancellationToken>()) .Returns(new Order { Id = 1, Total = 99.99m }); // Use in tests var service = new OrderService(repo); var result = await service.GetByIdAsync(1, CancellationToken.None); // Verify await repo.Received(1).SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>()); await repo.DidNotReceive().DeleteAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
FluentAssertions — Readable Assertions
-
// Basic assertions result.Should().NotBeNull(); result.Should().Be(42); result.Should().BeGreaterThan(0); result.Should().BeInRange(1, 100); // String assertions name.Should().StartWith("John"); name.Should().Contain("Doe"); name.Should().NotBeNullOrWhiteSpace(); name.Should().MatchRegex(@"^\w+@\w+\.\w+$"); // Collection assertions orders.Should().HaveCount(3); orders.Should().ContainSingle(o => o.Id == 1); orders.Should().BeInAscendingOrder(o => o.CreatedAt); orders.Should().AllSatisfy(o => o.Total.Should().BePositive()); orders.Should().NotContainNulls(); // Object assertions result.Should().BeEquivalentTo(expected); // deep comparison result.Should().BeOfType<Order>(); result.Should().BeAssignableTo<IEntity>(); // Exception assertions var act = () => service.GetByIdAsync(-1, CancellationToken.None); await act.Should().ThrowAsync<ArgumentException>() .WithMessage("*Invalid*"); // DateTime assertions order.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); order.CreatedAt.Should().BeBefore(DateTime.UtcNow); // Execution time assertions var act = () => SlowOperation(); act.ExecutionTime().Should().BeLessThan(TimeSpan.FromSeconds(2));
Testing Async Code
-
[Fact] public async Task GetByIdAsync_OrderExists_ReturnsOrder() { // Arrange var mockRepo = new Mock<IOrderRepository>(); mockRepo .Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>())) .ReturnsAsync(new Order { Id = 1, Total = 99.99m }); var service = new OrderService(mockRepo.Object); // Act var result = await service.GetByIdAsync(1, CancellationToken.None); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(1); } // Testing that an async method throws [Fact] public async Task GetByIdAsync_NegativeId_ThrowsArgumentException() { var service = new OrderService(Mock.Of<IOrderRepository>()); var act = () => service.GetByIdAsync(-1, CancellationToken.None); await act.Should().ThrowAsync<ArgumentOutOfRangeException>(); } // Testing cancellation [Fact] public async Task ProcessAsync_CancellationRequested_ThrowsOperationCancelled() { var service = new OrderService(Mock.Of<IOrderRepository>()); using var cts = new CancellationTokenSource(); cts.Cancel(); var act = () => service.ProcessAsync(cts.Token); await act.Should().ThrowAsync<OperationCanceledException>(); }
Integration Testing — WebApplicationFactory
- Test the real HTTP pipeline with a real DI container — swap only what you need
-
public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory; public OrderApiTests(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Remove real database var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)); if (descriptor is not null) services.Remove(descriptor); // Add in-memory database services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TestDb")); }); }); } [Fact] public async Task GetById_OrderExists_ReturnsOkWithOrder() { // Seed test data using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); db.Orders.Add(new Order { Id = 1, CustomerName = "Alice", Total = 99.99m }); await db.SaveChangesAsync(); // Act var client = _factory.CreateClient(); var response = await client.GetAsync("/api/orders/1"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var order = await response.Content.ReadFromJsonAsync<OrderDto>(); order.Should().NotBeNull(); order!.CustomerName.Should().Be("Alice"); } [Fact] public async Task GetById_OrderNotFound_ReturnsNotFound() { var client = _factory.CreateClient(); var response = await client.GetAsync("/api/orders/99999"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } }
Test Fixtures & Shared Context
-
IClassFixture<T>— shared context for all tests in a class-
public class DatabaseFixture : IAsyncLifetime { public AppDbContext Db { get; private set; } = null!; public async Task InitializeAsync() { var options = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; Db = new AppDbContext(options); await SeedTestDataAsync(); } public async Task DisposeAsync() => await Db.DisposeAsync(); private async Task SeedTestDataAsync() { Db.Orders.AddRange( new Order { Id = 1, CustomerName = "Alice", Total = 100m }, new Order { Id = 2, CustomerName = "Bob", Total = 200m }); await Db.SaveChangesAsync(); } } public class OrderRepositoryTests(DatabaseFixture fixture) : IClassFixture<DatabaseFixture> { [Fact] public async Task GetByIdAsync_ExistingOrder_ReturnsOrder() { var repo = new OrderRepository(fixture.Db); var order = await repo.GetByIdAsync(1, CancellationToken.None); order.Should().NotBeNull(); order!.CustomerName.Should().Be("Alice"); } }
-
-
ICollectionFixture<T>— shared context across multiple test classes-
[CollectionDefinition("Database")] public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { } [Collection("Database")] public class OrderServiceTests(DatabaseFixture fixture) { // shares the same DatabaseFixture instance with other classes in this collection }
-
Test Organization & Best Practices
| Practice | Why |
|---|---|
| One assert per test (ideally) | Each test verifies one behavior — clear failure messages |
| Test behavior, not implementation | Tests survive refactoring; don't verify internal method calls |
| Don't test private methods | Test via public API; if private logic is complex, extract a class |
| No test interdependence | Each test must pass in isolation; no shared mutable state |
Use CancellationToken.None |
Explicit; tests don't rely on cancellation unless testing it |
Name: MethodName_Condition_Expected |
Self-documenting; tells you what failed without reading the test |
Avoid Thread.Sleep in tests |
Flaky; use TaskCompletionSource or fake time providers |
Use TimeProvider instead of DateTime.Now |
Deterministic; test any point in time |
-
Testing time-dependent code
-
// Production code — uses injectable TimeProvider public class TrialService(TimeProvider clock) { public bool IsTrialExpired(User user) => clock.GetUtcNow() > user.TrialEndDate; } // Test — control the clock [Fact] public void IsTrialExpired_TrialEndsInFuture_ReturnsFalse() { var fakeTime = new FakeTimeProvider( new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero)); var service = new TrialService(fakeTime); var user = new User { TrialEndDate = new DateTimeOffset(2024, 12, 31, 0, 0, 0, TimeSpan.Zero) }; bool result = service.IsTrialExpired(user); result.Should().BeFalse(); }
-
What to Test vs What Not to Test
| ✓ Test | ✗ Don't Test |
|---|---|
| Business logic and rules | Framework code (ASP.NET Core, EF Core) |
| Validation logic | Getters/setters with no logic |
| Edge cases and boundary values | Third-party library internals |
| Error handling paths | Configuration wiring (test via integration tests) |
| Complex algorithms | Trivial code (constructors, simple mappings) |
| Public API contracts | Private implementation details |
| Custom middleware behavior | Auto-generated code |
Summary — Testing Checklist
| Topic | Key Takeaway |
|---|---|
| Framework | xUnit for tests, Moq/NSubstitute for mocking, FluentAssertions for assertions |
| AAA Pattern | Arrange → Act → Assert in every test |
| Naming | MethodName_Condition_ExpectedResult |
[Fact] |
Single test case, no parameters |
[Theory] |
Parameterized tests with [InlineData], [MemberData], [ClassData] |
| Mocking | Mock interfaces; setup returns, verify calls, capture arguments |
| FluentAssertions | Readable assertions: .Should().Be(), .HaveCount(), .ThrowAsync() |
| Async testing | async Task test methods; await act.Should().ThrowAsync<T>() |
| Integration tests | WebApplicationFactory<Program> with service overrides |
| Fixtures | IClassFixture<T> for class-level shared context |
| Time | Use TimeProvider / FakeTimeProvider instead of DateTime.Now |
| Coverage goal | Minimum 80% line coverage |