Tải bản đầy đủ (.pdf) (19 trang)

Kiến trúc tiến hóa và thiết kế nổi dần: Thiết kế dựa theo thử nghiệm, Phần 1 Cho phép thử nghiệm để điều khiển và cải tiến thiết kế của bạn Neal Ford, Kiến trúc phần mềm, ThoughtWorks doc

Bạn đang xem bản rút gọn của tài liệu. Xem và tải ngay bản đầy đủ của tài liệu tại đây (725.38 KB, 19 trang )

Kiến trúc tiến hóa và thiết kế nổi dần: Thiết kế dựa theo thử nghiệm, Phần 1
Cho phép thử nghiệm để điều khiển và cải tiến thiết kế của bạn
Neal Ford, Kiến trúc phần mềm, ThoughtWorks
Tóm tắt: Hầu hết các nhà phát triển nghĩ rằng phần mang lại lợi ích nhất của việc
áp dụng phát triển dựa theo thử nghiệm (TDD) là các thử nghiệm. Tuy nhiên, khi
đã thực hiện đúng, TDD cải thiện thiết kế tổng thể của mã lệnh của bạn. Bài viết
này trong loạt bài kiến trúc tiến hóa và thiết kế nổi dần thông qua một ví dụ mở
rộng sẽ chỉ ra thiết kế có thể rõ nét dần từ các mối quan tâm nổi lên sau các thử
nghiệm như thế nào. Việc thử nghiệm chỉ là hiệu quả phụ của TDD; phần quan
trọng là làm thế nào để nó thay đổi mã lệnh của bạn cho tốt hơn.
Một trong những biện pháp thực tiễn phổ biến để phát triển nhanh là TDD. TDD là
một phong cách viết phần mềm có sử dụng các thử nghiệm để giúp bạn hiểu được
bước cuối cùng của pha xác định các yêu cầu. Bạn viết các thử nghiệm trước khi
bạn viết mã lệnh, củng cố thêm hiểu biết của bạn về những cái mà mã lệnh phải
làm.
Hầu hết các nhà phát triển cho rằng lợi ích hàng đầu thu được từ TDD là tập hợp
toàn diện các thử nghiệm đơn vị mà bạn nhận được. Tuy nhiên, khi thực hiện
đúng, TDD có thể thay đổi thiết kế tổng thể của mã lệnh của bạn thành tốt hơn bởi
vì nó trì hoãn các quyết định cho đến thời điểm hợp lý cuối cùng. Bởi vì bạn
không thực hiện các quyết định thiết kế từ trước, nó bỏ ngỏ cho bạn các tùy chọn
thiết kế tốt hơn hoặc cấu trúc lại để thiết kế tốt hơn. Bài viết này đi từng bước
thông qua một ví dụ để minh họa sức mạnh của việc cho phép thiết kế nổi rõ lên từ
các quyết định xung quanh các thử nghiệm đơn vị.
Về loạt bài viết này
Loạt bài này nhằm mục đích cung cấp một cách nhìn mới mẻ về các khái niệm
thường được bàn luận nhưng khó nắm bắt ý nghĩa của thiết kế và kiến trúc phần
mềm. Thông qua các ví dụ cụ thể, Neal Ford sẽ mang lại cho bạn một nền móng
vững chắc về các biện pháp thực hành nhanh kiến trúc tiến hóa và thiết kế nổi dần.
Bằng cách lùi các quyết định thiết kế và kiến trúc quan trọng đến thời điểm hợp lý
cuối cùng, bạn có thể ngăn ngừa không cho những sự phức tạp không cần thiết
hủy hoại các dự án phần mềm của bạn.


Luồng công việc của TDD
Một từ quan trọng trong thuật ngữ phát triển dựa theo thử nghiệm là dựa theo, báo
hiệu rằng việc thử nghiệm điều khiển quá trình phát triển. Hình 1 cho thấy luồng
công việc của TDD:

Hình 1. Luồng công việc của TDD

