TDD in .NET: Practicing TDD in a Web API Project

TDD in .NET: Practicing TDD in a Web API Project
Author(s): Ajay Kumar
Last updated: 15 Nov 2025

Motivation: Why This Article?


Many developers struggle to apply Test-Driven Development (TDD) effectively in real-world Web API projects. Common challenges include unclear test boundaries, slow feedback cycles, and confusion about where to start—controller, service, or database? This article aims to bridge that gap by providing a practical, layered approach to TDD, using a simple but realistic use case. The goal is to help readers build confidence, avoid common pitfalls, and make informed decisions at every step of the development process.

Challenge: TDD is not just about writing tests first—it's about designing APIs that are robust, maintainable, and easy to change. This article shows how to achieve that, one layer at a time.

This article demonstrates how to apply Test-Driven Development (TDD) to a .NET Web API project, focusing on the controller, service, and database layers. We'll use a hypothetical Expense Manager application and a simple read-only use case—Get expenses by category—to show best practices and a clear, step-by-step flow.

Why TDD? TDD helps you build reliable, maintainable code by writing tests before implementation. It keeps your design focused and your codebase flexible.

System Breakdown


  • Controller — Receives HTTP requests, returns responses
  • Service — Contains business logic, enforces rules
  • Repository — Persists data, uses in-memory fakes for tests
Design Insight: Separating layers makes your code easier to test and maintain. Each layer has a clear responsibility.

Use Case: Get Expenses by Category


  • Feature: Fetch all expenses for a given category (e.g., "Food", "Travel")
  • API should return a list of matching expenses

Step 1: Controller Layer TDD


The TDD process begins with the user's perspective: what does the API need to deliver? For the Expense Manager, the client wants to fetch expenses by category. Consider the endpoint, the shape of the response, and edge cases (empty results, invalid category, etc.).

Write a failing test: The test should describe the ideal behavior. Use Moq to mock the service, focusing only on the controller's responsibility: receiving the request, delegating to the service, and returning the correct response.


[Fact]
public async Task GetExpensesByCategory_ReturnsExpensesForCategory()
{
    var mockService = new Mock<IExpenseService>();
    var category = "Food";
    var expected = new List<ExpenseDto> {
        new ExpenseDto { Title = "Lunch", Amount = 10, Category = "Food", Date = DateTime.Today },
        new ExpenseDto { Title = "Snacks", Amount = 5, Category = "Food", Date = DateTime.Today }
    };
    mockService.Setup(s => s.GetExpensesByCategoryAsync(category)).ReturnsAsync(expected);
    var controller = new ExpensesController(mockService.Object);

    var result = await controller.GetExpensesByCategory(category);

    var okResult = Assert.IsType<OkObjectResult>(result);
    var actual = Assert.IsAssignableFrom<IEnumerable<ExpenseDto>>(okResult.Value);
    Assert.Equal(expected.Count, actual.Count());
}
        
Controller test for GetExpensesByCategory

Tip: The test will fail at first, because the endpoint does not exist yet. This is the "Red" phase of TDD. This failure is feedback that guides the next step.

Implement minimal code: Add the endpoint to the controller, just enough to make the test compile and run. Avoid adding extra logic or validation here; the goal is to move quickly from red to green.


[HttpGet("category/{category}")]
public async Task<IActionResult> GetExpensesByCategory(string category)
{
    var expenses = await _service.GetExpensesByCategoryAsync(category);
    return Ok(expenses);
}
        
Controller endpoint for GetExpensesByCategory

Refactor: Once the test passes, review the controller for clarity and simplicity. Thin controllers are best—keep business logic out and delegate to the service.

Step 2: Service Layer TDD


The next focus is business logic. The service must filter expenses by category, handle edge cases, and ensure correctness. Consider what could go wrong: unknown categories, empty lists, performance with large datasets.

Write a failing test: Use an in-memory fake repository to keep tests fast and isolated. The test should check that only expenses matching the category are returned, and that the service doesn't leak implementation details.


[Fact]
public async Task GetExpensesByCategory_ReturnsOnlyMatchingCategory()
{
    var fakeRepo = new InMemoryExpenseRepository();
    fakeRepo.Add(new Expense { Title = "Lunch", Amount = 10, Category = "Food", Date = DateTime.Today });
    fakeRepo.Add(new Expense { Title = "Taxi", Amount = 20, Category = "Travel", Date = DateTime.Today });
    var service = new ExpenseService(fakeRepo);

    var result = await service.GetExpensesByCategoryAsync("Food");

    Assert.Single(result);
    Assert.Equal("Lunch", result.First().Title);
}
        
Service test for GetExpensesByCategory

