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