Unit Testing – Kiểm thử đơn vị trong lập trình

1. Tổng quan về Unit Testing

Unit Testing (kiểm thử đơn vị) là một kỹ thuật kiểm thử phần mềm nhằm xác minh tính chính xác của những đơn vị nhỏ nhất trong chương trình, chẳng hạn như một hàm, một phương thức hoặc một thành phần xử lý logic độc lập.

Mục tiêu của Unit Testing là kiểm tra xem một đơn vị mã nguồn có hoạt động đúng theo yêu cầu thiết kế hay không. Thay vì chạy toàn bộ ứng dụng rồi kiểm tra kết quả cuối cùng, lập trình viên sẽ kiểm tra từng phần nhỏ để nhanh chóng phát hiện lỗi và xác định chính xác vị trí cần sửa.

Ví dụ, trong một ứng dụng bán hàng, hệ thống có thể chứa nhiều chức năng:

  • Tính tổng giá trị đơn hàng.

  • Tính số tiền giảm giá.

  • Kiểm tra thông tin đăng nhập.

  • Xác thực dữ liệu người dùng.

  • Tính phí vận chuyển.

  • Kiểm tra số lượng sản phẩm còn trong kho.

Thay vì chỉ chạy ứng dụng và đặt hàng thử nghiệm, lập trình viên có thể viết các bài kiểm thử riêng cho từng chức năng. Nếu hàm tính giảm giá trả về kết quả sai, bài kiểm thử tương ứng sẽ thất bại và chỉ ra vấn đề ngay tại chức năng đó.

Unit Testing thường được thực hiện trong quá trình phát triển phần mềm và có thể được tự động hóa để chạy mỗi khi mã nguồn thay đổi.

2. Vai trò của Unit Testing trong quy trình phát triển phần mềm

Unit Testing là một phần quan trọng trong quy trình đảm bảo chất lượng phần mềm. Hoạt động này giúp lập trình viên xác minh tính đúng đắn của mã nguồn trước khi tích hợp các thành phần thành một hệ thống hoàn chỉnh.

2.1. Phát hiện lỗi ở giai đoạn sớm

Lỗi phần mềm có thể xuất hiện từ những phép tính đơn giản, điều kiện xử lý sai hoặc trường hợp dữ liệu không được dự đoán trước.

Nếu không có kiểm thử, những lỗi này có thể lan sang các thành phần khác và khiến việc tìm nguyên nhân trở nên khó khăn hơn.

Unit Testing giúp phát hiện lỗi ngay tại đơn vị đang được phát triển. Chẳng hạn, khi một hàm tính tổng đơn hàng xử lý sai trường hợp giảm giá bằng 0, bài kiểm thử có thể phát hiện vấn đề trước khi chức năng thanh toán được tích hợp.

2.2. Giảm chi phí sửa lỗi

Chi phí sửa lỗi thường tăng khi lỗi được phát hiện muộn. Một lỗi trong hàm xử lý dữ liệu có thể gây ảnh hưởng đến API, giao diện người dùng, cơ sở dữ liệu và những quy trình nghiệp vụ khác.

Unit Testing giúp giới hạn phạm vi kiểm tra, nhờ đó lập trình viên có thể xác định nguyên nhân gần với vị trí phát sinh lỗi nhất.

2.3. Ngăn lỗi tái xuất hiện

Trong quá trình bảo trì, một thay đổi nhỏ có thể vô tình làm hỏng chức năng đã hoạt động đúng trước đó. Hiện tượng này được gọi là Regression (hồi quy).

Khi có bộ Unit Testing phù hợp, lập trình viên có thể chạy lại những bài kiểm thử cũ sau mỗi lần sửa đổi mã nguồn. Nếu chức năng bị ảnh hưởng, bài kiểm thử sẽ cảnh báo sớm.

2.4. Hỗ trợ tái cấu trúc mã nguồn

Refactoring là quá trình cải thiện cấu trúc bên trong của mã nguồn mà không làm thay đổi hành vi bên ngoài được mong đợi.

Ví dụ, lập trình viên muốn tách một hàm lớn thành nhiều hàm nhỏ để dễ bảo trì hơn. Nếu đã có các bài Unit Testing bao phủ những trường hợp quan trọng, họ có thể kiểm tra xem việc thay đổi cấu trúc có làm sai kết quả xử lý hay không.

2.5. Cải thiện chất lượng thiết kế

Khi viết Unit Testing, lập trình viên thường phải suy nghĩ rõ ràng về đầu vào, đầu ra, điều kiện hợp lệ và những trường hợp ngoại lệ của từng hàm.

Quá trình này khuyến khích việc xây dựng các thành phần có trách nhiệm rõ ràng, ít phụ thuộc không cần thiết và dễ kiểm tra độc lập.

3. Khái niệm Unit trong Unit Testing

Unit là một đơn vị mã nguồn được lựa chọn để kiểm thử. Tùy thuộc vào ngôn ngữ lập trình và kiến trúc ứng dụng, đơn vị này có thể là một hàm, phương thức hoặc một thành phần xử lý logic.

Ví dụ, với một hệ thống quản lý sinh viên, các đơn vị có thể bao gồm:

  • calculateGPA(): tính điểm trung bình.

  • validateEmail(): kiểm tra định dạng email.

  • calculateTuition(): tính học phí.

  • isEligibleForScholarship(): kiểm tra điều kiện nhận học bổng.

  • calculateAttendanceRate(): tính tỷ lệ chuyên cần.

Mỗi đơn vị có thể được kiểm thử với nhiều bộ dữ liệu khác nhau.

Ví dụ, hàm calculateGPA() không chỉ cần kiểm tra một trường hợp điểm bình thường mà còn cần xem xét dữ liệu rỗng, danh sách điểm có giá trị không hợp lệ và những trường hợp đặc biệt theo quy tắc nghiệp vụ.

Một đơn vị được kiểm thử tốt cần có hành vi tương đối rõ ràng. Khi bài kiểm thử thất bại, lập trình viên nên có đủ thông tin để xác định đơn vị nào hoạt động sai và kết quả nào không đáp ứng mong đợi.

Tuy nhiên, khái niệm “đơn vị nhỏ nhất” không phải lúc nào cũng có một định nghĩa tuyệt đối. Một nhóm hàm nhỏ có thể được kiểm thử cùng nhau nếu chúng tạo thành một đơn vị logic hợp lý, miễn là phạm vi kiểm thử vẫn được kiểm soát.

4. Nguyên lý hoạt động của Unit Testing

Một bài Unit Testing thường có ba thành phần cơ bản: đầu vào, hành động cần kiểm tra và kết quả mong đợi.

Quy trình tổng quát:

1. Chuẩn bị dữ liệu

Thiết lập đầu vào và điều kiện kiểm thử

2. Thực thi đơn vị mã nguồn

Gọi hàm hoặc phương thức cần kiểm tra

3. So sánh kết quả

Đối chiếu kết quả thực tế với kết quả mong đợi

4. Đưa ra kết quả kiểm thử

Passed nếu đạt yêu cầu, Failed nếu không đạt

Ví dụ, cần kiểm tra một hàm cộng hai số nguyên.

  • Đầu vào: a = 5, b = 3.

  • Hành động: gọi hàm add(5, 3).

  • Kết quả mong đợi: 8.

  • Kết quả thực tế: 8.

  • Trạng thái: Passed.

Nếu hàm trả về 7, bài kiểm thử sẽ thất bại vì kết quả thực tế không khớp với kết quả mong đợi.

Điểm quan trọng là Unit Testing không chỉ chạy thử chương trình mà còn tự động xác nhận kết quả bằng những điều kiện kiểm tra cụ thể.

5. Mô hình AAA trong Unit Testing

AAA là mô hình tổ chức bài kiểm thử phổ biến, gồm ba giai đoạn: Arrange, Act và Assert.

5.1. Arrange – Chuẩn bị

Arrange là bước chuẩn bị dữ liệu đầu vào, tạo đối tượng cần kiểm thử và thiết lập những điều kiện cần thiết.

Ví dụ, nếu cần kiểm tra chức năng tính giảm giá, bước này sẽ xác định giá gốc và tỷ lệ giảm giá.

5.2. Act – Thực thi

Act là bước gọi hàm hoặc phương thức đang được kiểm thử.

Ví dụ, gọi calculateDiscount(100000, 10) để tính mức giảm giá 10% trên số tiền 100.000 đồng.

5.3. Assert – Xác nhận

Assert là bước so sánh kết quả thực tế với kết quả mong đợi.

Trong ví dụ trên, mức giảm giá mong đợi là 10.000 đồng. Nếu hàm trả về đúng giá trị này, bài kiểm thử vượt qua.

Ví dụ bằng JavaScript:

function calculateDiscount(price, percent) {
  return price * percent / 100;
}

test("tính giảm giá 10% trên 100000 đồng", () => {
  // Arrange
  const price = 100000;
  const percent = 10;

  // Act
  const result = calculateDiscount(price, percent);

  // Assert
  expect(result).toBe(10000);
});

Trong đoạn mã trên, test() định nghĩa một bài kiểm thử, còn expect() và toBe() được dùng để xác nhận kết quả. Đây là cú pháp thường gặp trong Jest và Vitest.

Mô hình AAA giúp bài kiểm thử dễ đọc, dễ hiểu và thuận tiện cho việc bảo trì.

6. Các thành phần cơ bản của một bài Unit Testing

6.1. Test Case

Test Case (trường hợp kiểm thử) là một tình huống cụ thể được xây dựng để xác minh hành vi của đơn vị mã nguồn.

Một Test Case thường xác định dữ liệu đầu vào, điều kiện thực hiện, kết quả mong đợi và kết quả thực tế.

Ví dụ, với chức năng kiểm tra mật khẩu:

  • Mật khẩu hợp lệ.

  • Mật khẩu quá ngắn.

  • Mật khẩu rỗng.

  • Mật khẩu không đáp ứng yêu cầu về ký tự.

  • Giá trị mật khẩu không đúng kiểu dữ liệu được hỗ trợ.

Mỗi tình huống có thể được xây dựng thành một bài kiểm thử riêng.

6.2. Test Suite

Test Suite là tập hợp nhiều bài kiểm thử có liên quan với nhau.

Ví dụ, một Test Suite dành cho chức năng đăng ký tài khoản có thể chứa các bài kiểm thử email hợp lệ, email không hợp lệ, mật khẩu yếu và dữ liệu bắt buộc bị thiếu.

Việc nhóm các bài kiểm thử thành Suite giúp tổ chức mã nguồn tốt hơn và cho phép chạy kiểm thử theo từng chức năng.

6.3. Assertion

Assertion là điều kiện xác nhận kết quả của bài kiểm thử.

Một số dạng Assertion phổ biến:

  • Kiểm tra hai giá trị có bằng nhau hay không.

  • Kiểm tra một giá trị là true hoặc false.

  • Kiểm tra kết quả có phải null hay không.

  • Kiểm tra danh sách có chứa phần tử cụ thể.

  • Kiểm tra hàm có phát sinh ngoại lệ hay không.

  • Kiểm tra một phương thức được gọi với tham số mong đợi.

Assertion là thành phần quan trọng vì nó xác định tiêu chí để kết luận bài kiểm thử thành công hay thất bại.

6.4. Test Runner

Test Runner là công cụ thực thi các bài kiểm thử, thu thập kết quả và báo cáo những trường hợp thành công hoặc thất bại.

Một số công cụ phổ biến gồm Jest, Vitest, JUnit, pytest và NUnit.

6.5. Test Fixture

