Tống quan về Unit Testing và mô hình phát triển phần mềm hiện đại TDD

Unit testing is a software development process in which the smallest testable parts of an application, called units, are individually scrutinized for proper operation. Software developers and sometimes QA staff complete unit tests during the development process.

Sự tồn tại của Unit Testing đã được biết đến trong một thời gian dài, được thừa nhận như một thành tựu quan trọng trong các nghiên cứu về nâng cao chất lượng phần mềm. Tuy nhiên xung quanh kỹ thuật khá là trừu tượng này vẫn còn có nhiều quan điểm trái ngược nhau nên hay không nên đưa vào quy trình phát triển phần mềm. Bài viết này sẽ cung cấp cho bạn đọc thấy được các lợi ích to lớn của Unit Testing, qua đó giới thiệu các chiến lược xây dựng hiệu quả và cuối cùng là tiếp cận một mô hình phát triển hiện đại TDD (Test-Driven Development).

Unit Testing là gì?

Unit Testing là kỹ thuật kiểm nghiệm các hoạt động của mọi chi tiết mã tại một quy trình tách biệt với quy trình phát triển. Nói một cách khác, Unit Testing kiểm tra các đơn vị mã tại một nơi cách ly với khu vực chứa mã nguồn chính của ứng dụng.

Trước khi Unit Testing ra đời, đã có rất nhiều dự án phần mềm thất bại vì đã không thể khống chế được số lượng lớn các lỗi phát sinh ngày càng tăng. Nguyên nhân thì rất nhiều, có thể kể đến như quá nhiều nhân viên mới ít kinh nghiệm gia nhập dự án, hoặc các module được chia sẻ cho quá nhiều người…Do đó, bất cứ ai cũng có thể phá hỏng tiển trình chung đó. Ứng dụng Unit Testing sẽ phát hiện ra các sai sót một cách kịp thời.

Unit Testing còn có thể giúp phát hiện các vấn đề tiềm ẩn và các lỗi thời gian thực sớm hơn cả trước khi chuyên viên kiểm định chất lượng (Quality Assurance hay gọi tắt là chuyên viên QA) tìm ra, thậm chí có thể sửa lỗi ngay cả khi các ý tưởng xây dựng vẫn còn nằm trong đầu. 

Unit Test là gì?

Unit test là các đoạn mã có cấu trúc giống như các đối tượng được xây dựng để kiểm tra từng bộ phận trong hệ thống. Mỗi một unit test sẽ gửi đi một thông điệp và kiểm tra liệu có nhận được câu trả lời đúng hay không, bao gồm: 

  • Các kết quả trả về mong muốn
  • Các lỗi ngoại lệ mong muốn được được ném ra

Về bản chất, các unit test hoạt động liên tục hoặc định kỳ để thăm dò và phát hiện các lỗi kỹ thuật trong suốt quá trình phát triển, do đó Unit Testing còn được gọi là kỹ thuật kiểm nghiệm tự động.

Unit Test có các đặc điểm sau:

  • Các unit test đóng vai trò như những người sử dụng đầu tiên của hệ thống
  • Các unit test chỉ có giá trị khi chúng có thể phát hiện ra các vấn đề tiềm ẩn hoặc lỗi kỹ thuật.

Ứng dụng của Unit Testing

  • Kiểm tra mọi đơn vị nhỏ nhất là các thuộc tính, sự kiện, thủ tục và hàm. 
  • Kiểm tra các trạng thái và các ràng buộc của đối tượng ở các mức sâu hơn mà thông thường chúng ta không thể truy cập được.
  • Kiểm tra các quy trình (process) và mở rộng hơn là các khung làm việc(workflow) là tập hợp của nhiều quy trình.

Unit Testing có liên quan chặt chẽ đến phương pháp kiểm nghiệm trong suốt sẽ được nói tới dưới đây.

Kiểm nghiệm hộp đen (Black Box Testing) và kiểm nghiệm trong suốt (White Box Testing)
Black Box Testing kiểm nghiệm xem các chức năng có hoạt động đúng như mô tả trong tài liệu dự án hay không. Trong phương pháp này, chuyên viên QA không quan tâm việc chuyên viên phát triển viết mã ra sao mà chỉ quan tâm họ có làm đúng ý đồ đặt ra hay không. 

