Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

Testing in .NET: Unit Tests, Mocking, and Integration Testing

February 14, 2025 · 6 min read

C#, .NET, Testing, Interview Prep

Introduction

Testing questions reveal whether a candidate writes maintainable code. Interviewers want to see that you understand test design, know when to mock, and can set up realistic integration tests. This post covers the patterns that come up most.


Unit Testing — The Practical Guide

TL;DR

Use xUnit, Moq for mocking, FluentAssertions for readable assertions. Test behavior, not implementation.

The Real Explanation

public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _repoMock;
    private readonly Mock<IEmailService> _emailMock;
    private readonly OrderService _sut;  // System Under Test

    public OrderServiceTests()
    {
        _repoMock = new Mock<IOrderRepository>();
        _emailMock = new Mock<IEmailService>();
        _sut = new OrderService(_repoMock.Object, _emailMock.Object);
    }

    [Fact]
    public async Task CreateOrder_WithValidOrder_SavesAndSendsEmail()
    {
        // Arrange
        var order = new Order { CustomerId = 1, Total = 100 };
        _repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
            .ReturnsAsync(new Order { Id = 1, CustomerId = 1, Total = 100 });

        // Act
        var result = await _sut.CreateOrderAsync(order);

        // Assert
        result.Should().NotBeNull();
        result.Id.Should().Be(1);

        _repoMock.Verify(r => r.SaveAsync(
            It.Is<Order>(o => o.CustomerId == 1)), Times.Once);

        _emailMock.Verify(e => e.SendOrderConfirmationAsync(
            It.IsAny<Order>()), Times.Once);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-100)]
    public async Task CreateOrder_WithInvalidTotal_ThrowsException(decimal total)
    {
        // Arrange
        var order = new Order { Total = total };

        // Act
        var act = () => _sut.CreateOrderAsync(order);

        // Assert
        await act.Should().ThrowAsync<ValidationException>()
            .WithMessage("*total*");
    }
}

FluentAssertions Power

// Collections
users.Should().HaveCount(3);
users.Should().Contain(u => u.Name == "John");
users.Should().BeInAscendingOrder(u => u.CreatedAt);
users.Should().OnlyContain(u => u.IsActive);

// Objects
user.Should().BeEquivalentTo(expectedUser, options =>
    options.Excluding(u => u.Id)
           .Excluding(u => u.CreatedAt));

// Exceptions
act.Should().Throw<InvalidOperationException>()
    .WithMessage("Cannot process*")
    .WithInnerException<ArgumentException>();

// Time-based (with tolerance)
order.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));

The Gotcha

// Testing async void - it's almost impossible
public async void BadEventHandler(object sender, EventArgs e)
{
    await DoSomethingAsync();
}

// Fix: Return Task, wrap in adapter for event handlers
public async Task HandleEventAsync()
{
    await DoSomethingAsync();
}

// Testing time-dependent code
public class ReportService
{
    public bool IsReportDue()
    {
        return DateTime.Now.Day == 1;  // Untestable!
    }
}

// Fix: Inject TimeProvider (.NET 8+)
public class ReportService(TimeProvider time)
{
    public bool IsReportDue()
    {
        return time.GetUtcNow().Day == 1;  // Testable!
    }
}

// In tests
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
var service = new ReportService(fakeTime);
service.IsReportDue().Should().BeTrue();

Mocking Strategies

When to Mock

MockDon't Mock
External services (APIs, email)The class under test
Database repositoriesSimple DTOs/value objects
File system operationsPure functions
Current time/randomCollections/LINQ

Mock Behaviors

// Strict vs Loose mocking
var strictMock = new Mock<IService>(MockBehavior.Strict);
// Throws if unexpected method called - catches over-mocking

var looseMock = new Mock<IService>(MockBehavior.Loose);
// Returns default values for unconfigured methods

// Callback for complex scenarios
_emailMock.Setup(e => e.SendAsync(It.IsAny<Email>()))
    .Callback<Email>(email => capturedEmails.Add(email))
    .ReturnsAsync(true);

// Sequential returns
_repoMock.SetupSequence(r => r.GetNextAsync())
    .ReturnsAsync(item1)
    .ReturnsAsync(item2)
    .ThrowsAsync(new InvalidOperationException("No more items"));

// Protected members
mockBase.Protected()
    .Setup<Task>("ProcessInternalAsync", ItExpr.IsAny<Order>())
    .ReturnsAsync(true);

