Unit Testing (Phần 1)
Giới Thiệu
Đặt Vấn Đề
Trong quá trình phát triển sản phẩm, chắc chắn mọi người đều có gặp một số vấn đề nhức nhối sau:
- Phát sinh nhiều bugs. Có đủ cấp độ từ minor đến critical.
- Ngại sửa hoặc refactor những feature đã chạy rồi, do sợ lại “đẻ" thêm bug.
- Khó thêm feature mới.
Những vấn đề này nếu được tích tụ theo từng ngày sẽ có ảnh hưởng rất xấu tới dự án nói riêng và team sản phẩm nói chung. Nó kéo chậm tốc độ phát triển ứng dụng (software entropy) và kéo chất lượng sản phẩm đi xuống. Từ Dev cho đến QC (Tester) đều sẽ cảm thấy “trầm cảm".
Giải Pháp
Để giải quyết vấn đề không hề đơn giản, yêu cầu 1 hệ thống giải pháp chặt chẽ, từ kiến trúc, thiết kế hệ thống cho đến kiến trúc trúc của codebase. Thậm chí, để triển khai hệ thống giải pháp một cách hiệu quả, nó sẽ phụ thuộc vào kinh nghiệm của những người thực hiện. Ngoài ra, để đảm bảo chất lượng sản phẩm, không thể thiếu những bộ kiểm thử chất lượng (test suite) của QC và Dev.
Giới Thiệu Chuỗi Bài Viết
Mục đích mình thực hiện series này là mong muốn nâng cấp chất lượng code của Dev Việt thông qua việc hiểu đúng và đủ tư tưởng của Unit Test, Integration Test. Hơn nữa, series này sẽ trình bày cách viết Unit Test, Integration Test một cách hiệu quả và có giá trị cho dự án.
Chuỗi bài viết gồm 3 phần:
- Phần 1: Cung cấp những kiến thức nền tảng
- Trình bày những khái niệm và tư tưởng của Unit Test
- Hiểu đúng về các chỉ số bao phủ (Coverage Metrics)
- Viết Unit Test cho code loại nào? Integration Test cho code loại nào?
- Phần 2: Mock & Kiến trúc codebase
- Dependency
- Phân biệt Mock vs Stub
- Kiến trúc codebase để thuận tiện cho việc mở rộng và việc viết test
- Functional Architect
- Hexagonal Architect
- Phần 3: Thực hành viết Unit Test, Integration Test hiệu quả và có giá trị
- 3 kiểu viết unit tests
- Cấu trúc và những kỹ thuật (best practices), sai lầm (anti-patterns) khi viết unit test
- Kỹ thuật viết Integration Test
Nếu bạn có đã biết về những khái niệm và kỹ thuật sẽ được trình bày thì mình hy vọng series này sẽ đem lại cho bạn một góc hình khác hoặc làm rõ những hiểu nhầm. Còn nếu bạn chưa có kinh nghiệm trong việc viết unit test thì bạn sẽ học được rất nhiều sau series này.
Phạm Vi
- Testing (kiểm thử) là một topic rất rộng. Series này mình chỉ tập trung vào những kiến thức nền tảng để phục vụ Unit Test, Integration Test.
- Unit Test có 2 trường phái là trường phái cổ điển (classical) và trường phái Luân Đôn (London). Series này mình viết theo trường phái cổ điển, bởi vì mình thấy trường phái London bộc lộ những điểm yếu trong tư tưởng và trong thực tiễn.
- Java là ngôn ngữ mình lấy làm ví dụ xuyên suốt trong series. Cách thực hiện sẽ tương tự đối với các ngôn ngữ khác nếu cùng chung 1 tư tưởng.
- Nếu bạn có góp ý về nội dung, vui lòng bình luận ở bên dưới giúp mình 👇 Mình rất vui khi nhận được góp ý của mọi người 🙏
Chồn Đất (Meerkats) là động vật có vú, thân hình nhỏ. Chồn đất sống đầy đàn, chủ yếu ở hoang mạc ở Châu Phi. Chúng nổi tiếng với sự thận trọng cao do sống trong môi trường luôn ẩn chứa nhiều mối nguy hiểm. Thậm chí, khi ngủ chúng nằm chồng lên nhau, một tai luôn vểnh lên để nghe ngóng động tĩnh xung quanh. Khi đi kiếm ăn, sẽ có những con “lính gác" đứng im lặng, quan sát. Lính gác sẽ luân phiên nhau trực. Nếu có kẻ thù xuất hiện, chúng sẽ la hét ầm ĩ để báo hiệu cả đàn chạy trốn. Chồn đất có thói quen phơi nắng và chải chuốt giúp cơ thể của chúng luôn ấm, tỉnh táo và thận trọng hơn.
Mục Đích của Unit Test
Trên thực tế, để viết được test suite hoàn chỉnh thì tỉ lệ giữ production code và test code nằm trong khoảng từ 1:1 tới 1:3. Đa số dev nhìn vào tỉ lệ này và cảm thấy e ngại khi phải viết unit test. Bản thân mình cũng có suy nghĩ này, viết một cách đối phó, thậm chí là không viết 😅 Việc viết một cách đối phó có thể phản tác dụng và không mang đúng ý nghĩa của Unit Test.
Do đó, để vượt qua rào cản tâm lý này, chúng ta phải nhận thức được đẩy đủ tầm quan trọng của Unit Test. Vậy mục đích của việc viết Unit Test là gì?
Unit Test giúp dự án phần mềm phát triển một cách bền vững.
Mình xin nhấn mạnh từ bền vững vì để phát triển 1 ứng dụng từ đầu, từ zero thì không quá khó khăn. Nhưng để duy trì sự phát triển một cách bền vững lại rất khó. Cái rào cản ngăn chúng ta không viết Unit Test, đó chính là công sức (effort). Nhưng đổi lại, effort cho cho việc viết test sẽ nâng cao chất lượng phần mềm.
Ngoài ra, viết test sẽ giúp level-up khả năng code của dev. Vì trong 4 Đặc điểm của clean code (readable, maintainable, scalable, testable) có khả năng kiểm thử (testable). Bạn viết code làm sao để dễ dàng viết test. Test code có mối quan hệ chặt chẽ với production mình sẽ trình bày trong các phần sau.
Unit Test là gì?
Định Nghĩa
Unit test là test được tự động hoá có 3 thuộc tính quan trọng sau:
- Xác minh (test) một đoạn code nhỏ (unit). Đoạn code thể hiện một hành vi có thể quan sát được (Observable Behavior) từ phía người dùng (client).
- Được thực hiện nhanh chóng. Nếu test đoạn code nhỏ mà chậm thì chắc chắn có vấn đề? Liệu đoạn code đó có thực sự nhỏ? Nhỏ về số lượng dòng code được thực thi, nhỏ về logic xử lý hay có gọi ra hệ thống ngoài (thuộc loại test khác - integration test)
- Được thực hiện độc lập giữa các unit test khác nhau. Việc này giúp có thể xử lý song song nhiều unit tests cùng lúc, tăng tốc độ thực hiện toàn bộ test suite.
Nếu bạn thấy định nghĩa trên còn trừu tượng, đừng lo! Mình sẽ trình bày rõ hơn ở các phần còn lại.
Độ Chính Xác
Ví dụ về thử nghiệm bộ kit test covid mới:
- Kit test là test suite
- Tình trạng sức khoẻ của X là code unit
- Kit báo dương tính là positive
- Kit báo âm tính là là negative
- Nếu kit báo positive và X mắc covid thật, ta gọi là true positive
- Nếu kit báo negative và X không mắc covid, ta gọi là true negative
- Nếu kit báo positive và X không mắc covid, ta gọi là false positive
- Nếu kit báo negative và X mắc covid, ta gọi là false negative
Ta đánh giá độ chính xác của bộ kit covid mới qua xác suất xảy ra 2 ở case false positive và false negative. Xác suất này càng thấp, tức là bộ kit test càng chính xác.
Ta có công thức tính độ chính xác như sau:
Đặc điểm của một Unit Test tốt
Một unit test tốt sẽ có 4 đặc điểm sau:
- Phản hồi nhanh
- Khả năng bảo trì
- Ngăn ngừa bugs (regressions)
- Kháng cự khi tái cấu trúc (refactoring)
Mình sẽ giải thích từng đặc điểm từ đơn giản đến phức tạp. Đầu tiên là phản hồi nhanh. Tương tự thuộc tính của unit tests, chạy tests càng nhanh thì bạn có thể chạy test liên tục, càng sớm phát hiện bug. Chúng ta đều biết, fix bug ngay sau thời điểm code sẽ tốn ít công sức hơn so với việc fix bug sau một thời gian động lại đoạn code đó.
Tiếp theo là khả năng bảo trì được đánh giá qua 2 khía cạnh:
- Có khó để đọc hiểu test? Code test quan trọng không kém code production. Viết làm sao cho dễ đọc, dễ sửa.
- Có khó để chạy test? Khi làm việc với hệ thống ngoài, việc mô phỏng hệ thống ngoài có dễ dàng để thuận tiện cho việc test.
Khi codebase ngày càng phình to, trở nên cồng kềnh dẫn đến khả năng cao phát sinh ra bugs. Tính ngăn ngừa bugs rất quan trọng trong trường hợp này. Để đánh giá đặc điểm này, ta dựa vào:
- Lượng production code được thực thi khi chạy test
- Độ phức tạp của đoạn code
Cuối cùng, cũng là đặc điểm quan trọng nhất, đó là khả năng kháng cự khi tái cấu trúc (refactoring). Nôm na là unit test vẫn hoạt động đúng trước và sau khi refactoring. Chúng ta đều biết refactor code là việc làm cần thiết và thường xuyên. Nếu unit chạy không đúng sau khi refactor, chúng ta sẽ không đảm bảo việc refactor đã đúng hay chưa. Để hiểu rõ hơn, chúng ta quay lại tìm hiểu 2 phương pháp kiểm thử cơ bản: Black Box testing vs White Box testing.
Black Box testing vs White Box testing
- Black Box testing là phương pháp kiểm thử phần mềm tập trung vào đầu vào và đầu ra của chức năng, không quan tâm đến cách thức hoạt động bên trong. Nghĩa là tester không biết cấu trúc bên trong hoặc không xem mã nguồn.
- White Box testing là phương pháp kiểm thử phần mềm tập trung vào cấu trúc bên trong. Nghĩa là tester có thể xem mã nguồn để biết được cấu trúc và cách thức hợp động của chức năng.
Ví dụ, ta có đoạn code build một con robot như sau:
public interface Component {
String build();
}
public class Header implements Component {
@Override
public String build() {
return "Header";
}
}
public class Body implements Component {
@Override
public String build() {
return "Arms Chest";
}
}
public class Feet implements Component {
@Override
public String build() {
return "Feet";
}
}
public class RobotBuilder {
public List<Component> components;
public RobotBuilder() {
components = new ArrayList<>();
components.add(new Header());
components.add(new Body());
components.add(new Feet());
}
public String build() {
String robot = "";
for (Component component: components) {
robot = robot + component.build() + " ";
}
return robot;
}
}
Nếu test theo phương pháp White box, ta có test code như sau:
@Test
public void testBuild() {
RobotBuilder robotBuilder = new RobotBuilder();
Assertions.assertEquals(3, robotBuilder.components.size());
Assertions.assertTrue(robotBuilder.components.get(0) instanceof Header);
Assertions.assertTrue(robotBuilder.components.get(1) instanceof Body);
Assertions.assertTrue(robotBuilder.components.get(2) instanceof Feet);
}
Unit test này kiểm tra số lượng component của con robot và thứ tự của các component, rất chi tiết. Nếu logic test của ta kiểm tra các thực thi chi tiết của chức năng (implement details) sẽ phát sinh ra 1 vấn đề. Giả sử, chúng ta refactor code, tách Body component ra làm 2 component là Arms và Chest thì unit test trên sẽ không pass mặc dù code của ta vẫn chạy đúng. Trường hợp này ta gọi là false positive.
Để khắc phục vấn đề này, unit test nên tập trung vào input và output của chức năng. Ta có thể viết lại unit test theo phương pháp Black Box như sau:
@Test
public void testBuild1() {
RobotBuilder robotBuilder = new RobotBuilder();
String expected = "Header Arms Chest Feet ";
Assertions.assertEquals(expected, robotBuilder.build());
}
Như sau khi refactor, unit test của ta vẫn chạy đúng và thể hiện tính kháng cự khi refactor. Đây cũng là lý do tại sao Black Box testing được xử lý rộng rãi hơn so với White Box testing.
Độ bao phủ (Coverage Metrics)
Phần này chúng ta sẽ tìm hiểu về những chỉ số unit test thường gặp và vấn đề của những chỉ số này.
Code Coverage
Chỉ số đầu tiên, cũng là chỉ số được sử dụng nhiều nhất, đó là Code Coverage, tạm dịch là độ phủ dòng. Được tính bằng tỉ lệ số dòng code được thực thi khi chạy test trên tổng số dòng code.
Ví dụ, ta có đoạn code sau:
public static boolean isLongString(String input) {
if (input.length() > 5) {
return true;
}
return false;
}
Unit test cho đoạn code trên:
@Test
public void testLongString() {
boolean result = StringUtil.isLongString("abcd");
Assertions.assertTrue(result);
}
Với unit test này, có 4 dòng code được thực thi (trừ dòng return true;). Như vậy, Code coverage = ⅘ = 0.8 = 80%.
Ta thử refactor lại code 1 chút xem sao:
public static boolean isLongString(String input) {
return input.length() > 5;
}
Ta rút gọn code từ 5 dòng thành 2 dòng nhưng ý nghĩa của đoạn code không đổi. Mặt khác, unit test cũng không đổi, nhưng ta lại có Code Coverage thay đổi = 2/2 = 1 = 100%. Do đó, Code Coverage sẽ gặp vấn đề với code rút gọn.
Branch Coverage
Một chỉ số nữa đó là Branch Coverage, tạm dịch là độ phủ nhánh. Branch coverage đem lại kết quả đánh giá chính xác hơn so với Code Coverage bởi vì nó xử lý được vấn đề code rút gọn của Code Coverage. Thay vì, tính trên số dòng code, Branch Coverage được tính bằng tỉ lệ của số nhánh được duyệt khi test trên tổ số nhánh của đoạn code.
Quay lại ví dụ ở trên, bộ test chỉ phủ 1 nhánh trong 2 nhánh của code, nghĩa là Branch Coverage = ½ = 0.5 = 50%.
Vấn đề Của Các Chỉ Số Độ Bao Phủ
Mặc dù, Branch Coverage đem lại kết quả tốt hơn so với Code Coverage do bắt được nhiều case hơn. Thế nhưng ta không thể chỉ dựa vào các chỉ số này để đánh giá chất lượng của bộ kiểm thử (test suite) vì 2 lý do.
Lý do đầu tiên, Test suite không đảm bảo kiểm tra hết đầu ra (outcomes) của đoạn code.
Ví dụ, ta sửa lại class StringUtil một chút như sau:
public class StringUtil {
public static boolean wasLastStringLong;
public static boolean isLongString(String input) {
boolean result = input.length() > 5;
wasLastStringLong = result;
return result;
}
}
Với unit test thì các chỉ số độ phủ đề không đổi (Code Coverage = 100%, Branch Code = 50%). Đầu ra lúc này không còn chỉ mỗi biến result mà còn biến wasLastStringLong. Thậm chí, ta có đẩy Branch Code lên 100% nhưng không check hết outcomes thì bộ test của ta cũng không thực sự chất lượng.
Lý do thứ hai là không chỉ số nào có thể kiểm tra code path của các thư viện ngoài.
Ví dụ, ta sử dụng hàm Integer.parseInt(input) trong logic thực thi bên trong có rất nhiều nhánh trong đó như check null, check độ dài tối thiểu, tối đa, … Thế nhưng khi chạy test, nó chỉ đếm 1 dòng code được thực thi. Unit test sẽ khó bắt được hết edge case.
Do đó, chỉ dựa vào chỉ số bao phủ (coverage metrics) để đánh giá chất lượng của test suite là chưa đủ. Cách tốt nhất coi chỉ số bao phủ là chỉ báo, không phải mục đích mà ta hướng tới.
Ví dụ về bệnh nhân trong bệnh viện. Nhiệt độ cơ thể ở trạng thái bình thường là 37 độ C, nhiệt kế của bệnh nhân báo 39 độ C, ám chỉ bệnh nhân đang sốt, chưa rõ nguyên nhân. Bác sĩ không thể đặt cái mốc 37 độ C làm đích cho bệnh nhân và làm mọi cách để hạ nhiệt độ cho bệnh nhân. Ví dụ như việc lắp điều hoà cạnh bệnh nhân, để luồng gió lạnh thổi vào da bệnh nhân. Có thể, cách này hạ được nhiệt độ của bệnh nhân nhưng mà là dưới 37 độ 🤪 Tất nhiên, cách này không giải quyết được vấn đề.
Tóm lại, độ bao phủ (coverage metrics) là bộ chỉ số tiêu cực chính xác (good negative indicator) nhưng lại là bộ chỉ số tích cực không chính xác (bad positive indicator). Ví dụ, ta có ngưỡng code coverage là 60%. Nếu coverage dưới ngưỡng này, nó ám chỉ chất lượng test suite, production code không tốt. Còn nếu coverage cao hơn ngưỡng này cũng không phản ánh test suite đạt chất lượng cao. Tuỳ thuộc vào độ phức tạp của dự án mà ta chọn 1 ngưỡng coverage cho phù hợp và không nên tạo tâm lý áp đạt cho cả team đạt được ngưỡng đó.
Giả sử, ta chọn ngưỡng code coverage là 60%. Vậy 60% này được test cho phần code nào? Phần tiếp theo sẽ giúp bạn trả lời câu hỏi này.
Bộ Kiểm Thử Nên Tập Trung Vào Phần Code Nào?
4 Loại Code
Đã bao giờ bạn tự hỏi, code của bạn được chia làm mấy loại chưa?
Để chúng ta cùng 1 hệ quy chiếu, code có thể được phân loại theo 2 chiều về:
- Độ phức tạp (Complexity) hoặc Tầm quan trọng của nghiệp vụ (Domain Significance)
- Số lượng sự phụ thuộc (Collaborators)
Kết hợp 2 chiều này, ta có 4 loại code như sau:
- Domain model, algorithms: là những đoạn code liên quan quan tới logic nghiệp vụ hoặc có logic phức tạp như thuật toán. Những đoạn code phức tạp (Complex code) thường là 1 phần của code nghiệp vụ nhưng không phải 100% vì có thể có một số thuật toán phức tạp không liên quan tới nghiệp vụ. Ví dụ: Logic tính số tiền được giảm giá trên tổng giá trị đơn hàng.
- Trivial Code: là đoạn code đơn giản, không ít hoặc không có sự phụ thuộc (collaborators). Ví dụ: đoạn code utility để cắt string
- Controllers: là những đoạn code chứa ít logic nghiệp vụ nhưng lại có nhiều sự phụ thuộc (collaborators), có nhiệm vụ điều hướng xử lý tới các service tương ứng. Ví dụ: trong nhiều Web Framework, có layer Controller để điều hướng xử lý tới các service domain tương ứng với request.
- Overcomplicated Code: là đoạn code có cả độ phức tạp cao và số lượng lớn sự phụ thuộc (collaborator). Nếu không có kỹ thuật xử lý khéo léo về tổ chức code thì rất dễ dẫn đến loại code này. Ví dụ: Chức năng xuất báo cáo của các kho hàng khu vực miền bắc. Bạn phải thu thập rất nhiều dữ liệu từ danh sách kho, danh mục hàng hoá đến số lượng tồn kho, tính phần trăm, … logic rất phức tạp.
Unit Test Cho Loại Code Nào?
Unit test tập trung vào phần code domain, algorithms sẽ đem lại nhiều giá trị và đơn giản, nhanh. Nó đem lại nhiều giá trị vì phần code domain, algorithm chứa logic quan trọng và phức tạp, do đó nó giúp tăng khả năng ngăn ngừa bugs. Còn nó đơn giản, nhanh bởi vì phần code này có ít sự phụ thuộc, không tốn công maintain và giả lập sự phụ thuộc khi test.
Integration Test Cho Loại Code Nào?
Trên thực tế, Integration Test thường dùng để kiểm tra việc tích hợp giữa hệ thống của bạn với các hệ thống ngoài có chạy đúng không. Nói cách khác, integration test dùng để kiểm tra phần code controller kết nối phần code domain với sự phụ thuộc bên ngoài (out-of-process dependency).
Các Loại Code Còn Lại Thì Sao?
Việc test Trivial Code là không cần thiết vì nó không đem lại nhiều giá trị vì nó đi sâu vào chi tiết, làm giảm kháng cự khi refactor. Cuối cùng, vấn đề nằm ở phần Overcomplicated Code, đây cũng là phần code khiến dev ngại viết test. Nhưng cũng rất nguy hiểm nếu không viết test. Ý tưởng để giải quyết vấn đề này là chúng ta cần refactor code làm tách Overcomplicated Code ra làm 2 phần rõ ràng là Domain Model và Controller. Điều này thể hiện mối quan hệ 2 chiều giữa production code với test code. Đối với kỹ thuật refactor sẽ được đề cập ở phần 3. Túm lại, ta cần quan tâm và viết test cho Overcomplicated Code.
Test Pyramid
Ta cần unit test kỹ phần domain code với số lượng nhiều để tối ưu giá trị nó đem lại và bổ trợ cho integration test.
Integration test có 1 hạn chế đó là nó làm việc các hệ thống ngoài nên sẽ mất công để giả lập sự phụ thuộc (mock, stub) và có feedback chậm. Do đó, ta nên chỉ test happy cases và case mà unit test không bắt được.
Càng lên cao, khả năng ngừ bug và tính kháng cự khi refactor càng tăng, nhưng tốc độ feedback lại chậm.
Lưu Ý
Như mình có đề cập ở đầu bài viết, có sự đánh đổi (trade-off) giữa việc viết unit test và công sức bỏ ra (effort). Đối với những dự án đơn giản hoặc mang tính chất PoC (Proof of Concept) có yêu cầu về deadline gấp hoặc các trường hợp tương tự, bạn nên cân nhắc việc có nên viết unit hay không hay code coverage bao nhiêu là phù hợp. Còn lại, đối với những dự án mang tính chất dài hơi, bạn nên viết unit test ngay từ ban đầu.
Các chỉ số về độ bao phủ mình đề cập ở bài viết này là tính trên toàn bộ codebase. Đối với một số tool chạy test, bạn có thể cấu hình loại bỏ (exclude) phần code Trivial, thậm chí chỉ để lại phần domain code. Như vậy, độ bao phủ chỉ tính trên domain code, do đó ta cần cấu hình ngưỡng của quality gate lên cao để đảm bảo chất lượng code.
Tổng Kết
Hy vọng sau phần đầu tiên của series Unit Test, bạn nắm được tư tưởng viết Unit Test.
- 4 Đặc điểm của Unit Test là gì? Đặc điểm nào quan trọng nhất?
- Có phải Code Coverage càng cao càng tốt không? Tại sao?
- Unit Test nên tập trung vào phần code nào? Tại sao?
Hẹn gặp lại các bạn ở phần 2 nhé.
Cám ơn bạn rất nhiều vì đã đọc tới đây 🙏
📚️ Ronin Engineer: https://ronin-engineer.github.io/register-post
🏢 System Design VN: https://fb.com/groups/systemdesign.vn
Tham Khảo
- Book: Unit Testing - Author: Vladimir Khorikov. Một cuốn sách hay. Ông tác giả viết sách này dành cho vợ ông. Hình vẽ trên bìa sách là vợ của ông 🫶
⚠️ Nhóm mình đang tuyển writer và graphic designer nếu anh em có hứng thú thì comment ở dưới giúp mình nhé. Khi tham gia anh em có quyền lợi sau:
- Trau dồi kỹ năng viết, kỹ năng chuyên môn
- Được chia sẻ tài liệu nghiên cứu
- Kết nối, học hỏi từ anh em trong nhóm
All rights reserved