Skip to content
Go back

Functional Testing: Unit Test, TDD và Specification

Functional testing thường được chia thành ba lớp chính: Unit Test, Integration Test và End-to-End Test. Unit Test tập trung vào việc kiểm tra từng đơn vị nhỏ của code, Integration Test kiểm tra sự kết hợp giữa nhiều thành phần như database, API hoặc external service, còn E2E Test kiểm tra một workflow hoàn chỉnh từ đầu đến cuối. Theo Testing Pyramid, phần lớn test nên nằm ở Unit Test, tiếp đến là Integration Test và cuối cùng là một số lượng nhỏ E2E Test. Cách phân chia này giúp test suite có tốc độ chạy nhanh và tập trung phần lớn validation ở những test nhỏ, dễ kiểm soát.

Unit Test

Một unit test nên tập trung vào behavior của một unit thay vì implementation details bên trong nó. Một nguyên tắc quan trọng là “test behavior, not implementation.” Điều này có nghĩa là chúng ta nên kiểm tra public function hoặc public interface của một component, thay vì viết test trực tiếp cho từng private function.

Ví dụ, thay vì viết test riêng cho validateEmail(), normalizeEmail() và saveUser(), chúng ta có thể kiểm tra behavior của createUser(). Khi truyền vào một email hợp lệ, user phải được tạo thành công; khi email không hợp lệ, function phải trả về validation error; khi email đã tồn tại, function phải trả về lỗi phù hợp. Các private function bên trong được kiểm tra gián tiếp thông qua public function.

Cách tiếp cận này giúp test ít phụ thuộc vào implementation hơn. Nếu sau này chúng ta thay đổi cách normalize email hoặc thay đổi cách lưu user, miễn là behavior của createUser() vẫn giữ nguyên thì unit test không cần thay đổi nhiều. Ngược lại, nếu một thay đổi nhỏ trong implementation khiến hàng loạt unit test phải sửa, đó có thể là dấu hiệu test đang phụ thuộc quá nhiều vào implementation details.

Một unit test cũng nên được thực hiện trong môi trường isolated. Nếu function đang test phụ thuộc vào database, external API hoặc một service khác, chúng ta có thể sử dụng test double như mock, stub hoặc fake để thay thế dependency đó. Mục tiêu là chỉ tập trung kiểm tra behavior của unit hiện tại, thay vì vô tình biến unit test thành integration test.

Tuy nhiên, mock không nên được sử dụng một cách máy móc. Nếu một test cần quá nhiều mock và expectation, có thể source code đang có quá nhiều coupling hoặc một function đang đảm nhận quá nhiều responsibility. Khi đó, thay vì tiếp tục bổ sung mock để làm test pass, có thể nên xem xét refactor source code.

Test behavior, không chỉ test happy path

Một unit test tốt không nên chỉ kiểm tra trường hợp mọi thứ hoạt động bình thường. Khi viết test, cần suy nghĩ về nhiều loại scenario khác nhau: happy path, failure scenarios, security cases và edge hoặc corner cases.

Ví dụ với một function createOrder(), happy path có thể rất đơn giản: user hợp lệ, product hợp lệ và order được tạo thành công. Nhưng thực tế còn nhiều trường hợp khác như product không tồn tại, quantity bằng 0 hoặc âm, user không có quyền, database bị lỗi, payment service timeout hoặc input có format không hợp lệ.

Những trường hợp này thường là nơi bug xuất hiện. Vì vậy, thay vì chỉ hỏi “function này có chạy đúng không?”, chúng ta nên hỏi thêm “function này sẽ làm gì khi input không hợp lệ hoặc dependency thất bại?”

Đặc biệt, edge cases thường rất dễ bị bỏ qua. Những giá trị như empty string, empty array, zero, negative number, giá trị nhỏ nhất hoặc lớn nhất được phép, input quá dài hay những trường hợp nằm ngay tại boundary của specification đều nên được xem xét.

Code Coverage không phải mục tiêu cuối cùng

Code coverage là một metric hữu ích nhưng không nên được xem là bằng chứng rằng hệ thống đã được test đầy đủ. Một project hoàn toàn có thể đạt 100% code coverage nhưng vẫn có bug nếu những scenario quan trọng chưa được kiểm tra.

Ví dụ, một function có thể được execute qua tất cả các dòng code, nhưng test chưa từng sử dụng một input nằm ngoài range hợp lệ. Trong trường hợp đó, coverage vẫn có thể rất cao nhưng behavior thực tế vẫn chưa được kiểm chứng.

Nói cách khác, code coverage cho biết code đã được chạy, nhưng không đảm bảo rằng tất cả behavior đã được kiểm tra. Vì vậy, thay vì chỉ đặt mục tiêu đạt một con số coverage nhất định, quan trọng hơn là xác định function có những scenario nào và liệu những scenario quan trọng đó đã được test hay chưa.

Test-Driven Development

TDD, hay Test-Driven Development, là cách phát triển trong đó test được sử dụng để drive implementation. Một vòng lặp TDD cơ bản gồm: viết test, chạy test và thấy test fail, viết implementation đơn giản nhất để test pass, sau đó refactor và lặp lại quá trình này.

Có thể hình dung workflow như sau:

Write Test
    ↓
Run Test → Fail
    ↓
Write simplest implementation
    ↓
Run Test → Pass
    ↓
Refactor
    ↓
Repeat

