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

19 251 0
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

Đang tải... (xem toàn văn)

Tài liệu hạn chế xem trước, để xem đầy đủ mời bạn chọn Tải xuống

Thông tin tài liệu

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 { [...]... 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... 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... 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 ,... 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. .. đỗ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... 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. .. 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... 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(),... 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... 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 . 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. 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. 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 đã

Ngày đăng: 07/08/2014, 10:22

Từ khóa liên quan

Tài liệu cùng người dùng

Tài liệu liên quan