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
| Mock | Don't Mock |
|---|---|
| External services (APIs, email) | The class under test |
| Database repositories | Simple DTOs/value objects |
| File system operations | Pure functions |
| Current time/random | Collections/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 Type | Scope | Speed | Use For |
|---|---|---|---|
| Unit | Single class | Fast | Business logic |
| Integration | Multiple components | Medium | API endpoints, DB queries |
| E2E | Full system | Slow | Critical user journeys |
Next up: Common Interview Questions - Quick reference for the most-asked C#/.NET topics.
Part 6 of the C# Interview Prep series.