Black Box Testing được chia ra nhiều phương pháp cụ thể hơn, bao gồm:

  • Functional Testing: Đây là giai đoạn kiểm nghiệm sớm trong vòng đời sản phẩm. Tập trung chủ yếu vào các chức năng nghiệp vụ như: thao tác người dùng, xử lý dữ liệu, tìm kiếm, xử lý nghiệp vụ, giao diện người dùng và khả năng tích hợp với các module khác.
  • System Testing: Được tiến hành trên một hệ thống tích hợp đầy đủ trong một môi trường người dùng thực sự nhằm đánh giá xem hệ thống đã làm đúng với những yêu cầu người dùng cuối hay chưa.
  • Smoke Testing: Một phương pháp kiếm định sơ bộ và nhanh chóng để xác nhận rằng những chức năng quan trọng nhất vẫn vận hành tốt, trong khi những thay đổi mới nhất không phá vỡ một cái gì đó thực sự nghiêm trọng.
  • User Acceptance Testing (hay beta testing): Giai đoạn kiểm nghiệm toàn bộ hệ thống lần cuối cùng trước khi phân phối sản phẩm ra thị trường với sự tham gia của đông đảo người dùng cuối như khách hàng, thành viên dự án, giám sát dự án, chuyên viên tư vấn, chuyên viên tiếp thị sản phẩm, hoặc thậm chí những khách hàng tự nguyện...

Blackbox Testing

Ngược lại White Box Testing là phương pháp kiểm nghiệm mức thấp hơn, bao gồm kiểm tra toàn diện mọi sự vận hành của từng chi tiết mã, các luồng tiến trình bên dưới và thậm chí cả các tiêu chuẩn viết mã tối ưu. Để thực hiện được điều này, ứng dụng Unit Testing mang lại hiệu quả lớn hơn nhiều so với cách kiểm tra truyền thống là gỡ rối theo từng dòng lệnh (Debugger).

Các thuật ngữ trong Unit Testing

Để những người chưa thực sự quen thuộc với các khái niệm trong lĩnh vực kiểm nghiệm chất lượng phần mềm, chúng tôi xin giới thiệu một số thuật ngữ cơ bản sau:

Assertion: Là một phát biểu mô tả các công việc kiểm tra cần tiến hành, thí dụ như: AreEqual(), IsTrue(), IsNotNull()…Mỗi một unit test gồm nhiều phát biểu assertion kiểm tra dữ liệu đầu ra, tính chính xác của các lỗi ngoại lệ ném ra và các vấn đề phức tạp khác như:
- Sự tồn tại của một đối tượng
- Điều kiện biên: Các giá trị có vượt ra ngoài giới hạn hay không
- Thứ tự thực hiện của các luồng dữ liệu

Test Point: Là một đơn vị kiểm tra nhỏ nhất, chỉ chứa đơn giản một assertion nhằm khẳng định tính đúng đắn của một chi tiết mã nào đó. Mọi thành viên dự án đều có thể viết một test point.
Test Case: Là một tập hợp các test point nhằm kiểm tra một đặc điểm chức năng cụ thể, thí dụ như toàn bộ giai đoạn người dùng nhập dữ liệu cho đến khi thông tin được nhập vào cơ sở dữ liệu. Trong nhiều trường hợp kiểm tra đặc biệt và khẩn cấp có thể không cần đến các test case (Ad Hoc Testing)
Test Suite: Là một tập hợp các test case định nghĩa cho từng module hoặc các hệ thống con. 
Regression Testing (hoặc Automated Testing): Là phương pháp kiểm nghiệm tự động sử dụng một phần mềm đặc biệt. Cùng một loại dữ liệu kiểm tra giống nhau nhưng được tiến hành nhiều lần lặp lại tự động nhằm ngăn chặn các lỗi cũ phát sinh trở lại. Kết hợp Regression Testing với Unit Testing sẽ đảm bảo các đoạn mã mới vẫn đáp ứng yêu cầu thay đổi và các đoạn mã cũ sẽ không bị ảnh hưởng bởi các hoạt động bảo trì.
Production Code: Phần mã chính của ứng dụng được chuyển giao cho khách hàng.
Unit Testing Code: Phần mã phụ để kiểm tra mã ứng dụng chính, không được chuyển giao cho khách hàng.

Vòng đời của Unit Test

Unit test có 3 trạng thái cơ bản:

  • Fail (trạng thái lỗi)
  • Ignore (tạm thời ngừng thực hiện)
  • Pass (trạng thái tích cực)

Toàn bộ unit test sẽ được vận hành trong một hệ thống tách biệt. Có rất nhiều phần mềm hỗ trợ thực thi các unit test với giao diện dễ nhìn và các chức năng hỗ trợ khác nhau. Tuy nhiên nhìn chung trạng thái của các unit test sẽ được phân biệt bởi các nút có các màu khác nhau: màu xanh (pass), màu vàng (ignore) và màu đỏ (fail).

Vòng đời của Unit Test
Unit Test với 3 trạng thái đèn: Xanh, Yellow và Red

Unit test chỉ thực sự đem lại hiệu quả khi:

  • Được vận hành lặp lại nhiều lần.
  • Tự động hoàn toàn.
  • Độc lập với các unit test khác.

Thiết kế unit test

