In this hands-on guide, we'll build a full-stack application from scratch using .NET for the backend and Angular for the frontend, but with one golden rule: no production code is written unless a test fails first. Welcome to the world of TDD!
The Challenge: Confidence in Your Code
How many times have you fixed a bug, only to accidentally break another part of the system? Or implemented a new feature, fearing that something might go wrong in production? A lack of confidence in our own code is one of the biggest enemies of a developer's productivity and peace of mind.
The solution? A safety net. A suite of automated tests that ensures your application behaves exactly as expected. And the best way to build this net is to weave it as you build the application itself.
In this article, we'll build a simple to-do list application using a modern stack:
- Backend: .NET 7 Web API
- Frontend: Angular 14+
- Methodology: Test-Driven Development (TDD) from end to end.
Our golden rule will be the classic TDD cycle:
Red -> Green -> Refactor.
Red: Write a test that fails because the feature doesn't exist yet.
Green: Write the simplest possible code to make the test pass.
Refactor: Improve the code without changing its behavior (and without breaking the test).
Let's start with the backend.
Part 1: The Test-Driven .NET Backend
Our goal is to create a GET /api/tasks
endpoint that returns a list of tasks.
Step 1: Red - Writing the First Test
First, we create a test project (using xUnit, NUnit, etc.) and write a test for our future TasksController
.
// In the Test project (e.g., Tasks.API.Tests/TasksControllerTests.cs)
public class TasksControllerTests
{
[Fact]
public async Task GetTasks_WhenCalled_ShouldReturnOkWithListOfTasks()
{
// Arrange
// The controller doesn't even exist yet, but we're already defining how it should behave.
var controller = new TasksController(); // <-- This will cause a compile error!
// Act
var result = await controller.GetTasks();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var tasks = Assert.IsAssignableFrom<IEnumerable<TaskItem>>(okResult.Value);
Assert.NotEmpty(tasks);
}
}
At this point, the code doesn't compile. TasksController
doesn't exist, the GetTasks
method doesn't exist. Perfect. We are in the Red phase.
Step 2: Green - Making the Test Pass
Now, let's go to our API project and write the simplest possible code to make the test compile and pass.
First, the TaskItem
model:
// In the API project (e.g., Tasks.API/Models/TaskItem.cs)
public class TaskItem
{
public int Id { get; set; }
public string Title { get; set; }
public bool IsCompleted { get; set; }
}
Now, the TasksController
:
// In the API project (e.g., Tasks.API/Controllers/TasksController.cs)
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetTasks()
{
// Warning: We are hardcoding the data.
// This is the shortest path to "Green". Refactoring comes later.
var tasks = new List<TaskItem>
{
new TaskItem { Id = 1, Title = "Learn TDD", IsCompleted = true }
};
// We use Task.FromResult to simulate an async operation
return await Task.FromResult(Ok(tasks));
}
}
If we run our test now, it should pass. We are in the Green phase.
Step 3: Refactor - Improving the Code
The code works, but it's using a hardcoded list. Let's refactor it to use a service, preparing for a future database connection, without breaking our test.
First, the service interface:
public interface ITaskService
{
Task<IEnumerable<TaskItem>> GetAllAsync();
}
Now, the refactored TasksController
using dependency injection:
public class TasksController : ControllerBase
{
private readonly ITaskService _taskService;
public TasksController(ITaskService taskService) // Dependency Injection
{
_taskService = taskService;
}
[HttpGet]
public async Task<IActionResult> GetTasks()
{
var tasks = await _taskService.GetAllAsync();
return Ok(tasks);
}
}
Of course, our test now breaks because the TasksController
constructor has changed. This is good! The test is protecting our architecture. Let's adjust it using a mock (with libraries like Moq or NSubstitute).
// Adjusted Test
[Fact]
public async Task GetTasks_WhenCalled_ShouldReturnOkWithListOfTasks()
{
// Arrange
var mockService = new Mock<ITaskService>();
mockService.Setup(service => service.GetAllAsync())
.ReturnsAsync(new List<TaskItem> { new TaskItem { Id = 1, Title = "Test" } });
var controller = new TasksController(mockService.Object);
// Act
var result = await controller.GetTasks();
// Assert
// ... same assertions as before
}
We run the tests again. Everything is Green. We now have cleaner, decoupled, and equally functional code.
Part 2: The Test-Driven Angular Frontend
Let's apply the same logic in Angular to create a component that displays the tasks.
Step 1: Red - Testing the Component
The Angular CLI already creates a test file (.spec.ts
) for us. Let's modify it to test if our TaskListComponent
calls the service and renders the data.
// task-list.component.spec.ts
describe('TaskListComponent', () => {
let component: TaskListComponent;
let fixture: ComponentFixture<TaskListComponent>;
let mockTaskService: jasmine.SpyObj<TaskService>;
beforeEach(async () => {
// We create a spy for our service
mockTaskService = jasmine.createSpyObj('TaskService', ['getTasks']);
await TestBed.configureTestingModule({
declarations: [TaskListComponent],
providers: [{ provide: TaskService, useValue: mockTaskService }]
}).compileComponents();
fixture = TestBed.createComponent(TaskListComponent);
component = fixture.componentInstance;
});
it('should call getTasks on init and render the tasks', () => {
// Arrange
const tasks: TaskItem[] = [{ id: 1, title: 'Test the component', isCompleted: false }];
mockTaskService.getTasks.and.returnValue(of(tasks)); // Simulate the Observable's return
// Act
fixture.detectChanges(); // Triggers ngOnInit and the component's lifecycle
// Assert
expect(mockTaskService.getTasks).toHaveBeenCalled(); // Was the service called?
const element: HTMLElement = fixture.nativeElement;
const taskTitle = element.querySelector('li');
expect(taskTitle?.textContent).toContain('Test the component'); // Was the title rendered?
});
});
This test will fail. Our component is empty. We are in the Red.
Step 2: Green - Implementing the Component
Now, let's write the minimal code in the component and template to make the test pass.
// task-list.component.ts
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
})
export class TaskListComponent implements OnInit {
tasks$: Observable<TaskItem[]>;
constructor(private taskService: TaskService) {}
ngOnInit(): void {
this.tasks$ = this.taskService.getTasks();
}
}
<!-- task-list.component.html -->
<ul>
<li *ngFor="let task of tasks$ | async">
{{ task.title }}
</li>
</ul>
And the TaskService
will make the HTTP call to our .NET API.
If we run the test now, it will pass. Green.
Step 3: Refactor
Our code is already quite clean thanks to the use of the async
pipe and Observables
. A possible refactoring would be to add error handling (and a test for it!), such as displaying a friendly message if the API fails. But for now, our cycle is complete.
Conclusion: More Than Just Tests
As we've seen, TDD isn't just about writing tests. It's a software design methodology. It forces us to think about the interface and behavior of our components and services before we implement them.
The result is code that is:
- More Reliable: The test suite is your safety net.
- More Decoupled: The need to mock dependencies encourages dependency injection and a cleaner design.
- Better Documented: The tests themselves serve as living documentation of how the system should behave.
Starting with TDD might feel slower at first, but the speed and confidence you gain in the long run are immeasurable. The next time you start a project, try writing a failing test first. It might just be the beginning of a new way of coding.
Top comments (0)