Luồng công việc trong hình 1 là:
1. Viết một thử nghiệm không thành công.
2. Viết mã lệnh để làm cho nó thông qua.
3. Lặp lại các bước 1 và 2.
4. Đồng thời cấu trúc lại quyết liệt.
5. Khi bạn không thể nghĩ đến bất kỳ thử nghiệm nào thêm nữa, bạn đã xong
việc.
Dựa theo thử nghiệm so với thử nghiệm sau
Việc phát triển - dựa theo thử nghiệm yêu cầu các thử nghiệm xuất hiện trước. Chỉ
sau khi bạn đã viết các thử nghiệm (và thất bại) bạn mới viết mã lệnh được thử
nghiệm. Nhiều nhà phát triển sử dụng một biến thể cách làm thử nghiệm được gọi
là phát triển thử nghiệm sau (TAD), ở đó bạn viết mã lệnh và sau đó viết các thử
nghiệm đơn vị. Trong trường hợp này, bạn vẫn nhận được các thử nghiệm, nhưng
bạn không nhận được các khía cạnh thiết kế nổi dần của TDD. Chẳng có gì ngăn
cản bạn viết mã lệnh cực kỳ ghớm guốc và sau đó lúng túng tìm cách để thử
nghiệm nó như thế nào. Khi viết mã lệnh trước, bạn đã nhúng các định kiến của
bạn về cách thức mã sẽ hoạt động ra sao, sau đó thử nghiệm nó. TDD đòi hỏi bạn
phải làm ngược lại: viết các thử nghiệm trước và cho phép nó thông báo cho bạn
cách làm thế nào để viết mã lệnh làm cho thử nghiệm thông qua. Để minh họa sự
khác biệt quan trọng này, tôi sẽ bắt đầu một ví dụ mở rộng.


Các số hoàn hảo

Để cho thấy các lợi ích thiết kế của TDD, tôi cần một bài toán để giải quyết. Trong
cuốn sách Phát triển dựa theo thử nghiệm của mình (xem Tài nguyên), Kent Beck
sử dụng tiền tệ làm một ví dụ — một sự minh họa khá tốt về TDD, nhưng hơi đơn
giản thái quá. Thách thức thực sự là phải tìm ra một ví dụ không phức tạp đến mức
mà bạn bị lạc lối trong lĩnh vực của bài toán nhưng đủ phức tạp để cho thấy giá trị
thực sự.
Vì mục đích ấy, tôi đã chọn các số hoàn hảo. Đối với những bạn không theo dõi
chuyện tầm phào toán học, khái niệm này có nguồn gốc từ trước Euclid (người đã
thực hiện một trong các chứng minh sớm nhất về việc tìm ra các số hoàn hảo).
Một số hoàn hảo là một số mà bằng tổng của các thừa số của nó. Ví dụ, 6 là một số
hoàn hảo bởi vì các thừa số của 6 (trừ chính số 6) là 1, 2 và 3 và 1 + 2 + 3 = 6.
Một định nghĩa nhiều tính thuật toán hơn cho một số hoàn hảo là một số mà tổng
các thừa số (trừ chính số đó) bằng chính số đó. Trong ví dụ của tôi, phép tính là 1
+ 2 + 3 +6 - 6 = 6.
Và đây là lĩnh vực bài toán cần giải quyết: tạo ra một trình tìm kiếm số hoàn hảo.
Tôi sẽ thực hiện lời giải cho bài toán này theo hai cách khác nhau. Trước tiên, tôi
sẽ tắt một phần của não bộ của tôi muốn thực hiện TDD và chỉ viết giải pháp, sau
đó viết các thử nghiệm cho nó. Rồi sau đó, tôi sẽ phát triển một phiên bản TDD
của giải pháp để tôi có thể so sánh và đối chiếu cả hai cách tiếp cận.
Đối với ví dụ này, tôi triển khai thực hiện một trình tìm kiếm một số hoàn hảo
bằng ngôn ngữ Java (phiên bản 5 hoặc mới hơn vì tôi sẽ sử dụng các chú thích
trong thử nghiệm của mình), JUnit 4.x (phiên bản mới nhất) và các trình phối hợp
Hamcrest từ kho mã của Google (xem Tài nguyên). Các trình phối hợp Hamcrest
cung cấp một cú pháp theo cách giao tiếp của con người phủ bên trên các trình
phối hợp JUnit tiêu chuẩn. Ví dụ, thay cho assertEquals(expected, actual), bạn có
thể viết assertEquals(actual, is(expected)), đọc lên nghe giống với một câu nói đời
thực hơn. Các trình phối hợp Hamcrest có kèm theo với JUnit 4.x (chỉ cần dùng
lệnh nhập khẩu (import) tĩnh); nếu bạn vẫn còn sử dụng JUnit 3.x, bạn có thể tải
về một phiên bản tương thích.
Thử nghiệm sau

