.NET Academy

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:

  1. πŸ”΄ 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).
  2. 🟒 Green β€” write the simplest code that makes it pass. Resist doing more.
  3. πŸ”΅ 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, fast

Each 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.
  • WebApplicationFactory runs 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 pass
  1. 1.What's the first step of the TDD cycle?

  2. 2.According to the test pyramid, which tests should you have the most of?

  3. 3.What does WebApplicationFactory let you do?

  4. 4.Why use Testcontainers for database tests?

To complete this lesson

  • β—‹ Pass the quiz (3/4 correct)