
Trước khi bàn giao lại cho khách hàng của mỗi dự án, các lập trình viên đều phải kiểm tra thật kỹ phần mềm và chắc chắn rằng các dòng code, các chức năng đều chạy đúng yêu cầu. Từ đó, chúng ta có khái niệm UnitTest (kiểm thử đơn vị). Mockito là Mocking framework, cung cấp cho bạn API viết test đơn giản, dễ dàng, giúp bạn test các hàm, class một cách riêng lẻ hay nói cách khác nó được sử dụng cho UnitTest. Một vài bạn mới bước chân vào dự án và nghe yêu cầu phải viết UnitTest có thể thấy lạ lẫm, khó hiểu, thậm chí có suy nghĩ “mình là lập trình viên chứ có phải tester đâu?”. Vậy UnitTest, Mockito là gì, thuộc công việc của bộ phận nào và có thật sự cần thiết không? Sau đây mình sẽ giải đáp cho mọi người các thông tin cơ bản, giới thiệu chung các khái niệm và vài cách viết đơn giản, dành cho những bạn intern hoặc fresher bắt đầu tìm hiểu và đang gặp khó khăn trong việc viết UnitTest.
1. UnitTest là gì ?
- UnitTest là loại kiểm thử phần mềm chia source code ra thành nhiều thành phần riêng lẻ, tách riêng 1 chức năng code để kiểm tra và xác minh tính chính xác của nó.
- Ví dụ: chúng ta sẽ chia 1 source code lớn theo từng phần nhỏ như các hàm (function), các phương thức (method),…Lúc này chúng ta tiến hành kiểm tra, phân tích kết quả, nếu phát hiện lỗi sẽ dễ dàng xác định nguyên nhân và khắc phục. Mọi thứ sẽ trở nên đơn giản vì chỉ khoanh vùng lỗi trong 1 unit đang được kiểm tra.
- UnitTest là công việc test của 1 lập trình viên, để tự kiểm tra các hàm được viết ra có chạy đúng theo yêu cầu hay không.
- Đây là đ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 UnitTest sẽ gửi thông số đầu vào và kiểm tra đầu ra có đúng yêu cầu hay không, thường bao gồm:
+ Các kết quả đúng mong muốn trả về.
+ Các ngoại lệ và lỗi mong muốn trả về.
2. Test case, phân loại test case
- Test case: là một mô tả dữ liệu đầu vào (input), hành động (action), hoặc một sự kiện (event), và kết quả truy vấn (expected response), nhằm kiểm tra từng chức năng của ứng dụng phần mềm hoạt động đúng hay không.
- Phân loại test case trong UnitTest:
+ Positive test case: là trường hợp test đảm bảo người dùng có thể thực hiện được thao tác với dữ liệu đầu vào chính xác:
+ Negative test case: là trường hợp test tìm cách gây lỗi cho ứng dụng bằng cách truyền dữ liệu đầu vào không hợp lệ:
+ Một function có thể có một hoặc nhiều test case:
3. Trạng thái của UnitTest
- UnitTest gồm 3 trạng thái được biểu diễn bằng 3 màu:
+ Trạng thái lỗi (fail): có màu đỏ, thể hiện case test đang bị lỗi.
+ Trạng thái tạm dừng (ignore): có màu vàng, thể hiện case đang tạm ngừng thực hiện (dữ liệu truyền vào có thể đang khác với giá trị thực tế).
+ Trạng thái đã qua case test (pass): có màu xanh, function đang test chạy đúng như mong muốn.
4. Trình tự của UnitTest
- Việc đầu tiên cần làm không phải bắt tay vào viết code mà là xác định các test case (case pass và case fail), các trường hợp lỗi có thể xảy ra từ đó chúng ta sẽ viết code theo các case đã thiết kế. Điều này giúp chúng ta có thể kiểm tra toàn diện không bỏ sót bất cứ trường hợp nào.
- Tiếp sau đây chúng ta sẽ sử dụng tới cấu trúc Given – When – Then.
+ Given: Thiết lập các điều kiện, thực hiện khởi tạo các đối tượng, tài nguyên cần thiết, chuẩn bị các dữ liệu đầu vào.
+ When: Gọi các phương thức cần kiểm tra.
+ Then: Kiểm tra luồng hoạt động của các phương thức (đúng hay sai?).
5. Những class cần test
- Thông thường chúng ta sẽ thực hiện kiểm tra và viết UnitTest cho những service có code xử lý logic nghiệp vụ nhằm kiểm tra những dữ liệu trả ra và các chức năng có chạy đúng không.
- Các class Util chứa các function xác thực, validate hỗ trợ cho các service chính.
- Không test các class Repository, Controller, Constant, Entity, Config…
6. Tại sao cần UnitTest?
Dự án nào cũng cần UnitTest bởi vì:
- Tạo ra môi trường lý tưởng để test mọi đoạn code, thăm dò và phát hiện lỗi chính xác, duy trì sự ổn định của toàn bộ project từ đó sẽ tiết kiệm thời gian.
- Phát hiện các thuật toán thực thi không hiệu quả, sai sót hoặc chạy vượt quá giới hạn thời gian.
- Phát hiện các vấn đề về thiết kế, xử lý hệ thống.
- Phát hiện các lỗi nghiêm trọng có thể xảy ra trong quá trình code xử lý logic của các service.
- Mang lại sự tự tin sau khi hoàn thành công việc.
7. Một UnitTest chuẩn, hợp lệ cần có những yếu tố gì?
- Dễ viết: có thể bao quát được nhiều trường hợp test mà không mất quá nhiều công sức.
- Dễ đọc: mô tả được chính xác dữ liệu và chức năng test.
- Tự động hóa: có thể lặp lại nhiều lần.
- Đồng nhất: kết quả trả về sau mỗi lần chạy là giống nhau.
- Độc lập: có thể thực thi riêng mà không phụ thuộc vào các thành phần khác trong hệ thống.
- Gặp lỗi có thể nhanh chóng xác định được vấn đề đang gặp phải.
- Khi service có sự thay đổi về code, các case test của chúng ta có khả năng sẽ không còn hoạt động đúng nữa, vì thế cũng cần phải sửa đổi, thêm hoặc xóa những test case cho phù hợp.
8. Viết UnitTest có tốn nhiều thời gian không?
- Có lẽ đây là thắc mắc của rất nhiều người khi mới làm quen với UnitTest và đang gặp khó khăn khi viết những dòng code test đầu tiên. Nhiều lập trình viên cho rằng không bắt buộc phải viết UnitTest, tin tưởng vào khả năng lập trình của họ đã rất tốt và những phần mềm họ viết ra sẽ không gặp bất cứ lỗi gì, hoặc cho rằng việc kiểm thử phần mềm đã có tester lo? Tuy nhiên nếu không thực hiện UnitTest, nếu phát sinh lỗi, số lỗi tìm thấy ở giai đoạn sau sẽ càng nhiều, càng phức tạp, sẽ tốn thêm thời gian, công sức và chi phí và có nguy cơ cao bị chậm tiến độ.
- Việc viết UnitTest có thể sẽ chậm trong thời gian đầu khi chúng ta mới làm quen, nhưng sau một thời gian đã hiểu và thành thạo thì mọi thứ sẽ trở nên nhanh chóng và dễ dàng hơn rất nhiều.
- Thông thường UnitTest sẽ chỉ chiếm khoảng 30-35% thời gian so với thời gian code service của chúng ta.
9. Framework viết UnitTest trong Java
Framework hỗ trợ việc viết UnitTest trong Java hiện nay gồm có TestNG và JUnit. Bài viết này sẽ chỉ đề cập đến JUnit và các tính năng cơ bản.
a. Định nghĩa
- Junit là một framework mã nguồn mở, miễn phí, đơn giản dùng để viết UnitTest cho Java. Trong Java chúng ta thường sẽ sử dụng method để làm UnitTest.
b. Các tính năng của JUnit
- Cung cấp các annotation để định nghĩa các phương thức kiểm thử.
- Cung cấp các assertion để kiểm tra các kết quả mong đợi.
- Cung cấp các test runner để thực thi các test script.
- Các test case viết trong Junit có thể chạy tự động và độc lập.
- Test case có thể được tổ chức thành các test suite
- Trả về kết quả test một cách trực quan: pass hoặc fail.
c. Các khái niệm cần biết khi làm quen với Junit
- Unit Test Case: là 1 chuỗi code để đảm bảo rằng đoạn code được test làm việc đúng như mong đợi. Mỗi function sẽ có nhiều test case, mỗi test case ứng với các trường hợp có thể xảy ra khi function chạy.
- Setup: đây là các hàm được chạy trước khi chạy các test case, thường được dùng để chuẩn bị dữ liệu cho các case test.
- Teardown: đây là hàm được chạy sau khi các test case chạy xong, thường để xóa dữ liệu, giải phóng bộ nhớ nếu có.
- Assert: mỗi test case sẽ có một hoặc nhiều câu lệnh assert, để kiểm tra tính đúng đắn của hàm.
- Test Suite: là tập hợp các test case và nó cũng có thể bao gồm nhiều test suite khác, có thể hiểu đây chính là tổ hợp của các test.
- Mock: đây có thể coi là một khái niệm quan trọng nhất, mock được hiểu là một đối tượng ảo, mô phỏng lại 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 code test đang chạy, nhằm kiểm tra tính đúng đắn của các hoạt động bên trong. Giả sử chúng ta có 2 service A và B, trong khi test có chức năng mà service A cần gọi đến service B, nhưng service B đang gặp lỗi hoặc chưa hoàn thành, lúc này chúng ta sẽ dùng đến mock để mô phỏng lại service B mà không cần chờ hoàn thành service chính. Nếu không có mock, công việc test sẽ trở nên khá rắc rối và mất thời gian.
Một số Annotation của Mockito:
@Mock: Khởi tạo một mock object và inject giá trị cho field này. Chúng ta không tạo ra đối tượng thực sự thay vào đó yêu cầu mockito tạo ra một đối tượng giả cho class này, các phương thức của class này chỉ được mô phỏng chứ không được thực thi thật sự, do đó các trạng thái của class chính cũng không bị thay đổi.
@Spy: được sử dụng để wrap một object thật, có thể gọi xử lý thật ở đối tượng này, tuy nhiên chúng ta thường sẽ spy một số phương thức trên các đối tượng đã được mock.
@InjectMock: được sử dụng ở mức field, để đánh dấu các field này cần inject các dependency. Mockito cố gắng inject các giá trị cho các field này thông qua constructor, setter hoặc property injection. Nó sẽ không throw bất kỳ lỗi nào nếu không tìm được injection phù hợp.
d. Coverage
- Đây là một kỹ thuật dùng để xác định xem các trường hợp test có thực sự bao phủ hết được code ứng dụng hay không và bao nhiêu đoạn code đang được kiểm tra khi chạy các case test đó.
- Junit hiện nay hỗ trợ coverage dựa theo số % những phần chính của source code gồm có:
+ Class: % số lớp đã được bao phủ.
+ Method: % số phương thức trong lớp đã được bao phủ.
+ Line: % số dòng đã được bao phủ.
- Dự án thực tế thường có 2 trường hợp:
+ Khách hàng yêu cầu viết UnitTest: chúng ta phải viết các class test sao cho tất cả các test case trong đó có thể kiểm tra và bao phủ đúng đến số % được quy định (có thể là 80-90-100%) do khách hàng.
+ Không yêu cầu: chúng ta vẫn nên viết UnitTest để tự kiểm tra lại các chức năng tránh các lỗi không ngờ đến, tuy nhiên số % coverage có thể linh động theo từng service được viết (>50%).
10. Hướng dẫn
- Đầu tiên chúng ta tạo 1 project spring boot:
- Đặt tên và cấu hình version cho project:
- Tiếp theo, mình tạo một ví dụ đơn giản chức năng CRUD 1 member:
+ Tạo class entity:
+ Tạo class repository:
+ Tạo class service:
+ Tạo class MemberServiceImpl:
+ Tạo class controller:
- Sau khi đã tạo xong 1 project đơn giản với chức năng thêm, sửa, xóa, tìm kiếm, chúng ta thực hiện viết UnitTest cho các chức năng đó. Ở đây mình dùng Junit4 và Mockito có hỗ trợ trong SpringBoot:
+ Mỗi class unit test thường bắt đầu bằng annotation @RunWith(SpringRunner.class)
+ Thực hiện @InjectMock service chính cần test và @Mock với các class mà service chính đang gọi đến (nếu có):
+ Tiếp theo chúng ta sẽ viết các case test theo từng trường hợp đã chuẩn bị, mỗi case cần test sẽ bắt đầu với @Test, có thể chạy độc lập, không ảnh hưởng đến các case còn lại. Ở đây mình đang viết test case cho case tìm kiếm tất cả các member (getAllMember), đầu tiên mình khởi tạo mới một đối tượng member, set data và add vào 1 list, list này chính là data sẽ thực hiện so sánh với data lấy ra từ service chính để kiểm tra xem luồng chạy và chức năng có hoạt động đúng theo yêu cầu hay không. (assertEquals sẽ so sánh 2 dữ liệu lấy được từ service và dữ liệu mà chúng ta đã chuẩn bị).
Tương tự các chức năng còn lại cũng viết theo cấu trúc như vậy.
- Sau đây mình sẽ cho các bạn thấy việc viết Junit trong một dự án thực tế:
+ Service cần test, ở đây mình đã phân tích và chia các case cần phải test trong function này. Như chúng ta đã nói bên trên, 1 function có thể có khá nhiều các case test, tùy theo độ phức tạp được viết ra.
- Mỗi nhánh if có chứa return sẽ trả về các kết quả khác nhau, vì thế đa phần mỗi nhánh đó sẽ được coi là 1 test case.
- Thoạt nhìn trông hơi rắc rối nhưng nếu quan sát kỹ ta sẽ thấy nhanh if thứ 3 không chứa return, có thể chạy xuống 1 trong 2 nhanh if-else bên dưới, vì vậy chúng ta sẽ chia chúng ra làm 2 test case, 1 case kiểm tra nhánh if, case còn lại kiểm tra nhánh else.
- Chúng ta có method bên dưới là getHiroRankList, nó không phải là một test case riêng mà sẽ trở thành điều kiện cần test của case 3 và case 4 đang gọi tới ở bên trên.
- Trong function trên có một vài phương thức được lấy từ các class Repository, đó chính là các phương thức mà chúng ta sẽ mock, để mô phỏng lại và không làm thay đổi class chính.
+ Class test, chúng ta import các package cần thiết, inject mock service chính và mock các class liên quan:
+ Case đầu tiên mình test, sẽ test nhánh if thứ nhất với điều kiện của service chính là kiểm tra các giá trị được get bởi hiroRankFilterDTO == null:
+ Case test của chúng ta sẽ viết:
- Thực hiện khởi tạo HiroSalaryOrRankFilterDTO và khai báo 1 string currentUserName là dữ liệu cần truyền vào.
- Mock memberRepository lấy ra chức năng findMemberManagementByUserNameAndPeriod, ở đây mình để giá trị truyền vào là any (ngoài ra để chính xác hơn chúng ta có anyDate, anyString, anyInt,…), nếu truyền vào 1 giá trị any, mockito sẽ tự hiểu kiểu dữ liệu cần truyền vào và sẽ tìm đúng kiểu để truyền, chúng ta không cần khai báo thêm. Trường hợp mock này chúng ta thenReturn một đối tượng MemberManagement mới theo kiểu cần trả về ở service.
- Sau đó, gọi method ở service, truyền vào giá trị yêu cầu và so sánh, bởi vì kiểu trả về là ResponseEntity nên mình thực hiện so sánh qua HttpStatus.
+ Case thứ hai, sẽ test nhánh if tiếp theo với điều kiện của service chính như sau, ở đây tạm hiểu là các giá trị được get bởi hiroRankFilterDTO phải lấy ra được dữ liệu:
+ Case test của chúng ta sẽ viết:
- Khởi tạo một đối tượng HiroSalaryOrRankFilterDTO và set data cho nó.
- Khởi tạo một đối tượng HiroRank và set data (dữ liệu cần chuẩn bị có thể kéo lên xem lại tại service bên trên).
- Mock hiroRankingRepository lấy ra chức năng findAllByUserIdAndPeriod, mình để giá trị truyền vào là any. Trường hợp mock này chúng ta thenReturn một đối tượng HiroRank đã có data bên trên.
- Sau đó, gọi method ở service, truyền vào giá trị yêu cầu và so sánh, bởi vì kiểu trả về là ResponseEntity nên mình thực hiện so sánh qua HttpStatus.
- Sau khi mock, data test sẽ được truyền tới phương thức trong service:
Các case còn lại tương tự.
+ Sau khi đã viết xong chúng ta, chạy coverage để xem độ bao phủ kiểm tra của test case đối với service là bao nhiêu:
- Chọn class test, nhấn vào biểu tượng coverage cạnh biểu tượng run và debug.
+ Mình đã viết hết tất cả các case nên service sẽ coverage được 100%:
+ Và màn hình code bên service chính của chúng ta sẽ có những đường màu xanh lá ( trạng thái pass) nằm bên trái, cạnh những line code đã được coverage:
Xem tất cả tại: https://gitlab.hivetech.vn/chilk/demojunit
Lý Kim Chi