Hướng Dẫn Xây Dựng Ứng Dụng Full Stack với .NET và Angular theo Phương Pháp TDD
Trong hướng dẫn thực hành này, chúng ta sẽ xây dựng một ứng dụng full-stack từ đầu sử dụng .NET cho backend và Angular cho frontend, nhưng với một quy tắc vàng: không viết mã sản xuất trừ khi một bài kiểm tra thất bại trước. Chào mừng đến với thế giới TDD!
Thách Thức: Niềm Tin vào Mã của Bạn
Bạn đã bao giờ sửa một lỗi, chỉ để vô tình làm hỏng một phần khác của hệ thống chưa? Hay thực hiện một tính năng mới, lo sợ rằng có điều gì đó có thể sai trong môi trường sản xuất? Thiếu tự tin vào mã của chính mình là một trong những kẻ thù lớn nhất của năng suất và tâm trí bình yên của một lập trình viên.
Giải pháp? Một mạng lưới an toàn. Một bộ bài kiểm tra tự động đảm bảo ứng dụng của bạn hoạt động chính xác như mong đợi. Và cách tốt nhất để xây dựng mạng lưới này là dệt nó khi bạn xây dựng ứng dụng.
Trong bài viết này, chúng ta sẽ xây dựng một ứng dụng danh sách việc cần làm đơn giản sử dụng một công nghệ hiện đại:
- Backend: .NET 7 Web API
- Frontend: Angular 14+
- Phương pháp: Phát Triển Dựa Trên Kiểm Tra (TDD) từ đầu đến cuối.
Quy tắc vàng của chúng ta sẽ là chu trình TDD cổ điển:
Đỏ -> Xanh -> Tái cấu trúc.
- Đỏ: Viết một bài kiểm tra mà thất bại vì tính năng chưa tồn tại.
- Xanh: Viết mã đơn giản nhất có thể để làm cho bài kiểm tra qua.
- Tái cấu trúc: Cải thiện mã mà không thay đổi hành vi của nó (và không làm hỏng bài kiểm tra).
Hãy bắt đầu với backend.
Phần 1: Backend .NET Dựa Trên Kiểm Tra
Mục tiêu của chúng ta là tạo ra một endpoint GET /api/tasks trả về danh sách các nhiệm vụ.
Bước 1: Đỏ - Viết Bài Kiểm Tra Đầu Tiên
Đầu tiên, chúng ta tạo một dự án kiểm tra (sử dụng xUnit, NUnit, v.v.) và viết một bài kiểm tra cho TasksController trong tương lai của chúng ta.
csharp
// Trong dự án kiểm tra (ví dụ: Tasks.API.Tests/TasksControllerTests.cs)
public class TasksControllerTests
{
[Fact]
public async Task GetTasks_WhenCalled_ShouldReturnOkWithListOfTasks()
{
// Sắp xếp
// Controller chưa tồn tại, nhưng chúng ta đã xác định cách nó nên hoạt động.
var controller = new TasksController(); // <-- Điều này sẽ gây ra lỗi biên dịch!
// Hành động
var result = await controller.GetTasks();
// Kiểm tra
var okResult = Assert.IsType<OkObjectResult>(result);
var tasks = Assert.IsAssignableFrom<IEnumerable<TaskItem>>(okResult.Value);
Assert.NotEmpty(tasks);
}
}
Tại thời điểm này, mã không biên dịch. TasksController không tồn tại, phương thức GetTasks không tồn tại. Tuyệt vời. Chúng ta đang ở trong giai đoạn Đỏ.
Bước 2: Xanh - Làm Bài Kiểm Tra Qua
Bây giờ, hãy đến dự án API của chúng ta và viết mã đơn giản nhất có thể để làm cho bài kiểm tra biên dịch và qua.
Đầu tiên, mô hình TaskItem:
csharp
// Trong dự án API (ví dụ: Tasks.API/Models/TaskItem.cs)
public class TaskItem
{
public int Id { get; set; }
public string Title { get; set; }
public bool IsCompleted { get; set; }
}
Bây giờ, TasksController:
csharp
// Trong dự án API (ví dụ: Tasks.API/Controllers/TasksController.cs)
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetTasks()
{
// Cảnh báo: Chúng ta đang mã hóa dữ liệu cứng.
// Đây là con đường ngắn nhất để đạt được "Xanh". Tái cấu trúc sẽ đến sau.
var tasks = new List<TaskItem>
{
new TaskItem { Id = 1, Title = "Học TDD", IsCompleted = true }
};
// Chúng ta sử dụng Task.FromResult để mô phỏng một hoạt động bất đồng bộ
return await Task.FromResult(Ok(tasks));
}
}
Nếu chúng ta chạy bài kiểm tra bây giờ, nó sẽ qua. Chúng ta đang ở trong giai đoạn Xanh.
Bước 3: Tái cấu trúc - Cải Thiện Mã
Mã hoạt động, nhưng đang sử dụng một danh sách mã hóa cứng. Hãy tái cấu trúc nó để sử dụng một dịch vụ, chuẩn bị cho một kết nối cơ sở dữ liệu trong tương lai, mà không làm hỏng bài kiểm tra của chúng ta.
Đầu tiên, giao diện dịch vụ:
csharp
public interface ITaskService
{
Task<IEnumerable<TaskItem>> GetAllAsync();
}
Bây giờ, TasksController đã tái cấu trúc sử dụng Dependency Injection:
csharp
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);
}
}
Tất nhiên, bài kiểm tra của chúng ta bây giờ bị hỏng vì constructor của TasksController đã thay đổi. Điều này là tốt! Bài kiểm tra đang bảo vệ kiến trúc của chúng ta. Hãy điều chỉnh nó bằng cách sử dụng một mock (với các thư viện như Moq hoặc NSubstitute).
csharp
// Bài kiểm tra đã điều chỉnh
[Fact]
public async Task GetTasks_WhenCalled_ShouldReturnOkWithListOfTasks()
{
// Sắp xếp
var mockService = new Mock<ITaskService>();
mockService.Setup(service => service.GetAllAsync())
.ReturnsAsync(new List<TaskItem> { new TaskItem { Id = 1, Title = "Kiểm tra" } });
var controller = new TasksController(mockService.Object);
// Hành động
var result = await controller.GetTasks();
// Kiểm tra
// ... các kiểm tra giống như trước
}
Chúng ta chạy lại các bài kiểm tra. Mọi thứ đều Xanh. Chúng ta giờ đã có mã sạch hơn, tách biệt và vẫn hoạt động.
Phần 2: Frontend Angular Dựa Trên Kiểm Tra
Hãy áp dụng cùng một logic trong Angular để tạo một thành phần hiển thị các nhiệm vụ.
Bước 1: Đỏ - Kiểm Tra Thành Phần
Angular CLI đã tự động tạo một tệp kiểm tra (.spec.ts) cho chúng ta. Hãy sửa đổi nó để kiểm tra xem TaskListComponent có gọi dịch vụ và hiển thị dữ liệu hay không.
typescript
// task-list.component.spec.ts
describe('TaskListComponent', () => {
let component: TaskListComponent;
let fixture: ComponentFixture<TaskListComponent>;
let mockTaskService: jasmine.SpyObj<TaskService>;
beforeEach(async () => {
// Tạo một spy cho dịch vụ của chúng ta
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', () => {
// Sắp xếp
const tasks: TaskItem[] = [{ id: 1, title: 'Kiểm tra thành phần', isCompleted: false }];
mockTaskService.getTasks.and.returnValue(of(tasks)); // Giả lập việc trả về Observable
// Hành động
fixture.detectChanges(); // Kích hoạt ngOnInit và vòng đời của thành phần
// Kiểm tra
expect(mockTaskService.getTasks).toHaveBeenCalled(); // Dịch vụ có được gọi không?
const element: HTMLElement = fixture.nativeElement;
const taskTitle = element.querySelector('li');
expect(taskTitle?.textContent).toContain('Kiểm tra thành phần'); // Tiêu đề có được hiển thị không?
});
});
Bài kiểm tra này sẽ thất bại. Thành phần của chúng ta rỗng. Chúng ta đang ở trong giai đoạn Đỏ.
Bước 2: Xanh - Triển Khai Thành Phần
Bây giờ, hãy viết mã tối thiểu trong thành phần và mẫu để làm cho bài kiểm tra qua.
typescript
// 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();
}
}
html
<!-- task-list.component.html -->
<ul>
<li *ngFor="let task of tasks$ | async">
{{ task.title }}
</li>
</ul>
Và TaskService sẽ thực hiện cuộc gọi HTTP đến API .NET của chúng ta.
Nếu chúng ta chạy bài kiểm tra bây giờ, nó sẽ qua. Xanh.
Bước 3: Tái cấu trúc
Mã của chúng ta đã khá sạch nhờ vào việc sử dụng async pipe và Observables. Một khả năng tái cấu trúc là thêm xử lý lỗi (và một bài kiểm tra cho nó!), chẳng hạn như hiển thị một thông điệp thân thiện nếu API thất bại. Nhưng bây giờ, chu trình của chúng ta đã hoàn tất.
Kết Luận: Hơn Cả Những Bài Kiểm Tra
Như chúng ta đã thấy, TDD không chỉ là viết các bài kiểm tra. Nó là một phương pháp thiết kế phần mềm. Nó buộc chúng ta phải suy nghĩ về giao diện và hành vi của các thành phần và dịch vụ của chúng ta trước khi chúng ta triển khai chúng.
Kết quả là mã:
- Đáng Tin Cậy Hơn: Bộ bài kiểm tra là mạng lưới an toàn của bạn.
- Tách Biệt Hơn: Nhu cầu giả lập các phụ thuộc khuyến khích Dependency Injection và thiết kế sạch hơn.
- Được Tài Liệu Tốt Hơn: Các bài kiểm tra tự thân phục vụ như tài liệu sống về cách hệ thống nên hoạt động.
Bắt đầu với TDD có thể cảm thấy chậm hơn một chút lúc đầu, nhưng tốc độ và sự tự tin mà bạn đạt được trong dài hạn là vô giá. Lần tới khi bạn bắt đầu một dự án, hãy thử viết một bài kiểm tra thất bại trước. Nó có thể chỉ là khởi đầu của một cách lập trình mới.