Listing 1 hiển thị phiên bản đầu tiên của PerfectNumberFinder:

Listing 1. The test-after PerfectNumberFinder

public class PerfectNumberFinder1 {
public static boolean isPerfect(int number) {
// get factors
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(number);
for (int i = 2; i < number; i++)
if (number % i == 0)
factors.add(i);

// sum factors
int sum = 0;
for (int n : factors)
sum += n;

// decide if it's perfect
return sum - number == number;
}
}

Đây không phải là mã đặc biệt đẹp, nhưng nó hoàn thành được công việc. Tôi bắt
đầu bằng cách liệt kê tất cả các thừa số dưới dạng một danh sách động (một
ArrayList). Tôi thêm 1 và số đích vào danh sách. (Tôi tuân thủ công thức đã cho ở
trên và liệt kê tất cả các thừa số, bao gồm số 1 và chính số đó). Sau đó, tôi duyệt
qua các thừa số cho đến khi gặp chính số đó, kiểm tra lần lượt từng số để xem nó
có phải một thừa số không. Nếu đúng, tôi thêm số đó vào danh sách. Tiếp theo, tôi

lấy tổng tất cả các thừa số và cuối cùng là viết một phiên bản Java của công thức
đã chỉ ra ở trên để xác định số hoàn hảo.
Bây giờ, tôi cần một thử nghiệm đơn vị theo cách thử nghiệm sau để xác định xem
chương trình có hoạt động đúng hay không. Tôi cần ít nhất hai thử nghiệm: một để
xem báo cáo kết quả các số hoàn hảo có đúng không và thử nghiệm kia sẽ kiểm tra
để tôi không nhận được các xác thực sai. Các thử nghiệm đơn vị có trong Listing
2:

Listing 2. Các thử nghiệm đơn vị cho PerfectNumberFinder

public class PerfectNumberFinderTest {
private static Integer[] PERFECT_NUMS = {6, 28, 496, 8128, 33550336};

@Test public void test_perfection() {
for (int i : PERFECT_NUMS)
assertTrue(PerfectNumberFinder1.isPerfect(i));
}

@Test public void test_non_perfection() {
List<Integer>expected = new ArrayList<Integer>(
Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 100000; i++) {
if (expected.contains(i))
assertTrue(PerfectNumberFinder1.isPerfect(i));
else
assertFalse(PerfectNumberFinder1.isPerfect(i));
}
}

@Test public void test_perfection_for_2nd_version() {

for (int i : PERFECT_NUMS)
assertTrue(PerfectNumberFinder2.isPerfect(i));
}

@Test public void test_non_perfection_for_2nd_version() {
List<Integer> expected = new
ArrayList<Integer>(Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 100000; i++) {
if (expected.contains(i))
assertTrue(PerfectNumberFinder2.isPerfect(i));
else
assertFalse(PerfectNumberFinder2.isPerfect(i));
}
assertTrue(PerfectNumberFinder2.isPerfect(PERFECT_NUMS[4]));
}
}

Tại sao dùng "_" trong các tên thử nghiệm?
Đặt dấu gạch dưới trong các tên của phương thức khi viết các thử nghiệm đơn vị là
một trong những thói quen viết mã lệnh của tôi. Tất nhiên, tiêu chuẩn Java nói rõ
rằng kiểu bướu lạc đà mới là cách đúng đắn để viết các tên của phương thức.
Nhưng tôi vẫn duy trì các tên của phương thức thử nghiệm khác với các tên của
phương thức bình thường. Các tên của phương thức thử nghiệm cần cho biết
phương thức đang thử nghiệm cái gì, và do đó chúng trở thành các tên dài, diễn tả
hoàn toàn chính xác những gì bạn muốn khi phân tách ra. Tuy nhiên, việc đọc các
tên dài theo kiểu “bướu lạc đà” là khó khăn, đặc biệt là trong một trình chạy thử
nghiệm đơn vị, nơi có hàng chục hoặc hàng trăm thử nghiệm xuất hiện, vì rất
nhiều các tên thử nghiệm bắt đầu giống nhau và chỉ khác nhau ở gần phía cuối.
Trong tất cả các dự án mà tôi đã tiến hành, tôi ủng hộ mạnh mẽ việc sử dụng các
dấu gạch dưới (chỉ dùng cho các tên thử nghiệm) để làm cho chúng dễ đọc hơn.

