TDD and integration testing
Drive design with red-green-refactor, and test your app with its real database and HTTP pipeline.
12 min read
You will learn
- Follow the red-green-refactor cycle
- Explain the test pyramid
- Write integration tests for an ASP.NET Core API with WebApplicationFactory
- Use real dependencies in tests with Testcontainers
Test-driven development
TDD flips the usual order: write the test first, then the code. You work in tiny cycles:
- π΄ Red β write a test for the next small bit of behavior. Run it; it fails (if it passes, the test is wrong or the feature already exists).
- π’ Green β write the simplest code that makes it pass. Resist doing more.
- π΅ Refactor β clean up the code (and tests) while all tests stay green.
Repeat, a few minutes per cycle. Example, building a price calculator:
// π΄ 1. A failing test
[Fact]
public void Total_NoItems_IsZero() => Assert.Equal(0m, new Cart().Total);
// π’ 2. Simplest code
public class Cart { public decimal Total => 0; }
// π΄ 3. Next test forces real behavior
[Fact]
public void Total_TwoItems_SumsPriceTimesQuantity()
{
var cart = new Cart();
cart.Add("Pen", price: 1.20m, quantity: 3);
cart.Add("Book", price: 10m, quantity: 1);
Assert.Equal(13.60m, cart.Total);
}
// π’ 4. Implement, then π΅ refactorβ¦Benefits: every line of code is covered by a test, designs stay small and testable (hard-to-test code is felt immediately), and the tests document intent. TDD is a discipline, not a religion β many teams use it for business logic and write tests afterwards elsewhere.
The test pyramid
β² End-to-end (browser, whole system) few, slow, brittle
β²β²β² Integration (app + real DB/HTTP) some
β²β²β²β²β²β² Unit (isolated logic) many, fastEach level catches different bugs. Unit tests can't tell you the SQL is wrong or the JSON property name changed β that's what integration tests are for.
Integration tests for ASP.NET Core
WebApplicationFactory<TProgram> (package Microsoft.AspNetCore.Mvc.Testing) starts your real app in memory β with its routing, middleware, DI, validation and serialization β and gives you an HttpClient to call it:
public class OrdersApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task CreateOrder_ReturnsCreatedWithLocation()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/orders", new { productId = 1, quantity = 2 });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
[Fact]
public async Task CreateOrder_InvalidQuantity_Returns400()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/orders", new { productId = 1, quantity = 0 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}You can replace services for tests β a fake email sender, a fixed clock β with factory.WithWebHostBuilder(b => b.ConfigureTestServices(...)).
Real databases with Testcontainers
Mocking EF Core or using its in-memory provider hides real problems: constraints, transactions and SQL translation behave differently. Testcontainers starts a real database in a disposable Docker container for your tests:
public class DatabaseFixture : IAsyncLifetime
{
public PostgreSqlContainer Db { get; } = new PostgreSqlBuilder().Build();
public Task InitializeAsync() => Db.StartAsync();
public Task DisposeAsync() => Db.DisposeAsync().AsTask();
}Point your app's connection string at Db.GetConnectionString(), apply migrations, and test against the real engine.
Coverage β a tool, not a goal
dotnet test --collect:"XPlat Code Coverage" reports which lines your tests execute. Low coverage reveals untested areas; high coverage doesn't prove the tests check anything meaningful. Aim for well-tested business logic, not a magic percentage.
Key takeaways
- TDD: red β green β refactor, in small cycles; it drives simple, testable designs.
- The test pyramid: many unit tests, some integration tests, few end-to-end tests.
WebApplicationFactoryruns your real API in memory for integration tests.- Testcontainers gives tests a real, disposable database; treat coverage as a guide, not a target.
Quiz
4 questions Β· 3 correct to pass1.What's the first step of the TDD cycle?
2.According to the test pyramid, which tests should you have the most of?
3.What does WebApplicationFactory let you do?
4.Why use Testcontainers for database tests?
To complete this lesson
- β Pass the quiz (3/4 correct)