Ví dụ, khi cần implement calculateShippingFee(), thay vì viết toàn bộ logic ngay từ đầu, chúng ta có thể bắt đầu bằng một scenario đơn giản: với order trên một mức giá nhất định thì shipping fee bằng 0. Test ban đầu sẽ fail vì implementation chưa tồn tại hoặc chưa đáp ứng behavior. Sau đó viết implementation đơn giản nhất để test pass.

Khi có thêm requirement, chúng ta tiếp tục bổ sung scenario mới. Ví dụ order dưới mức miễn phí thì phải tính shipping fee, order bằng đúng boundary thì phải xử lý theo rule tương ứng, hoặc input không hợp lệ phải trả về error. Implementation dần được hình thành thông qua các test cases này.

Một trong những lợi ích của TDD là chúng ta không cần phải thiết kế toàn bộ implementation ngay từ đầu. Design có thể được hình thành dần trong quá trình viết test, implement và refactor. Đồng thời, test suite trở thành safety net giúp chúng ta refactor code mà vẫn có khả năng phát hiện regression.

Behavior-Driven Development

BDD, hay Behavior-Driven Development, tập trung nhiều hơn vào việc mô tả behavior của hệ thống. Một scenario thường được diễn đạt theo cấu trúc Given – When – Then.

Ví dụ:

Given an empty shopping cart
When a product is added
Then the cart should contain one product

Trong đó Given mô tả trạng thái ban đầu, When mô tả action đang thực hiện và Then mô tả behavior hoặc kết quả được mong đợi.

Cách viết này khiến test giống như một specification nhỏ cho behavior của code. Khi đọc test, developer có thể hiểu được function hoặc component được kỳ vọng hoạt động như thế nào mà không nhất thiết phải đọc toàn bộ implementation.

Naming test

Test name cũng đóng vai trò quan trọng vì nó giúp người đọc hiểu scenario mà test đang kiểm tra. Một test name tốt thường nên cho biết function nào đang được test, scenario nào đang xảy ra và kết quả được kỳ vọng.

Ví dụ:

test_pop__when_stack_is_empty()

hoặc:

it_should_raise_error_when_popping_item_from_empty_stack()

Điều quan trọng không nằm ở việc chọn convention nào, mà là project nên có một cách đặt tên nhất quán và dễ đọc. Khi test suite lớn dần, chỉ cần nhìn vào danh sách test cũng có thể hiểu được một phần specification của component.

Specification-Driven Development

TDD không phải là cách duy nhất để bắt đầu từ behavior. Một cách tiếp cận khác được đề cập là Unit Specification-Driven Development (USDD). Thay vì liên tục chuyển qua lại giữa test và implementation, chúng ta có thể xác định specification và các scenario trước, sau đó mới chuyển chúng thành test và implementation.

Ví dụ với một ConfigParser, trước khi viết code có thể xác định trước các scenario:

Valid JSON
→ return Config

Invalid JSON
→ return error

Missing required property
→ return error

Missing optional property
→ use default value

Invalid property type
→ return error

Value out of range
→ return error

Sau khi các scenario đã rõ, chúng ta có thể tạo test cho chúng và bắt đầu implementation. Cách này đặc biệt hữu ích khi function có nhiều rule hoặc nhiều failure scenario, vì nó buộc chúng ta suy nghĩ về specification trước khi bị cuốn vào implementation.

Specification ban đầu cũng không nhất thiết phải hoàn hảo. Trong quá trình implementation, chúng ta có thể phát hiện thêm những scenario chưa được nghĩ tới. Khi đó, scenario mới nên được bổ sung vào specification và test suite thay vì chỉ sửa implementation. Theo thời gian, test suite trở thành một phần executable specification của hệ thống.

Một cách đơn giản để viết test tốt hơn

Có thể bắt đầu mỗi function bằng một vài câu hỏi đơn giản:

Function này phải làm gì?

Happy path là gì?

Nó có thể fail ở đâu?

Có input nào không hợp lệ hoặc liên quan đến security không?

Có edge case nào dễ bị bỏ quên không?

Từ những câu hỏi này, chúng ta có thể tạo ra danh sách scenario trước khi viết implementation.

Ví dụ:

createUser()

valid user
→ created

existing email
→ error

invalid email
→ error

missing required field
→ error

empty input
→ error

extremely long input
→ error

Sau đó những scenario này có thể trở thành unit tests.

Điểm quan trọng nhất không nhất thiết là phải chọn TDD, BDD hay USDD. Những phương pháp này khác nhau về workflow, nhưng đều hướng đến một tư duy chung: xác định behavior và scenario trước, sau đó dùng test để bảo vệ behavior đó.

Vì vậy, thay vì bắt đầu bằng câu hỏi “Tôi phải viết test cho đoạn code này như thế nào?”, có thể bắt đầu bằng một câu hỏi đơn giản hơn:

“Code này phải làm gì, và những trường hợp nào có thể xảy ra?”

Khi specification và scenario đã rõ, việc viết test và implementation thường trở nên dễ dàng hơn rất nhiều.

Và mỗi khi tìm thấy một bug, thay vì chỉ sửa bug đó, hãy tìm scenario đã bị bỏ sót và biến nó thành một test. Khi đó, mỗi bug được phát hiện không chỉ làm code tốt hơn mà còn làm cho test suite của hệ thống mạnh hơn.

Previous Post
Next Post