Mã này cho kết quả đúng là các số hoàn hảo nhưng nó chạy rất chậm với thử
nghiệm phủ định do phải kiểm tra quá nhiều số. Các vấn đề hiệu suất có thể xuất
hiện từ các thử nghiệm đơn vị đã đưa tôi quay về với mã lệnh để xem xem tôi có
thể thực hiện một số cải tiến không. Hiện tại, tôi duyệt qua suốt vòng lặp cho đến
khi gặp chính số đó để thu được các thừa số. Nhưng tôi có cần phải đi xa như thế
không? Không, nếu như tôi có thể thu hoạch các thừa số theo từng cặp. Tất cả các
thừa số đều có cặp (ví dụ, nếu số đích là số 28, khi tôi tìm thấy thừa số 2, tôi cũng
có thể lấy luôn thừa số 14). Tôi chỉ cần đi tiếp lên tới căn bậc 2 của số đích là tôi
có thể thu được các thừa số theo cặp. Vì mục đích này, tôi cải tiến các thuật toán
và cấu trúc lại mã lệnh cho Listing 3:

Listing 3. Phiên bản thuật toán đã cải tiến

public class PerfectNumberFinder2 {
public static boolean isPerfect(int number) {
// get factors
List<Integer> factors = new
ArrayList<Integer>();
factors.add(1);
factors.add(number);
for (int i = 2; i <= sqrt(number); i++)
if (number % i == 0) {
factors.add(i);
factors.add(number / i);
}

// sum factors
int sum = 0;
for (int n : factors)
sum += n;


// decide if it's perfect
return sum - number == number;
}
}

Mã này chạy trong một thời gian khá dài nhưng một số kết quả thử nghiệm không
thành công. Té ra là khi bạn thu thập các thừa số theo các cặp, bạn vô tình lấy ra
các số hai lần khi đạt đến căn bậc hai của số đích. Ví dụ, đối với số 16, có căn bậc
hai là 4, vô tình được thêm vào danh sách hai lần. Điều này rất dễ dàng sửa chữa
bằng cách tạo một điều kiện canh giữ trường hợp này, như được hiển thị trong
Listing 4:

Listing 4. Thuật toán cải tiến đã sửa lỗi

for (int i = 2; i <= sqrt(number); i++)
if (number % i == 0) {
factors.add(i);
if (number / i != i)
factors.add(number / i);
}

Bây giờ tôi có một phiên bản kiểm tra sau của trình tìm số hoàn hảo. Nó làm việc
được nhưng có một số vấn đề về thiết kế kéo theo. Trước tiên, tôi đã sử dụng các
dòng chú thích phân tách các phần của mã lệnh. Đây luôn luôn là hương vị của mã
lệnh: nó là một tiếng kêu cứu để cấu trúc lại thành các phương thức riêng. Cái mới
mà tôi vừa thêm vào có lẽ cần một chú thích để giải thích những gì mà điều kiện
canh giữ bé nhỏ ấy sẽ làm, nhưng bây giờ tôi sẽ để mặc thế đã. Vấn đề lớn nhất
nằm ở độ dài của nó. Nguyên tắc ngón tay cái của tôi đối với các dự án Java nói
rằng không nên có phương thức nào dài hơn 10 dòng mã. Nếu một phương thức

vượt quá con số này, nó gần như chắc chắn là làm nhiều hơn một điều mà nó
không nên làm. Phương thức này rõ ràng vi phạm nguyên tắc ấy, do đó tôi sẽ thử
một cách khác, lần này sẽ sử dụng TDD.


Thiết kế nổi dần thông qua TDD
Câu thần chú Ấn độ dành cho viết mã TDD là: "Cái điều đơn giản nhất để tôi có
thể viết một thử nghiệm cho nó là gì ?". Trong trường hợp này, đó có phải là "là
một số hoàn hảo hay là không?". Không — câu trả lời là điều này quá rộng. Tôi
phải phân rã bài toán và suy nghĩ "số hoàn hảo" có nghĩa là gì. Tôi có thể dễ dàng
đi đến kết quả là một số bước cần thiết để khám phá ra một số hoàn hảo:
 Tôi cần các thừa số của số đang xét.
 Tôi cần phải xác định xem một số có phải là thừa số không.
 Tôi cần phải lấy tổng các thừa số.
