Trong bài trước chúng ta đã cùng tìm hiểu tổng quan về kiểm thử (Testing) trong phát triển phần mềm. Trong bài này, chúng ta sẽ cùng đi chi tiết hơn về Unit Testing và mô hình phát triển phần mềm hiện đại TDD (Test-Driven Development).

Unit Testing (kiểm thử đơn vị)

Định nghĩa

Nhắc lại một chút khái niệm về Unit Testing ở bài trước: Unit Testing (UT) là một mức kiểm thử phần mềm với mục đích để xác nhận từng unit của phần mềm được phát triển đúng như được thiết kế. UT là mức test nhỏ nhất trong bất kỳ phần mềm nào. UT bản thân nó là cái gì đó khá trừu tượng vì tùy dự án, chúng ta có thể quy định “unit” ở mức độ nào. Thông thường, “unit” sẽ được quy định giới hạn trong một hàm (method) hay một class. Trong thực tế, tùy vào kinh nghiệm và kĩ năng, developer sẽ đưa ra quyết định viết các UT như thế nào cho phù hợp, có thể test đầu vào đầu ra của hàm, hay kiểm tra một phần hoặc toàn bộ class.

Unit test là test do developer viết, được chạy để kiểm tra các hàm do developer viết ra có sai hay không. UT thường được chạy mỗi khi build để đảm bảo các hàm đều chạy đúng sau khi ta sửa code.

UT là các đoạn code 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 UT sẽ gửi một số yêu cầu đầu vào và kiểm tra kết quả đầu ra có đú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 đoạn mã UT 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 đó UT còn được gọi là kỹ thuật kiểm nghiệm tự động.

UT có các đặc điểm sau:

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

Vòng đời của UT

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

  • Fail (trạng thái lỗi).
  • Ignore (tạm ngừng thực hiện).
  • Pass (trạng thái làm việc).

Toàn bộ UT đượ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 UT với giao diện trực quan. Thông thường, trạng thái của UT được biểu hiện bằng các màu khác nhau: màu xanh (pass), màu vàng (ignore) và màu đỏ (fail).

UT 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 UT khác.

Thiết kế UT

Tham khảo thêm: https://martinfowler.com/bliki/GivenWhenThen.html

Mỗi UT đều được tiết kế theo trình tự sau:

  • Given: 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ả, …
  • When: Triệu gọi các phương thức cần kiểm tra.
  • Then: 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 (nếu có).

Ứng dụng của UT

  • 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à 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 – tập hợp của nhiều quy trình).

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

  • 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 làm một kết nối cơ sở dữ liệu thất bại, ứng dụng bị treo vì một phép toán chia cho không, các thủ tục đưa ra lỗi ngoại lệ sai có thể phá hỏng ứng dụng một cách bí ẩn, …
  • Mọi UT phải bắt đầu với trạng thái “fail” và chuyển trạng thái “pass” sau một số thay đổi hợp lý đối với mã chính.
  • Mỗi khi viết một đoạn mã quan trọng, hãy viết các UT 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 UT tương ứng để khống chế.
  • Ứng với mỗi đố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ả UT 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 UT lỗi cho chúng ta biết thay đổi nào là nguyên nhân gây lỗi.
  • Để tăng hiệu quả và giảm rủi ro khi viết các UT, cần sử dụng nhiều phương thức kiểm tra khác nhau. Hãy viết càng đơn giản càng tốt.
  • Cuối cùng, viết UT cũng đòi hỏi sự nỗ lực, kinh nghiệm và sự sáng tạo như viết phần mềm.

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

Trong UT, 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ì UT không còn là quy trình riêng lẻ, nhiều đối tượng (đơn vị chương trình) không làm việc độc lập mà tương tác với các đối tượng khác như kết nối mạng, cơ sở dữ liệu hay 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 “Mock Object” hay đối tượng ảo (hoặc đối tượng giả).

Định nghĩa

Mock object (MO) 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.

Thay vì lấy data từ một real service, chúng ta sử dụng một bộ test data mà input và output đã được định nghĩa rõ ràng từ một Mock Object và chúng ta có thể dùng nó cho đối tượng muốn test.

Đặc điểm

  • Đơ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 kiểm tra.

Lợi ích

  • Đả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 UT vận hành nhanh hơn.
  • Giúp tiếp cận hướng đối tượng tốt hơn. Nhờ MO chúng ta có thể phát hiện interface cần tách ở một số lớp.
  • Dễ dàng cho việc kiểm nghiệm. 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 MO đơn giản hơn để kiểm tra nhanh liên kết giữa các thủ tục, công việc kiểm nghiệm có thể tiến hành nhanh hơn.

Phạm vi sử dụng

MO được sử dụng trong các trường hợp sau:

  • Khi cần lập trạng thái giả của một đối tượng thực trước khi các UT 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, …).
  • Khi cần 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). 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, MO 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.
  • Khi cần kiểm tra một số thủ tục hoặc các biến của thành viên bị hạn chế truy cập. Bằng cách kế thừa MO 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).
  • Khi 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 UT.
  • Khi cần kiểm nghiệm mã vận hành có tương tác với hệ thống bên ngoài.

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