Test Fixture là dữ liệu hoặc trạng thái cần thiết để thực hiện bài kiểm thử. Nó có thể bao gồm đối tượng mẫu, cấu hình, dữ liệu giả lập và những bước thiết lập ban đầu.

Ví dụ, khi kiểm thử một phương thức tính tổng giỏ hàng, Test Fixture có thể tạo sẵn một giỏ hàng chứa ba sản phẩm với giá trị xác định.

Fixture giúp các bài kiểm thử sử dụng dữ liệu nhất quán, đồng thời giảm việc lặp lại các bước chuẩn bị.

7. Đặc điểm của một bài Unit Testing chất lượng

Một bài Unit Testing tốt cần đáp ứng nhiều tiêu chí để có thể sử dụng lâu dài trong dự án.

7.1. Tính độc lập

Các bài kiểm thử nên hoạt động độc lập, không phụ thuộc vào thứ tự thực thi hoặc kết quả của những bài kiểm thử khác.

Nếu một bài kiểm thử làm thay đổi trạng thái dùng chung khiến bài khác thất bại, bộ kiểm thử sẽ khó duy trì và kết quả trở nên thiếu tin cậy.

7.2. Tính lặp lại

Cùng một bài kiểm thử, với cùng điều kiện đầu vào và môi trường phù hợp, nên cho kết quả nhất quán.

Bài kiểm thử không nên phụ thuộc tùy tiện vào thời gian thực, mạng Internet, dữ liệu bên ngoài hoặc trạng thái không được kiểm soát.

7.3. Tính tự động

Bài kiểm thử nên có thể chạy bằng công cụ mà không cần người dùng thao tác thủ công trong từng lần thực thi.

Điều này giúp tích hợp kiểm thử vào quy trình phát triển và kiểm tra mã nguồn thường xuyên.

7.4. Tính dễ đọc

Tên bài kiểm thử cần thể hiện rõ hành vi đang được kiểm tra và kết quả mong đợi.

Ví dụ:

test("trả về 0 khi giỏ hàng không có sản phẩm", () => {
  expect(calculateTotal([])).toBe(0);
});

Tên bài kiểm thử cho biết cả tình huống lẫn hành vi mong đợi, giúp người đọc hiểu mục đích mà không phải phân tích toàn bộ phần triển khai.

7.5. Tính nhanh

Unit Testing thường được thiết kế để thực thi nhanh vì có thể chứa hàng trăm hoặc hàng nghìn bài kiểm thử.

Nếu một bài kiểm thử đơn giản phải chờ kết nối cơ sở dữ liệu hoặc gọi API thật, tốc độ của bộ kiểm thử có thể giảm đáng kể.

Vì vậy, các phụ thuộc bên ngoài thường được thay thế bằng những thành phần giả lập khi phù hợp.

7.6. Tính ổn định

Bài kiểm thử không nên thường xuyên thất bại do những yếu tố ngẫu nhiên không liên quan đến logic đang kiểm tra.

Những bài kiểm thử không ổn định được gọi là Flaky Tests. Chúng làm giảm niềm tin vào hệ thống kiểm thử và khiến lập trình viên khó phân biệt lỗi thật với lỗi do môi trường.

8. Các trường hợp cần kiểm thử

Một hàm có thể hoạt động đúng với dữ liệu thông thường nhưng thất bại khi gặp dữ liệu biên hoặc đầu vào không hợp lệ. Do đó, việc lựa chọn Test Case cần có kế hoạch rõ ràng.

8.1. Trường hợp thông thường

Đây là những đầu vào hợp lệ và thường gặp trong hoạt động của ứng dụng.

Ví dụ, hàm tính tổng đơn hàng nhận hai sản phẩm có giá 100.000 đồng và 200.000 đồng. Kết quả mong đợi là 300.000 đồng.

8.2. Trường hợp biên

Boundary Cases là những tình huống nằm tại hoặc gần giới hạn của dữ liệu.

Ví dụ, với chức năng xác định tuổi được phép đăng ký tài khoản, các trường hợp cần kiểm tra có thể là 17, 18 và 19 tuổi.

Nếu điều kiện yêu cầu người dùng phải đủ 18 tuổi, kết quả phải phản ánh chính xác giới hạn này.

8.3. Trường hợp dữ liệu không hợp lệ

Các đầu vào không hợp lệ có thể bao gồm:

  • Giá trị âm khi nghiệp vụ không cho phép.

  • Chuỗi rỗng.

  • Giá trị null hoặc undefined.

  • Sai kiểu dữ liệu.

  • Dữ liệu vượt quá giới hạn quy định.

  • Danh sách thiếu những phần tử bắt buộc.

Cách xử lý có thể là trả về lỗi, phát sinh ngoại lệ hoặc từ chối dữ liệu, tùy theo thiết kế của chức năng.

8.4. Trường hợp ngoại lệ

Một số đơn vị mã nguồn có thể phát sinh lỗi khi gặp điều kiện đặc biệt, chẳng hạn như chia cho 0 hoặc nhận dữ liệu không thể xử lý.

Unit Testing cần xác nhận chương trình phản ứng đúng theo yêu cầu, thay vì chỉ kiểm tra các giá trị trả về thông thường.

8.5. Trường hợp dữ liệu rỗng

Dữ liệu rỗng là tình huống phổ biến trong các chức năng xử lý danh sách, tìm kiếm, lọc và tổng hợp.

Ví dụ, hàm tính tổng giỏ hàng cần có quy định rõ khi giỏ hàng không chứa sản phẩm. Nếu nghiệp vụ yêu cầu tổng tiền bằng 0, bài kiểm thử phải xác nhận điều đó.

9. Phân biệt Unit Testing và các cấp độ kiểm thử khác

Unit Testing là một cấp độ trong hệ thống kiểm thử phần mềm. Nó không thay thế toàn bộ những hình thức kiểm thử khác mà thường được kết hợp với chúng.

Integration testing vs end-to-end testing: Key insights- Tricentis