Best Practice: Avoid real database calls in unit tests. Fakes let you control the data and keep the feedback loop tight.

Implement minimal code: Add filtering logic to the service, just enough to pass the test. Do not optimize or add extra features yet—TDD is about incremental progress.


public async Task<IEnumerable<ExpenseDto>> GetExpensesByCategoryAsync(string category)
{
    var all = await _repository.GetAllAsync();
    return all.Where(e => e.Category == category)
              .Select(e => new ExpenseDto { Title = e.Title, Amount = e.Amount, Category = e.Category, Date = e.Date });
}
        
Service filtering logic

Refactor: After the test passes, look for duplication, unclear names, or opportunities to improve performance. Keep the service focused on business rules.

Step 3: Repository Layer TDD


Persistence is the foundation. The repository should be reliable and predictable. Use an in-memory list for tests, which simulates database behavior without the overhead.

Write a failing test: Test that expenses can be added and retrieved. Consider edge cases: duplicate entries, empty lists, thread safety if needed.


public class InMemoryExpenseRepository : IExpenseRepository
{
    private readonly List<Expense> _expenses = new();
    public void Add(Expense expense) => _expenses.Add(expense);
    public Task<List<Expense>> GetAllAsync() => Task.FromResult(_expenses);
}
        
Repository fake for expenses

Best Practice: Fakes are great for TDD—they keep tests fast and isolated from infrastructure. Use them to validate persistence logic before integrating with a real database.

Refactor: Once the repository works as expected, consider adding interfaces, improving naming, and preparing for future database integration.

Visual Flow: Layered Architecture

The flow of a typical Web API request is:

ControllerServiceRepositoryDB/Fake

Test Boundaries: Why Mock, Why DTOs?


Controller tests should mock the service layer to focus on request handling, validation, and response formatting. This keeps tests fast and isolated from business logic and data access.

DTOs (Data Transfer Objects) are used to shape the data sent to and from the API. They prevent leaking domain models and allow for versioning and validation. Always use DTOs in controller and service boundaries.

Best Practice: Use unit tests for controllers and services, and reserve integration tests for end-to-end scenarios.

Repository Layer: Expanded


The repository manages data access. In tests, use an in-memory fake to avoid external dependencies. In production, use a real implementation (e.g., EFCore).


[Fact]
public void AddAndRetrieveExpense_WorksCorrectly()
{
    var repo = new InMemoryExpenseRepository();
    var expense = new Expense { Title = "Lunch", Amount = 10, Category = "Food", Date = DateTime.Today };
    repo.Add(expense);
    var all = repo.GetAllAsync().Result;
    Assert.Contains(expense, all);
}
        
Repository test example

Note: A real repository (e.g., using EFCore) would connect to a database, handle queries, and manage transactions. Fakes are for fast, isolated tests.

Theory: Making Good TDD Decisions


TDD is more than just writing tests first—it's about making design decisions that lead to maintainable, flexible code. Here are some key principles and decision points to help you succeed:

  • Start with the API contract: Think about what your endpoint should do and how clients will use it. Write tests that reflect real usage.
  • Test one responsibility at a time: Each test should focus on a single behavior. This makes failures easy to diagnose and code easier to refactor.
  • Mock only what you don't control: Use Moq for external dependencies (services, repositories), but use real value objects and DTOs in your tests.
  • Prefer fakes for persistence: In-memory fakes let you test business logic without slow or flaky infrastructure. This keeps your TDD cycle fast.
  • Let tests drive design: If a test is hard to write, your code may be too tightly coupled. Refactor to introduce interfaces or break up responsibilities.
  • Refactor often: After each green test, clean up your code. Move logic to private methods, clarify names, and remove duplication.
  • Document your process: Keep a diary or log of your TDD steps. This helps you learn and makes it easier to onboard others.
Design Insight: TDD is a feedback loop. Use test failures and successes to guide your next design decision, not just to check correctness.

By following these principles, you'll be able to make better decisions at each step of the TDD cycle, leading to APIs that are robust, easy to change, and well-documented.

Troubleshooting TDD Pitfalls


  • Test not discovered? Check method/class attributes and naming.
  • Over-mocking? Only mock external dependencies, not value objects.
  • Brittle tests? Focus on behavior, not implementation details.
  • Slow tests? Use fakes and avoid real database/API calls.
Tip: Refactor often and keep tests focused on one responsibility.

Summary


By following TDD across controller, service, and repository layers, you build robust, maintainable APIs. Use Moq for mocking, in-memory fakes for fast cycles, and keep each test focused. Document each step and test run for clarity and learning.

Copyright © 2025 Dev Codex

An unhandled error has occurred. Reload 🗙