MO 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 UT.
  • 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 UT.
  • 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, …
  • 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 xử lý 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 MO để mô phỏng thao tác của người dùng, nhờ đó 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 (MO)

Thông thường, nếu số lượng MO không nhiều, chúng ta có thể tự thiết kế. Nếu không muốn mất nhiều thời gian tự thiết kế một số lượng lớn MO, bạn có thể tải về các công cụ có sẵn thông dụng hiện nay như Mockito, PowerMockito, … Các phần mềm này cung cấp nhiều API cho phép xây dựng MO 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 UT. Nói chung, việc thiết kế MO gồm 3 bước chính sau đây:

  • Đư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.
  • Viết nội dung cho đối tượng thực dựa trên interface như thông thường.
  • Trích interface từ đối tượng thực và triển khai MO dựa trên interface đó.

Lưu ý: MO 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 thiết kế ứng dụng trở nên phức tạp. Một cách làm 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 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 những liên kết mắt xích giữa các MO vì chúng có thể làm cho thiết kế UT trở nên phức tạp.

Test-Driven Development (TDD)

Trong những năm gần đây, khái niệm TDD đượ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 UT 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: 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 mã kiểm tra trước khi viết mã chính, chỉ được viết sau khi đạt đủ số lượng UT 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 UT với trạng thái đầu tiên là “fail”, tiếp theo cần viết mã để các UT chuyển trạng thái “pass”, và cuối cùng hiệu chỉnh 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

  • 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 UT tiến hành kiểm tra tất cả các interface, tạo ra các MO 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 xây dựng một phiên bản mới.

Lợi ích

  • 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 tỏa 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 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. Trong mô hình này, 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 UT cho tình huống và các MO cần thiết. Người còn lại tập trung viết mã để các UT chuyển sang trạng thái “pass”; giúp giảm thiểu lỗi 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: Bất kỳ sự thay đổi nào cũng đều không ảnh hưởng đến các đối tượng khác.
    • Highly-Cohesive: Có tính chất khép kín theo nghĩa chỉ thực hiện những chức 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 UT đượ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 UT càng “mịn” theo nghĩa không thể chia nhỏ hoặc không thể bổ sung đượ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 UT 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 đó cần kiểm tra định kỳ các trạng thái của UT để đảm bảo việc cập nhật không phá vỡ tính đúng đắn của các đoạn mã cũ.

Ngoài những ưu điểm lớn kể trên thì TDD cũng có những nhược điểm:

  • Viết test case song song với implement nên mất thời gian và công sức phát triển.
  • Nó cần người có kinh nghiệm trong việc viết test case. Bởi nếu test case không chính xác nó khiến những ưu điểm của TDD không còn tác dụng.
  • TDD cần thay đổi tư duy lập trình viên nên gây khó khăn cho những người chưa có kinh nghiệm lập trình hoặc mới bắt đầu tiếp cận phương pháp này.

Quy trình thực hiện

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à cách kiểm tra công việc đó như thế nào.
  2. Tạo test suite ứng với module đó.
  3. Bắt tay thiết kế sơ bộ tất cả các UT 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 lỗi vào một danh sách công việc cần kiểm nghiệm.
  4. Viết mã để đảm bảo các UT được biên dịch.
  5. Thực thi các UT, vì mã chính của module chưa tồn tại nên trạng thái là “fail”.
  6. Viết mã cho module để thay đổi trạng thái UT, có thể bổ sung UT nếu cần thiết.
  7. Chạy lại toàn bộ test suite và quan sát các UT lỗi, lặp lại bước 6-7 cho đến khi tất cả UT đều đạt trạng thái “pass”.
  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ã không còn hoạt động… đồng thời viết chú giải 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 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 và gia tăng sự độc lập về mặt nghiệp vụ của module hiện hành. Cần lưu ý kiểm tra lại trạng thái tất cả các UT sau mỗi lần hiệu chỉnh vì rất có thể công việc này sẽ gây ra lỗi ở đâu đó.

Chiến lược phát triển với TDD

Mỗi công ty 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ế 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 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ụ việc kiểm nghiệm cả hai khía cạnh phát triển và chức năng.
  • Chia nhỏ dự án ra nhiều quy trình nhỏ hơn dựa trên ngữ cảnh, giúp việc viết UT được dễ dàng hơn. Để kiểm tra hiệu quả của toàn bộ ứ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.
  • 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á quản lý mã nguồn như tích hợ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 UT sẽ luôn đảm bảo cho dự án 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 tính riêng có khả năng kiểm soát các thay đổi mã nguồn.

Lời kết

UT 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 hoàn thiện. Kết hợp UT với chiến lược phát triển TDD sẽ giúp bạn xây dựng được các phần mềm chất lượng và ổn định.

Tài liệu tham khảo: