Giải thích về TDD và BDD
TDD = Test-Driven Development (Phát triển hướng kiểm thử) BDD = Behavior-Driven Development (Phát triển hướng hành vi)
Phát triển hướng hành vi (BDD)
BDD xoay quanh tư duy sau: Đừng kiểm thử mã nguồn. Hãy kiểm thử hành vi.
Đây là một sự chuyển đổi về cách tiếp cận kiểm thử. Vì vậy, trong BDD, một số thuật ngữ mới được giới thiệu:
- Test suites (bộ kiểm thử) trở thành specifications (đặc tả),
- Test cases (các ca kiểm thử) trở thành scenarios (tình huống),
- Chúng ta không kiểm thử mã, mà xác minh hành vi.
Hãy làm rõ bằng một ví dụ sau đây!
Ví dụ Java
Nếu bạn không quen với Java, hãy xem trong repo để tìm phiên bản bằng ngôn ngữ khác (đã có: Java, Python, JavaScript, C#, Ruby, Go).
public class UsernameValidator { public boolean isValid(String username) { if (isTooShort(username)) { return false; } if (isTooLong(username)) { return false; } if (containsIllegalChars(username)) { return false; } return true; } boolean isTooShort(String username) { return username.length() < 3; } boolean isTooLong(String username) { return username.length() > 20; } // allows only alphanumeric and underscores boolean containsIllegalChars(String username) { return !username.matches("^[a-zA-Z0-9_]+$"); }
}
Lớp UsernameValidator
kiểm tra xem một tên người dùng có hợp lệ không (từ 3 đến 20 ký tự, chỉ chứa chữ, số và _
). Trả về true
nếu vượt qua tất cả các kiểm tra, ngược lại false
.
Làm sao để kiểm thử nó? Nếu ta kiểm thử mã nguồn có làm đúng như nó làm hay không, thì sẽ như sau:
@Test
public void testIsValidUsername() { // create spy / mock UsernameValidator validator = spy(new UsernameValidator()); String username = "User@123"; boolean result = validator.isValidUsername(username); // Check if all methods were called with the right input verify(validator).isTooShort(username); verify(validator).isTooLong(username); verify(validator).containsIllegalCharacters(username); // Now check if they return the correct thing assertFalse(validator.isTooShort(username)); assertFalse(validator.isTooLong(username)); assertTrue(validator.containsIllegalCharacters(username));
}
Cách này không tốt. Nếu sau này ta thay đổi logic trong isValidUsername
, ví dụ thay isTooShort()
và isTooLong()
bằng isLengthAllowed()
?
=> Kiểm thử sẽ hỏng. Vì nó gắn chặt với cách hiện thực. Điều này không tốt chút nào.
Trong BDD, ta chỉ xác minh hành vi mong muốn. Ví dụ:
@Test
void shouldAcceptValidUsernames() { // Examples of valid usernames assertTrue(validator.isValidUsername("abc")); assertTrue(validator.isValidUsername("user123")); ...
} @Test
void shouldRejectTooShortUsernames() { // Examples of too short usernames assertFalse(validator.isValidUsername("")); assertFalse(validator.isValidUsername("ab")); ...
} @Test
void shouldRejectTooLongUsernames() { // Examples of too long usernames assertFalse(validator.isValidUsername("abcdefghijklmnopqrstuvwxyz")); ...
} @Test
void shouldRejectUsernamesWithIllegalChars() { // Examples of usernames with illegal chars assertFalse(validator.isValidUsername("user@name")); assertFalse(validator.isValidUsername("special$chars")); ...
}
Cách này tốt hơn rất nhiều. Nếu thay đổi cách cài đặt bên trong, kiểm thử vẫn chạy tốt miễn là hành vi không đổi.
Việc cài đặt không còn quan trọng, ta chỉ đặc tả hành vi mong muốn. Vì vậy, trong BDD, không gọi là “bộ kiểm thử” mà là “đặc tả” (specification).
Tất nhiên ví dụ trên đơn giản và chưa bao quát hết BDD, nhưng nó làm rõ điểm cốt lõi: kiểm thử mã vs xác minh hành vi.
Có phải chỉ là công cụ?
Nhiều người nghĩ BDD là viết bằng cú pháp Gherkin với Cucumber hay SpecFlow:
Feature: User login Scenario: Successful login Given a user with valid credentials When the user submits login information Then they should be authenticated and redirected to the dashboard
Dù những công cụ này rất hữu ích, BDD không giới hạn ở chúng. BDD là về hành vi, không phải công cụ. Bạn có thể áp dụng BDD với các công cụ đó, hoặc không cần công cụ cũng được.
Phát triển hướng kiểm thử (TDD)
TDD đơn giản là: Viết kiểm thử trước, trước cả khi viết code.
Tức là bạn viết một kiểm thử cho một tính năng chưa tồn tại. Và dĩ nhiên, nó sẽ thất bại. Điều này nghe có vẻ lạ lúc đầu, nhưng TDD tuân theo một chu trình đơn giản:
Red-Green-Refactor
- Red: Viết một kiểm thử thất bại mô tả chức năng mong muốn.
- Green: Viết ít mã nhất để kiểm thử đó thành công.
- Refactor: Cải thiện mã (và kiểm thử nếu cần) trong khi vẫn giữ tất cả kiểm thử thành công.
Chu trình này đảm bảo mọi đoạn mã đều có lý do tồn tại, giúp giảm lỗi và tăng độ tin cậy.
Ba luật của TDD (Uncle Bob)
- Robert C. Martin (Uncle Bob) đã tổng kết TDD thành 3 luật:
- Không được viết mã thực tế trừ khi để làm cho một kiểm thử đơn vị thất bại trở thành thành công.
- Không được viết quá nhiều kiểm thử đơn vị, chỉ đủ để nó thất bại (bao gồm lỗi biên dịch).
- Không được viết quá nhiều mã thực tế, chỉ đủ để kiểm thử hiện tại thành công.
Kết hợp cả TDD + BDD!
TDD và BDD bổ sung cho nhau, và nên dùng cả hai.
- TDD đảm bảo mã đúng chức năng thông qua kiểm thử thất bại và chu trình Red-Green-Refactor.
- BDD đảm bảo kiểm thử tập trung vào hành vi mong muốn, không phụ thuộc vào cách hiện thực.
Hãy viết kiểm thử theo phong cách TDD để phát triển từng bước nhỏ, sau đó dùng tư duy BDD để viết chúng như những kịch bản kiểm thử rõ ràng, tập trung vào kết quả.
Kết quả mang lại là mã nguồn:
- Chính xác: Nhờ kiểm thử nghiêm ngặt.
- Dễ bảo trì: Kiểm thử không phụ thuộc vào cách hiện thực.
- Thiết kế tốt: Viết kiểm thử trước thúc đẩy sự tách biệt và module hóa.
Một ví dụ khác về BDD
Không theo BDD:
@Test
public void testHandleMessage() { Publisher publisher = new Publisher(); List<BuilderList> builderLists = publisher.getBuilderLists(); List<Log> logs = publisher.getLogs(); Message message = new Message("test"); publisher.handleMessage(message); // Verify build was created assertEquals(1, builderLists.size()); BuilderList lastBuild = getLastBuild(builderLists); assertEquals("test", lastBuild.getName()); assertEquals(2, logs.size());
}
Theo BDD:
@Test
public void shouldGenerateAsyncMessagesFromInterface() { Interface messageInterface = Interfaces.createFrom(SimpleMessageService.class); PublisherInterface publisher = new PublisherInterface(messageInterface, transport); // When we invoke a method on the interface SimpleMessageService service = publisher.createPublisher(); service.sendMessage("Hello"); // Then a message should be sent through the transport verify(transport).send(argThat(message -> message.getMethod().equals("sendMessage") && message.getArguments().get(0).equals("Hello") ));
}
Cảm ơn các bạn đã theo dõi!