Integration Testing

Kiểm thử tích hợp

Kiểm tra sự phối hợp giữa nhiều thành phần, chẳng hạn API với cơ sở dữ liệu hoặc các module với nhau.

Complete Guide To Selenium Test Automation & Reporting - Testmo

System Testing / End-to-End Testing

Kiểm thử hệ thống và luồng nghiệp vụ

Xác minh hệ thống hoặc quy trình người dùng từ đầu đến cuối, chẳng hạn đăng nhập, thêm sản phẩm vào giỏ hàng và thanh toán.

9.1. Unit Testing và Integration Testing

Unit Testing tập trung vào hành vi của một đơn vị riêng lẻ. Integration Testing tập trung vào việc những đơn vị hoặc hệ thống con phối hợp với nhau có đúng hay không.

Ví dụ, một hàm tính tổng tiền có thể vượt qua Unit Testing khi nhận danh sách sản phẩm mẫu. Tuy nhiên, khi tích hợp với cơ sở dữ liệu, hệ thống có thể lấy sai giá sản phẩm hoặc áp dụng sai quy tắc chuyển đổi dữ liệu.

Những lỗi liên quan đến tương tác giữa các thành phần cần được phát hiện bằng kiểm thử tích hợp.

9.2. Unit Testing và End-to-End Testing

End-to-End Testing (E2E) kiểm tra một luồng hoạt động gần với cách người dùng thực sự sử dụng ứng dụng.

Ví dụ, E2E có thể mô phỏng việc đăng nhập, chọn sản phẩm, nhập địa chỉ giao hàng và hoàn tất đơn hàng.

Trong khi đó, Unit Testing chỉ cần kiểm tra riêng hàm tính tổng tiền, hàm xác thực thông tin hoặc hàm áp dụng mã giảm giá.

E2E thường có phạm vi rộng hơn và chi phí thực thi cao hơn, nhưng có thể phát hiện những vấn đề không thể tìm thấy bằng kiểm thử từng đơn vị riêng lẻ.

9.3. Unit Testing và Manual Testing

Manual Testing là kiểm thử được thực hiện thủ công bởi người kiểm thử hoặc lập trình viên.

Người thực hiện có thể thao tác trên giao diện, nhập dữ liệu, quan sát kết quả và đánh giá trải nghiệm thực tế.

Unit Testing chủ yếu sử dụng các bài kiểm thử tự động để xác minh hành vi được định nghĩa trước. Hai phương pháp bổ sung cho nhau: kiểm thử tự động hỗ trợ kiểm tra lặp lại, còn kiểm thử thủ công có thể giúp đánh giá những khía cạnh như giao diện, khả năng sử dụng và trải nghiệm người dùng.

10. Các công cụ Unit Testing phổ biến

Công cụ phù hợp phụ thuộc vào ngôn ngữ lập trình, nền tảng phát triển và hệ sinh thái của dự án.

How to Use Jest to Quickly Test Your JavaScript Applications - DEV Community

Jest

Jest là framework kiểm thử JavaScript được sử dụng trong nhiều dự án JavaScript và React. Nó hỗ trợ tổ chức Test Suite, Assertion, Mocking và tạo báo cáo kết quả.

Vitest Logo PNG, Vector (AI, EPS, CDR, PDF, SVG) - iconLogoVector

Vitest

Vitest là công cụ kiểm thử JavaScript và TypeScript tích hợp tốt với hệ sinh thái Vite. Nó hỗ trợ chạy kiểm thử, Assertion, Mock và báo cáo độ bao phủ mã nguồn.

JUnit - Opsera

JUnit

JUnit là framework kiểm thử phổ biến trong hệ sinh thái Java. Nó hỗ trợ tổ chức và thực thi các bài kiểm thử cho phương thức, lớp và những đơn vị xử lý nghiệp vụ.

GitHub - pytest-dev/pytest: The pytest framework makes it easy to write small tests, yet scales to support complex functional testing · GitHub

pytest

pytest là framework kiểm thử Python với cú pháp đơn giản, hỗ trợ Fixture, Parametrization và hệ thống Plugin.

Mastering Unit Testing with NUnit: A Guide for Beginners & Experts

NUnit

NUnit là framework kiểm thử dành cho .NET, thường được sử dụng trong các dự án C#. Nó hỗ trợ Test Case, Assertion và kiểm thử tham số.

“Testing in Flutter: The Complete Guide to Unit, Widget & Integration Tests” | by Niteshwarik | Medium

flutter_test

Đây là thư viện kiểm thử được cung cấp trong hệ sinh thái Flutter, hỗ trợ viết Unit Testing và Widget Testing cho ứng dụng Dart/Flutter.

11. Thực hành Unit Testing với JavaScript và Vitest

Để hiểu rõ cách áp dụng Unit Testing, có thể xây dựng một chức năng tính tổng đơn hàng và viết bài kiểm thử cho từng tình huống.

11.1. Xây dựng hàm cần kiểm thử

Tạo file cart.js:

export function calculateTotal(items) {
  if (!Array.isArray(items)) {
    throw new TypeError("items phải là một mảng");
  }

  return items.reduce((total, item) => {
    return total + item.price * item.quantity;
  }, 0);
}

Hàm calculateTotal() nhận một mảng sản phẩm. Mỗi sản phẩm có thuộc tính price và quantity.

Hàm sử dụng reduce() để tính tổng giá trị của tất cả sản phẩm. Nếu đầu vào không phải mảng, hàm phát sinh TypeError.

Đây là logic nghiệp vụ tương đối độc lập nên phù hợp để bắt đầu với Unit Testing.

11.2. Cài đặt môi trường kiểm thử

Khởi tạo dự án Node.js và cài Vitest:

npm init -y
npm install -D vitest

Trong file package.json, bổ sung script:

{
  "type": "module",
  "scripts": {
    "test": "vitest run"
  }
}

Giữ nguyên những trường khác mà dự án đã có trong package.json.