Integration Testing with WebApplicationFactory

TL;DR

Use WebApplicationFactory to test your API end-to-end with a real (but test-configured) ASP.NET Core application.

The Real Explanation

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // Replace database with in-memory
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}");
            });

            // Replace external services with fakes
            services.RemoveAll<IEmailService>();
            services.AddSingleton<IEmailService, FakeEmailService>();

            // Replace HTTP clients
            services.AddHttpClient("PaymentGateway")
                .AddHttpMessageHandler(() => new FakePaymentHandler());
        });

        builder.UseEnvironment("Testing");
    }
}

public class OrderApiTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly CustomWebApplicationFactory _factory;

    public OrderApiTests(CustomWebApplicationFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateOrder_ReturnsCreated_WithValidOrder()
    {
        // Arrange
        var order = new CreateOrderRequest
        {
            CustomerId = 1,
            Items = new[] { new OrderItem { ProductId = 1, Quantity = 2 } }
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/orders", order);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var created = await response.Content.ReadFromJsonAsync<Order>();
        created.Should().NotBeNull();
        created!.Id.Should().BeGreaterThan(0);

        // Verify in database
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var savedOrder = await db.Orders.FindAsync(created.Id);
        savedOrder.Should().NotBeNull();
    }

    [Fact]
    public async Task CreateOrder_WithAuth_ReturnsOrder()
    {
        // Add auth header
        _client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", GenerateTestToken());

        var response = await _client.GetAsync("/api/orders/99");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

Test Isolation

// Each test needs clean state
public class OrderApiTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
    public async Task InitializeAsync()
    {
        // Reset database before each test
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.EnsureDeletedAsync();
        await db.Database.EnsureCreatedAsync();
    }

    public Task DisposeAsync() => Task.CompletedTask;
}

Testing EF Core

In-Memory vs SQLite

// In-memory: Fast but doesn't support relational features
services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase("TestDb"));

// SQLite: Supports more SQL features
services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite("DataSource=:memory:"));

// For SQLite in-memory, keep connection open
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();  // Must stay open!

services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(connection));

Repository Testing

public class OrderRepositoryTests : IDisposable
{
    private readonly AppDbContext _context;
    private readonly OrderRepository _sut;

    public OrderRepositoryTests()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        _context = new AppDbContext(options);
        _sut = new OrderRepository(_context);
    }

    [Fact]
    public async Task GetByCustomer_ReturnsOnlyCustomerOrders()
    {
        // Arrange
        _context.Orders.AddRange(
            new Order { CustomerId = 1, Total = 100 },
            new Order { CustomerId = 1, Total = 200 },
            new Order { CustomerId = 2, Total = 150 }
        );
        await _context.SaveChangesAsync();

        // Act
        var orders = await _sut.GetByCustomerAsync(1);

        // Assert
        orders.Should().HaveCount(2);
        orders.Should().OnlyContain(o => o.CustomerId == 1);
    }

    public void Dispose() => _context.Dispose();
}

Interview Questions

Q: What's the difference between a fake, stub, and mock?

Answer:

  • Fake: Working implementation (in-memory database)
  • Stub: Returns canned data, no verification
  • Mock: Verifies interactions happened

In practice, Moq blurs these lines - you use it for stubs and mocks.

Q: How do you test code that uses HttpClient?

Answer:

// Use IHttpClientFactory and mock the handler
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler.Protected()
    .Setup<Task<HttpResponseMessage>>(
        "SendAsync",
        ItExpr.IsAny<HttpRequestMessage>(),
        ItExpr.IsAny<CancellationToken>())
    .ReturnsAsync(new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = new StringContent(JsonSerializer.Serialize(expectedData))
    });

var client = new HttpClient(mockHandler.Object);

Q: What makes a good unit test?

Answer: FIRST principles:

  • Fast: Milliseconds, not seconds
  • Isolated: No dependencies between tests
  • Repeatable: Same result every run
  • Self-validating: Pass/fail, no manual checking
  • Timely: Written with or before the code

Summary

Test TypeScopeSpeedUse For
UnitSingle classFastBusiness logic
IntegrationMultiple componentsMediumAPI endpoints, DB queries
E2EFull systemSlowCritical user journeys

Next up: Common Interview Questions - Quick reference for the most-asked C#/.NET topics.


Part 6 of the C# Interview Prep series.

← Previous

C# Quick Reference Guide

Next →

Azure Integration Patterns: Service Bus and Functions