Mỗi một unit test đều được tiến hành theo trình tự các bước như sau:

  • Thiết lập các điều kiện cần thiết: khởi tạo các đối tượng, xác định tài nguyên cần thiết, xây dựng các dữ liệu giả…
  • Triệu gọi các phương thức cần kiểm tra.
  • Kiểm tra sự hoạt động đúng đắn của các phương thức.
  • Dọn dẹp tài nguyên sau khi kết thúc kiểm tra.

Các lợi ích của Unit Test

Thời gian đầu hầu hết chúng ta vẫn do dự khi phải viết unit test thay vì tập trung vào viết mã chính cho các chức năng nghiệp vụ. Công việc viết unit test có thể ngốn nhiều thời gian như viết một phần mềm thông thường và quan trọng hơn, mã unit test sẽ không được chuyển giao cho khách hàng. Tuy nhiên, các lợi ích to lớn do unit test đem lại chúng ta không thể không tính đến như:

  • Tạo ra môi trường lý tưởng để kiểm tra bất kỳ một đoạn mã nào, có khả năng thăm dò và phát hiện lỗi chính xác, duy trì sự ổn định của toàn bộ ứng dụng và giúp tiết kiệm thời gian hơn so với các công việc gỡ rối truyền thống.
  • Phát hiện các thuật toán thực thi không hiệu quả, các thủ tục chạy vượt quá giới hạn thời gian tối thiểu.
  • Phát hiện các vấn đề về thiết kế, xử lý hệ thống hoặc thậm chí các mô hình thiết kế.
  • Phát hiện ra các lỗi nghiêm trọng có thể xảy ra trong những tình huống rất hẹp.
  • Tạo ra hàng rào an toàn cho các khối mã: Bất kỳ một sự thay đổi nào cũng có thể  tác động đến hàng rào này, báo cho chúng ta biết những nguy hiểm tiềm tàng có thể xảy ra.
    Các unit test tạo thành hàng rào an toàn cho mã ứng dụng
    Các unit test tạo thành hàng rào an toàn cho mã ứng dụng
  • Unit Testing là môi trường lý tưởng để tiếp cận các thư viện bên ngoài một cách tốt nhất (third-party APIs). Sẽ rất nguy hiểm nếu như chúng ta ứng dụng ngay các thư viện này mà không kiểm tra kỹ lưỡng công dụng của các thủ tục trong thư viện. Dành ra thời gian viết các unit test kiểm tra từng thủ tục là phương pháp tốt nhất để khẳng định sự hiểu đúng đắn về cách sử dụng thư viện đó. Ngoài ra unit test cũng được sử dụng để phát hiện ra sự khác biệt giữa phiên bản mới và phiên bản cũ của cùng một thư viện.

Trong môi trường làm việc cạnh tranh thì các unit test còn có tác dụng rất lớn đến năng suất làm việc của bạn như:

  • Giải phóng chuyên viên QA khỏi các công việc kiểm tra phức tạp.
  • Làm tăng sự tự tin khi hoàn thành một công việc. Chúng ta thường có cảm giác không chắc chắn về các đoạn mã của mình như liệu các lỗi có quay lại hay không, ai đó có thể gây ra tác động đến hoạt động của module hiện hành hay không, hoặc liệu công việc hiệu chỉnh mã có gây ra một hư hỏng đâu đó...
  • Là công cụ đánh giá năng lực của bạn. Số lượng các test case chuyển trạng thái tích cực sẽ thể hiện tốc độ làm việc năng suất của bạn.

Chiến lược viết mã hiệu quả với Unit Test

  • Phân tích các tình huống có thể xảy ra đối với mã. Đừng bỏ qua các tình huống tồi tệ nhất có thể xảy ra, thí dụ dữ liệu nhập có thể làm một kết nối cơ sở dữ liệu thất bại, ứng dụng có thể bị treo vì một phép toán chia cho không, các thủ tục ném ra lỗi ngoại lệ sai có thể phá hỏng ứng dụng một cách bí ẩn…
  • Mọi unit test phải bắt đầu với trạng thái “fail” và chuyển trạng thái tích cực sau một số thay đổi hợp lý đối với mã chính. Các unit test sẽ không thực sự đem lại hiệu quả nếu chúng ta chỉ tập trung thay đổi mã unit test nhằm nhận được trạng thái tích cực. 
  • Mỗi khi viết một đoạn mã quan trọng, hãy viết các unit test tương ứng cho đến khi bạn không thể nghĩ thêm tình huống nào nữa.
  • Nhập một số lượng đủ lớn các giá trị đầu vào để phát hiện điểm yếu của mã theo nguyên tắc:

-    Nếu nhập giá trị đầu vào hợp lệ, thì kết quả trả về cũng phải hợp lệ
-    Nếu nhập giá trị đầu vào không hợp lệ, thì kết quả trả về phải không hợp lệ

  • Sớm nhận biết các đoạn mã không ổn định và có nguy cơ gây lỗi cao, viết unit test tương ứng để khống chế rủi ro và các khả năng phát sinh về sau.
  • Hãy nhớ một nguyên tắc: càng nghĩ ra nhiều test point cho một unit test thì chất lượng unit test càng cao.
  • Ứng với mỗi một đối tượng nghiệp vụ (business object) hoặc đối tượng truy cập dữ liệu (data access object), nên tạo ra một lớp kiểm tra riêng vì những lỗi nghiêm trọng có thể phát sinh từ các đối tượng này.
  • Để ngăn chặn các lỗi có thể phát sinh trở lại, thực thi tự động tất cả các unit test mỗi khi có một sự thay đổi quan trọng, hãy làm công việc này mỗi ngày. Các unit test lỗi sẽ cho chúng ta biết thay đổi nào vừa được tạo ra là nguyên nhân gây ra lỗi.
  • Để tăng hiệu quả và giảm rủi ro khi viết các unit test, cần phải sử dụng nhiều phương thức kiểm tra khác nhau. Chúng ta cũng không nên bị phụ thuộc quá nhiều vào các unit test, hãy viết càng đơn giản càng tốt.
  • Cuối cùng, viết unit test cũng đòi hỏi sự nỗ lực, kinh nghiệm và sự sáng tạo như viết một phần mềm. Tránh để xảy ra trường hợp đoạn mã thực sự vận hành đúng đắn trong khi các unit test lại báo cáo rằng đoạn mã đó có vấn đề. 

Trước khi kết thúc phần này, chúng tôi có một lời khuyên nhỏ là viết unit test cũng thực sự là đơn giản như viết mã một phần mềm, và điều bạn cần phải làm ngay bây giờ là không ngừng thực hành nó. Hãy nhớ rằng Unit Testing chỉ thực sự mang lại lợi ích lớn nếu chúng ta đặt vấn đề chất lượng phần mềm lên hàng đầu hơn là chỉ nhằm kết thúc công việc đúng thời hạn. Và khi đã thành thạo với các công việc viết unit test rồi, bạn có thể đọc thêm về các kỹ thuật xây dựng unit test phức tạp hơn, trong số đó có mô hình các đối tượng ảo sẽ được trình bày trong phần tiếp theo.

Xây dựng Unit Test với mô hình các đối tượng ảo (Mock Object)

Trong Unit Testing, mỗi một đối tượng hay một phương thức riêng lẻ được kiểm tra tại một thời điểm và chúng ta chỉ quan tâm đến các trách nhiệm của chúng có được thực hiện đúng hay không. Tuy nhiên trong các dự án phần mềm phức tạp thì Unit Testing không còn là quy trình riêng lẻ, nhiều đơn vị không làm việc độc lập, mà nó tương tác với các đơn vị khác như kết nối mạng, cơ sở dữ liệu hay thậm chí giá thị trường chứng khoán của Amazon(thông qua dịch vụ web). Như vậy công việc kiểm nghiệm có thể bị trì hoãn gây tác động xấu đến quy trình phát triển chung. Để giải quyết các vấn đề này người ta đưa ra mô hình các mock object hay còn gọi là các đối tượng ảo (hoặc đối tượng giả)

Định nghĩ mock object

Mock object là một đối tượng ảo mô phỏng các tính chất và hành vi giống hệt như đối tượng thực được truyền vào bên trong khối mã đang vận hành nhằm kiểm tra tính đúng đắn của các hoạt động bên trong.

Đặc điểm của Mock Object 

  • Đơn giản hơn đối tượng thực nhưng vẫn giữ được sự tương tác với các đối tượng khác
  • Không lặp lại nội dung đối tượng thực
  • Cho phép thiết lập các trạng thái riêng trợ giúp cho việc kiểm tra

Lợi ích của Mock Object

  • Đảm bảo công việc kiểm nghiệm không bị gián đoạn bởi các yếu tố bên ngoài, giúp các chuyên viên tập trung vào một chức năng nghiệp vụ cụ thể, từ đó tạo ra unit test vận hành nhanh hơn.
  • Tạo ra các ràng buộc mềm dẻo hơn trong tương tác đối tượng, bất cứ vấn đề nào cũng có thể giải quyết được thông qua mock object, nhờ đó tăng khả năng bền vững của unit test. 
  • Giúp tiếp cận hướng đối tượng tốt hơn. Nhờ các mock object chúng ta có thể phát hiện ra rằng một số lớp cần phải tách ra các interface.
  • Dễ dàng cho việc kiểm tra thứ tự gọi đúng của các thủ tục. Thay vì gọi các đối tượng thực vận hành nặng nề, chúng ta có thể gọi các mock object đơn giản hơn để có thể kiểm tra nhanh các liên kết giữa các thủ tục.
  • Tăng tính mềm dẻo trong khi kiểm nghiệm. Theo thiết kế, các thủ tục nhất thiết phải được thực thi theo một trình tự nào đó, sử dụng mock object có thể phá vỡ liên kết này nhờ đó công việc kiểm nghiệm có thế được tiến hành nhanh hơn.