11.3. Viết các bài kiểm thử

Tạo file cart.test.js:

import { describe, it, expect } from "vitest";
import { calculateTotal } from "./cart.js";

describe("calculateTotal", () => {
  it("tính tổng của nhiều sản phẩm", () => {
    const items = [
      { price: 100000, quantity: 2 },
      { price: 50000, quantity: 1 },
    ];

    expect(calculateTotal(items)).toBe(250000);
  });

  it("trả về 0 khi danh sách rỗng", () => {
    expect(calculateTotal([])).toBe(0);
  });

  it("trả về giá trị đúng khi số lượng bằng 0", () => {
    const items = [
      { price: 100000, quantity: 0 },
    ];

    expect(calculateTotal(items)).toBe(0);
  });

  it("phát sinh lỗi khi đầu vào không phải mảng", () => {
    expect(() => calculateTotal(null)).toThrow(TypeError);
  });
});

Trong ví dụ này, các bài kiểm thử bao gồm trường hợp bình thường, danh sách rỗng, số lượng bằng 0 và dữ liệu không đúng kiểu.

11.4. Chạy bộ kiểm thử

Mở Terminal tại thư mục dự án và chạy:

npm test

Vitest sẽ tìm những file kiểm thử phù hợp, thực thi từng Test Case và hiển thị kết quả.

Nếu toàn bộ điều kiện xác nhận đều đúng, các bài kiểm thử sẽ Passed. Nếu có kết quả không khớp, Vitest sẽ hiển thị thông tin để lập trình viên tìm nguyên nhân.

Lưu ý: ví dụ trên tập trung vào logic tính tổng. Nếu xây dựng ứng dụng thực tế, cần bổ sung quy tắc kiểm tra price, quantity, giá trị âm, dữ liệu thiếu và những trường hợp nghiệp vụ khác.

12. Unit Testing trong Flutter và Dart

Trong Flutter, Unit Testing thường được dùng để kiểm tra những thành phần xử lý logic không phụ thuộc trực tiếp vào giao diện, chẳng hạn hàm tính toán, lớp quản lý dữ liệu, bộ chuyển đổi dữ liệu hoặc phương thức xử lý nghiệp vụ.

Flutter cung cấp thư viện flutter_test, được sử dụng phổ biến để kiểm thử mã nguồn Dart và thành phần Flutter.

12.1. Tạo hàm cần kiểm thử

Tạo file lib/calculator.dart:

class Calculator {
  int add(int a, int b) {
    return a + b;
  }

  int subtract(int a, int b) {
    return a - b;
  }

  int multiply(int a, int b) {
    return a * b;
  }
}

Lớp Calculator chứa ba phương thức tính toán đơn giản. Mỗi phương thức có thể được kiểm thử độc lập bằng các giá trị đầu vào khác nhau.

12.2. Viết bài kiểm thử

Tạo file test/calculator_test.dart:

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/calculator.dart';

void main() {
  group('Calculator', () {
    late Calculator calculator;

    setUp(() {
      calculator = Calculator();
    });

    test('cộng hai số nguyên', () {
      expect(calculator.add(2, 3), equals(5));
    });

    test('trừ hai số nguyên', () {
      expect(calculator.subtract(5, 3), equals(2));
    });

    test('nhân hai số nguyên', () {
      expect(calculator.multiply(4, 3), equals(12));
    });
  });
}

Thay your_app bằng tên package thực tế trong file pubspec.yaml.

Trong đoạn mã trên:

  • group() nhóm các bài kiểm thử có liên quan.

  • setUp() khởi tạo đối tượng trước mỗi bài kiểm thử.

  • test() khai báo một trường hợp kiểm thử.

  • expect() xác nhận kết quả thực tế.

  • equals() xác định giá trị mong đợi.

12.3. Chạy Unit Testing trong Flutter

Sử dụng lệnh:

flutter test

Flutter sẽ thực thi các bài kiểm thử trong thư mục test.

Trong dự án thực tế, Unit Testing có thể được dùng để kiểm tra các lớp xử lý nghiệp vụ, chẳng hạn tính tổng giỏ hàng, tính điểm trung bình, kiểm tra dữ liệu đầu vào và xử lý trạng thái đăng nhập.

Đối với giao diện Flutter, cần phân biệt Unit Testing với Widget Testing. Unit Testing tập trung vào logic, còn Widget Testing kiểm tra hành vi và cấu trúc của widget trong môi trường kiểm thử Flutter.

13. Mock, Stub và Fake trong Unit Testing

Một vấn đề thường gặp khi viết Unit Testing là đơn vị mã nguồn phụ thuộc vào những thành phần bên ngoài, chẳng hạn API, cơ sở dữ liệu hoặc dịch vụ gửi email.

Nếu bài kiểm thử gọi trực tiếp những dịch vụ thật, thời gian thực thi có thể tăng lên và kết quả dễ bị ảnh hưởng bởi mạng hoặc trạng thái dữ liệu.

Test Double là thuật ngữ chung chỉ những đối tượng thay thế được dùng trong kiểm thử. Mock, Stub và Fake là một số dạng phổ biến.

13.1. Stub

Stub cung cấp dữ liệu trả về được xác định trước để hỗ trợ bài kiểm thử.

Ví dụ, khi kiểm tra hàm xử lý thông tin người dùng, Stub có thể trả về một người dùng mẫu thay vì truy vấn cơ sở dữ liệu thật.

Mục tiêu chính là kiểm soát đầu vào từ phụ thuộc để tập trung vào logic cần kiểm tra.

13.2. Mock

Mock là đối tượng giả lập thường được cấu hình để kiểm tra những tương tác cụ thể, chẳng hạn một phương thức có được gọi hay không và được gọi với tham số nào.

Ví dụ, khi xử lý đơn hàng thành công, bài kiểm thử có thể xác minh dịch vụ gửi email xác nhận đã được gọi đúng một lần.

