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

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.

Neal Ford, Kiến trúc phần mềm, ThoughtWorks

Neal Ford là một kiến trúc sư phần mềm và Meme Wrangler tại Thought Works, một văn phòng tư vấn CNTT toàn cầu. Ông cũng thiết kế và phát triển các ứng dụng, tài liệu hướng dẫn, các bài báo trên tạp chí, học liệu và các bài thuyết trình video/DVD; và ông là tác giả hoặc người biên tập các cuốn sách bao trùm nhiều loại công nghệ, bao gồm cả cuốn sách gần đây nhất là The Productive Programmer. Ông tập trung vào việc thiết kế và xây dựng ứng dụng doanh nghiệp có quy mô lớn. Ông cũng là một diễn giả được quốc tế hoan nghênh tại hội nghị của các nhà phát triển trên toàn thế giới



20 05 2009

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 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ệmdự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 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
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.

Tài nguyên

Học tập

  • Hamcrest matchers: Một thư viện các trình phối hợp đối tượng cho phép bạn định nghĩa thế nào là "khớp" để sử dụng trong các khung công tác khác.
  • Test-Driven Development (Kent Beck, Addison-Wesley, 2003): Beck, nhà sáng lập Lập trình đỉnh cao, sử dụng các ví dụ dựa trên tiền tệ để giải thích TDD.
  • Smalltalk Best Practice Patterns (Kent Beck, Prentice Hall, 1996): Tìm hiểu thêm về mẫu phương thức cấu thành.
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): Một phiên bản dài hơn của ví dụ trong bài viết này xuất hiện trong chương "Phát triển dựa vào thử nghiệm” trong cuốn sách gần đây nhất của Neal Ford.
  • "Emergent Optimization in Test Driven Design" (Michael Feathers): Thử nghiệm giúp tránh được sự tối ưu hóa quá sớm như thế nào.
  • Duyệt qua technology bookstore để tìm các sách về chủ đề kỹ thuật này và các chủ đề kỹ thuật khác.
  • developerWorks Java technology zone: Tìm hàng trăm bài viết về mọi khía cạnh của lập trình Java.

Lấy sản phẩm và công nghệ

  • JUnit: Tải về JUnit.

Thảo luận

Bình luận

developerWorks: Đăng nhập

Các trường được đánh dấu hoa thị là bắt buộc (*).


Bạn cần một ID của IBM?
Bạn quên định danh?


Bạn quên mật khẩu?
Đổi mật khẩu

Bằng việc nhấn Gửi, bạn đã đồng ý với các điều khoản sử dụng developerWorks Điều khoản sử dụng.

 


Ở lần bạn đăng nhập đầu tiên vào trang developerWorks, một hồ sơ cá nhân của bạn được tạo ra. Thông tin trong bản hồ sơ này (tên bạn, nước/vùng lãnh thổ, và tên cơ quan) sẽ được trưng ra cho mọi người và sẽ đi cùng các nội dung mà bạn đăng, trừ khi bạn chọn việc ẩn tên cơ quan của bạn. Bạn có thể cập nhật tài khoản trên trang IBM bất cứ khi nào.

Thông tin gửi đi được đảm bảo an toàn.

Chọn tên hiển thị của bạn



Lần đầu tiên bạn đăng nhập vào trang developerWorks, một bản trích ngang được tạo ra cho bạn, bạn cần phải chọn một tên để hiển thị. Tên hiển thị của bạn sẽ đi kèm theo các nội dung mà bạn đăng tải trên developerWorks.

Tên hiển thị cần có từ 3 đến 30 ký tự. Tên xuất hiện của bạn phải là duy nhất trên trang Cộng đồng developerWorks và vì lí do an ninh nó không phải là địa chỉ email của bạn.

Các trường được đánh dấu hoa thị là bắt buộc (*).

(Tên hiển thị cần có từ 3 đến 30 ký tự)

Bằng việc nhấn Gửi, bạn đã đồng ý với các điều khoản sử dụng developerWorks Điều khoản sử dụng.

 


Thông tin gửi đi được đảm bảo an toàn.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=70
Zone=Công nghệ Java
ArticleID=390986
ArticleTitle=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
publish-date=05202009