Phạm vi sử dụng

Mock object chỉ được sử dụng cần thiết trong các trường hợp sau:

  • Cần thiết lập trạng thái giả của một đối tượng thực trước khi các unit test có liên quan được đưa vào vận hành (thí dụ kết nối cơ sở dữ liệu, giả định trạng thái lỗi server…).
  • Cần thiết lập trạng thái cần thiết cho một số tính chất nào đó của đối tượng đã bị khoá quyền truy cập (các biến, thủ tục, hàm, thuộc tính riêng được khai báo private). Một thiết kế là tốt nếu như không phải lúc nào các tính chất của một đối tượng cũng có thể được mở rộng phạm vi truy cập ra bên ngoài vì điều này có thể trực tiếp phá vỡ liên kết giữa các phương thức theo một trình tự sắp đặt trước, từ đó dẫn đến kết quả có thể bị xử lý sai. Tuy nhiên, mock object có thể thiết lập các trạng thái giả mà vẫn đảm bảo các yêu cầu ràng buộc, các nguyên tắc đúng đắn và các quan hệ của đối tượng thực.
  • Cần kiểm tra một số thủ tục hoặc các biến thành viên bị hạn chế truy cập. Bằng cách kế thừa mock object từ đối tượng thực chúng ta có thể kiểm tra các thành viên đã được bảo vệ(khai báo protected).
  • Cần loại bỏ các hiệu ứng phụ của một đối tượng nào đó không liên quan đến unit test.
  • Cần kiểm nghiệm rằng mã vận hành có tương tác ít nhất một lần với hệ thống bên ngoài.

Các dạng đối tượng được mô phỏng

Mock object mô phỏng các loại đối tượng sau đây:

  • Các đối tượng thực mới chỉ được mô tả trên bản thiết kế nhưng chưa tồn tại dưới dạng mã, hoặc các module chưa sẵn sàng cung cấp các dữ liệu cần thiết để vận hành unit test.
  • Các đối tượng thực có các thủ tục chưa xác định rõ ràng về mặt nội dung (mới chỉ mô tả trong interface) nhưng được đòi hỏi sử dụng gấp trong các unit test.
  • Các đối tượng thực rất khó để cài đặt (thí dụ đối tượng xử lý các trạng thái của server) 
  • Các đối tượng thực xử lý một tình huống khó xảy ra. Thí dụ lỗi kết nối mạng, lỗi ổ cứng…Mock object được xây dựng để giả định các tình huống này.
  • Các đối tượng có các tính chất và hành vi phức tạp, các trạng thái luôn thay đổi và các quan hệ chặt chẽ với nhiều đối tượng khác.
  • Các đối tượng vận hành chậm chạp. Công việc kiểm tra hiện hành không liên quan đến thao tác xử lý đối tượng này.
  • Đối tượng thực liên quan đến giao diện tương tác người dùng. Không người dùng nào có thể ngồi kiểm nghiệm các chức năng hộ bạn hết ngày này qua ngày khác. Tuy nhiên bạn có thể dùng mock object để mô phỏng lại thao tác của người dùng và do đó công việc có thể được diễn biến lặp lại và hoàn toàn tự động.

Thiết kế mock object

Thông thường nếu số lượng các mock object không nhiều, chúng ta có thể tự thiết kế các mock object. Nếu không muốn tự thiết kế một số lượng lớn các mock object đòi hỏi nhiều thời gian, có thể tải về các công cụ có sẵn thông dụng hiện nay như EasyMock, jMock, NMock…Các phần mềm này cung cấp nhiều API cho phép xây dựng các mock object và các kho dữ liệu giả dễ dàng hơn, cũng như kiểm tra tự động các số liệu trong unit test. Tuy nhiên nhìn chung thiết kế các mock object sẽ có 3 bước chính sau đây:

  1. Đưa ra interface để mô tả đối tượng. Tất cả các tính chất và thủ tục quan trọng cần kiểm tra phải được mô tả trong interface.
  2. Viết nội dung cho đối tượng thực dựa trên interface như thông thường. 
  3. Trích rút interface từ đối tượng thực và triển khai một mock object dựa trên interface đó. 