Hướng theo ý tưởng tìm điều đơn giản nhất ấy, mục nào trong số các mục trong
danh sách trên có vẻ là mục đơn giản nhất ? Tôi nghĩ rằng đó là mục xác định xem
một số có phải là thừa số của một số khác không. Vậy đây là phép thử nghiệm đầu
tiên của tôi, nó có trong Listing 5:

Listing 5. Kiểm tra xem "một số có phải là thừa số không?"

public class Classifier1Test {

@Test public void is_1_a_factor_of_10() {
assertTrue(Classifier1.isFactor(1, 10));
}
}

Phép kiểm tra đơn giản này là tầm thường đến mức ngớ ngẩn và nó chính là cái tôi
muốn. Để thực hiện thử nghiệm này, bạn phải có một lớp có tên là Classifier1, với

một phương thức isFactor(). Vì vậy, tôi phải tạo ra một khung sườn cấu trúc của
lớp này trước khi tôi thậm chí có thể nhận được một thanh màu đỏ. Việc viết các
thử nghiệm đơn vị tầm thường quá đỗi này cho phép bạn dựng lên một kết cấu
trước khi bạn cần bắt đầu suy nghĩ về lĩnh vực của bài toán theo một cách có ý
nghĩa. Tôi muốn suy nghĩ về chỉ một điều ở một thời điểm và điều này cho phép
tôi tiếp tục làm việc trên khung sườn cấu trúc mà không phải lo về các sắc thái của
bài toán mà tôi đang giải quyết. Sau khi biên dịch những thứ trên và thanh màu đỏ
xuất hiện, tôi ở tư thế sẵn sàng để viết mã, hiển thị trong Listing 6:

Listing 6. Lần đầu tiên thông qua thử nghiệm với phương thức thừa số

public class Classifier1 {
public static boolean isFactor(int factor, int number) {
return number % factor == 0;
}
}

Tốt rồi, thật đẹp và đơn giản, và nó làm được việc. Bây giờ tôi có thể chuyển sang
nhiệm vụ đơn giản nhất tiếp theo: nhận một danh sách các thừa số của một số. Thử
nghiệm xuất hiện trong 7:

Listing 7. Thử nghiệm tiếp theo: Các thừa số của một số đã cho

@Test public void factors_for() {
int[] expected = new int[] {1};
assertThat(Classifier1.factorsFor(1), is(expected));
}

Listing 7 chứa thử nghiệm đơn giản nhất mà tôi phải cố gắng làm để nhận được
các thừa số, vì thế bây giờ tôi có thể viết mã lệnh đơn giản nhất để thông qua được

thử nghiệm này (và cấu trúc lại nó sau này để làm cho nó tinh tế hơn). Phương
thức tiếp theo xuất hiện trong Listing 8:

Listing 8. Phương thức đơn giản factorsFor()

public static int[] factorsFor(int number) {
return new int[] {number};
}

Mặc dù phương thức này làm việc đúng, nó giữ tôi tạm dừng trên đường đi. Có vẻ
như để cho isFactor() thành phương thức tĩnh (static) là một ý tưởng tốt, bởi vì nó
chỉ trả về kết quả dựa trên đầu vào của nó. Tuy nhiên, bây giờ tôi cũng đã để cho
factorsFor() là phương thức tĩnh, có nghĩa là tôi phải chuyển một tham số được gọi
là number cho cả hai phương thức. Mã lệnh này trở thành quá thủ tục, đó là hậu
quả phụ của việc lạm dụng phương thức tĩnh. Để sửa chữa điều này, tôi sẽ cấu trúc
lại hai phương thức mà tôi đã có, đây là việc đơn giản là vì cho đến nay mới chỉ có
một ít mã như vậy. Lớp Classifier đã cấu trúc lại xuất hiện trong Listing 9:

Listing 9. Lớp Classifier đã cải tiến

public class Classifier2 {
private int _number;

public Classifier2(int number) {
_number = number;
}

public boolean isFactor(int factor) {
return _number % factor == 0;
}

}

Tôi đã làm cho number thành một biến thành viên trong lớp Classifier2, điều này
cho phép tôi tránh được việc chuyển đi chuyển lại nó như một tham số tới một bó
các phương thức tĩnh.
Mục tiếp theo trong danh sách phân rã ở trên nói rằng tôi cần phải tìm ra các thừa
số của một số. Vì vậy, thử nghiệm tiếp theo của tôi cần kiểm tra điều này (hiển thị
trong Listing 10):

Listing 10. Thử nghiệm tiếp theo: Các thừa số của một số

