Unit Testing Best Practices Thực Sự Quan Trọng

Viết unit test là một trong những thứ nghe như việc nhà — cho đến khi một "thay đổi nhỏ" làm sập cả hệ thống. Lúc đó mấy cái test trở thành bạn thân luôn.
Nhưng đây là điều mà hầu hết tutorial không nói cho bạn: test tệ còn tệ hơn không có test. Chúng cho bạn sự tự tin giả tạo, slow down cả team, và break mỗi khi bạn refactor. Mình đã dành nhiều năm viết cả hai loại, và muốn chia sẻ cái gì thực sự hiệu quả.
Bạn Sẽ Học Được:
✅ AAA pattern để structure mọi test
✅ Tại sao mỗi test chỉ nên có đúng một lý do để fail
✅ Cách giữ test isolated và deterministic
✅ Khi nào nên mock — và khi nào không
✅ Cách tiếp cận "magic 80%" cho code coverage
✅ Code Python thực tế xuyên suốt bài viết
AAA Pattern — Structure Mọi Test Giống Nhau
Arrange-Act-Assert pattern là tiêu chuẩn vàng cho test structure. Nó giữ test dễ đọc và đảm bảo ai trong team cũng hiểu đang verify cái gì.
# Arrange - Setup objects, data, và precondition
def test_apply_discount_reduces_price():
original_price = 100.0
discount_percent = 20
# Act - Chạy function cần test
result = apply_discount(original_price, discount_percent)
# Assert - Verify kết quả
assert result == 80.0Mọi test đều theo cùng structure này:
- Arrange: Setup objects, data, và precondition
- Act: Chạy function hoặc method cần test
- Assert: Verify kết quả đúng như expected
Khi ai đó đọc test của bạn, họ phải hiểu ngay: "Đây là setup, đây là cái mình đang làm, và đây là cái nên xảy ra."
Test Một Thứ Mỗi Test Case - Một Lý Do Để Fail
Mỗi unit test nên có đúng một lý do để fail. Nếu bạn assert mười thứ khác nhau trong một test, thì không thể nào diagnose được cái gì sai.
# ❌ Tệ — test quá nhiều thứ cùng lúc
def test_user_registration():
user = register_user("alice", "[email protected]", "password123")
assert user.username == "alice"
assert user.email == "[email protected]"
assert user.is_active is True
assert user.role == "member"
assert len(user.permissions) == 3
assert user.created_at is not None# ✅ Tốt — tách test cho từng concern
def test_register_user_sets_username():
user = register_user("alice", "[email protected]", "password123")
assert user.username == "alice"
def test_register_user_is_active_by_default():
user = register_user("alice", "[email protected]", "password123")
assert user.is_active is True
def test_register_user_has_member_role():
user = register_user("alice", "[email protected]", "password123")
assert user.role == "member"Khi test với một assertion fail, cái test method name đã nói cho bạn cái gì bị break. Khi test với mười assertion fail, bạn phải đọc traceback, tìm line number, rồi mới biết cái nào trong mười cái đó bị sai.
Giữ Test Isolated và Deterministic
Một test không bao giờ nên phụ thuộc vào kết quả của test khác. Chạy bất kỳ thứ tự nào, chạy song song — kết quả luôn phải giống nhau.
Không Shared Mutable State
# ❌ Tệ — các test share state qua global list
cart_items = []
def test_add_item():
cart_items.append("laptop")
assert len(cart_items) == 1
def test_add_another_item():
cart_items.append("mouse")
assert len(cart_items) == 2 # Phụ thuộc vào test_add_item chạy trước!# ✅ Tốt — mỗi test tạo state riêng
def test_add_item():
cart = ShoppingCart()
cart.add("laptop")
assert cart.count() == 1
def test_add_another_item():
cart = ShoppingCart()
cart.add("mouse")
assert cart.count() == 1 # Independent, deterministicFast Execution
Unit test phải chạy trong milliseconds, không phải seconds. Nếu test mất vài giây, có lẽ bạn đang làm integration test — gọi database thật, call API, hoặc đọc file system.
| Loại Test | Tốc Độ Điển Hình | Test Cái Gì |
|---|---|---|
| Unit Test | 1–10 ms | Một function hoặc class |
| Integration Test | 100–1000 ms | Nhiều component kết hợp |
| End-to-End Test | 1–30 seconds | Full user flow qua hệ thống |
Mock External Dependencies
Khi code giao tiếp với database, API, hay filesystem, dùng mock hoặc stub để simulate những interaction đó.
from unittest.mock import Mock
def test_get_user_profile_returns_formatted_name():
# Arrange — mock database
mock_db = Mock()
mock_db.find_user.return_value = {"first_name": "Alice", "last_name": "Smith"}
service = UserService(db=mock_db)
# Act
profile = service.get_profile(user_id=42)
# Assert
assert profile.display_name == "Alice Smith"
mock_db.find_user.assert_called_once_with(42)Test không cần database thật. Nó verify rằng code của bạn format tên đúng — chứ không phải PostgreSQL có đang chạy hay không.
Đặt Tên Test Rõ Ràng
Tên test phải đọc như một câu mô tả requirement. Khi test fail trong CI, chỉ cần đọc tên là biết cái gì bị break.
Pattern phổ biến: MethodName_StateUnderTest_ExpectedBehavior
# ❌ Tệ — không nói lên gì
def test1():
...
def test_login():
...
# ✅ Tốt — đọc như specification
def test_login_with_empty_password_returns_error():
...
def test_login_with_expired_token_redirects_to_signin():
...
def test_calculate_tax_with_zero_amount_returns_zero():
...Khi bạn nhìn test report như thế này, bạn hiểu ngay behavior của codebase:
✅ test_login_with_valid_credentials_returns_token
✅ test_login_with_wrong_password_returns_401
❌ test_login_with_expired_token_redirects_to_signin
✅ test_login_with_locked_account_returns_403Bạn biết chính xác scenario nào bị break mà không cần đọc một dòng code test nào.
Test Public API, Đừng Test Implementation
Tránh test private method trực tiếp. Nếu bạn thấy cần test private method, đó thường là dấu hiệu class đang làm quá nhiều — logic đó nên để trong class riêng với public interface.
# ❌ Tệ — test implementation detail nội bộ
def test_user_service_internal_hash_password():
service = UserService()
hashed = service._hash_password("secret") # Truy cập private method
assert hashed.startswith("$2b$")
# ✅ Tốt — test behavior qua public API
def test_user_service_create_user_stores_hashed_password():
service = UserService(db=mock_db)
service.create_user("alice", "secret")
stored_password = mock_db.save_user.call_args[0][1]
assert stored_password != "secret" # Đã hash, không phải plaintextTại sao quan trọng: Khi bạn refactor internal (đổi hashing algorithm, đổi tên private method), test kiểm tra behavior vẫn pass. Test kiểm tra implementation thì fail ngay — dù thực tế chẳng có gì sai cả.
Isolate Khỏi Framework và External Service
Nghĩ code của bạn như phi công trong flight simulator. Bạn muốn test phi công có hạ cánh được không (logic của bạn) — chứ không cần đường băng thật (framework và external service).
External Service — Isolate Tuyệt Đối
Nếu test gọi database thật hoặc third-party API, nó không còn là unit test nữa — nó là integration test.
# ❌ Integration test giả vờ là unit test
def test_get_weather():
result = weather_service.get_current("Ho Chi Minh City") # Gọi API thật
assert result["temperature"] > 0
# ✅ Unit test thật — mock HTTP call
def test_get_weather_parses_response():
mock_client = Mock()
mock_client.get.return_value = {"temp": 32, "unit": "celsius"}
service = WeatherService(client=mock_client)
result = service.get_current("Ho Chi Minh City")
assert result["temperature"] == 32Framework — Tách Business Logic Ra
Bạn sẽ muốn tránh "framework bloat" trong test. Nếu phải boot cả application context chỉ để check math function, architecture đang coupled quá chặt.
# ❌ Cần FastAPI chạy để test business logic
from fastapi.testclient import TestClient
from main import app
def test_discount_calculation():
client = TestClient(app)
response = client.post("/api/discount", json={"price": 100, "percent": 20})
assert response.json()["final_price"] == 80.0
# ✅ Test logic trực tiếp — không cần framework
def test_discount_calculation():
result = calculate_discount(price=100, percent=20)
assert result == 80.0Standard Library — Đừng Mock Mọi Thứ
Bạn không cần mock len(), str.split(), hay math.sqrt(). Chúng nhanh, stable, và deterministic. Chỉ mock những thứ chậm, non-deterministic, hoặc có side effect.
Nhắm "Magic 80%" — Không Phải 100%
Đừng ám ảnh với 100% code coverage. Nhắm 100% dẫn đến test những thứ trivial (như getter và setter) mà bỏ qua edge case phức tạp.
Tập trung vào ba khu vực:
1. Happy Path
Flow bình thường, expected qua code.
def test_transfer_money_succeeds():
sender = Account(balance=1000)
receiver = Account(balance=500)
transfer(sender, receiver, amount=200)
assert sender.balance == 800
assert receiver.balance == 7002. Boundary Condition
Chuyện gì xảy ra ở 0, -1, giá trị maximum, hay empty list?
def test_transfer_exact_balance_succeeds():
sender = Account(balance=100)
receiver = Account(balance=0)
transfer(sender, receiver, amount=100)
assert sender.balance == 0
assert receiver.balance == 100
def test_transfer_zero_amount_does_nothing():
sender = Account(balance=100)
receiver = Account(balance=50)
transfer(sender, receiver, amount=0)
assert sender.balance == 100
assert receiver.balance == 503. Error Handling
Có raise đúng exception khi mọi thứ sai không?
import pytest
def test_transfer_insufficient_funds_raises_error():
sender = Account(balance=50)
receiver = Account(balance=100)
with pytest.raises(InsufficientFundsError):
transfer(sender, receiver, amount=200)
def test_transfer_negative_amount_raises_error():
sender = Account(balance=100)
receiver = Account(balance=50)
with pytest.raises(ValueError):
transfer(sender, receiver, amount=-50)Quick Reference: Test Tốt vs. Test Tệ
| Tiêu Chí | Unit Test Tốt | Unit Test Tệ |
|---|---|---|
| Speed | Chạy trong milliseconds | Chậm — chờ I/O |
| Reliability | Deterministic — cùng kết quả mọi lúc | Flaky — fail random |
| Scope | Nhỏ, tập trung vào một unit of logic | Đi qua nhiều layer |
| Maintenance | Dễ update khi logic thay đổi | Brittle — vỡ khi refactor nhỏ |
| Naming | test_login_with_empty_password_returns_error | test1 hoặc test_login |
| Assertion | Một logical concept mỗi test | Mười assertion trong một test |
| Dependency | Mock — không cần DB hay API thật | Cần service đang chạy |
Thay Đổi Mindset
Viết test tốt không phải là đạt con số coverage. Mà là xây dựng sự tự tin.
Mỗi test bạn viết nên trả lời một câu hỏi: "Nếu ai đó thay đổi code này ngày mai, test này có bắt được nếu có gì đó hỏng không?"
Nếu câu trả lời là có — đó là test tốt. Nếu câu trả lời là "nó có thể vỡ dù chẳng có gì sai" — đó là test tệ, và bạn nên xóa nó đi.
"Test không bao giờ fail thì vô giá trị. Test luôn fail cũng vô giá trị. Test chỉ fail khi có gì đó thật sự hỏng — đó mới là vàng."
Mục tiêu không phải có nhiều test hơn. Mà là có đúng test — nhanh, reliable, focused, và thực sự có ích.
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.