Lưu ý rằng mock object phải được đưa vào quy trình kiểm nghiệm tách biệt. Cách này có thể sinh ra nhiều interface không thực sự cần thiết có thể làm cho cho thiết kế ứng dụng trở nên phức tạp. Một cách khác là kế thừa một đối tượng đang tồn tại và cố gắng mô phỏng các hành vi càng đơn giản nhất càng tốt, như trả về một dữ liệu giả chẳng hạn. Đặc biệt tránh tạo ra các liên kết mắt xích giữa các mock object (chaining mock object) vì chúng có thể làm cho thiết kế unit test trở nên phức tạp.

Các đối tượng unit test tương tác với cả đối tượng ảo và đối tượng thực
Các đối tượng unit test tương tác với cả đối tượng ảo và đối tượng thực

Test-Driven Development (TDD) là gì?

Trong những năm gần đây một khái niệm mới nổi lên có tên gọi TDD (viết tắt của Test Driven Development) được đưa ra dựa trên mô hình phát triển phần mềm khá nổi tiếng XP (Extreme Programming). TDD là một chiến lược phát triển sử dụng kỹ thuật Unit Testing theo nguyên tắc tạo ra các công đoạn kiểm nghiệm trước khi xây dựng mã.

Ý tưởng chính của TDD nằm ở chỗ: Trước khi bạn bắt tay viết mã, hãy nghĩ về những gì phải làm trước. Không giống như lập trình truyền thống, trong TDD chúng ta viết các mã kiểm tra trước khi viết mã chính, và các đoạn mã mới chỉ được viết sau khi đạt được đủ số lượng unit test cần thiết cho các tình huống có thể xảy ra

Có thể hiểu TDD là một quy trình vòng tròn bắt đầu bởi các unit test với trạng thái đầu tiên là “fail”, tiếp theo cần viết mã đủ để các unit test chuyển trạng thái tích cực “pass”, và cuối cùng hiệu chỉnh lại mã cho đơn giản hơn. Quy trình này được tái diễn liên tục đối với mọi đơn vị chương trình cho đến khi kết thúc hoàn toàn dự án.

Đặc điểm của TDD

  • Là quy trình phát triển tăng dần theo kịch bản và gắn chặt với các công đoạn kiểm nghiệm trước khi đưa ứng dụng vào vận hành thực sự.
  • Là phương pháp phát triển phần mềm ở đó áp dụng kỹ thuật Unit Testing tiến hành kiểm tra tất cả các interface, tạo ra các mock object cần thiết mô phỏng sự vận hành của ứng dụng ở một nơi riêng biệt. 
  • Tạo ra bộ khung vận hành tự động cho tẩt cả các thao tác kiểm nghiệm bộ phận trong hệ thống mỗi khi một phiên bản mới được xây dựng.

Các lợi ích khi thực hiện theo TDD

  • TDD là một kỹ thuật giúp định hình ý tưởng thiết kế hơn là kiểm nghiệm mã chương trình. Thực hiện theo TDD sẽ làm sáng tỏ thêm các yêu cầu bài toán, giải toả sự bế tắc trong khi đi tìm giải pháp, phát hiện sớm các vấn đề về thiết kế và tránh được những công việc phải làm lại. 
  • TDD là một phần bổ trợ không thể thiếu được trong các công việc lập trình theo nhóm nhỏ, thường là hai người cùng phát triển một module(pair programming). Trong mô hình này, theo luân phiên một người có nhiệm vụ nghĩ về tình huống kiểm tra tiếp theo, viết unit test cho tình huống và các mock object cần thiết. Người còn lại tập trung viết mã để các unit test chuyển sang trạng thái “pass”. Lần lượt hai người hoán chuyển công việc cho nhau và hiệu quả đem lại là các sai lầm có thể được giảm thiểu so với khi làm việc độc lập.
  • TDD định hướng cho nhóm thiết kế vận dụng tốt các phương pháp hướng đối tượng (các đối tượng cần kiểm tra phải thực thi một interface là một thí dụ), đặc biệt có thể thu được thiết kế tốt theo hai nguyên tắc:
    • Loosely-Coupled: Nếu một lớp là loosely-coupled, bất kỳ một sự thay đổi nào lên lớp đó sẽ không ảnh hưởng đến các đối tượng khác.
    • Highly-Cohesive: Một lớp được gọi là highly-cohesive nếu lớp đó mang tính chất khép kín theo nghĩa chỉ thực hiện những chức năng có khả năng gần với nhau về mặt nghiệp vụ và thiết kế, đồng thời loại ra những chức năng ít có liên quan đến các chức năng chính. 
  • Lợi ích quan trọng cuối cùng của TDD là xây dựng các đoạn mã chất lượng và an toàn, tập trung hơn, giảm phân mảnh mã và giảm rủi ro xảy ra ngoài dự kiến. 

