.NET Testing: Unit, Integration, and E2E Test Strategies
# .NET Testing: Unit, Integration, and E2E Test Strategies
Testing in .NET should protect business-critical behavior, not just increase coverage numbers. A good strategy balances speed, confidence, and maintenance cost. In this guide, I walk through the full testing spectrum with real code examples, tooling recommendations, and lessons from production systems.
Build a Practical Test Pyramid
The test pyramid is not just theory. It directly shapes how fast your CI pipeline runs and how much confidence each deployment carries.
Unit Tests
Unit tests validate domain rules and pure application logic. They should be deterministic, fast, and free from infrastructure dependencies. A well-written unit test reads like a specification.
public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _orderRepoMock;
private readonly Mock<IInventoryService> _inventoryMock;
private readonly OrderService _sut;
public OrderServiceTests()
{
_orderRepoMock = new Mock<IOrderRepository>();
_inventoryMock = new Mock<IInventoryService>();
_sut = new OrderService(_orderRepoMock.Object, _inventoryMock.Object);
}
[Fact]
public async Task PlaceOrder_WithSufficientStock_ShouldCreateOrder()
{
class=class="code-string">"code-comment">// Arrange
var request = new PlaceOrderRequest(class="code-string">"SKU-class="code-number">001", quantity: class="code-number">3);
_inventoryMock
.Setup(x => x.CheckStock(class="code-string">"SKU-class="code-number">001"))
.ReturnsAsync(new StockResult(Available: class="code-number">10));
class=class="code-string">"code-comment">// Act
var result = await _sut.PlaceOrderAsync(request);
class=class="code-string">"code-comment">// Assert
result.Should().NotBeNull();
result.Status.Should().Be(OrderStatus.Confirmed);
_orderRepoMock.Verify(x => x.SaveAsync(It.IsAny<Order>()), Times.Once);
}
[Fact]
public async Task PlaceOrder_WithInsufficientStock_ShouldReturnOutOfStock()
{
class=class="code-string">"code-comment">// Arrange
var request = new PlaceOrderRequest(class="code-string">"SKU-class="code-number">001", quantity: class="code-number">50);
_inventoryMock
.Setup(x => x.CheckStock(class="code-string">"SKU-class="code-number">001"))
.ReturnsAsync(new StockResult(Available: class="code-number">2));
class=class="code-string">"code-comment">// Act
var result = await _sut.PlaceOrderAsync(request);
class=class="code-string">"code-comment">// Assert
result.Status.Should().Be(OrderStatus.OutOfStock);
_orderRepoMock.Verify(x => x.SaveAsync(It.IsAny<Order>()), Times.Never);
}
}Key principles for unit tests:
Integration Tests
Integration tests verify that components work together correctly: database mappings, API contracts, messaging boundaries, and third-party service interactions. They are slower than unit tests but catch issues that unit tests cannot.
public class OrderApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public OrderApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_ReturnsCreatedStatus()
{
class=class="code-string">"code-comment">// Arrange
var request = new { Sku = class="code-string">"SKU-class="code-number">001", Quantity = class="code-number">2 };
var content = new StringContent(
JsonSerializer.Serialize(request),
Encoding.UTF8,
class="code-string">"application/json");
class=class="code-string">"code-comment">// Act
var response = await _client.PostAsync(class="code-string">"/api/orders", content);
class=class="code-string">"code-comment">// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<OrderResponse>();
body!.Sku.Should().Be(class="code-string">"SKU-class="code-number">001");
body.Status.Should().Be(class="code-string">"Confirmed");
}
[Fact]
public async Task CreateOrder_WithInvalidPayload_ReturnsBadRequest()
{
class=class="code-string">"code-comment">// Arrange
var request = new { Sku = class="code-string">"", Quantity = -class="code-number">1 };
var content = new StringContent(
JsonSerializer.Serialize(request),
Encoding.UTF8,
class="code-string">"application/json");
class=class="code-string">"code-comment">// Act
var response = await _client.PostAsync(class="code-string">"/api/orders", content);
class=class="code-string">"code-comment">// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}Setting up `WebApplicationFactory` with overridden services:
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
class=class="code-string">"code-comment">// Remove the real database context
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
class=class="code-string">"code-comment">// Add in-memory database for fast tests
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase(class="code-string">"TestDb"));
class=class="code-string">"code-comment">// Replace external services with fakes
services.AddScoped<IPaymentGateway, FakePaymentGateway>();
});
}
}Integration Tests with Testcontainers
For tests that need a real database engine, Testcontainers spins up Docker containers on the fly. This gives you production-like fidelity without manual setup.
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage(class="code-string">"postgres:class="code-number">16-alpine")
.WithDatabase(class="code-string">"testdb")
.WithUsername(class="code-string">"test")
.WithPassword(class="code-string">"test")
.Build();
private AppDbContext _dbContext = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_dbContext = new AppDbContext(options);
await _dbContext.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _postgres.DisposeAsync();
}
[Fact]
public async Task SaveOrder_PersistsToDatabase()
{
class=class="code-string">"code-comment">// Arrange
var repo = new OrderRepository(_dbContext);
var order = new Order(class="code-string">"SKU-class="code-number">001", class="code-number">3, OrderStatus.Confirmed);
class=class="code-string">"code-comment">// Act
await repo.SaveAsync(order);
var retrieved = await repo.GetByIdAsync(order.Id);
class=class="code-string">"code-comment">// Assert
retrieved.Should().NotBeNull();
retrieved!.Sku.Should().Be(class="code-string">"SKU-class="code-number">001");
retrieved.Quantity.Should().Be(class="code-number">3);
}
}End-to-End Tests
E2E tests protect a few critical user journeys. They run against a fully deployed (or locally composed) environment. Keep the scope focused to avoid flaky suites.
Recommended .NET Tooling
| Tool | Purpose |
|------|---------|
| xUnit | Test runner with excellent parallel execution |
| FluentAssertions | Readable, chainable assertions |
| Moq | Mock framework for interface-based dependencies |
| NSubstitute | Alternative mock framework with cleaner syntax |
| Testcontainers | Docker-based test infrastructure |
| Bogus | Realistic fake data generation |
| Respawn | Fast database cleanup between tests |
| Verify | Snapshot testing for complex outputs |
FluentAssertions in Practice
FluentAssertions transforms cryptic test failures into clear diagnostics:
class=class="code-string">"code-comment">// Instead of:
Assert.Equal(class="code-string">"Confirmed", order.Status);
class=class="code-string">"code-comment">// Expected: "Confirmed", Actual: "Pending"
class=class="code-string">"code-comment">// Use:
order.Status.Should().Be(class="code-string">"Confirmed");
class=class="code-string">"code-comment">// Expected order.Status to be "Confirmed", but found "Pending".
class=class="code-string">"code-comment">// Collection assertions
orders.Should().HaveCount(class="code-number">3)
.And.OnlyContain(o => o.Total > class="code-number">0)
.And.BeInAscendingOrder(o => o.CreatedAt);
class=class="code-string">"code-comment">// Exception assertions
var act = () => service.PlaceOrderAsync(invalidRequest);
await act.Should().ThrowAsync<ValidationException>()
.WithMessage(class="code-string">"*SKU*required*");Test Data Management
Hardcoded test data turns into a maintenance burden fast. Use builders and fixtures to keep tests readable and resilient to schema changes.
Test Data Builders
public class OrderBuilder
{
private string _sku = class="code-string">"SKU-class="code-number">001";
private int _quantity = class="code-number">1;
private OrderStatus _status = OrderStatus.Pending;
private DateTime _createdAt = DateTime.UtcNow;
public OrderBuilder WithSku(string sku) { _sku = sku; return this; }
public OrderBuilder WithQuantity(int qty) { _quantity = qty; return this; }
public OrderBuilder WithStatus(OrderStatus s) { _status = s; return this; }
public OrderBuilder CreatedAt(DateTime dt) { _createdAt = dt; return this; }
public Order Build() => new Order(_sku, _quantity, _status)
{
CreatedAt = _createdAt
};
}
class=class="code-string">"code-comment">// Usage in tests:
var order = new OrderBuilder()
.WithSku(class="code-string">"SKU-PREMIUM")
.WithQuantity(class="code-number">5)
.WithStatus(OrderStatus.Confirmed)
.Build();Shared Fixtures with xUnit
public class DatabaseFixture : IAsyncLifetime
{
public AppDbContext DbContext { get; private set; } = null!;
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage(class="code-string">"postgres:class="code-number">16-alpine")
.Build();
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_container.GetConnectionString())
.Options;
DbContext = new AppDbContext(options);
await DbContext.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await DbContext.DisposeAsync();
await _container.DisposeAsync();
}
}
[CollectionDefinition(class="code-string">"Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection(class="code-string">"Database")]
public class ProductRepositoryTests
{
private readonly DatabaseFixture _fixture;
public ProductRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task GetAll_ReturnsSeededProducts()
{
var repo = new ProductRepository(_fixture.DbContext);
var products = await repo.GetAllAsync();
products.Should().NotBeEmpty();
}
}What to Test and What to Skip
Not everything deserves a test. Testing the wrong things wastes time and creates friction without adding confidence.
Always Test
Usually Skip
Gray Area (Test If Complex)
In my .NET services, I follow this test coverage strategy: 80% of my tests are unit tests covering domain and application logic, 15% are integration tests hitting the database and API layer with Testcontainers, and 5% are E2E smoke tests for critical user flows. I do not chase a coverage number; I focus on testing behaviors that would cause real damage if broken.
CI Integration
Tests only matter if they run automatically. Here is a GitHub Actions workflow that runs the full test suite on every pull request.
name: .NET Test Suite
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run Unit Tests
run: dotnet test --no-build --configuration Release --filter "Category=Unit" --logger "trx;LogFileName=unit-results.trx"
- name: Run Integration Tests
run: dotnet test --no-build --configuration Release --filter "Category=Integration" --logger "trx;LogFileName=integration-results.trx"
env:
ConnectionStrings__Default: "Host=localhost;Port=5432;Database=testdb;Username=test;Password=test"
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: '**/*.trx'
reporter: dotnet-trxCategorizing Tests for CI
Use xUnit traits to tag tests so CI can run them in separate stages:
[Trait(class="code-string">"Category", class="code-string">"Unit")]
public class PricingServiceTests
{
[Fact]
public void CalculateDiscount_GoldMember_Returns20Percent()
{
var service = new PricingService();
var discount = service.CalculateDiscount(MemberTier.Gold, 100m);
discount.Should().Be(20m);
}
}
[Trait(class="code-string">"Category", class="code-string">"Integration")]
public class PaymentGatewayTests : IClassFixture<CustomWebApplicationFactory>
{
class=class="code-string">"code-comment">// Integration tests that require infrastructure
}Common Testing Mistakes
These are patterns I have seen repeatedly in .NET projects that lead to fragile, slow, or misleading test suites.
1. Testing Implementation Instead of Behavior
class=class="code-string">"code-comment">// Bad: tightly coupled to internal method calls
_repoMock.Verify(x => x.BeginTransaction(), Times.Once);
_repoMock.Verify(x => x.CommitTransaction(), Times.Once);
_loggerMock.Verify(x => x.Log(It.IsAny<string>()), Times.Exactly(class="code-number">3));
class=class="code-string">"code-comment">// Good: verify the observable outcome
var result = await _sut.ProcessPaymentAsync(request);
result.IsSuccessful.Should().BeTrue();
result.TransactionId.Should().NotBeEmpty();2. Shared Mutable State Between Tests
class=class="code-string">"code-comment">// Bad: tests depend on execution order
private static List<Order> _orders = new();
[Fact]
public void Test1_AddOrder() { _orders.Add(new Order()); }
[Fact]
public void Test2_CheckCount() { _orders.Should().HaveCount(class="code-number">1); } class=class="code-string">"code-comment">// Flaky!
class=class="code-string">"code-comment">// Good: each test creates its own state
[Fact]
public void AddOrder_IncreasesCount()
{
var orders = new List<Order>();
orders.Add(new Order());
orders.Should().HaveCount(class="code-number">1);
}3. Over-Mocking
class=class="code-string">"code-comment">// Bad: mocking the thing you are testing
var mockService = new Mock<IOrderService>();
mockService.Setup(x => x.PlaceOrder(It.IsAny<Order>())).Returns(true);
var result = mockService.Object.PlaceOrder(new Order());
class=class="code-string">"code-comment">// Congratulations, you tested Moq, not your code.
class=class="code-string">"code-comment">// Good: mock dependencies, test the real service
var repoMock = new Mock<IOrderRepository>();
var service = new OrderService(repoMock.Object);
var result = service.PlaceOrder(new Order());4. Ignoring Error Paths
If your service catches exceptions and returns error results, test those paths. The happy path is often obvious; the error path is where bugs hide.
5. Giant Arrange Blocks
If your test setup is 40 lines long, extract it into a builder or fixture. Tests should be easy to read at a glance.
6. Not Cleaning Up Test Data
Integration tests that leave data behind cause cascading failures. Use `Respawn` or transaction rollback to reset state between tests:
public class IntegrationTestBase : IAsyncLifetime
{
private readonly Respawner _respawner;
protected readonly AppDbContext DbContext;
public async Task InitializeAsync()
{
_respawner = await Respawner.CreateAsync(connectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = new[] { class="code-string">"public" }
});
}
public async Task DisposeAsync()
{
await _respawner.ResetAsync(connectionString);
}
}Conclusion
Strong .NET testing is about strategic coverage: fast unit tests for business logic, meaningful integration checks with real infrastructure, and minimal but critical end-to-end protection. The goal is not 100% coverage but 100% confidence in the behaviors that matter.
In my .NET services, I have found that investing time in test infrastructure (builders, fixtures, shared factories) pays off massively. A test suite that is easy to write and maintain gets written. One that is painful gets skipped.
I can help you design a realistic test strategy with clear ROI.
Related Articles
Clean Architecture in .NET: Building Scalable Project Structure
Apply Clean Architecture in .NET projects. A guide to layers, dependency management, and testable code.
Dependency Injection in .NET: Core Concepts and Implementation
Understand and implement Dependency Injection in .NET correctly. Service lifetimes, registration patterns, and best practices.
.NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps
Design CI/CD pipelines for .NET projects. GitHub Actions, Azure DevOps, and deployment strategies.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch