Phần thứ 2 này của bài viết Kiến trúc tiến hóa và thiết kế nổi dần sẽ hoàn tất các bước hướng dẫn về một ví dụ được mở rộng, cho thấy cách làm thế nào để thiết kế có thể xuất hiện dần t
Trang 1Kiến trúc tiến hóa và thiết kế nổi dần: Thiết kế hướng theo kiểm thử, phần 2
Bàn luận thêm về việc cho phép dùng kiểm thử để định hướng và cải thiện thiết kế của bạn
Neal Ford, Kiến trúc phần mềm, ThoughtWorks
Tóm tắt: Kiểm thử chỉ là một tác dụng phụ của việc phát triển hướng theo kiểm
thử (TDD - test-driven development); khi được thực hiện đúng cách, TDD sẽ cải
thiện thiết kế tổng thể của mã của bạn Phần thứ 2 này của bài viết Kiến trúc tiến hóa và thiết kế nổi dần sẽ hoàn tất các bước hướng dẫn về một ví dụ được mở
rộng, cho thấy cách làm thế nào để thiết kế có thể xuất hiện dần từ các mối quan tâm nảy sinh trong quá trình kiểm thử
Đây là phần thứ hai của bài viết gồm hai phần, nghiên cứu cách sử dụng TDD như thế nào để cho phép làm nổi dần các bước thiết kế tốt hơn từ quá trình viết kiểm thử trước khi bạn viết mã Tại phần 1, tôi đã viết một phiên bản của trình tìm số hoàn hảo (perfect numbers), sử dụng cách phát triển kiểm thử sau (viết các phép kiểm thử sau khi viết mã) Sau đó, tôi đã viết một phiên bản sử dụng TDD (viết các phép kiểm thử trước khi viết mã, cho phép kiểm thử chi phối thiết kế mã lệnh)
Ở cuối phần 1, tôi thấy rằng tôi đã mắc phải một lỗi cơ bản khi suy nghĩ về loại cấu trúc dữ liệu được sử dụng để lưu giữ danh sách các số hoàn hảo: bản năng mách bảo tôi bắt đầu bằng một danh sách mảng (ArrayList), nhưng tôi thấy rằng phép trừu tượng hóa thành kiểu tập hợp (Set) Tôi sẽ bắt đầu từ điểm này, mở rộng các thảo luận theo cách mà bạn có thể cải thiện chất lượng của các phép kiểm thử của bạn và kiểm tra chất lượng của mã lệnh cuối cùng
Chất lượng kiểm thử
Phép kiểm thử sử dụng cách trừu tượng hóa thành kiểu Set tốt hơn có trong liệt kê 1:
Liệt kê 1 Kiểm thử đơn vị với cách trừu tượng hóa thành Set tốt hơn
@Test public void add_factors() {
Set<Integer> expected =
Trang 2new HashSet<Integer>(Arrays.asList(1, 2, 3, 6));
Classifier4 c = new Classifier4(6);
c.addFactor(2);
c.addFactor(3);
assertThat(c.getFactors(), is(expected));
}
Mã này kiểm thử một trong những phần quan trọng nhất trong miền bài toán của tôi: lấy các ước số của một số Tôi muốn kiểm tra hành vi đó một cách kỹ lưỡng bởi vì nó là phần phức tạp nhất của bài toán, dễ bị gặp lỗi nhất Tuy nhiên, nó chứa một cấu trúc cồng kềnh, đó là: new HashSet (Arrays.asList (1, 2, 3, 6)) Ngay cả với sự hỗ trợ của IDE hiện đại, cấu trúc này làm cho mã lệnh rắc rối: gõ nhập new,
gõ nhập Has và để mã bên trong tiếp tục; gõ nhập <Int và để mã bên trong tiếp tục, thật chán Tôi sẽ làm cho điều này trở nên dễ dàng hơn
Về loạt bài viết này
Loạt bài viết này nhằm cung cấp một phối cảnh tươi mới về các khái niệm thường được thảo luận nhưng khó nắm bắt về kiến trúc và thiết kế phần mềm Thông qua các ví dụ cụ thể, Neal Ford mang đến cho bạn một nền tảng vững chắc cho cách
làm thực tế lanh lẹn của kiến trúc tiến hóa và thiết kế nổi dần Bằng cách trì hoãn
các quyết định quan trọng về thiết kế và kiến trúc cho đến thời điểm chịu trách nhiệm cuối cùng, bạn có thể ngăn ngừa được những phức tạp không cần thiết không để chúng ngầm phá hoại các dự án phần mềm của bạn
Kiểm thử theo nguyên tắc Moist
Một trong những câu “châm ngôn” để viết mã lệnh tốt có trong cuốn The
Pragmatic Programmer (Lập trình viên thực dụng) của các tác giả Andy Hunt và
Dave Thomas (xem mục Tài nguyên) — là nguyên tắc DRY (Don't Repeat
Yourself – Đừng lặp lại chính mình) Nó khuyên nhủ bạn tránh mọi sự lặp lại mã của bạn vì điều này thường gây ra các vấn đề Tuy nhiên, nguyên tác DRY không
áp dụng cho các kiểm thử đơn vị Các kiểm thử đơn vị thường phải kiểm tra các sắc thái hành vi của mã được kiểm thử, dẫn đến các tình huống tương tự và trùng lặp nhau Mã sao chép và dán để tạo ra kết quả mong đợi trong Liệt kê 1 (hàm
Trang 3HashSet (Arrays.asList (1, 2, 3, 6)) mới) là một ví dụ tuyệt vời về điều này bởi vì bạn sẽ muốn có rất nhiều biến thể của nó trong các phép kiểm thử khác nhau (N.D: tác giả chơi chữ ở đây khi đưa ra nguyên tắc Moist “Moist” – nghĩa là “ẩm ướt” đối lập với DRY- nghĩa là “khô”)
Quy tắc ngón tay cái TDD của tôi là các kiểm thử chỉ là ẩm (moist) chứ không phải là ướt sũng nước (drenched) Ý tôi muốn nói là một số trùng lắp trong các phép kiểm thử có thể chấp nhận được (và không tránh khỏi), nhưng bạn không nên
đi quá xa, tạo ra các cấu trúc cồng kềnh lặp đi lặp lại Để đạt mục đích này, tôi sẽ tái cấu trúc phép kiểm thử của mình để cung cấp một phương thức phụ trợ riêng tư (private) để giúp tôi xử lý cách viết hàm tạo phổ biến này, nó có trong liệt kê 2:
Liệt kê 2 Phương thức phụ trợ để giữ cho phép thử của tôi ở mức “ẩm”
private Set<Integer> expectationSetWith(Integer numbers) {
return new HashSet<Integer>(Arrays.asList(numbers));
}
Mã trong Liệt kê 2 làm cho tất cả các phép kiểm thử của tôi về các ước số trở nên sạch hơn nhiều, như đã thấy trong phép kiểm thử thể hiện trong Liệt kê 3, được viết lại từ liệt kê 1:
Liệt kê 3 Phép kiểm thử “ẩm hơn” để kiểm tra các ước số của một số
@Test public void factors_for_6() {
Set<Integer> expected = expectationSetWith(1, 2, 3, 6);
Classifier4 c = new Classifier4(6);
c.calculateFactors();
Trang 4assertThat(c.getFactors(), is(expected));
}
Bởi vì bạn đang viết các phép kiểm thử không có nghĩa là bạn phải vứt bỏ đi các nguyên tắc thiết kế tốt Phép kiểm thử là các loại mã lệnh khác, nhưng các nguyên tắc tốt (mặc dù khác) cũng được áp dụng đối với chúng
Các điều kiện biên
TDD khuyến khích các nhà phát triển phần mềm viết một phép kiểm thử không thực hiện được khi viết phép kiểm thử đầu tiên cho một chức năng mới nào đó Điều này tránh việc phép kiểm thử vô tình chạy thông suốt trong mọi trường hợp, làm cho phép kiểm thử thực sự không kiểm tra bất cứ điều gì (phép kiểm thử thừa – tautology test) Các phép kiểm thử cũng có thể xác minh hành vi mà bạn nghĩ rằng bạn là đúng nhưng chưa kiểm tra đủ để tự tin Các phép kiểm thử này không nhất thiết phải là trước tiên thất bại (mặc dù thất bại khi bạn nghĩ rằng phép kiểm thử sẽ thông suốt là điều hoàn toàn tốt bởi vì bạn đã tìm ra một lỗi tiềm tàng) Suy nghĩ về việc kiểm thử dẫn bạn đến xem xét những gì có thể kiểm thử được
Một số trường hợp kiểm thử thường không được chú ý là các điều kiện biên: mã của bạn sẽ làm gì khi phải đối mặt với đầu vào bất thường? Khi viết nhiều phép kiểm thử đối với phương thức getFactors() sẽ mở ra cho bạn suy nghĩ về những đầu vào hợp lý và không hợp lý nào có thể xảy ra
Với mục đích này, tôi sẽ bổ sung một số phép thử dành cho các điều kiện biên đáng chú ý, được thể hiện trong liệt kê 4:
Liệt kê 4 Các điều kiện biên cho phân tích ước số
@Test public void factors_for_100() {
Classifier5 c = new Classifier5(100);
c.calculateFactors();
Trang 5assertThat(c.getFactors(),
is(expectationSetWith(1, 100, 2, 50, 4, 25, 5, 20, 10)));
}
@Test(expected = InvalidNumberException.class)
public void cannot_classify_negative_numbers() {
new Classifier5(-20);
}
@Test public void factors_for_max_int() {
Classifier5 c = new Classifier5(Integer.MAX_VALUE);
c.calculateFactors();
assertThat(c.getFactors(), is(expectationSetWith(1, 2147483647)));
}
Con số 100 dường như thú vị bởi vì nó có rất nhiều ước số Bằng cách kiểm thử cho các số khác nhau, tôi nhận ra rằng trong miền bài toán việc có các số âm là vô nghĩa, do đó, tôi đã viết một phép kiểm thử (và thực sự phép thử này đã thất bại trước khi tôi sửa lỗi ấy) để loại trừ các số âm Nghĩ về các số âm cũng làm cho tôi nghĩ về MAX_INT: Phải chăng giải pháp của tôi nên xem xét những gì sẽ xảy ra nếu người sử dụng hệ thống cần các số lớn, kiểu long? Giả định ban đầu của tôi chỉ giới hạn ở các số kiểu interger, nhưng tôi cần phải chắc chắn rằng đây là một giả định hợp lệ
Trang 6Thu thập các yêu cầu là quá trình nén chịu thiệt (lossy compression – khi nén sẽ
bị mất thông tin)
Bạn hãy nhìn xung quanh mình và tìm một bức tranh hoặc tác phẩm nghệ thuật Giả sử rằng bức tranh đó chứa 2 triệu điểm ảnh (pixel) Điều gì sẽ xảy ra nếu bạn nén bức tranh đó để chỉ có 2.000 điểm ảnh? Bức tranh đó vẫn còn trông như cũ không? (Có lẽ thế nếu đó là một bức tranh của Rothko (N.D: hoạ sĩ theo trường phái trừu tượng, tranh của ông chỉ gồm các mảng mầu), nhưng đó là một trường hợp hiếm hoi) Thao tác nén bằng cách loại bỏ các thông tin là một thuật toán nén chịu thiệt Nếu bạn dùng phiên bản đã nén và cố gắng khôi phục lại nó thành 2 triệu điểm ảnh, thì bạn sẽ cần phải thực hiện một số ngón nghề Đôi khi bạn có thể đoán đúng, nhưng không phải trong mọi trường hợp
Các phiên làm việc yêu cầu “big design up front" (N.D: phương thức "thiết kế hoàn hảo trước, viết mã chương trình sau”, thường gắn với mô hình thác nước trong phát triển phần mềm) truyền thống là quá trình nén chịu thiệt đối với những
gì mà một ứng dụng cần làm Các nhà phân tích nghiệp vụ không thể lường trước mọi vấn đề sẽ phát sinh, do đó các nhà phát triển sẽ phải tạo ra các thông tin để điền vào các chi tiết Các nhà phát triển nổi tiếng là những người làm việc này rất
tệ, dẫn đến nhiều điều bực mình giữa những người xác định các yêu cầu và những người thực hiện các yêu cầu đó
Các quy trình lanh lẹn nỗ lực giảm bớt sự mất mát thông tin này bằng cách trì hoãn thuật toán giải nén càng muộn càng tốt và luôn luôn trông cậy vào một ai đó
có thể trả lời câu hỏi về những điều thực sự nên làm Thiết kế mà không có chi tiết thiết kế là điều không thể, vì vậy dù phương thức luận của bạn là gì, thì bạn phải tìm ra một cách hoàn toàn khả dĩ để điền vào các chi tiết chắc chắn bị loại bỏ bởi quá trình thu thập và xác định
Việc kiểm thử các điều kiện biên buộc bạn phải đặt dấu hỏi cho các giả định của bạn Rất dễ đưa ra các giả định không hợp lệ khi mã hóa một giải pháp Trong thực tế, đây là một trong những điểm yếu của việc thu thập các yêu cầu truyền thống - nó không bao giờ có thể tập hợp đủ chi tiết để loại bỏ các câu hỏi khi triển khai thực hiện, chắc chắn sẽ xảy ra Quá trình thu thập các yêu cầu là một dạng nén chịu thiệt
Bởi vì có quá nhiều điều bị bỏ sót bởi quá trình xác định những gì mà một phần mềm phải làm, bạn cần một cơ chế tại chỗ để giúp bạn tạo lại các câu hỏi mà bạn phải đưa ra để hiểu nó hoàn toàn Phỏng đoán về những gì những người kinh doanh thực sự mong muốn là điều nguy hiểm vì bạn sẽ nhận được phần lớn câu trả sai Sử dụng các phép kiểm thử để kiểm tra các điều kiện biên giúp bạn tìm ra các vấn đề để hỏi, mà hầu hết chúng là câu hỏi về cách hiểu vấn đề Việc tìm ra các câu hỏi đúng có ý nghĩa rất nhiều trong việc đạt được một thiết kế tốt
Trang 7Các phép kiểm thử dương và âm
Khi bắt đầu việc khảo sát các vấn đề này, tôi phân rã nó thành nhiều tác vụ con Khi tôi viết các phép kiểm thử, tôi phát hiện một tác vụ phân rã quan trọng Sau đây là toàn bộ danh sách các tác vụ:
1 Tôi cần các ước số của số đang xét
2 Tôi cần phải xác định xem một số có phải là một ước số không
3 Tôi cần phải xác định làm thế nào để bổ sung các ước số vào danh sách các ước số
4 Tôi cần phải tính tổng các ước số
5 Tôi cần phải xác định xem một số có là hoàn hảo không
Hai tác vụ còn lại là tính tổng các ước số và kiểm tra tính hoàn hảo của số đang xét Không có gì ngạc nhiên xảy ra với hai tác vụ này; hai phép kiểm thử cuối cùng có trong liệt kê 5:
Liệt kê 5 Hai phép kiểm thử cuối cùng cho các số hoàn hảo
@Test public void sum() {
Classifier5 c = new Classifier5(20);
c.calculateFactors();
int expected = 1 + 2 + 4 + 5 + 10 + 20;
assertThat(c.sumOfFactors(), is(expected));
}
@Test public void perfection() {
Trang 8int[] perfectNumbers =
new int[] {6, 28, 496, 8128, 33550336};
for (int number : perfectNumbers)
assertTrue(classifierFor(number).isPerfect());
}
Sau khi xem trang web Wikipedia để tìm một vài số hoàn hảo đầu tiên, tôi có thể viết một phép kiểm thử, kiểm tra xem tôi thực tế có thể tìm thấy các số hoàn hảo hay không Nhưng tôi chưa kết thúc Kiểm thử dương chỉ là một nửa công việc Tôi cũng cần một phép kiểm thử để kiểm tra xem liệu tôi có vô tình nhận nhầm một số không hoàn hảo Với mục đích này, tôi viết một phép thử âm, như trong liệt kê 6:
Liệt kê 6 Phép thử âm để đảm bảo rằng việc phân loại số hoàn hảo làm việc chính xác
@Test public void test_a_bunch_of_numbers() {
Set<Integer> expected = new HashSet<Integer>(
Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 33550340; i++) {
if (expected.contains(i))
assertTrue(classifierFor(i).isPerfect());
else
assertFalse(classifierFor(i).isPerfect());
Trang 9}
}
Mã này cho biết rằng thuật toán số hoàn hảo của tôi làm việc một cách chính xác, nhưng nó rất chậm Tôi có thể đoán được lý do tại sao bằng cách xem phương thức calculateFactors() của tôi, hiển thị trong liệt kê 7:
Liệt kê 7 Phương thức getFactors() đơn sơ
public void calculateFactors() {
for (int i = 2; i < _number; i++)
if (isFactor(i))
addFactor(i);
}
Vấn đề biểu hiện trong Liệt kê 7 tương tự như vấn đề trong phiên bản mã kiểm thử sau trong Phần 1 của loạt bài: Mã lệnh thu thập các ước số đi suốt toàn bộ con đường cho đến tận chính số đó Tôi có thể cải thiện mã này bằng cách thu thập các ước số theo cặp, cho phép tôi chỉ phân tích tới căn bậc hai của số đang xét, như được thể hiện trong phiên bản mã đã tái cấu trúc trong liệt kê 8:
Liệt kê 8 Phiên bản đã tái cấu trúc, hoạt động tốt hơn của phương thức
calculateFactors()
Trang 10public void calculateFactors() {
for (int i = 2; i < sqrt(_number) + 1; i++)
if (isFactor(i))
addFactor(i);
}
public void addFactor(int factor) {
_factors.add(factor);
_factors.add(_number / factor);
}
Đây là cách tái cấu trúc mã lệnh tương tự cách mà tôi đã làm trong phiên bản mã kiểm thử sau (trong Phần 1), nhưng lần này có sự thay đổi trong hai phương thức khác nhau Sự thay đổi ở đây đơn giản hơn vì tôi đã trừu tượng hóa chức năng addFactors() thành một phương thức riêng của nó, và phiên bản này sử dụng cách trừu tượng hóa thành Set, loại bỏ việc kiểm thử vụng về để chắc chắn rằng tôi không nhận các ước số hai lần như trong phiên bản kiểm thử sau
Nguyên tắc chỉ đạo của việc tối ưu hóa luôn luôn phải là làm cho đúng, sau đó làm cho nhanh Một bộ đầy đủ các phép kiểm thử đơn vị làm cho việc kiểm tra các
hành vi trở nên dễ dàng, cho phép bạn tự do chơi trò chơi “What if” với việc tối ưu hóa mà không cần lo lắng rằng bạn đã làm sai điều gì đó
Tôi đã làm xong với phiên bản mã hướng theo kiểm thử của trình tìm số hoàn hảo Toàn bộ mã của lớp này được hiển thị trong liệt kê 9
Liệt kê 9 Phiên bản TDD đầy đủ của trình phân loại số
Trang 11public class Classifier6 {
private Set<Integer> _factors;
private int _number;
public Classifier6(int number) {
if (number < 1)
throw new InvalidNumberException( "Can't classify negative numbers"); _number = number;
_factors = new HashSet<Integer>(); _factors.add(1);
_factors.add(_number);
}
private boolean isFactor(int factor) { return _number % factor == 0;
}
public Set<Integer> getFactors() {
return _factors;
}