Trong TDD càng nhiều unit test được tạo ra thì càng có nhiều khả năng khống chế nhanh chóng các lỗi nghiêm trọng xảy ra. Các unit test càng “mịn” theo nghĩa không thể chia nhỏ hoặc không thể bổ sung thêm các test point được nữa thì khả năng đáp ứng yêu cầu kiểm nghiệm càng cao. Khi đã thiết kế đủ các unit test có khả năng phát hiện chính xác bất kỳ một lỗi kỹ thuật nào, chúng ta có thể yên tâm chuyển giao module cho chuyên viên QA kiểm định chức năng(functional testing). Tuy nhiên trong suốt giai đoạn phát triển sau đó chúng ta cũng cần phải kiểm tra định kỳ các trạng thái của unit test để đảm bảo rằng việc cập nhật không phá vỡ các đoạn mã cũ.

Quy trình thực hiện TDD

Trình tự thực hiện trong TDD như sau:

  1. Đối với một module, nghĩ về các công việc sẽ làm và hình dung ra kiểm tra công việc đó như thế nào. 
  2. Tạo một test suite ứng với module đó.
  3. Bắt tay vào thiết kế sơ bộ tất cả các unit test có thể nghĩ ra. Bước này thực chất là thu thập các tình huống có thể phát hiện ra lỗi vào một danh sách các công việc cần kiểm nghiệm. 
  4. Viết mã để đảm bảo các unit test được dịch.
  5. Thực thi các unit test, vì mã chính của module chưa tồn tại nên trạng thái vẫn là “fail” (nút đỏ được bật). 
  6. Viết mã cho module để thay đổi trạng thái unit test, có thể bổ sung các unit test nếu cần thiết.
  7. Chạy lại toàn bộ test suite và quan sát các unit test lỗi, lặp lại bước 6-7 cho đến đến khi tất cả các nút xanh được bật
  8. Hiệu chỉnh mã để loại bỏ các phần lặp lại, các khối mã và các phân nhánh, liên kết thiếu hợp lý hoặc các khối mã “chết” không còn hoạt động…, đồng thời viết giải thích các phần quan trọng. Hãy thực hiện công việc này thường xuyên vì chúng ta sẽ không có thời gian quay lại một lần nữa cho công việc hiệu chỉnh. 

Bước cuối cùng có ý nghĩa rất lớn trong việc giảm sự phụ thuộc vào các module khác (loosely-coupling) và gia tăng sự độc lập về mặt nghiệp vụ của module hiện hành (highly-cohesion). Cần lưu ý kiểm tra lại trạng thái tất cả các unit test sau mỗi lần hiệu chỉnh vì rất có thể công việc này sẽ gây ra một hư hỏng đâu đó.

Ứng dụng của TDD trong kỹ thuật hiệu chỉnh mã (Refactory) 

Điều gì xảy ra nếu một module có nhiều đoạn chắp vá hoặc đoạn mã “chết” mà nếu chỉ sơ ý sửa đi một chút, một loạt các lỗi cũ xuất hiện trở lại mà chúng ta không hiểu nguyên nhân ở đâu. Có nhiều lý do khiến điều này xảy ra như:

  • Ràng buộc thời gian gấp gáp
  • Lạm dụng quá nhiều công đoạn “cắt và dán” (Cut and Paster Programming) khiến không thể kiểm soát được mã.
  • Kiến trúc hệ thống thiếu sự vững chắc và linh hoạt
  • Sai sót trong quá trình giải quyết vấn đề
  • Các lập trình viên cấp thấp thiếu kinh nghiệm về kiểm soát mã
  • Thiếu một người lãnh đạo tốt về quản lý kỹ thuật

Vậy Refactory là gì?

Refactory là một thuật ngữ để chỉ kỹ thuật hiệu chỉnh theo một số nguyên tắc chặt chẽ tạo ra mã tốt hơn, thay đổi cấu trúc của mã nhưng không làm thay đổi sự hoạt động của mã, tạo ra nhiều thuận lợi cho công việc bảo trì về sau. 

Refactory luôn đóng vai trò vô cùng quan trọng trong việc đảm bảo chất lượng phần mềm với những công việc chính sau đây:

  • Cải thiện lại thiết kế của những module không chắc chắn, thiếu ổn định, khó thích ứng với thay đổi do đó cần được sửa chữa, thậm chí khi module đó vẫn hoạt động đúng. 
  • Phân tích chi tiết các khối mã bên trong thủ tục dựa trên kỹ thuật đo lường “code coverage” nhằm tìm ra các vấn đề đối với các phát biểu, các điều kiện, các phân nhánh thủ tục, các quan hệ giữa các đối tượng hoặc các vùng mã thiếu an toàn. 
  • Phân chia một lớp thành nhiều lớp nhỏ hơn và sử dụng tính năng uỷ quyền (delegation) thay vì để một lớp làm mọi thứ.
  • Triển khai lại các lớp một cách hợp lý sao cho các module bên ngoài có thể khai thác truy cập thông qua các interface.
  • Thiết kế lại tầm nhìn của các lớp, module… một cách hiệu quả. Giảm thiểu số lượng các lớp hay biến có tầm nhìn “public” để tạo ra mã an toàn hơn.
  • Cấu trúc lại mã thành nhiều phương thức ngắn và đơn giản hơn.
  • Tạo ra các mã hướng đối tượng đơn giản, dễ bảo trì hơn.  
  • Tổ chức lại thiết kế tạo ra sự khép kín (encapsulation), độc lập về mặt nghiệp vụ(cohesion) và nới lỏng sự phụ thuộc vào các module khác (loosely-coupling). Điều này không những thuận lợi cho công việc bảo trì về sau mà còn giúp thiết kế các unit test và mock object dễ dàng hơn.

