[TapChiLapTrinh] Tầm quan trọng của việc học “phát triển hướng kiểm thử” (TDD) là không thể xem thường. Đó nguyên nhân chính các tranh luận về TDD lại phân cực như vậy. Những người có kinh nghiệm về TDD đã nắm được các kĩ thuật từ rất lâu và họ nghĩ rằng chúng là đương nhiên. Các nhà phát triển không có kinh nghiệm thì bị choáng ngợp bởi những kiến thức họ cần phải học. Phần còn lại là những tên ngờ nghệch-can đảm đã thử dùng các kĩ thuật theo phong cách TDD để rồi cảm thấy rất thống khổ. Họ khóc lóc, than vãn và gọi TDD là “phát triển hướng nhạt nhẽo” (Tedium Driven Development). Tuy nhiên, một kiểm thử tốt sẽ đảm bảo việc phân chia rõ ràng các mối quan tâm, liên tục đưa ra các phản hồi xem các đoạn mã của ta có chạy đúng như mong muốn không và tạo ra một loại mã nguồn có chi phí bảo trì rẻ hơn. Hừm?
Một mặt, kiểm thử tự động (automated testing) có thể tạo ra rất nhiều khó khăn cho việc thêm vào các thay đổi trong mã nguồn. Điều này đúng dù bạn có muốn thêm vào một tính năng mới hay thay đổi một tính năng đã tồn tại. Ví dụ: nếu bạn thay đổi nguyên mẫu (signature) phương thức của một lớp hay interface phổ biến, sẽ có một hiệu ứng sóng lan ra toàn bộ mã nguồn của bạn. Mặt khác, nếu bạn muốn làm cho mã của mình thật linh hoạt, sẽ cần có một lượng mã dư thừa khổng lồ để cung cấp cho bạn sự linh hoạt mà có thể bạn sẽ chẳng dùng tới. Trong cả hai trường hợp, việc nắm rõ mong muốn tương tác của người dùng với phần mềm là rất hữu ích.
Những vấn đề nghiêm trọng có thể xảy đến với cả những nhà phát triển tài ba. Thực hành TDD đúng nghĩa – với các kiểm thử chấp nhận tự động (automated acceptance test), đòi hỏi một lượng kiến thức chuyên ngành phi kĩ thuật vững vàng. Bạn cần biết kiểm thử nào có ý nghĩa để viết. Nếu bạn không hiểu “công việc kinh doanh”, bạn đang mạo hiểm tự quàng dây quanh cổ mình, và cái dây đó sẽ treo chết bạn. Về cơ bản, TDD ngầm đòi hỏi phải làm việc chặt chẽ với các chuyên gia nghiệp vụ – những người mà rất có thể sẽ không quan tâm hoặc không sẵn có. Khi bạn có thể gặp họ, bạn sẽ không muốn bị chật vật với các khái niệm.
Từ quan điểm kinh doanh, TDD có vấn đề vì nó đòi hỏi thời gian. Ví dụ: trong trường hợp một công ty khởi nghiệp (start-up company), có khả năng thực tế rằng bạn sẽ phải trả một cái giá cho việc tung ra sản phẩm muộn. Bạn mạo hiểm để cho đối thủ giành mất thị trường. Kể cả khi phần mềm của họ có nhiều lỗi hơn, họ vẫn đã làm ra tiền. Theo 22 quy luật bất biến của Marketting (22 Immutable Laws of Marketing) của Ries & Trout, đầu bạn chỉ có chỗ trống cho ba người chơi. Có bao nhiêu hãng thuốc đánh răng bạn có thể kể ra nếu không kiểm tra trong siêu thị? Hoặc rõ hơn, bạn có biết ai là người thứ hai đã vượt qua Đại Tây Dương nếu không tìm kiếm trên Google hoặc Wikipedia không? (Gợi ý: không phải Charles Lindberg hay Amelia Earhart). Đó là giới hạn về trí nhớ và nhận thức của con người.
Ngoài các chi phí chậm trễ, lẽ đương nhiên sẽ có các chi phí cho việc duy trì một đội phát triển gồm: đội ngũ phát triển, đội ngũ kiểm thử, máy tính, văn phòng. Kể cả khi tất cả các thứ đó là ảo, thì vẫn tồn tại một chi phí thật. Một khi bạn có một ý niệm rõ ràng về giá trị của thời gian, bạn có thể đánh giá cách tiếp cận của mình với TDD.
Bắt đầu từ con số 0
Đầu tiên, bạn cần phải học các mẫu và kĩ thuật có liên quan, sau đó đầu tư thời gian để áp dụng chúng. Từ góc nhìn của một người chưa biết gì, bạn cần nắm được cách vận hành của một số kĩ thuật và công nghệ nếu muốn sử dụng TDD một cách hiệu quả. Nếu đội của bạn đã có kinh nghiệm, có thể đó sẽ không phải vấn đề lớn. Nhưng nếu bạn đang bắt đầu từ con số 0 thì… bạn cũng không cần phải học mọi thứ về TDD, mà chỉ cần biết những thứ sau để có một cái nhìn thực tế:
– Interface
– Dependency injection and Inversion of control ( IoC- “Tiêm” phụ thuộc và đảo ngược dòng điều khiển)
– Mocks and Stubs (các đối tượng giả định và các phương thức giả định)
– Unit Testing (kiểm thử đơn vị)
– Automated Acceptance Testing (kiểm thử chấp nhận tự động)
Thành thật mà nói thì tôi cũng đã mất khá nhiều thời gian chỉ để nắm bắt được các khái niệm trên, chưa nói đến việc sử dụng chúng một cách một cách hiệu quả với tư cách một nhà phát triển. Tôi nhanh chóng nhận ra rằng mình cần phải hiểu cơ chế để viết các kiểm thử đơn vị, tiêm phụ thuộc và các đối tượng giả định trước khi có thể chạy theo TDD. Khi tôi đã hiểu các khái niệm, tôi cần phải tìm ra xem chúng trông như thế nào trong Visual Studio, và chúng không hề rõ ràng như tôi tưởng, đặc biệt với ngôn ngữ tôi đang làm việc lúc bấy giờ là C# và C++. Điều đó có nghĩa là tôi đã phải tìm những công cụ thích hợp, học cách làm việc với chúng và sau đó là viết một kiểm thử có ý nghĩa.
Nhưng thứ được nhắc đến trên là những điểm mấu chốt của kiểm thử tự động, và vì vây, chúng là điều kiện tiên quyết cho một TDD hiệu quả. Và nếu chẳng may, bạn chỉ muốn áp dụng đặc tả phát triển đầu tiên, bạn cũng cần phải hiểu tất cả các thứ trên!
Nếu bạn có thể nắm vững chúng, bạn sẽ nhận được một phần thưởng xứng đáng. TDD có thể tiết kiệm cho bạn một lượng thời gian khổng lồ khi mà mã nguồn của bạn có khuynh hướng trở nên đồ sộ và phức tạp. XProgramming.com nói rằng: “Trong vòng đời của một dự án, một kiểm thử tự động có thể tiết kiệm một lượng chi phí gấp hàng trăm lần chi phí tạo ra chính nó bằng cách tìm và canh chừng các lỗi. Bạn càng khổ công viết ra kiểm thử nào thì bạn lại càng cần nó vì nó sẽ càng tiết kiệm cho bạn. Kiểm thử tự động mang lại một khoảng lợi nhuận lớn hơn rất nhiều so với chi phí tạo ra. ” Điều này được kiểm chứng một cách thuyết phục qua nghiên cứu của Capers Jones. Đều là “linh hoạt” (agile), nhưng eXtreme Programming (XP) vượt trội hơn Scrum một cách rõ rệt trong các dự án với hơn 1000 điểm chức năng (function point) trở lên. Dữ liệu của Jones tập hợp từ hàng nghìn dự án phần mềm và ông xem TDD là điểm khác biệt chính.
Và hơn thế nữa, không chỉ là điều kiện tiên quyết, chính các kĩ thuật trên còn là giải pháp thay thế cho TDD. Sẽ không có ý nghĩa gì khi bạn tự giam mình vào kỳ vọng của một kiểm thử hoàn hảo có thể biến mã nguồn của bạn thành vàng. Bạn nên học và áp dụng các kĩ thuật một cách độc lập. Mỗi kĩ thuật sẽ có ích để giải quyết một loại vấn đề nhất định. Khi bạn đã hiểu cách mà từng kĩ thuật hoạt động, bạn sẽ có thể áp dụng chúng ngay lập tức vào các khó khăn cần giải quyết.
Không làm gì cả
Tôi đang hơi thái quá nhưng sự thực là vậy. Đôi khi, bạn làm một nguyên mẫu chỉ để học một cái gì đó. Đôi khi, bạn tạo ra một công cụ với mục đích riêng của bạn, chẳng hạn như một dòng mã để tự động hóa một việc gì đó rất nhàm chán. Đôi khi, bạn chẳng cần quan tâm đến lỗi một tí nào hết, chẳng có ai than phiền với bạn là có lỗi cả vì sẽ chẳng có ai được chứng kiến những gì bạn làm ra.
Thực tế hơn, nếu bạn đang tìm cách phác thảo giải pháp kĩ thuật cho một vấn đề, bạn đơn giản chỉ muốn biết liệu một vài công nghệ có thể hoạt động cùng nhau không. Thông tin đó có thể sẽ có một giá trị rất lớn với bạn. Ward Cunningham mô tả “phác thảo” trên C2.com wiki như sau: “Tôi thường hỏi Kent [Beck], ‘Cái gì là cái đơn giản nhất ta có thể lập trình ra để tự thuyết phục rằng ta đang đi đúng hướng?’ Bằng cách bỏ các vấn đề ra ngoài tâm trí, ta thường đi đến một cách giải quyết đơn giản và thuyết phục hơn. Kent đã gọi đó là “phác thảo” (spike). Tôi cảm thấy điều này là đặc biệt có ích khi áp dụng vào việc duy trì các khung làm việc lớn. ” Việc viết các kiểm thử tự động sẽ chỉ làm cho việc tìm ra cách phác thảo lâu hơn, đặc biệt nếu bạn không định làm như vậy trong mã nguồn của mình.
Trước khi bạn nghĩ đến hiệu suất, hãy nghĩ đến hiệu quả trước. Trích một câu nói hoàn toàn từ góc nhìn kĩ thuật của Mary Poppendieck: “Đầu tiên hãy làm đúng việc, sau đó hãy làm đúng cách”. Đôi khi, các kĩ thuật TDD có thể sẽ cản trở bạn.
Interface
Một mẹo để không phải thay đổi cách tiếp cận của bạn quá nhiều là dùng nhiều interface hơn. Interface tách biệt các khái niệm mà bạn hiểu ra khỏi bất kì sự cài đặt cụ thể nào. Bạn đã luôn suy nghĩ như vậy một cách tự nhiên và nó sẽ giúp mã của bạn hoạt động tương tự.
Ví dụ: một cái bàn, là một khái niệm độc lập với bất kì thể hiện (instance) bàn cụ thể nào khác. Tuy nhiên, vẫn có một số lượng nhất định cách sử dụng một cái bàn. Bạn sẽ mong muốn một cái bàn có những đặc tính nhất định. Bạn có thể đặt lên trên bàn một số thứ như cái cốc, cái đĩa. Một cái bàn sẽ có chỗ cho mọi người ngồi, để ăn sáng chẳng hạn, và như vậy tạo ra một cuộc thảo luận. Một số bàn đặc biệt có thể sử dụng như bàn học, nhưng không hề phủ nhận các chức năng cơ bản của một cái bàn. Ở một khía cạnh nào đó, bất kể bạn dùng từ “cái bàn” trong tiếng Việt, “table” trong tiếng Anh, “tabla” trong tiếng Tây Ban Nha hay “Biăo” trong tiếng Trung Quốc, người sử dụng ngôn ngữ đó đều sẽ có những mặc định như nhau về cách một cái bàn có thể được sử dụng, ở khía cạnh nhỏ hơn, đó là tính kế thừa.
Nói ngắn gọn, một khái niệm trừu tượng sẽ làm cơ sở cho bất kì thể hiện nào của một đối tượng. Vì thế, nó yêu cầu bạn phải sắp xếp mã nguồn của mình một cách trừu tượng có ý nghĩa. Bạn sẽ có thể thay đổi các lớp dễ dàng hơn rất nhiều khi bạn xây dựng thêm các chức năng sau này.
Hơn thế nữa, kể cả việc chèn thêm interface vào mã nguồn đã có sẵn cũng cung cấp thêm một lợi ích về kiến trúc: ngăn ngừa những tác dụng phụ không mong muốn. Việc chèn thêm interface vào mã nguồn một cách thô bạo bắt buộc mã phải độc lậptự cung ứng. Interface là một công cụ giới hạn mang tính khái niệm. Tương tự như việc một biến có thể giới hạn phạm vi một cách cục bộ vào một phương thức, một lớp hay một vùng tên (namespace), một interface giống như là một giới hạn phạm vi nghiệp vụ cụ thể. Nó buộc các thành phần cụ thể, có liên kết làm việc cùng với nhau mà không hề tương tác với mã nguồn của nhau. Điều này giảm sự phức tạp của mã nguồn một cách đáng kể vì nó sẽ đồng bộ cùng với cách hiểu trực giác về vấn đề của bạn.
Tuy vậy, việc dùng quá nhiều interface sẽ làm bùng nổ số lượng các lớp. Ví dụ: trong C#, nếu bạn tạo ra một interface cho mỗi lớp, bạn sẽ có thêm 50% mã nguồn dù không hề thêm vào một chức năng nào. Trong các hệ thống lớn, việc này tạo ra một lượng khổng lồ các dòng mã phụ.
Để dùng kĩ thuật này hiệu quả, hãy thêm interface vào nếu nó phục vụ một chức năng hoặc nó nằm trong một ranh giới quan trọng. Ví dụ: có một interface cho việc gửi tin nhắn với một phương thức “gửi” sẽ phá hủy hoàn toàn sự phụ thuộc của bạn bất kì công nghệ gửi tin nào. Nó cũng dễ dàng hơn cho bạn khi thêm một “xe chuyển tin” (message bus) vào mã nguồn. Việc sử dụng interface trong khuôn khổ các thành phần sẽ làm mã của bạn trở nên linh họat, độc lập và dễ thay đổi hơn.
Inversion of Control (IoC) và Dependency Injection (DI)
Lập trình là một loại hình giải quyết vấn đề tập trung, thông qua việc học hỏi. Bạn sẽ không thể tránh khỏi việc biết rất ít về vấn đề trước khi đưa vào một giải pháp chứ không phải là sau khi giải pháp của bạn đi vào thực tiễn. Dùng interface sẽ thuận lợi hơn cho việc thử nghiệm các biện pháp thay thế.
Một khi bạn đã hiểu được các interface hoạt động như thế nào, IoC và DI là những cách tuyệt vời để sử dụng interface. Chúng không dễ để nắm bắt được trong những lần đầu, nhưng xứng đáng để nghiên cứu chi tiết. IoC miêu tả một quan hệ tổng quát dưới dạng hợp đồng, DI đặc biệt sử dụng interface như một hợp đồng mã nguồn.
Ragu Pattabi giải thích IoC như sau:
Nếu bạn đi theo 2 bước đơn giản sau, bạn đã thành công với IoC:
1. Phân tách việc-cần-làm khỏi khi-nào-làm
2. Đảm bảo việc-cần-làm biết ít nhất có thể về khi-nào-làm và ngược lại
Với IoC, bạn không chỉ phân tách mã nguồn khỏi công đoạn cài đặt mà còn chia nó ra nhỏ hơn nữa, dựa theo thời gian. Hay nói cách khác: khi nào và với giả định về các điều kiện đã tồn tại như thế nào thì mã được gọi ra.
Trong một đoạn mã nhỏ, việc này có vẻ như làm cho nó phức tạp thêm. Trong các hệ thông lớn, đó là cứu tinh của dự án. Mỗi thành phần của một hệ thống, hay việc-cần-làm, được định nghĩa rất rõ ràng và rất độc lập. Nó được tách hoàn toàn khỏi phần khi-nào-làm. Ví dụ: phần khởi động sẽ tải các tài nguyên vào thời điểm bắt đầu chương trình. Như vậy sẽ dễ dàng hơn rất nhiều khi lắp ghép các thành phần đơn lẻ vào với nhau, như việc xếp hình lego. Làm như vậy sẽ dễ dàng hơn cho bạn suy ngẫm về mã nguồn của mình, vì cách làm này sẽ đồng bộ với cách hiểu của bạn về những sự tương tác theo thời gian.
Bạn được gì từ IoC?
– Sự phân tách thời điểm thực thi
– Tránh những “mã nguồn nối” gây ra sự phụ thuộc
– Các thành phần chỉ cần lo về hợp đồng mà không cần một giả định nào nữa
– Không còn các tác dụng phụ lên các mô-đun khác
Năng lực loại bỏ các giả định là vô cùng quan trọng. Nó đơn giản hóa mã của bạn bằng cách loại bỏ mọi sự phụ thuộc lẫn nhau. Thường sẽ có những giả định ẩn trong mã nguồn: IoC đảm bảo rằng mọi giả định sẽ nằm ở mức interface hoặc hợp đồng. Thật vô giá!
DI là một kiểu mẫu thông thường nhằm giúp bạn đạt được IoC, đặc biệt khi sử dụng interface. Các yếu tố bên ngoài được đưa vào các lớp với tư cách interface để lớp chỉ có thể sử dụng interface. Mã nguồn cần thiết sẽ được quyết định vào thời điểm chạy.
Có 3 cách tiếp cận thông thường với DI, kèm theo qua interface ở:
1. Mức phương thức khởi tạo: vào thời điểm khởi tạo, bạn truyền vào một đối tượng bất kỳ có cài đặt một interface cụ thể. Chỉ các phương thức của interface được sử dụng trong lớp.
2. Mức getter/setter: giống như mức khởi tạo, nhưng sự thực thi có thể được thông qua vào bất kì thời điểm nào trong vòng đời của đối tượng. Nó linh hoạt hơn nhưng cũng khó dự đoán hơn.
3. Một container chuyên biệt giải quyết các phụ thuộc. Đưa vào một interface, nó trả về một cài đặt cụ thể của interface đó.
DI là một công cụ phân tách các phụ thuộc một cách rành mạch để chúng có thể được thêm vào khi cần, vào thời điểm chạy. DI cũng khiến việc đưa vào các stub hay mock dễ dàng hơn khi kiểm thử các chức năng của một lớp. Nó bắt bạn phải tạo ra các lớp với sự rành mạch cao và liên kết thấp. Cái gì cần ở cùng nhau sẽ ở cùng nhau. Cái gì có thể độc lập sẽ được tách ra.
Giả vờ với Stub và Mock
Một khi bạn đã có thể tiêm “interface” vào đối tượng của mình, bạn đột nhiên có khả năng thay đổi các đối tượng và các tương tác một cách nhanh chóng. Ngay lập tức câu hỏi được đặt ra: thay đổi chúng thành cái gì? Để có thể hiểu được mã của chính mình và kiểm thử nó hiệu quả, bạn sẽ mong muốn xây dựng một kiểm thử khai thác theo một mẫu “khoa học”. Nó giúp bạn giữ nguyên mọi thứ, VD như:
– Các lớp khác
– tệp thư viện
– tham số đầu vào
để bạn có thể cô lập từng phương thức riêng lẻ hoặc một vài thành phần. Từ góc nhìn của một kiểm thử giả định, thứ duy nhất quan trọng là: đối tượng trông như thế nào và hoạt động ra sao từ quan điểm của những thành phần phụ thuộc vào nó. Sự riêng tư của đối tượng là tùy vào bạn.
Kiểm thử đóng thế (test double) thường là cách hiệu quả nhất để làm việc đó. Một dạng của mẫu thiết kế đại điện (proxy pattern) trong cuốn “Design Patterns: Elements of Reusable Object-Oriented Software”, kiểm thử đóng thế giúp bạn vờ như đối tượng kiểm thử đang ở trong môi trường thật. Nó giúp bạn làm một thí nghiệm trí não, là một công cụ giúp máy tính “tưởng tượng”, tạo ra một tình huống giả định để giả tạo kịch bản bạn mong đợi sẽ xảy ra.
Trong trường hợp bạn không chắc chắn, bạn chỉ sử dụng kiểm thử đóng thế để thử một tính cách cụ thể của một lớp thực đang được kiểm thử. Ngạc nhiên thay, điều này được cho dĩ nhiên trong các tài liệu trực tuyến mà tôi tìm được. Không có nghĩa lý gì để kiểm thử một kiểm thử đóng thế, tất nhiên trừ phi bạn đang viết một khung kiểm thử.
Có rất nhiều loại kiểm thử đóng thể, với nhiều loại tên kì dị và tuyệt vời: Dummy vs. Stub vs. Spy vs. Fake vs. Mock. Trong thực tế, stub và mock sẽ bao trùm hầu hết các kịch bản thường gặp.
Stubs là con át chủ bài của kiểm thử tự động. Khi bạn không thể điều khiển các tham số đầu vào gián tiếp của một kiểm thử, stub sẽ phá vỡ mọi thành phần phụ thuộc của đối tượng bạn đang kiểm thử. Nó cung cấp câu trả lời đã định nghĩa sẵn khi bạn gọi đến sự phụ thuộc mà không phải tạo một phiên bản cho sự phụ thuộc đó. Lợi ích là gì? Bạn sẽ tạo ra một bong bóng nước xung quanh đối tượng của mình và tương tác với nó một cách chính xác bằng cách dùng stub – để xác nhận rằng nó làm những gì mà bạn muốn trong từng kịch bản.
Ví dụ: Nếu đối tượng của bạn xác nhận rằng có một tờ vé số trị giá 1 triệu đô la, nó sẽ in ra câu “chúc mừng”. Bạn sẽ dùng stub để cài đặt một interface được chia sẻ với một tờ vé số. Để kiểm tra xem đối tượng có cư xử tốt và chúc mừng bạn không, stub này sẽ giả tạo tờ vé số trị giá 1 triệu đô la. Trong trường hợp đó, đối tượng sẽ phải in ra câu “chúc mừng“. Stub có thể sẽ dàng tạo ra những tờ vé số giả tưởng như vậy.
Mock phức tạp hơn một chút, nó giúp bạn tạo ra những giả định rõ ràng về cách mà đối tượng hoạt động. Mock giúp bạn chắc chắn rằng đối tượng của mình gọi đến phương thức của các thành phần phụ thuộc. Thay vì tìm kiếm trong dữ liệu đầu vào, mock giúp bạn xác nhận phương thức của một sự phụ thuộc cụ thể có được gọi đến vào thời điểm chạy hay không.
Nếu đối tượng bảo thành phần phụ thuộc phải ‘quạc’ (tiếng vịt kêu), nó nên ‘quạc’.
Khi tôi mới bắt đầu, bài viết của Martin Fowler về stubs and mocks đã rất có ích, mặc dù các ví dụ của ông được viết bằng Java. Không ai là hoàn hảo cả :). Martin khuyên nên tránh dùng nhiều hơn một mock trên một phương thức trong khi kiểm thử. Sau đó, bạn kiểm thử nhiều tương tác trong một kiểm thử đơn vị. Nếu cần thiết, bạn có thể dùng bao nhiêu stub cũng được – để cô lập các lớp và các phương thức cụ thể.
Một khung kiểm thử tốt sẽ giúp bạn tạo ra các stub và mock từ các interface mà bạn định nghĩa. Điều này giúp bạn tránh được việc viết quá nhiều mã và các lớp dư thừa khi muốn cô lập các đoạn mã. Bạn cũng không cần phải lo lắng về các thành phần phụ thuộc. Bạn có thể kết nối các lớp của mình một cách nhanh chóng, tất cả những gì cần có là các interface.
Kiểm thử đơn vị
Cuối cùng, khi đã có tất cả các miếng ghép, ta đến giai đoạn mà kiểm thử đơn vị bắt đầu có ý nghĩa. Cuốn “The Art of Unit Testing” của Roy Overshove sẽ rất có ích ở đây. Ở bất kì thời điểm nào, bạn sẽ viết một kiểm thử đơn vị để kiểm tra xem một phương thức cụ thể có hoạt động đúng như mong muốn không. Bởi vì một thể hiện của lớp với các interface tại ranh giới của nó, bạn có thể dễ dàng tạo ra các stub và mock từ các interface đó.
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”, theo sau bởi
3. “trường hợp có mùi” – hay: báo cáo lỗi (bugs)
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 của tôi 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.
Tại sao cần quan tâm?
Dù danh sách này được trình bày một cách tuyến tính, trong thực tế, bạn có thể sử dụng bất kì tổ hợp nào của các kĩ thuật trên và vẫn thu được những lợi ích nhất định. Trong một vài trường hợp, việc thêm vào các interface sẽ làm mã nguồn của bạn dễ bảo trì hơn và bạn sẽ không phải sử dụng những thứ còn lại nữa. Đôi khi, việc phân chia các mối quan tâm ra rõ ràng cũng đã là đủ – bạn có thể chắc chắn rằng sẽ không có các hiệu ứng phụ trên bất kì phía nào của một interface. Trong một số trường hợp khác, phải có một bộ các kiểm thử đơn vị và kiểm thử chấp nhận mới đủ, đặc biệt trong trường hợp lô-gíc cốt lõi của một chức năng hệ thống quan trọng. Ví dụ: trong một phần mềm điều khiển giao thông hàng không, khi có sự liên quan đến tính mạng con người, một lỗi nhỏ cũng không thể chấp nhận được. Những kĩ thuật trên có thể giúp bạn ngăn ngừa hầu hết các lỗi nhỏ và tất cả các lỗi to, xấu xí, được tạo ra do sự vô ý nằm ẩn sâu bên trong. Trong trường hợp này, có thể bạn sẽ muốn sử dụng các phương pháp chính thống để chứng tỏ rằng không thể có lỗi trong việc tính toán các giá trị quan trọng.
Rất nhiều các chi tiết kĩ thuật hữu ích bị thất lạc trong cuộc chiến triết lý về vấn đề: TDD có phải cách tiếp cận tốt nhất trong một dự án cụ thể không? Đôi khi điều đó là đúng, đôi khi không. Việc thử nghiệm xem cái gì là tốt nhất cho bạn là hoàn toàn xứng đáng. Tuy nhiên, đến cuối cùng, quyết định của bạn sẽ phụ thuộc vào việc bạn cho rằng nó sẽ tiết kiệm hay tiêu tốn thời gian của mình. Bạn cũng nên cân nhắc xem liệu dự án của mình có bắt đầu cần đến các kiểm thử đó không.
Theo tôi, TDD gây tranh cãi như vậy là vì từng thành viên trong nhóm có lượng thời gian tiêu tốn khác biệt hoàn toàn. Cố vấn sản phẩm Don Reinertsen nói rằng chi phí của sự chậm trễ là một thước đo rõ ràng khi chạy một sản phẩm mới, trong các công ty loại nhỏ và cả các công ty lớn. Khi bắt đầu các buổi huấn luyện của ông, trong các nhóm sản xuất, sự dự đoán chi phí chậm trễ chênh lệch nhau khoảng 100 lần. Dù mỗi lần đều là cùng một công ty, cùng một sản phẩm, cùng một thị trường, công nghệ, con người. Có thể cho rằng, không có ai đưa vấn đề đó ra và vì vậy ai cũng mặc nhận rằng mọi người đều có sự mặc nhận giống mình.
Với tư cách một nhà phát triển phần mềm, người ta sẽ mong đợi bạn có thể cung cấp một dự đoán thật chính xác về sản phẩm của mình. Đôi khi, bạn sẽ mạo hiểm hi sinh đứa con đầu lòng của mình nếu bạn hiểu sai “công việc kinh doanh”. Tuy vậy, chúng ta cũng không hỏi “công việc kinh doanh” về thiệt hại dự tính do chậm trễ. Dù việc đó yêu cầu một số kĩ năng kế toán và marketing để có thể đưa ra một con số, bạn có thể khai thác nó mà không nhất thiết phải biết con số đó được tạo ra như thế nào. Tương tự vậy, chủ sản phẩm của bạn hoặc nhà tài trợ cho dự án không cần biết tại sao dự đoán nỗ lực phát triển sản phẩm lại lớn (hoặc nhỏ) để có thể khai thác chúng. Dù những con số có không chính xác, chúng cũng có nhiều khả năng chính xác hơn bất kì con số nào trong khoảng ước tính của nhóm bạn (nếu bạn xem lại, sẽ có thể lớn hơn hoặc nhỏ hơn 100 lần).
Cho đến khi nhóm của bạn có một sự đồng thuận cho việc đánh giá thời gian trên thị trường hay thậm chí một dự tính chi phí chậm trễ được cung cấp từ bên ngoài, khi đó bạn mới có thể xác định xem bạn có thể dùng TDD hay các “họ hàng” của nó được không. Khi bạn đã có một bức tranh rõ ràng về thiệt hại do chậm trễ, bạn có thể tính ra xem việc tiết kiệm dài hạn và phòng chống lỗi nhờ TDD liệu sẽ thực sự giúp sản phẩm của bạn kiếm ra tiền hay không.
Nguồn: Step-1-Start-TDD-Step-2-Step-3-Profit
Tác giả: Lukasz Szyrmer
Người dịch: Nguyễn Minh Tân | Biên tập: Phạm Anh Đới
cảm ơn anh vì bài viết hay 😀