13.3. Fake

Fake là một triển khai thay thế có hoạt động thực tế nhưng được đơn giản hóa để phục vụ kiểm thử.

Ví dụ, một kho dữ liệu Fake có thể lưu thông tin người dùng trong bộ nhớ thay vì ghi vào cơ sở dữ liệu thật.

Fake hữu ích khi cần kiểm thử một luồng xử lý tương đối đầy đủ nhưng không muốn phụ thuộc vào hệ thống bên ngoài.

13.4. Ví dụ sử dụng phụ thuộc giả lập

Giả sử một dịch vụ cần lấy thông tin người dùng từ Repository:

export class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async getUserName(id) {
    const user = await this.userRepository.findById(id);

    if (!user) {
      return "Unknown";
    }

    return user.name;
  }
}

Có thể kiểm thử bằng Repository giả:

import { describe, it, expect, vi } from "vitest";
import { UserService } from "./user-service.js";

describe("UserService", () => {
  it("trả về tên người dùng khi tìm thấy", async () => {
    const repository = {
      findById: vi.fn().mockResolvedValue({
        id: 1,
        name: "Tien",
      }),
    };

    const service = new UserService(repository);

    await expect(service.getUserName(1)).resolves.toBe("Tien");

    expect(repository.findById).toHaveBeenCalledWith(1);
  });

  it("trả về Unknown khi không tìm thấy", async () => {
    const repository = {
      findById: vi.fn().mockResolvedValue(null),
    };

    const service = new UserService(repository);

    await expect(service.getUserName(99)).resolves.toBe("Unknown");
  });
});

Hai bài kiểm thử trên không cần kết nối cơ sở dữ liệu. Chúng tập trung vào cách UserService xử lý kết quả mà Repository trả về.

Việc sử dụng Mock hoặc Stub cần có chủ đích. Nếu giả lập quá nhiều chi tiết triển khai nội bộ, bài kiểm thử có thể trở nên dễ hỏng khi cấu trúc mã nguồn thay đổi dù hành vi bên ngoài vẫn đúng.

14. Code Coverage và phạm vi kiểm thử

Code Coverage (độ bao phủ mã nguồn) là chỉ số cho biết phần mã nguồn nào đã được thực thi trong quá trình kiểm thử.

Một số loại Coverage phổ biến:

  • Statement Coverage: tỷ lệ câu lệnh đã được thực thi.

  • Branch Coverage: mức độ bao phủ các nhánh điều kiện, chẳng hạn cả nhánh if đúng và sai.

  • Function Coverage: tỷ lệ hàm hoặc phương thức đã được gọi.

  • Line Coverage: tỷ lệ dòng mã được thực thi.

Ví dụ, một hàm có điều kiện if/else có thể được chạy trong bài kiểm thử nhưng chỉ đi qua nhánh if. Khi đó, một phần logic chưa được xác minh.

Để tạo báo cáo Coverage với Vitest, có thể cài thêm gói hỗ trợ:

npm install -D @vitest/coverage-v8

Sau đó chạy:

npx vitest run --coverage

Coverage giúp nhận diện những phần mã nguồn chưa được kiểm thử, nhưng không trực tiếp chứng minh chương trình không có lỗi.

Một bộ kiểm thử có độ bao phủ 100% vẫn có thể bỏ sót lỗi nếu các Assertion không kiểm tra đúng kết quả hoặc không bao gồm những điều kiện nghiệp vụ quan trọng.

Vì vậy, cần kết hợp Coverage với chất lượng Test Case, mức độ quan trọng của chức năng và các rủi ro thực tế của ứng dụng.

15. Test-Driven Development và Unit Testing

Test-Driven Development (TDD) là phương pháp phát triển phần mềm trong đó lập trình viên viết bài kiểm thử trước khi triển khai chức năng tương ứng.

TDD thường được mô tả bằng chu trình Red – Green – Refactor.

Red

Viết kiểm thử và xác nhận nó thất bại

Green

Viết mã tối thiểu để kiểm thử thành công

Refactor

Cải thiện mã và chạy lại kiểm thử

Sau bước Refactor, tiếp tục chu trình với yêu cầu tiếp theo.

15.1. Red – Viết kiểm thử trước

Lập trình viên xác định hành vi mong muốn và viết Test Case tương ứng. Vì chức năng chưa được triển khai, bài kiểm thử dự kiến sẽ thất bại.

15.2. Green – Triển khai chức năng

Lập trình viên viết phần mã nguồn tối thiểu để đáp ứng điều kiện kiểm thử. Khi bài kiểm thử vượt qua, hành vi cơ bản đã được xác nhận.

15.3. Refactor – Cải thiện mã nguồn

Sau khi kiểm thử thành công, lập trình viên cải thiện cấu trúc mã, loại bỏ phần lặp lại và nâng cao khả năng bảo trì. Sau đó, toàn bộ những bài kiểm thử liên quan được chạy lại để kiểm tra tính đúng đắn.

Unit Testing thường là nền tảng quan trọng của TDD, nhưng không phải mọi dự án có Unit Testing đều áp dụng TDD. Một nhóm phát triển vẫn có thể viết kiểm thử sau khi đã triển khai chức năng.

16. Quy trình triển khai Unit Testing trong dự án thực tế

Để Unit Testing mang lại giá trị, nhóm phát triển cần tích hợp nó vào quy trình làm việc thay vì chỉ viết kiểm thử ở giai đoạn cuối.

16.1. Xác định phạm vi kiểm thử

Bắt đầu bằng việc xác định những thành phần có logic quan trọng, dễ phát sinh lỗi hoặc được sử dụng ở nhiều nơi.

Các hàm tính toán, quy tắc nghiệp vụ, xác thực dữ liệu và xử lý trạng thái thường là những ứng viên phù hợp.

16.2. Xác định hành vi mong đợi

