Back to blog

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

testingunit-testsoftware-engineeringbest-practicespython
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.0

Mọi test đều theo cùng structure này:

  1. Arrange: Setup objects, data, và precondition
  2. Act: Chạy function hoặc method cần test
  3. 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, deterministic

Fast 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 TestTốc Độ Điển HìnhTest Cái Gì
Unit Test1–10 msMột function hoặc class
Integration Test100–1000 msNhiều component kết hợp
End-to-End Test1–30 secondsFull 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_403

Bạ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 plaintext

Tạ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"] == 32

Framework — 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.0

Standard 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 == 700

2. 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 == 50

3. 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ốtUnit Test Tệ
SpeedChạy trong millisecondsChậm — chờ I/O
ReliabilityDeterministic — cùng kết quả mọi lúcFlaky — fail random
ScopeNhỏ, tập trung vào một unit of logicĐi qua nhiều layer
MaintenanceDễ update khi logic thay đổiBrittle — vỡ khi refactor nhỏ
Namingtest_login_with_empty_password_returns_errortest1 hoặc test_login
AssertionMột logical concept mỗi testMười assertion trong một test
DependencyMock — không cần DB hay API thậtCầ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.