Refactory được thực hiện theo từng bước nhỏ ứng với các thay đổi nhỏ. Các rủi ro về lỗi phát sinh sau mỗi lần hiệu chỉnh luôn được ngăn chặn bởi một hệ thống thực hiện tự động các unit test. Hơn nữa các công việc nêu trên chỉ có thể được tiến hành hiệu quả nhờ tuân thủ chiến lược TDD một cách đúng đắn, do đó có thể phát hiện các đoạn mã kém hiệu quả hoặc thiếu an toàn cần phải được hiệu chỉnh. Hơn nữa, TDD sẽ tạo ra thói quen hiệu chỉnh mã không ngừng, giúp xử lý các thay đổi về chức năng dễ dàng hơn.

Các chiến lược phát triển hiệu quả với TDD

Mỗi một tổ chức phần mềm đều có cách điều hành quản lý phát triển phần mềm khác nhau, nhưng tất cả đều có chung một mục đích là giảm số lỗi xuống mức nhỏ nhất có thể và khống chế các lỗi phát sinh trở lại. Tuy nhiên nhìn chung một quy trình phát triển phần mềm lý tưởng không thể thiếu các bước quan trọng sau đây:

  • Thiết kế một dự án thử nghiệm riêng, độc lập, tách biệt với khu vực phát triển. Không gắn dự án thử nghiệm đó vào trong phiên bản sản phẩm được giao cho khách hàng, vì điều này có thể làm tăng kích thước sản phẩm. 
  • Xây dựng một cơ sở dữ liệu các test suite cho mọi module phục vụ cho việc kiểm nghiệm cả hai khía cạnh phát triển và kiểm nghiệm chức năng (acceptance testing).
  • Chia nhỏ dự án ra làm nhiều quy trình nhỏ hơn dựa trên ngữ cảnh giúp công việc viết unit test được dễ dàng hơn. Để kiểm tra hiệu quả của toàn bộ của ứng dụng, tốt nhất là kiểm tra hiệu quả của mọi đơn vị mã nhỏ nhất.
  • Có thể thiết lập các cơ sở dữ liệu riêng cho dự án thử nghiệm lưu trữ tất cả các giá trị đầu vào và các kết quả trả về mong muốn…XML sẽ là cách tiếp cận tốt nhất cho những cơ sở dữ liệu loại này URL. 
  • Tích hơp công việc kiểm nghiệm thành một phần trong quy trình tự động hoá các công việc quản lý mã nguồn như lắp ráp toàn bộ công việc, biên dịch vào cuối ngày làm việc… Mỗi một công việc như vậy được gọi là một “build”. Về quy trình này có thể tham khảo ứng dụng nguồn mở Ant trong Java (hoặc NAnt cho .NET), hay các công cụ thương mại như CruiseControl hoặc Anthill. 
  • Cuối cùng thay vì kiểm nghiệm bằng tay, hãy để máy tính thực hiện tự động và gửi báo cáo cho bạn. Các thông báo email tự động hàng ngày về tình trạng của các build hoặc unit test sẽ luôn đảm bảo cho dự án được thông suốt. Tất cả các công việc này có thể được tiến hành trên một máy trạm riêng (continuous integration server) có khả năng kiểm soát các thay đổi đối với mã nguồn (Source Control). 

Lời kết

Unit Testing là một phương pháp hỗ trợ phát triển phần mềm đang được áp dụng và vẫn đang được tiếp tục nghiên cứu phát triển ngày càng hoàn thiện với nhiều mẫu thiết kế phức tạp hơn. Kết hợp Unit Testing với chiến lược phát triển TDD, cùng với kỹ năng thực hành thành thục sẽ giúp bạn xây dựng được các ứng dụng phần mềm chất lượng và ổn định. Một ngày nào đó bạn sẽ thấy rằng viết unit test cũng dễ như viết mã thông thường vậy.

Phạm Đình Trường 
Theo tạp chí PC World Việt Nam