Mỗi đơn vị cần có tiêu chí kiểm thử rõ ràng.

Ví dụ, với chức năng tính phí vận chuyển, cần xác định mức phí theo khoảng cách, điều kiện miễn phí và cách xử lý dữ liệu không hợp lệ.

16.3. Thiết kế Test Case

Xây dựng các tình huống bình thường, trường hợp biên và tình huống lỗi. Mỗi bài kiểm thử nên tập trung vào một hành vi cụ thể để kết quả dễ hiểu.

16.4. Viết và thực thi bài kiểm thử

Sử dụng framework phù hợp với dự án để tổ chức Test Suite, Assertion và dữ liệu kiểm thử.

Khi bài kiểm thử thất bại, cần phân tích nguyên nhân thay vì chỉ sửa mã nguồn cho đến khi kết quả chuyển sang Passed.

16.5. Tự động hóa trong CI/CD

Continuous Integration (CI) cho phép tự động chạy kiểm thử khi mã nguồn được đẩy lên kho lưu trữ hoặc khi tạo Pull Request.

Ví dụ, quy trình CI có thể thực hiện:

  1. Tải mã nguồn.

  2. Cài đặt các phụ thuộc.

  3. Chạy kiểm tra định dạng và phân tích tĩnh.

  4. Thực thi Unit Testing.

  5. Tạo báo cáo kết quả và Coverage.

  6. Ngăn việc hợp nhất mã nếu các điều kiện bắt buộc không đạt.

Việc tự động hóa giúp phát hiện lỗi trước khi thay đổi được đưa vào nhánh chính hoặc triển khai lên môi trường sử dụng thực tế.

16.6. Bảo trì bộ kiểm thử

Khi yêu cầu nghiệp vụ thay đổi, những bài kiểm thử liên quan cần được cập nhật. Đồng thời, nên loại bỏ các bài kiểm thử trùng lặp hoặc không còn phản ánh hành vi mong muốn.

Bộ kiểm thử cần phát triển cùng mã nguồn để tiếp tục cung cấp thông tin đáng tin cậy.

17. Những lỗi thường gặp khi viết Unit Testing

17.1. Chỉ kiểm tra trường hợp thành công

Một bài kiểm thử có thể xác nhận chức năng hoạt động với dữ liệu hợp lệ nhưng bỏ sót những tình huống như dữ liệu rỗng, giá trị biên hoặc lỗi đầu vào.

Cách cải thiện là phân tích các nhánh logic và xác định rõ những tình huống có khả năng gây lỗi.

17.2. Kiểm thử phụ thuộc vào môi trường bên ngoài

Nếu Unit Testing phải gọi API thật hoặc truy cập cơ sở dữ liệu thật, kết quả có thể bị ảnh hưởng bởi mạng, cấu hình hoặc dữ liệu thay đổi.

Nên tách biệt logic cần kiểm thử và sử dụng Test Double khi phù hợp. Những tương tác với dịch vụ thật nên được xác minh ở cấp độ kiểm thử tích hợp.

17.3. Assertion không đủ chặt chẽ

Một bài kiểm thử chỉ xác nhận kết quả khác null có thể vẫn thành công khi hàm trả về dữ liệu sai.

Cần sử dụng điều kiện xác nhận phù hợp với yêu cầu. Nếu mong đợi kết quả là 10000, nên kiểm tra chính xác giá trị đó thay vì chỉ kiểm tra rằng hàm có trả về một con số.

17.4. Kiểm thử quá nhiều chi tiết nội bộ

Nếu bài kiểm thử phụ thuộc vào tên biến, thứ tự gọi những hàm nội bộ hoặc cấu trúc triển khai không quan trọng, việc Refactoring có thể khiến hàng loạt bài kiểm thử thất bại dù chức năng vẫn đúng.

Nên tập trung vào hành vi có ý nghĩa đối với đơn vị đang kiểm thử.

17.5. Chạy kiểm thử nhưng không phân tích kết quả

Chạy lệnh kiểm thử chỉ là một bước trong quy trình. Khi có bài kiểm thử thất bại, cần đọc thông báo, xác định đầu vào, kết quả thực tế và kết quả mong đợi để tìm nguyên nhân.

Không nên bỏ qua những bài kiểm thử thất bại hoặc vô hiệu hóa chúng chỉ để làm cho toàn bộ bộ kiểm thử chuyển sang Passed.

17.6. Chạy theo thứ tự phụ thuộc

Nếu bài kiểm thử này cần bài kiểm thử khác chạy trước để tạo dữ liệu hoặc thiết lập trạng thái, bộ kiểm thử sẽ dễ gặp lỗi khi chạy song song hoặc chạy riêng lẻ.

Nên đảm bảo mỗi bài kiểm thử tự chuẩn bị dữ liệu cần thiết và dọn dẹp trạng thái sau khi hoàn thành khi có sử dụng tài nguyên dùng chung.

18. Ưu điểm và hạn chế của Unit Testing

18.1. Ưu điểm

  • Phát hiện lỗi logic ở phạm vi nhỏ.

  • Giảm thời gian xác định nguyên nhân khi có lỗi.

  • Hỗ trợ bảo trì và tái cấu trúc mã nguồn.

  • Giảm nguy cơ lỗi hồi quy.

  • Tài liệu hóa hành vi mong đợi của từng đơn vị.

  • Có thể tự động hóa trong CI/CD.

  • Khuyến khích thiết kế phần mềm có trách nhiệm rõ ràng và ít phụ thuộc không cần thiết.

18.2. Hạn chế

  • Cần thời gian để xây dựng và bảo trì bộ kiểm thử.

  • Không thể đảm bảo toàn bộ hệ thống không có lỗi.

  • Có thể bỏ sót lỗi tích hợp giữa nhiều thành phần.

  • Những bài kiểm thử phụ thuộc quá nhiều vào cấu trúc nội bộ có thể dễ bị hỏng.

  • Cần kiến thức về thiết kế Test Case và công cụ kiểm thử để đạt hiệu quả tốt.

  • Một số chức năng có nhiều trạng thái hoặc phụ thuộc phức tạp cần thêm công sức để thiết lập môi trường kiểm thử.