@Test public void factors_for_6() {
int[] expected = new int[] {1, 2, 3, 6};
Classifier2 c = new Classifier2(6);
assertThat(c.getFactors(), is(expected));
}

Bây giờ, tôi sẽ thử triển khai thực hiện phương thức trả về một mảng các thừa số
của một tham số đã cho, hiển thị trong 11:

Listing 11. Lần đầu tiên thông qua thử nghiệm với phương thức getFactors()

public int[] getFactors() {
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(_number);
for (int i = 2; i < _number; i++) {
if (isFactor(i))
factors.add(i);
}

int[] intListOfFactors = new int[factors.size()];
int i = 0;
for (Integer f : factors)
intListOfFactors[i++] = f.intValue();
return intListOfFactors;
}

Mã này cho phép vượt qua thử nghiệm, nhưng khi suy nghĩ lại, thật dễ sợ! Điều
này đôi lúc xảy ra khi bạn điều tra tỷ mỉ cách triển khai thực hiện mã lệnh bằng
cách sử dụng các thử nghiệm. Có gì khủng khiếp như vậy về các mã này? Trước
hết, nó rất dài và phức tạp và nó cũng mắc nhược điểm là vấn đề "làm nhiều hơn
một thứ". Bản năng của tôi đã dẫn tôi trở lại với việc dùng một mảng int[], nhưng
nó sẽ tăng thêm khá nhiều sự phức tạp vào mã lệnh ở dưới cùng và không đạt
được bất cứ thứ gì cho tôi. Đó là một con đường dốc trơn trượt khó đi khi bắt đầu
suy nghĩ quá nhiều về việc làm cho mọi thứ thuận tiện hơn dành cho các phương
thức tương lai mà có thể gọi phương thức này. Bạn cần phải có một lý do có sức
thuyết phục để thêm một cái gì đó phức tạp như thế vào mối nối này và tôi còn
chưa có một sự biện hộ nào cho việc này. Việc xem xét kỹ mã này gợi ý rằng có lẽ
các thừa số cũng nên tồn tại như một trạng thái bên trong của lớp, cho phép tôi
tách riêng ra phần chức năng của phương thức này.
Một trong những đặc điểm có ích mà các thử nghiệm làm nổi lên là các phương
thức thực sự kết dính. Kent Beck đã viết về điều này trong một cuốn sách có ảnh
hưởng tên là Các mẫu thực tiễn tốt nhất của Smalltalk (Smalltalk Best Practice
Patterns ) (xem Tài nguyên). Trong cuốn sách đó, Kent đã định nghĩa một mẫu
được gọi là phương thức cấu thành (composed method). Mẫu phương thức cấu
thành định nghĩa ba khẳng định then chốt:
 Chia chương trình của bạn thành các phương thức thực hiện một công việc
có thể nhận biết được.
 Giữ cho tất cả các phép toán trong một phương thức có cùng một mức độ
trừu tượng hóa.

 Điều này sẽ tự nhiên dẫn đến các chương trình với nhiều phương thức nhỏ,
mỗi phương thức có độ dài vài dòng.
Phương thức cấu thành là một trong những đặc điểm thiết kế có ích mà TDD
khuyến khích và tôi đã vi phạm rõ ràng mẫu này trong phương thức getFactors() ở
Listing 11. Tôi có thể sửa chữa nó bằng cách thực hiện các bước sau:
1. Nâng các thừa số lên thành trạng thái bên trong.
2. Di chuyển đoạn mã khởi tạo các thừa số vào hàm tạo.
3. Loại bỏ đoạn mã mạ vàng nhằm chuyển đổi kết quả thành mảng int[] và xử
lý nó sau nếu điều này là có ích.
4. Thêm một thử nghiệm khác cho addFactors().
Bước thứ tư là khá tế nhị, nhưng quan trọng. Việc viết phiên bản mã có lỗi này đã
để lộ ra rằng vòng phân rã đầu tiên của tôi đã không đầy đủ. Dòng mã
addFactors() giấu vào giữa phương thức dài này là hành vi thử nghiệm được. Nó
tầm thường đến mức mà tôi đã không nhận thấy điều này khi lần đầu tiên xem xét
bài toán, nhưng bây giờ tôi đã thấy rồi. Điều này thường xuyên xảy ra. Một thử
nghiệm có thể dẫn bạn đến phân rã tiếp tục bài toán thành các đoạn ngày càng nhỏ
hơn, mỗi đoạn đều có thể thử nghiệm.
Tôi sẽ tạm dừng bài toán lớn hơn về getFactors() vào lúc này và giải quyết bài
toán mới nhỏ nhất của tôi. Như vậy, thử nghiệm tiếp theo của tôi là addFactors(),
được hiển thị trong Listing 12:

Listing 12. Thử nghiệm với addFactors()

@Test public void add_factors() {
Classifier3 c = new Classifier3(6);
c.addFactor(2);
c.addFactor(3);
assertThat(c.getFactors(), is(Arrays.asList(1, 2, 3, 6)));
}


Đoạn mã cần thử nghiệm, được hiển thị trong Listing 13, là rất đơn giản:

Listing 13. Mã lệnh đơn giản để cộng các thừa số

public void addFactor(int factor) {
_factors.add(factor);
}

Tôi chạy thử nghiệm đơn vị của tôi, hoàn toàn tin tưởng rằng tôi sẽ thấy một thanh
màu xanh lục, nhưng nó thất bại! Làm thế nào mà một thử nghiệm đơn giản đến
như vậy lại thất bại? Nguyên nhân gốc rễ xuất hiện trong Hình 2:

Hình 2. Nguyên nhân gốc rễ của thử nghiệm không thành công

Danh sách mà tôi mong đợi có các giá trị 1, 2, 3, 6 nhưng thực tế trả về là 1, 6, 2,
3. Ôi, đó là vì tôi đã thay đổi mã để thêm 1 và chính số đích vào hàm tạo. Một giải
pháp cho vấn đề này sẽ là luôn viết như tôi mong muốn, giả sử rằng số 1 và chính
số đích luôn luôn được viết trước hết. Nhưng đó có phải là giải pháp đúng không?
Không. Vấn đề ở chỗ căn bản hơn nhiều. Các thừa số có phải là một danh sách các
số không? Không, chúng là một tập hợp các số. Giả thiết đầu tiên (không đúng)
của tôi dẫn đến việc sử dụng một danh sách các số nguyên dành cho các thừa số,
nhưng đó là một phép trừu tượng hóa tồi. Bằng việc cấu trúc lại mã lệnh, bây giờ
tôi sử dụng các tập hợp thay vì các danh sách, tôi không chỉ khắc phục được vấn
đề này mà còn làm cho giải pháp tổng thể trở nên tốt hơn vì bây giờ tôi đang sử
dụng phép trừu tượng hóa chính xác hơn.
Đây đúng là một kiểu suy nghĩ thiếu sót khi cho rằng các thử nghiệm có thể phơi
bày ra, có phải bạn viết các thử nghiệm trước khi bạn viết mã để che giấu việc
phán xét bạn. Bây giờ, nhờ thử nghiệm đơn giản này, toàn bộ thiết kế mã lệnh của
tôi thành tốt hơn vì tôi đã phát hiện một cách trừu tượng hóa thích hợp hơn.



Kết luận
Cho đến nay, tôi đã thảo luận về thiết kế nổi dần trong bối cảnh của bài toán số
hoàn hảo. Nói riêng, lưu ý rằng phiên bản đầu tiên của giải pháp (phiên bản kiểm
tra sau) đã phạm cùng một giả thiết sai lầm về các kiểu dữ liệu. "Thử nghiệm sau"
kiểm tra các chức năng của mã của bạn ở mức chi tiết thô, chứ không phải ở mức
các phần riêng biệt. TDD kiểm tra các khối nền tảng làm nên chức năng ở mức chi
tiết thô ấy, phơi bày ra nhiều thông tin hơn trong quá trình làm việc.
Trong bài viết tiếp theo, tôi sẽ tiếp tục bài toán số hoàn hảo, minh họa nhiều ví dụ
về các loại thiết kế có thể xuất hiện nếu bạn để lộ ra cách thức của các thử nghiệm
của bạn. Khi tôi có phiên bản TDD đầy đủ, tôi sẽ so sánh một vài số liệu thống kê
giữa hai cơ sở mã. Tôi cũng sẽ xử lý một số câu hỏi thiết kế khó khăn khác về
TDD, ví dụ như có hay không và khi nào thì thử nghiệm các phương thức riêng.

Mục lục

 Luồng công việc của TDD
 Các số hoàn hảo
 Thiết kế nổi dần thông qua TDD
 Kết luận

×