TÌM HIỂU CHUYÊN SÂU VỀ UNIT TESTING TRONG FLUTTER
1. Unit Testing là gì?
Trong quá trình phát triển phần mềm, việc viết code chỉ là một phần của công việc. Sau khi xây dựng một chức năng, lập trình viên cần xác định chức năng đó có hoạt động chính xác theo yêu cầu hay không. Đặc biệt, khi dự án ngày càng lớn, số lượng hàm, lớp và các thành phần xử lý dữ liệu tăng lên, việc kiểm tra bằng cách chạy ứng dụng thủ công sẽ mất nhiều thời gian và dễ bỏ sót lỗi.
Để giải quyết vấn đề này, lập trình viên sử dụng các phương pháp kiểm thử phần mềm, trong đó có Unit Testing.
Unit Testing, hay kiểm thử đơn vị, là phương pháp kiểm thử những đơn vị nhỏ của chương trình một cách độc lập nhằm xác định chúng có hoạt động đúng với yêu cầu và kết quả mong đợi hay không.
Một đơn vị trong chương trình có thể là một hàm, một phương thức hoặc một lớp thực hiện một nhiệm vụ cụ thể. Trong Flutter, Unit Testing thường được sử dụng để kiểm tra logic viết bằng ngôn ngữ Dart, chẳng hạn như tính toán, kiểm tra dữ liệu đầu vào, xử lý trạng thái hoặc chuyển đổi dữ liệu.
Ví dụ, trong một ứng dụng bán hàng, lập trình viên xây dựng chức năng tính tổng tiền của giỏ hàng. Khi người dùng mua 3 sản phẩm có đơn giá 100.000 đồng, hệ thống phải trả về tổng tiền 300.000 đồng.
Nếu hàm tính tổng tiền trả về 30.000 đồng hoặc 3.000.000 đồng, điều đó cho thấy logic xử lý có vấn đề.
Thay vì phải mở ứng dụng, tìm sản phẩm, thêm vào giỏ hàng và kiểm tra kết quả bằng mắt, lập trình viên có thể viết một bài Unit Test để tự động gọi hàm tính tổng tiền và so sánh kết quả với giá trị mong đợi.
Như vậy, Unit Testing giúp kiểm tra tính chính xác của logic mà không cần thực hiện toàn bộ quy trình sử dụng ứng dụng.
2. Bản chất của Unit Testing
Bản chất của Unit Testing là kiểm tra mối quan hệ giữa dữ liệu đầu vào và kết quả đầu ra của một đơn vị chương trình.
Thông thường, một hàm nhận dữ liệu đầu vào, thực hiện một số phép xử lý và trả về kết quả. Unit Test sẽ xác định trước kết quả mong đợi, sau đó thực thi hàm và so sánh kết quả thực tế.
Ví dụ, ta có hàm tính tổng tiền:
int calculateTotal(int price, int quantity) {
return price * quantity;
}
Hàm này nhận hai tham số:
price: Đơn giá sản phẩm.quantity: Số lượng sản phẩm.
Nếu gọi:
calculateTotal(100000, 3);
Kết quả mong đợi là 300000.
Một bài Unit Test sẽ kiểm tra điều kiện này. Nếu kết quả trả về không bằng 300000, bài kiểm thử thất bại.
Điều quan trọng là Unit Testing không chỉ kiểm tra chương trình có chạy được hay không. Một chương trình có thể chạy bình thường, không xuất hiện Exception nhưng vẫn tính toán sai.
Ví dụ, nếu lập trình viên viết nhầm:
return price + quantity;
Chương trình vẫn có thể thực thi mà không bị crash, nhưng kết quả hoàn toàn sai về mặt nghiệp vụ.
Unit Test có khả năng phát hiện những lỗi logic như vậy nếu bài test kiểm tra đúng kết quả mong đợi.
3. Vì sao Unit Testing quan trọng trong dự án thực tế?
3.1. Phát hiện lỗi sớm
Trong quá trình phát triển, lỗi được phát hiện càng sớm thì việc sửa chữa thường càng đơn giản.
Ví dụ, một hàm tính giảm giá được sử dụng ở trang chi tiết sản phẩm, giỏ hàng và thanh toán. Nếu hàm này tính sai, nhiều chức năng khác cũng có thể hiển thị số tiền không chính xác.
Nếu Unit Test phát hiện lỗi ngay khi hàm được viết, lập trình viên có thể sửa trước khi chức năng được tích hợp vào nhiều màn hình.
3.2. Hạn chế lỗi sau khi chỉnh sửa code
Khi bổ sung tính năng mới, lập trình viên thường phải chỉnh sửa những đoạn code đã tồn tại.
Ví dụ, ban đầu ứng dụng chỉ hỗ trợ giảm giá theo phần trăm. Sau đó, cửa hàng muốn hỗ trợ thêm mã giảm giá theo số tiền cố định.
Trong quá trình chỉnh sửa, lập trình viên có thể vô tình làm sai logic giảm giá theo phần trăm.
Nếu các bài Unit Test cũ vẫn được giữ lại, hệ thống có thể chạy lại chúng để kiểm tra chức năng cũ còn hoạt động đúng hay không.
Đây là một phần của Regression Testing, tức kiểm thử nhằm phát hiện những lỗi xuất hiện trở lại hoặc phát sinh sau khi hệ thống thay đổi.
3.3. Giúp code dễ bảo trì
Khi một hàm có bài test rõ ràng, lập trình viên khác có thể đọc bài test để hiểu hàm được kỳ vọng hoạt động như thế nào.
Ví dụ, một bài test có tên “Không cho phép số lượng âm” thể hiện rõ quy tắc nghiệp vụ của chức năng tính tiền.
Nhờ đó, bài test không chỉ phục vụ kiểm tra mà còn có thể đóng vai trò như tài liệu mô tả hành vi của chương trình.
3.4. Hỗ trợ làm việc nhóm
Trong dự án có nhiều thành viên, một lập trình viên có thể chỉnh sửa code do người khác viết.
Nếu dự án có các bài Unit Test, người chỉnh sửa có thể chạy test để kiểm tra xem những thay đổi của mình có làm hỏng chức năng đã có hay không.
Điều này đặc biệt hữu ích khi dự án sử dụng Git và CI/CD để tự động kiểm tra code sau mỗi lần cập nhật.
4. Unit Testing khác gì với việc kiểm tra thủ công?
Kiểm tra thủ công là việc lập trình viên hoặc người kiểm thử trực tiếp sử dụng ứng dụng để quan sát kết quả.
Ví dụ, mở trang giỏ hàng, chọn sản phẩm, thay đổi số lượng và xem tổng tiền.
Unit Testing sử dụng code để kiểm tra một đơn vị chương trình.
Hai phương pháp có ưu điểm riêng.
Kiểm tra thủ công phù hợp khi cần đánh giá trải nghiệm người dùng, bố cục giao diện hoặc những tình huống khó tự động hóa.
Unit Testing phù hợp khi cần kiểm tra logic một cách nhanh chóng, lặp lại nhiều lần và có kết quả rõ ràng.
Một Unit Test có thể chạy hàng trăm lần mà không cần người dùng thao tác thủ công.
Tuy nhiên, Unit Testing không thể tự xác nhận rằng giao diện đẹp, nút bấm dễ sử dụng hoặc toàn bộ quy trình đặt hàng hoạt động đúng.
Vì vậy, dự án thực tế cần kết hợp Unit Testing với những phương pháp kiểm thử khác.
5. Cấu trúc Arrange – Act – Assert
Một trong những cách tổ chức bài Unit Test phổ biến là Arrange – Act – Assert, viết tắt là AAA.
Arrange là giai đoạn chuẩn bị dữ liệu và những điều kiện cần thiết.
Act là giai đoạn thực hiện hành động cần kiểm tra.
Assert là giai đoạn xác nhận kết quả thực tế có đáp ứng yêu cầu hay không.
Ví dụ:
test('Tính tổng tiền chính xác', () {
// Arrange
const price = 100000;
const quantity = 3;
// Act
final result = calculateTotal(
price,
quantity,
);
// Assert
expect(result, 300000);
});
Ở bước Arrange, bài test chuẩn bị đơn giá và số lượng.
Ở bước Act, bài test gọi hàm calculateTotal().
Ở bước Assert, bài test sử dụng expect() để kiểm tra kết quả.
Cách tổ chức AAA giúp bài test dễ đọc và giúp lập trình viên nhanh chóng xác định đang chuẩn bị gì, kiểm tra thao tác nào và mong đợi kết quả ra sao.
6. Các thành phần quan trọng trong Unit Testing của Flutter
6.1. Hàm test()
Hàm test() được sử dụng để khai báo một trường hợp kiểm thử.
Ví dụ:
test('Kiểm tra tổng tiền', () {
expect(2 + 3, 5);
});
Tham số đầu tiên là tên bài test.
Tham số thứ hai là hàm chứa nội dung kiểm thử.
Tên bài test nên mô tả rõ hành vi cần kiểm tra. Ví dụ, “Trả về 0 khi giỏ hàng rỗng” sẽ dễ hiểu hơn “Test 1”.
6.2. Hàm expect()
expect() là công cụ dùng để xác nhận kết quả.
Ví dụ:
expect(10, 10);
Bài test thành công vì giá trị thực tế và giá trị mong đợi giống nhau.
Ví dụ khác:
expect(10, 20);
Bài test thất bại vì hai giá trị khác nhau.
Ngoài so sánh bằng nhau, expect() còn hỗ trợ nhiều Matcher để kiểm tra điều kiện khác.
Ví dụ:
expect(10, greaterThan(5));
expect('Flutter', contains('Flut'));
expect([1, 2, 3], hasLength(3));
Nhờ Matcher, lập trình viên có thể kiểm tra nhiều dạng dữ liệu và điều kiện khác nhau.
6.3. Hàm group()
group() được sử dụng để nhóm các bài test liên quan.
Ví dụ:
group('Kiểm thử giỏ hàng', () {
test('Tính tổng tiền', () {
expect(calculateTotal(100000, 2), 200000);
});
test('Giỏ hàng rỗng', () {
expect(calculateTotal(100000, 0), 0);
});
});
Việc nhóm test giúp tổ chức mã kiểm thử rõ ràng, đặc biệt khi một lớp có nhiều chức năng.
6.4. setUp() và tearDown()
setUp() được sử dụng để chuẩn bị điều kiện trước mỗi bài test trong phạm vi tương ứng.
tearDown() được sử dụng để dọn dẹp sau mỗi bài test.
Ví dụ, nếu nhiều bài test cần một đối tượng giỏ hàng mới, có thể tạo đối tượng đó trong setUp().
Nếu bài test tạo tài nguyên cần giải phóng, có thể sử dụng tearDown() để dọn dẹp.
Hai cơ chế này giúp giảm code lặp lại và hạn chế việc bài test này ảnh hưởng đến bài test khác.
7. Ví dụ đầy đủ: Unit Testing chức năng tính tiền
Giả sử một ứng dụng bán hàng có yêu cầu:
- Tổng tiền bằng đơn giá nhân với số lượng.
- Nếu số lượng bằng 0, tổng tiền bằng 0.
- Không cho phép đơn giá âm.
- Không cho phép số lượng âm.
Đầu tiên, tạo file lib/cart_calculator.dart:
class CartCalculator {
int calculateTotal({
required int price,
required int quantity,
}) {
if (price < 0) {
throw ArgumentError(
'Đơn giá không được âm',
);
}
if (quantity < 0) {
throw ArgumentError(
'Số lượng không được âm',
);
}
return price * quantity;
}
}
Sau đó, tạo file test/cart_calculator_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/cart_calculator.dart';
void main() {
late CartCalculator calculator;
setUp(() {
calculator = CartCalculator();
});
group('CartCalculator', () {
test('Tính đúng tổng tiền', () {
final result = calculator.calculateTotal(
price: 100000,
quantity: 3,
);
expect(result, 300000);
});
test('Số lượng bằng 0', () {
final result = calculator.calculateTotal(
price: 100000,
quantity: 0,
);
expect(result, 0);
});
test('Không chấp nhận đơn giá âm', () {
expect(
() => calculator.calculateTotal(
price: -100000,
quantity: 2,
),
throwsArgumentError,
);
});
test('Không chấp nhận số lượng âm', () {
expect(
() => calculator.calculateTotal(
price: 100000,
quantity: -2,
),
throwsArgumentError,
);
});
});
}
Trong ví dụ trên, my_app là tên package minh họa, cần thay bằng tên package của dự án thực tế.
Điểm đáng chú ý là các bài test không cần mở trang giỏ hàng.
Chúng chỉ tạo đối tượng CartCalculator, gọi phương thức cần kiểm tra và so sánh kết quả.
Nếu sau này lập trình viên sửa logic tính tiền, chỉ cần chạy lại những bài test để kiểm tra các quy tắc cũ còn được đáp ứng hay không.
8. Test Case, Edge Case và Negative Test
Khi viết Unit Test, lập trình viên cần xác định những trường hợp nào phải được kiểm tra.
Test Case là một trường hợp kiểm thử cụ thể.
Ví dụ, giá 100.000 đồng và số lượng 3 là một test case của hàm tính tổng tiền.
Edge Case là trường hợp nằm tại ranh giới hoặc điều kiện đặc biệt.
Ví dụ, số lượng bằng 0 hoặc giá trị giảm giá bằng 100% là những trường hợp biên.
Negative Test là bài kiểm thử sử dụng dữ liệu hoặc tình huống không hợp lệ để xác nhận chương trình xử lý đúng.
Ví dụ, truyền số lượng -2 và mong đợi chương trình phát sinh ArgumentError.
Một bộ Unit Test tốt thường kết hợp cả dữ liệu thông thường, dữ liệu biên và dữ liệu không hợp lệ.
Nếu chỉ kiểm tra những trường hợp dễ nhất, bài test có thể bỏ sót nhiều lỗi quan trọng.
9. Kiểm thử hàm bất đồng bộ với Future
Trong Flutter, nhiều thao tác sử dụng Future, chẳng hạn đọc dữ liệu, xử lý bất đồng bộ hoặc gọi API.
Unit Test cũng có thể kiểm tra những hàm trả về Future.
Ví dụ:
Future<int> calculateTotalAsync() async {
return 300000;
}
Bài test:
test('Kiểm tra hàm bất đồng bộ', () async {
final result = await calculateTotalAsync();
expect(result, 300000);
});
Ở đây, async cho phép bài test chờ thao tác bất đồng bộ hoàn tất.
await lấy kết quả từ Future.
Nếu hàm bất đồng bộ phát sinh Exception, bài test có thể sử dụng Matcher phù hợp để xác nhận hành vi lỗi được mong đợi.
Điểm quan trọng là bài test phải chờ thao tác bất đồng bộ hoàn tất trước khi kết luận kết quả.
Nếu quên await hoặc không trả về Future đúng cách, bài test có thể kết thúc trước khi thao tác cần kiểm tra hoàn thành.
10. Mock, Fake và Stub trong Unit Testing
Trong dự án thực tế, một lớp có thể phụ thuộc vào nhiều thành phần khác.
Ví dụ, ProductService cần lấy dữ liệu từ ProductRepository.
Nếu repository gọi API thật, bài test sẽ phụ thuộc vào Internet và máy chủ.
Để kiểm tra logic độc lập, lập trình viên có thể sử dụng Test Double, tức những đối tượng thay thế thành phần thật trong quá trình kiểm thử.
Một số loại Test Double phổ biến gồm Mock, Fake và Stub.
Mock thường được sử dụng khi cần kiểm tra những tương tác giữa đối tượng đang kiểm thử và thành phần phụ thuộc.
Stub cung cấp những phản hồi được chuẩn bị trước cho các lời gọi.
Fake là một cách triển khai đơn giản thay thế thành phần thật, ví dụ một repository lưu dữ liệu trong bộ nhớ.
Trong Dart, có thể sử dụng thư viện như Mockito hoặc Mocktail, nhưng với ví dụ nhỏ, tự viết Fake thường dễ hiểu hơn.
Ví dụ:
abstract class ProductRepository {
Future<int> getProductCount();
}
class FakeProductRepository
implements ProductRepository {
@override
Future<int> getProductCount() async {
return 5;
}
}
class ProductService {
final ProductRepository repository;
ProductService(this.repository);
Future<bool> hasProducts() async {
final count =
await repository.getProductCount();
return count > 0;
}
}
Bài test:
test('Có sản phẩm khi số lượng lớn hơn 0', () async {
final repository = FakeProductRepository();
final service = ProductService(repository);
final result = await service.hasProducts();
expect(result, true);
});
Trong ví dụ này, ProductService không cần kết nối Backend thật.
Bài test sử dụng FakeProductRepository để trả về số lượng sản phẩm đã được chuẩn bị.
Nhờ đó, bài test có thể tập trung vào logic của ProductService.
11. Dependency Injection hỗ trợ Unit Testing như thế nào?
Dependency Injection là kỹ thuật cung cấp những thành phần phụ thuộc từ bên ngoài thay vì để một lớp tự tạo tất cả dependency bên trong.
Ví dụ, nếu ProductService tự khởi tạo một repository kết nối API thật, việc thay repository bằng Fake sẽ khó khăn hơn.
Ngược lại, nếu repository được truyền vào constructor:
ProductService(this.repository);
Bài test có thể truyền FakeProductRepository.
Trong ứng dụng thực tế, có thể truyền repository thật.
Như vậy, Dependency Injection giúp giảm sự phụ thuộc cứng giữa các thành phần và làm cho code dễ kiểm thử hơn.
Đây cũng là một trong những lý do các dự án Flutter thường sử dụng kiến trúc tách biệt UI, Service và Repository.
12. Test Coverage là gì?
Test Coverage là chỉ số thể hiện mức độ code được thực thi trong quá trình chạy bài kiểm thử.
Trong Flutter, có thể chạy:
flutter test --coverage
Công cụ có thể tạo báo cáo coverage tại:
coverage/lcov.info
Ví dụ, nếu một báo cáo cho biết line coverage đạt 80%, điều đó thường có nghĩa 80% số dòng code được tính trong báo cáo đã được thực thi trong quá trình kiểm thử.
Tuy nhiên, coverage không trực tiếp chứng minh tính chính xác của chương trình.
Một bài test có thể gọi hàm nhưng không kiểm tra đầy đủ kết quả.
Ngoài ra, một hàm có nhiều nhánh điều kiện có thể cần nhiều test case để kiểm tra những đường xử lý khác nhau.
Vì vậy, không nên chỉ chạy theo tỷ lệ coverage mà bỏ qua chất lượng của từng bài test.
13. Test-Driven Development (TDD) là gì?
Test-Driven Development, viết tắt là 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 test trước khi viết hoặc hoàn thiện phần code đáp ứng yêu cầu.
Một chu trình TDD phổ biến là:
Red → Green → Refactor
Red: Viết bài test cho hành vi mong muốn. Bài test ban đầu thất bại vì chức năng chưa được triển khai hoặc chưa đúng.
Green: Viết phần code tối thiểu cần thiết để bài test thành công.
Refactor: Cải thiện cấu trúc code mà vẫn giữ những bài test thành công.
Ví dụ, khi xây dựng chức năng kiểm tra mật khẩu, lập trình viên có thể viết trước bài test yêu cầu mật khẩu phải có ít nhất 8 ký tự.
Sau đó, viết hàm validation để đáp ứng bài test.
Cuối cùng, cải thiện code và chạy lại test.
TDD có thể giúp lập trình viên tập trung vào hành vi mong muốn và thiết kế code dễ kiểm thử.
Tuy nhiên, TDD không bắt buộc đối với mọi dự án hoặc mọi chức năng. Điều quan trọng là hiểu mục tiêu của phương pháp và áp dụng khi phù hợp.
14. Khi nào nên sử dụng Unit Testing?
Unit Testing đặc biệt phù hợp với những chức năng có logic rõ ràng và có thể xác định kết quả mong đợi.
Ví dụ:
- Tính tổng tiền đơn hàng.
- Tính giảm giá.
- Kiểm tra email và mật khẩu.
- Chuyển đổi dữ liệu JSON sang Model.
- Xác định trạng thái đơn hàng.
- Kiểm tra điều kiện áp dụng mã giảm giá.
- Sắp xếp hoặc lọc danh sách sản phẩm.
- Xử lý một phần logic của State Management.
Tuy nhiên, những vấn đề như giao diện có hiển thị đúng hay không hoặc nút bấm có điều hướng chính xác hay không thường cần Widget Test hoặc Integration Test.
15. Những sai lầm thường gặp khi viết Unit Test
Một sai lầm phổ biến là chỉ kiểm tra trường hợp thành công và bỏ qua dữ liệu không hợp lệ.
Sai lầm thứ hai là viết bài test phụ thuộc vào API hoặc cơ sở dữ liệu thật, khiến kết quả không ổn định.
Sai lầm thứ ba là viết một bài test quá dài và kiểm tra nhiều hành vi không liên quan.
Sai lầm thứ tư là đặt tên bài test không rõ ràng.
Sai lầm thứ năm là chỉ kiểm tra một hàm có được gọi hay không mà không kiểm tra kết quả quan trọng.
Sai lầm thứ sáu là sửa bài test để nó thành công mà không xác nhận lại yêu cầu nghiệp vụ.
Sai lầm cuối cùng là cho rằng Unit Test có thể thay thế mọi phương pháp kiểm thử khác.
16. Kết luận
Unit Testing là phương pháp kiểm thử những đơn vị nhỏ của chương trình nhằm xác định logic có hoạt động đúng với yêu cầu hay không.
Trong Flutter, Unit Testing thường tập trung vào các hàm và lớp Dart, đặc biệt là logic nghiệp vụ, validation, xử lý dữ liệu và State Management.
Các công cụ như test(), group(), expect(), setUp() và những kỹ thuật như Mocking hoặc Dependency Injection giúp xây dựng bài kiểm thử có tổ chức.
Unit Testing giúp phát hiện lỗi sớm, hỗ trợ bảo trì code và giảm nguy cơ chức năng cũ bị ảnh hưởng khi dự án thay đổi.
Tuy nhiên, chất lượng kiểm thử phụ thuộc vào cách lựa chọn test case và xác định kết quả mong đợi. Một bộ Unit Test tốt không chỉ kiểm tra trường hợp thông thường mà còn cần xem xét các tình huống biên và dữ liệu không hợp lệ.
17. Tài liệu tham khảo
- Flutter Unit Testing: https://docs.flutter.dev/cookbook/testing/unit/introduction
- Dart Testing: https://dart.dev/tools/testing
- Flutter Testing Overview: https://docs.flutter.dev/testing/overview
- Mockito: https://pub.dev/packages/mockito
- Mocktail: https://pub.dev/packages/mocktail








Khoá học lập trình game con rắn cho trẻ em