Unit Testing phát huy hiệu quả tốt nhất khi được kết hợp với Integration Testing, E2E Testing, kiểm tra mã nguồn và các hoạt động đảm bảo chất lượng khác.

19. Nguyên tắc xây dựng bộ Unit Testing bền vững

Một bộ Unit Testing có giá trị lâu dài cần được thiết kế với mục tiêu kiểm tra hành vi thực sự quan trọng, không chỉ tăng số lượng bài kiểm thử.

Các nguyên tắc nên áp dụng:

  • Ưu tiên logic nghiệp vụ quan trọng và những khu vực có rủi ro cao.

  • Đặt tên bài kiểm thử rõ ràng, mô tả hành vi mong đợi.

  • Tách biệt dữ liệu kiểm thử và hạn chế trạng thái dùng chung.

  • Kiểm tra cả trường hợp bình thường, trường hợp biên và đầu vào không hợp lệ.

  • Sử dụng Mock, Stub hoặc Fake có chọn lọc.

  • Giữ bài kiểm thử nhanh, ổn định và dễ chạy lại.

  • Không xem Code Coverage là thước đo duy nhất của chất lượng.

  • Chạy bộ kiểm thử tự động trong quy trình tích hợp liên tục.

  • Cập nhật kiểm thử khi yêu cầu nghiệp vụ thay đổi.

  • Xem lỗi kiểm thử là thông tin cần phân tích, không phải trở ngại cần loại bỏ.

Ngoài ra, không nhất thiết phải viết Unit Testing cho mọi dòng mã nguồn với mức độ chi tiết giống nhau. Các hàm chuyển đổi đơn giản và những quy tắc nghiệp vụ phức tạp có thể có mức độ rủi ro khác nhau. Việc phân bổ công sức kiểm thử nên dựa trên tác động của lỗi, mức độ phức tạp và tần suất thay đổi.

20. Kết luận

Unit Testing là kỹ thuật kiểm thử phần mềm nhằm xác minh tính đúng đắn của những đơn vị mã nguồn riêng lẻ. Bằng cách kiểm tra đầu vào, hành vi và kết quả mong đợi, lập trình viên có thể phát hiện lỗi sớm, giảm nguy cơ lỗi hồi quy và nâng cao khả năng bảo trì ứng dụng.

Để triển khai hiệu quả, cần nắm vững các thành phần như Test Case, Test Suite, Assertion, Test Runner, mô hình AAA, Test Double và Code Coverage. Đồng thời, việc lựa chọn công cụ phù hợp với từng ngôn ngữ, viết những trường hợp kiểm thử có ý nghĩa và tích hợp chúng vào CI/CD sẽ giúp bộ kiểm thử trở thành một phần thiết thực của quy trình phát triển.

Unit Testing không thay thế các cấp độ kiểm thử khác và cũng không đảm bảo phần mềm hoàn toàn không có lỗi. Tuy nhiên, khi được xây dựng có chủ đích và duy trì đúng cách, nó là nền tảng quan trọng để phát triển phần mềm ổn định, dễ mở rộng và dễ bảo trì.

Bài viết khác

Severity vs Priority(Độ nghiêm trọng vs Mức độ ưu tiên)

1.Severity và Priority là gì? Phân biệt ra sao? Trong bức tranh toàn cảnh của Vòng đời kiểm thử phần mềm (Software Testing Lifecycle – STLC), việc phát hiện ra lỗi (Bug/Defect) mới chỉ đóng góp 50% vào thành công của quy trình đảm bảo chất lượng. 50% còn lại phụ thuộc hoàn toàn vào […]

Bug Lifecycle & Bug Report

1.Bug Lifecycle & Bug Report là gì? Trong quản lý chất lượng phần mềm, Bug Lifecycle (Vòng đời của lỗi) và Bug Report (Báo cáo lỗi) là hai khái niệm cực kỳ quan trọng giúp kiểm thử viên (Tester) và lập trình viên (Developer) trao đổi, theo dõi và sửa lỗi một cách chuyên nghiệp. […]

Language Switching trong ứng dụng đa ngôn ngữ

Language Switching trong ứng dụng đa ngôn ngữ 1. Tổng quan về Language Switching Language Switching là chức năng cho phép người dùng thay đổi ngôn ngữ hiển thị của ứng dụng theo nhu cầu cá nhân. Thay vì chỉ sử dụng một ngôn ngữ cố định, ứng dụng có thể cung cấp nhiều lựa […]

UTC & Local Time Conversion trong lập trình

UTC & Local Time Conversion trong lập trình UTC & Local Time Conversion là một chủ đề quan trọng trong lập trình, đặc biệt khi xây dựng ứng dụng di động, website, hệ thống quản lý dữ liệu, API và các ứng dụng hoạt động trên nhiều quốc gia. Việc xử lý thời gian không […]

Date / Number / Currency Formatting trong lập trình

Date / Number / Currency Formatting trong lập trình Date / Number / Currency Formatting là một chủ đề quan trọng trong phát triển phần mềm, đặc biệt đối với các ứng dụng đa ngôn ngữ, ứng dụng thương mại điện tử, hệ thống quản lý tài chính và các ứng dụng cần hiển thị […]

Localization (l10n) trong phát triển phần mềm

Localization (l10n) trong phát triển phần mềm 1. Tổng quan về Localization Localization (l10n) là quá trình điều chỉnh một ứng dụng hoặc sản phẩm phần mềm để phù hợp với ngôn ngữ, định dạng dữ liệu, quy ước và đặc điểm văn hóa của một thị trường hoặc khu vực cụ thể. Localization giúp […]

Leave a Reply

Your email address will not be published. Required fields are marked *