NỘI DUNG BÀI VIẾT
Bối cảnh phức tạp của nghề sản xuất phần mềm hiện nay
Phần mềm hiện nay có nhu cầu phát hành rất sớm, và liên tục. Một phần mềm mất hơn 6 tháng mới phát hành được có lẽ sẽ nuốt hết tiền để sống của startup khai sinh ra nó. Một dịch vụ đột phá nhưng mất từng ấy thời gian mới phát triển xong có lẽ đã bị đè bẹp bởi các đối thủ khác nhanh hơn.
Các chức năng của phần mềm cũng thay đổi liên tục hơn. Windows XP không thay đổi nhiều trong suốt vòng đời của nó, nhưng Windows 10 giờ đây có bản cập nhật “lột xác” theo mùa trong năm.
Bối cảnh như vậy khiến cho việc tái thiết kế và thêm chức năng được thực hiện liên tục. Điều đó khiến cho chương trình dễ có nợ kỹ thuật không được khử hơn, và chi phí để khử các nợ kỹ thuật này ngày càng lớn thêm theo thời gian.
TDD là gì?
TDD là viết tắt của Test Driven Development – phát triển (mà trong đó sự phát triển) được lái bởi kiểm thử, là một phương pháp cải tiến cho các nhà phát triển phầm mềm chuyên nghiệp, giúp nâng cao năng suất và hiệu quả phầm mềm. TDD bao gồm sự kết hợp của hai thành tố: TFD – Test-First Development (Phát triển kiểm thử đầu tiên – tức là bạn cần phải viết ra các trường hợp kiểm thử trước khi viết mã lệnh) và Refactoring (Tái cấu trúc – thay đổi cấu trúc đoạn mã sau khi các kiểm thử được thực thi để cải tiến đoạn mã tốt hơn).
Chúng ta có thể hiểu theo cách khác. TDD là một cách để tư duy thông qua các mô tả về chương trình hoặc qua bản thiết kế trước khi mà bạn bắt tay vào viết mã lệnh. Trong quy trình phát triển phần mềm linh hoạt (Agile Software Development) TDD là một trong những tiến trình đóng vai trò quan trọng.
Tại sao nên sử dụng TDD để phát triển phần mềm?
TDD thường được sử dụng phát triển phần mềm một cách chuyên nghiệp, đặc biệt là khi kết hợp với mô hình phát triển phần mềm Agile, bởi TDD mang đến các lợi ích sau:
– Hiểu đúng bài toán cần giải quyết
– Hướng vào mục tiêu rõ ràng, tránh được việc viết những đoạn chương trình thừa
– Các thành phần của chương trình làm đến đâu chắc chắn đến đấy do đó khả năng bảo trì, mở rộng và kế thừa cao.
– Làm ví dụ minh hoạ cho nhà phát triển
Hình vẽ dưới đây so sánh sự khác biệt khi phát triển phần mềm sử dụng TDD so với mô hình truyền thống
Hình 1: So sánh giá trị mã lệnh khi sử dụng TDD với mô hình truyền thống
Trong hình 1, trục thẳng đứng biểu diễn chi phí phải trả cho viết mã lệnh khi sử dụng TDD ngay từ thời điểm đầu tiên thấp hơn rất nhiều so với các phương pháp truyền thống.
Nhà phát triển bắt đầu bằng việc đầu tiên là viết ra một kiểm thử nhỏ – trước khi viết mã, việc này luôn luôn đơn giản, dễ, và nhanh. Sau đó viết mã để vượt qua kiểm thử đó. Tổng thời gian để viết kiểm thử, và mã để vượt qua kiểm thử xấp xỉ thời gian lập trình một cách trực tiếp. Nhưng bởi đã có kiểm thử đơn vị, nhà phát triển không cần phải kiểm thử thủ công mỗi khi viết rõ dần chức năng. Kiểm thử đơn vị rõ ràng là một lãi kép.
Trong khi viết kiểm thử đơn vị, nhà phát triển làm rõ yêu cầu bài toán. Và nhờ đó không có sự hiểu sai một đặc tả bài toán ở mã thực thi.
Khi chạy kiểm thử, nhà phát triển nhận được phản hồi ngay lập tức. Kể cả khi chức năng còn chưa hoàn thành. Loại bỏ khả năng có phần nào đó không tốt nhưng không được phát hiện sớm.
Ngoài ra, kiểm thử đơn vị còn có lợi cho thiết kế hệ thống. Kiểm thử mô tả thiết kế hệ thống, và kiểm thử tới từ mong muốn của khách hàng, vậy nên mong muốn của khách hàng trực tiếp được chuyển tải tới thiết kế thông qua kiểm thử. Thiết kế tốt cho mong muốn của khách hàng là một thiết kế tốt.
Quy trình làm việc với TDD
Chiến lược trong TDD được thể hiện qua hình minh họa sau:
Hình 2: Quy trình làm việc với TDD
TDD bắt đầu bằng việc chú ý vào mục tiêu đầu tiên là viết một kiểm thử cho chương trình. Khi thực thi, kiểm thử sẽ thất bại. Bước tiếp theo nhà phát triển chú ý vào mục tiêu vượt qua kiểm thử. Tại bước tiếp theo, nhà phát triển đặt mục tiêu tái cấu trúc để chương trình có mã tốt hết mức có thể. Mã tốt nghĩa là:
- Vượt qua tất cả các kiểm thử
- Không có mã lặp
- Rõ ý
- Không có mã không phục vụ ba mục tiêu trên
Luồng công việc chính trong TDD
Hình 3: Các luồng công việc chính trong TDD
Việc phát triển – dựa theo hướng kiểm thử 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, chúng sẽ bắt đầu một ví dụ.
Ví dụ minh họa về TDD
Để cho thấy các lợi ích thiết kế của TDD, cần một bài toán để giải quyết. Dưới đây là một minh họa về TDD khi thực hiện chương trình tính tổng của hai số nguyên cho trước, mã lệnh viết bằng java, đơn vị kiểm thử dùng JUnit.
Sprint 1: Tạo kiểm thử và làm cho nó thất bại
Phiên bản đầu tiên của Calculator dành cho việc tính tổng của hai số nguyên, hàm ném ra lỗi khi được gọi đến
package cal; public class Calculator { public int add(int x, int y) { throw new UnsupportedOperationException("not support operator"); } }
Bây giờ cần tạo ra kiểm thử thứ nhất: hàm testAdd() kiểm tra mã lệnh trên có thực thi không?
public class CalculatorTest { @Test public void testAdd() { int x = 1; int y = 1; Calculator instance = new Calculator(); int expResult = 2; int result = instance.add(x, y); assertEquals(expResult, result); } }
Với x, y cùng nhận giá trị 1, và kết quả mong đợi expResult là 2. Khi gọi hàm add(x, y) thì luôn trả về thông báo lỗi, do hàm add chưa hỗ trợ thao tác tính toán trong đó.
Sprint 2: Quay lại phiên bản đầu tiên của Calculator để sửa lại mã lệnh theo cách đơn giản nhất có thể, làm cho kiểm thử vượt qua.
Phiên bản thứ hai của Calculator
package cal; public class Calculator { public int add(int x, int y) { return x + y; } }
Sau khi sửa xong mã lệnh để giúp kiểm thử không lỗi, ta chạy lại kiểm thử đầu tiên testAadd thì thấy nó đã vượt qua.
Sprint 3: Tạo tiếp kiểm thử thứ hai kiểm tra xem nếu cộng một số với một số có giá trị bằng giá trị lớn nhất theo kiểu dữ liệu lưu trữ.
… @Test public void testAdd2() { int x = Integer.MAX_VALUE; int y = 1; Calculator instance = new Calculator(); try { int result = instance.add(x, y); assertFalse(true); } catch (Exception e) { assertTrue(true); } } …
Khi chạy kiểm thử này ta thấy nó bị thất bại vì không thể cộng thêm bất kỳ giá trị nào nữa cho x khi mặc định nó đã nhận giá trị lớn nhất. Dòng lệnh int result = instance.add(x, y) đặt trong khối lệnh try… catch sẽ ném ra lỗi nếu như có bất cứ thông báo nào lỗi nào xảy ra assertFalse(true) và bắt lỗi nếu không có thông báo lỗi được gửi đến assertTrue(true). Và khi ta chạy chương trình thì đúng là có thông báo lỗi được đưa ra.
Sprint 4: Để kiểm thử vượt qua được ta lại quay lại phiên bản thứ hai của Calculator và thiết kế, cấu trúc lại cho đến khi kiểm thử vượt qua.
Phiên bản thứ ba của Calculator
package cal; public class Calculator { public int add(int x, int y) { if (x / 2 + y / 2 >= Integer.MAX_VALUE / 2) { throw new RuntimeException("out of range exception"); } return x + y; } }
Nếu tổng của hai số x, y vượt quá khoảng giới hạn thì lỗi “out of range exception” sẽ được ném ra.
Sprint 5: Quay lại bản kiểm thử thứ hai testAdd2() và thực thi kiểm thử này thấy nó đã được vượt qua
Sprint 6: Tương tự như thế đối với trường hợp nếu cộng giá trị nhỏ nhất của một số với một số âm. Ta tiếp tục thiết kế kiểm thử.
Ta có bản kiểm thử thứ 3
… @Test public void testAdd3() { int x = Integer.MIN_VALUE; int y = -1; Calculator instance = new Calculator(); try { int result = instance.add(x, y); assertFalse(true); } catch (Exception e) { assertTrue(true); } } …
Bản kiểm thử này cũng đưa ra lỗi và cần quay lại phiên bản thứ 3 của Calculator để sửa cho đến khi hết lỗi.
Ta có phiên bản thứ tư của Calculator
package cal; public class Calculator { public int add(int x, int y) { if (x / 2 + y / 2 >= Integer.MAX_VALUE / 2) { throw new RuntimeException("out of range exception"); } if (x / 2 + y / 2 <= Integer.MIN_VALUE / 2) { throw new RuntimeException("out of range exception"); } return x + y; } }
Sprint 7: Như vậy ta đã tạo được ít nhất ba bản kiểm thử đơn vị cho một hàm rất đơn giản là cộng hai số nguyên. Bạn có thể suy nghĩ tiếp và tạo thêm các bản kiểm thử khác và kiểm lỗi rồi quay lại Calculator để thiết kế và tái cấu trúc lại sao cho tất cả các bản kiểm thử này được vượt qua.
Sprint 8: Giả sử ta đã vượt qua tất cả các bản kiểm thử, nhưng chúng ta lại nhìn thấy tên biến x, y trong hàm add không được rõ nghĩa. Ta có thể cấu trúc lại hàm add để nó phù hợp hơn như sau:
package cal; public class Calculator { public int add(int firstOperand, int secondOperand) { if (firstOperand / 2 + secondOperand / 2 >= Integer.MAX_VALUE / 2) { throw new RuntimeException("out of range exception"); } if (firstOperand / 2 + secondOperand / 2 <= Integer.MIN_VALUE / 2) { throw new RuntimeException("out of range exception"); } return firstOperand + secondOperand; } }
Vậy qua các bản kiểm thử và các lần tái cấu trúc ta đã có được phiên bản đầy đủ và tốt của Calculator.
Vấn đề đặt ra là có những chiến lược nào để giúp ta thiết kế kiểm thử và tái cấu trúc mã nguồn?
Chiến lược tạo kiểm thử
Một khung kiểm thử tốt sẽ giúp bạn tránh được việc viết quá nhiều mã dư thừa. Đã có rất nhiều các phương pháp viết kiểm thử, trong bài này nói về hai phương pháp kiểm thử đơn vị và kiếm thử chấp nhận tự động.
Bạn có thể đọc thêm trong cuốn “The Art of Unit Testing” của Roy Overshove về kiểm thử đơn vị.
Trong kiểm thử tự động, thành phần cơ bản nhỏ nhất là “phương thức dưới kiểm thử” (MUT). Lý tưởng là mỗi một kiểm thử chỉ xác nhận một khía cạnh của một hàm trong một lớp. Nếu kiểm thử được đặt tên hợp lý, bạn sẽ biết ngay kiểm thử nào đang có vấn đề. Hãy thử theo một con đường lô-gíc xuyên suốt mã nguồn của bạn, càng chi tiết thì càng có ý nghĩa thiết thực. Khi bạn đã có đủ các kiểm thử, bằng cách chạy chúng, bạn có thể chứng minh rằng mọi phương thức đều hoạt động đúng như mong muốn.
Khi viết các kiểm thử đơn vị, bạn nên:
1. Bắt đầu với “trường hợp chính” hay: các kiểm thử của một chức năng đã định.
2. “trường hợp biên”.
Hình 4: Minh họa tạo kiểm thử với trường hợp biên
3. “trường hợp có mùi” – hay: báo cáo lỗi (bugs)
Hình 5: Minh họa tạo kiểm thử với trường hợp biên
Thường thì việc tạo ra các trường hợp tốt là đủ với kiểm thử đơn vị, vì các trường hợp khác có thể được đưa vào một cách dễ dàng khi cần thiết – với điều kiện cấu trúc chương trình của bạn có đủ độ linh hoạt.
Với việc tạo ra các kiểm thử đơn vị tự động, bạn có thể chắc rằng:
– Các chức năng của phương thức không ngẫu nhiên bị thay đổi
– Lớp sẽ tiếp tục hoạt động như bạn mong đợi nếu nó vượt qua các kiểm thử sau khi sắp xếp lại mã nguồn
– Sự tương tác giữa các lớp là rõ ràng
Các kiểm thử đơn vị sẽ giúp bạn tìm ra vấn đề trong mã nguồn của mình từ rất sớm, trước cả khi bạn đưa nó cho một người khác xem xét. Bạn sẽ không cần sử dụng phần mềm tìm lỗi (debugger). Kiểm thử còn là một hợp đồng phần mềm vì nó sẽ thông báo ngay lập tức với bạn khi mã ngừng hoạt động như đã đặc tả. Ở một mức độ nào đó, nó giúp ích cho việc thiết kế. Nó cụ thể hóa giải pháp mà không cần phải thực thi các chi tiết. Sẽ dễ dàng hơn cho bạn khi tập trung vào cách đơn giản nhất có thể để giải quyết yêu cầu.
Kiểm thử chấp nhận tự động
Kiểm thử đơn vị rất tỉ mỉ. Nó đi sâu vào chi tiết, có nghĩa là bạn sẽ có thể bỏ lỡ thứ gì đó lớn và dễ thấy, và đương nhiên, những thứ lớn và dễ thấy là điều mà khách hàng quan tâm nhất. Chúng thường là những thứ mà họ có thể thực sự nhìn thấy. Khách hàng mong đợi các chức năng làm việc được với nhau mà không cần biết từng bộ phận được gắn kết ra sao. Họ muốn lái một chiếc xe mà không cần phải chỉnh từng bộ phận của động cơ để nó có thể chạy.
Cho kiểm thử chấp nhận tự động vào. Đó là một khái niệm đa năng chung cho môt số cách tiếp cận như: phát triển hướng hành vi (behavior driven development – BDD), kiểm thử tích hợp (integration test) và kiểm thử đối mặt khách hàng (customer facing test). Chúng giúp bạn xác nhận rằng khách hàng vẫn đang hài lòng, rằng bạn không vừa đập chết một chức năng ưa thích của họ với những thay đổi vừa đưa vào. Chúng là những kiểm thử dùng để nắm bắt những yêu cầu cốt lõi của khách hàng. Kết quả là, chúng thường hoạt động ở những tầng cao hơn rất nhiều so với tầng đơn vị. Chúng kéo theo rất nhiều các lớp có liên quan để kiểm tra xem các lớp đó có làm việc như mong đợi không.
Hầu hết các ‘kiểm thử chấp nhận tự động’ chấp nhận các sự phụ thuộc đã cho. Nếu một đối tượng yêu cầu sự phụ thuộc, nó sẽ tạo một thể hiện cho đối tượng đó, mà không cố gắng tách lẻ mọi thứ ra. Đó là một con dao hai lưỡi. Nói chung tính năng đó sẽ dễ dàng hơn để làm việc tại mức độ này. Vì các kiểm thử sẽ chứng minh rằng một chức năng cụ thể làm việc đúng như khách hàng mong muốn. Kiểm thử đó tồn tại để xác nhận sự kì vọng của bạn. Sự xác nhận đó có rất nhiều giá trị thương mại. Cùng lúc đó, bạn sẽ cố tránh sự phân tách mã nguồn. Quá trình phân tách đó rất đau đớn và tốn thời gian – nhưng, nếu bạn để nó đó quá lâu, mã nguồn của bạn sẽ trở nên hỗn độn. Khi đó sẽ rất khó để làm việc và thay đổi mã nguồn.
Ngược lại, một kiểm thử đơn vị được cài đặt đúng cách – tức là với DI, sẽ cắt mã của bạn ra thành những phương thức và lớp hoàn toàn độc lập. Mọi thay đổi được giữ một cục bộ. Và điều đó luôn đúng dù bạn viết kiểm thử trước hay sau khi mã nguồn đã tồn tại. Nếu bạn có đủ các kiểm thử đơn vị, sẽ rất dễ dàng để đưa thay đổi vào. Các kiểm thử xác nhận rằng bạn không phá vỡ bất kì chức năng đã tồn tại nào cả. Chúng cũng làm những dự đoán của bạn chính xác và đáng tin cậy hơn. Bạn thậm chí không cần dùng phần mềm tìm lỗi. Kiểm thử chấp nhận tự động thì không giúp bạn trung thực như vậy.
Kiểm thử chấp nhận tự động vẫn có thể hữu dụng khi dùng để sắp xếp lại mã nguồn. Bạn sẽ ngăn chặn được những “tai nạn hồi qui” cho khách hàng. Điều mà xảy ra khá thường xuyên trong những dự án dài với nhiều chức năng. Những hồi quy tiềm năng thường xảy ra nhiều lần khi làm việc với phần mềm, trước cả khi bạn bắt đầu giai đoạn thử nghiệm. Nếu bạn biết mình vừa đánh vỡ thứ gì, bạn có thể sửa nó mà không cần làm phiền ai khác.
Khi bạn khám phá ra những yêu cầu mới, hay những mong đợi của khách hàng, bạn có thể viết ra những kiểm thử chấp nhận tự động để xác nhận mã nguồn của mình có làm đúng như vậy không. Khi bạn thêm vào các chức năng, bạn sẽ phải trải qua một sự bùng nổ rất nhiều các con đường khả dĩ khác nhau. Có một kiểm thử chấp nhận tự động sẽ rất hữu ích để chắc rằng bạn đã thỏa mãn những thứ cơ bản. Bạn có thể tập trung viết các thuật toán tốt, vì một lượng lớn các kiểm thử đơn giản sẽ cho biết bạn đã thỏa mãn chúng hay chưa.
Chúng phục vụ như là các lưới chắn an toàn khi bạn thử nghiệm. Ví dụ: nhờ vào các kiểm thử chấp nhận tự động tốt, bạn sẽ không cần phải liên tục kiểm tra thủ công xem việc viết đè một tập tin đã được đổi tên có vượt ra ngoài kịch bản hay tạo ra một tập tin hoàn toàn mới không. Mỗi cách làm sẽ có một vài kiểm thử nhỏ và sẽ cho bạn phản hồi ngay lập tức.
Công cụ yêu thích của tôi cho việc đó là Fitnesse. Ví dụ các bảng trên wiki. Chúng được nối vào kiểm thử khai thác – thứ mà sẽ phiên bản hóa một số lớp và xác nhận các lớp làm đúng những gì bạn mong đợi. Bởi vì tất cả thông tin đều ở trên wiki, tất cả mọi người trong nhóm đều có thể đóng góp để xây dựng bộ kiểm thử: từ những nhà phân tích nghiệp vụ, đến các phát triển viên và các kiểm thử viên. Điều này làm cho việc thảo luận dễ dàng hơn để có thể hiểu một cách chính xác vấn đề. Những nhà phát triển cũng tham gia vào quá trình này, và vì vậy, họ sẽ có cơ hội tạo ra những mã nguồn để giải quyết đúng vấn đề.
Hiểu sai yêu cầu là một dạng lãng phí lớn nhất trong nhiều dự án phần mềm, với một sự hao tổn đáng kể, cỡ khoảng hơn 50%. Hơn thế nữa, chúng có một tác động tiêu cực lớn đến dự án. Scott Ambler tóm tắt như sau: “Chi phí để khắc phục vấn đề sẽ vô cùng lớn nếu lỗi xảy ra là hậu quả của việc hiểu sai yêu cầu, dẫn đến việc làm hư hại một lượng lớn dữ liệu. Hoặc trong trường hợp phần mềm thương mại, hay ít nhất là phần mềm “đối mặt với khách hàng” được sử dụng bởi đối tác của công ty, sự bẽ mặt bởi phần mềm lỗi sẽ rất đáng kể (khách hàng sẽ không còn tin tưởng vào bạn nữa chẳng hạn).” Và như vậy, giảm thiểu khả năng lỗi như thế chính là tăng thêm khă năng dự án sẽ thực sự cho khách hàng cái mà họ muốn.
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 vì tuôn xuống như mưa các suy nghĩ lầm lẫn.
Ghi nhớ khi làm việc với TDD là TDD nói về vấn đề thiết kế chứ không phải kiểm thử hay TDD là tài liệu tham đầu tiên khi xây dựng dự án. Hãy giữ nó ở mức độ đơn giản nhất có thể thì sẽ thành công (Keep – It – Simple – Stupid)
Công cụ trong lập trình để triển khai với TDD
Hình 6: Các công cụ triển khai với TDD
Ví dụ minh họa trong bài sử dụng với JUNIT