TDD in .NET: Practicing TDD in a Web API Project
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.
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.
System Breakdown
- Controller — Receives HTTP requests, returns responses
- Service — Contains business logic, enforces rules
- Repository — Persists data, uses in-memory fakes for tests
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 GetExpensesByCategoryImplement 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 GetExpensesByCategoryRefactor: 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 GetExpensesByCategoryImplement 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 logicRefactor: 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 expensesRefactor: 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:
Controller → Service → Repository → DB/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.
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 exampleTheory: 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.
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.
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.
