Kiế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 ppt
Tài liệu hạn chế xem trước, để xem đầy đủ mời bạn chọn Tải xuống
1
/ 18 trang
THÔNG TIN TÀI LIỆU
Thông tin cơ bản
Định dạng
Số trang
18
Dung lượng
1,27 MB
Nội dung
Kiế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 = new 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 HashSet (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(); assertThat(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(); assertThat(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ệ. Thu 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. Cá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() { int[] 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()); } } 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() public 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ố [...]... thức và do đó nên được tái cấu trúc) Việc viết mã như là các khối xây dựng nhỏ làm cho mã có thêm khả năng được tái sử dụng, vì thế điều này nên được coi là một trong những tiêu chí thiết kế chính của bạn Việc sử dụng các phép kiểm thử để giúp tiến triển dần thiết kế của bạn sẽ khuyến khích viết các phương thức hợp thành được, như vậy sẽ cải thiện thiết kế của bạn Đo chất lượng mã Ngay đầu Phần 1 của. .. kiểm thử sau Ngay cả đối với bài toán nhỏ này, đó là một sự khác biệt đáng kể Tóm tắt Trong hai bài viết vừa qua của loạt bài Kiến trúc tiến hóa thiết kế nổi dần tôi đã trình bày sâu về các lợi ích của kiểm thử trước khi bạn viết mã của mình Bạn có được phương thức đơn giản hơn, được trừu tượng hóa tốt hơn, có thể tái sử dụng dễ hơn như các khối xây dựng Và bạn có các phép kiểm thử miễn phí! Việc kiểm. .. các phép kiểm thử miễn phí! Việc kiểm thử có thể dẫn bạn quay lại tuyến đường thiết kế tốt hơn nếu bạn đi trệch ra Một trong những cái hại ngầm đến một thiết kế tốt là những nhà thiết kế và các định kiến của họ Việc cắt đứt khỏi ý nghĩ của bạn những phần đã vô tình quyết định sai là điều khó khăn TDD cung cấp một cách thức thành thói quen để cho các giải pháp nổi lên như bong bóng từ các bài toán thay... đó) Xem phần Tài nguyên để biết thông tin tải về (N.D: NCSS là số câu lệnh về lô gic, không bị xuống dòng để chèn thêm các giải thích, thường sẽ ít hơn so với số dòng lệnh (Source Lines of Code SLOC) là số dòng mã nguồn về mặt vật lý)., Việc chạy JavaNCSS với phiên bản mã kiểm thử sau cho các kết quả như trong hình 2: Hình 2 Độ phức tạp chu số của trình tìm kiếm số hoàn hảo, phiên bản kiểm thử sau... ích của phương thức hợp thành Giả sử bạn đã viết trình tìm kiếm số hoàn hảo TDD của mình, và một số nhóm khác trong công ty của bạn đã viết một phiên bản kiểm thử sau của trình tìm kiếm số hoàn hảo (một ví dụ về trình tìm kiếm này có tại Phần 1 của loạt bài này) Bây giờ, những người sử dụng của bạn chạy vào phòng hốt hoảng: "Chúng ta cũng phải xác định cả các số thừa và các số thiếu nữa!" Trong một số... trong những lợi ích của mã được phát triển hướng theo kiểm thử đã đề cập trong Phần 1 của bài biết này là khả năng hợp thành, dựa trên bản mẫu phương thức hợp thành của Kent Beck (xem mục Tài nguyên) Phương thức hợp thành khuyến khích xây dựng các phần mềm với nhiều phương thức kết dính nhau TDD tạo điều kiện thuận lợi cho cách làm này bởi vì bạn phải có các bó nhỏ các chức năng để kiểm thử được Phương... phiên bản mã kiểm thử sau Tôi đã cho các bạn thấy một số bằng chứng nhỏ nhặt, nhưng lấy gì để chứng minh điều này? Tất nhiên, ta không có biện pháp hoàn toàn khách quan nào để đánh giá chất lượng của mã, nhưng ta có một số thước đo có thể cho biết các kích thước nhất định của chất lượng mã; một trong những kích thước đó là tính phức tạp đo lường (xem phần Tài nguyên), do Thomas McCabe tạo ra để đo độ phức... nổi lên như bong bóng từ các bài toán thay vì tuôn xuống như mưa các suy nghĩ lầm lẫn Trong phần tiếp theo của loạt bài viết, tôi sẽ tạm dừng nói đến kiểm thử và nói về hai mẫu hình quan trọng của thế giới lập trình Smalltalk: phương thức hợp thành và nguyên lý chỉ một mức trừu tượng Mục lục Chất lượng kiểm thử Đo chất lượng mã Tóm tắt ... (cyclomatic complexity) của mã Công thức khá đơn giản: lấy số lượng các cung trừ đi số các nút rồi cộng với 2, ở đây các cung là tuyến đường thi hành và các nút là các dòng mã Để lấy ví dụ, bạn hãy xem xét các mã trong liệt kê 11: Liệt kê 11 Phương thức Java đơn giản để xác định độ phức tạp chu số public void doit() { if (c1) { f1(); } else { f2(); } if (c2) { f3(); } else { f4(); } } Nếu bạn vẽ sơ đồ phương... biểu đồ dòng chảy, bạn có thể dễ dàng đếm được số lượng các cung và các nút và tính toán độ phức tạp chu số, như trong hình 1 Phương thức này có độ phức tạp chu số là 3 (8 -7 + 2) Hình 1 Các nút và các cung của phương thức doit () Để đo hai phiên bản mã của trình tìm số hoàn hảo, tôi sẽ sử dụng một công cụ mã nguồn mở đo tính phức tạp chu số của Java gọi là JavaNCSS ("NCSS" là viết tắt của "non-commenting . Kiế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. 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. 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