Khóa học
Kiểm thử là một chủ đề rất lớn trong phát triển phần mềm. Trước khi một sản phẩm phần mềm đến tay người dùng cuối, rất có thể nó đã trải qua nhiều loại kiểm thử như kiểm thử tích hợp, kiểm thử hệ thống và kiểm thử chấp nhận. Mục tiêu của quá trình kiểm thử kỹ lưỡng này là đảm bảo hành vi của ứng dụng hoạt động đúng như kỳ vọng từ góc nhìn của người dùng cuối. Cách tiếp cận này được gọi là phát triển hướng hành vi (BDD).
Gần đây, sự quan tâm đến phát triển hướng kiểm thử (TDD) đã tăng mạnh trong giới lập trình viên. Đi sâu vào chi tiết sẽ quá phức tạp cho phạm vi bài viết, nhưng ý tưởng chung là đảo ngược quy trình phát triển và kiểm thử truyền thống - bạn viết các unit test trước, rồi triển khai thay đổi mã cho đến khi các bài test vượt qua.
Trong bài viết này, chúng ta sẽ tập trung vào unit test và cụ thể là cách thực hiện bằng một framework kiểm thử Python phổ biến có tên Pytest.
Unit test là gì?
Unit test là một dạng kiểm thử tự động - nghĩa là kế hoạch kiểm thử được thực thi bởi script thay vì thủ công bởi con người. Đây là lớp kiểm thử đầu tiên và thường được viết dưới dạng các hàm xác nhận hành vi của các chức năng khác nhau trong một chương trình phần mềm.

Các cấp độ kiểm thử phần mềm
Ý tưởng đằng sau các bài test này là cho phép lập trình viên cô lập đơn vị mã nhỏ nhất có ý nghĩa logic và kiểm tra rằng nó hoạt động như mong đợi. Nói cách khác, unit test xác nhận rằng một thành phần đơn lẻ của chương trình phần mềm hoạt động đúng như nhà phát triển dự định.
Lý tưởng nhất, các bài test này nên khá nhỏ - càng nhỏ càng tốt. Một lý do để xây dựng các bài test nhỏ hơn là hiệu quả hơn, vì kiểm thử các đơn vị nhỏ sẽ giúp mã kiểm thử chạy nhanh hơn. Một lý do khác là nó cho bạn cái nhìn sâu hơn về cách mã ở mức hạt mịn cư xử khi được ghép lại.
Tại sao chúng ta cần unit test?
Lý do chung cho việc cần thiết phải thực hiện unit test là các nhà phát triển phải đảm bảo mã họ viết đáp ứng tiêu chuẩn chất lượng trước khi cho phép nó vào môi trường sản xuất. Tuy nhiên, còn một số yếu tố khác góp phần vào sự cần thiết của unit test. Hãy đi sâu hơn vào một vài lý do đó.
Tiết kiệm tài nguyên
Thực hiện unit test giúp lập trình viên phát hiện lỗi trong giai đoạn xây dựng phần mềm, ngăn chúng đi sâu hơn vào vòng đời phát triển. Điều này tiết kiệm tài nguyên vì không phải trả chi phí sửa lỗi ở giai đoạn sau - đồng thời người dùng cuối cũng ít có khả năng phải đối mặt với mã lỗi.
Tài liệu bổ sung
Một lý do thuyết phục khác là unit test đóng vai trò như một lớp tài liệu "sống" bổ sung cho sản phẩm phần mềm của bạn. Lập trình viên có thể đơn giản tham chiếu tới các unit test để có cái nhìn tổng thể về hệ thống, vì chúng mô tả cách các thành phần nhỏ hơn nên hoạt động.
Tăng tự tin
Rất dễ mắc những lỗi nhỏ khi bạn đang viết một tính năng. Tuy nhiên, hầu hết lập trình viên sẽ đồng ý rằng tốt hơn nhiều là xác định các điểm gãy trong codebase trước khi đưa vào môi trường sản xuất: unit test mang lại cơ hội này.
Có thể nói rằng “mã được bao phủ bởi unit test có thể được coi là đáng tin cậy hơn mã không có”. Những chỗ hỏng trong tương lai có thể được phát hiện nhanh hơn nhiều so với mã không có test, tiết kiệm thời gian và tiền bạc. Lập trình viên cũng hưởng lợi từ tài liệu bổ sung để hiểu codebase nhanh hơn, và thêm vào đó là sự tự tin rằng nếu họ mắc lỗi, unit test sẽ bắt được thay vì người dùng cuối.
Các framework kiểm thử Python
Python đã trở nên cực kỳ phổ biến trong những năm qua. Cùng với sự phát triển đó, số lượng framework kiểm thử cũng tăng theo, mang đến vô số công cụ giúp bạn kiểm thử mã Python. Phân tích chi tiết từng công cụ vượt quá phạm vi bài viết, nhưng chúng ta sẽ điểm qua một số framework kiểm thử Python phổ biến.
unittest
Unittest là một framework tích hợp sẵn của Python cho unit test. Nó lấy cảm hứng từ JUnit trong ngôn ngữ Java. Vì đi kèm với Python, bạn không cần cài thêm module và đa số lập trình viên dùng nó để bắt đầu làm quen với kiểm thử.
Pytest
Pytest có lẽ là framework kiểm thử Python được dùng rộng rãi nhất - nghĩa là có một cộng đồng lớn hỗ trợ bạn khi gặp khó khăn. Đây là framework mã nguồn mở cho phép viết các bộ test đơn giản, gọn nhẹ, đồng thời hỗ trợ unit test, kiểm thử chức năng và kiểm thử API.
doctest
doctest kết hợp hai thành phần cốt lõi của kỹ nghệ phần mềm: tài liệu và kiểm thử. Chức năng này đảm bảo các chương trình phần mềm được ghi tài liệu và kiểm thử đầy đủ để chạy đúng như mong đợi. doctest đi kèm thư viện chuẩn của Python và khá dễ học.
nose2
Nose2, hậu duệ của nose, về bản chất là unittest với plugin. Mọi người thường gọi nose2 là “unit test mở rộng” hoặc “unit test với plugin” do mối liên hệ chặt chẽ với framework unit test tích hợp của Python. Vì thực chất là phần mở rộng của unittest, nose2 rất dễ tiếp nhận đối với những ai quen với unittest.
Testify
Testify, một framework Python cho unit, integration và system test, được biết đến như framework được thiết kế để thay thế unittest và nose. Framework này tích hợp nhiều plugin và có đường cong học tập khá mượt nếu bạn đã quen với unittest.
Hypothesis
Hypothesis cho phép lập trình viên tạo các unit test dễ viết hơn nhưng mạnh mẽ khi chạy. Vì framework được xây dựng để hỗ trợ các dự án khoa học dữ liệu, nó giúp tìm ra các trường hợp biên không rõ ràng trong quá trình viết test bằng cách sinh các ví dụ đầu vào phù hợp với các thuộc tính bạn định nghĩa.
Trong hướng dẫn của chúng ta, chúng ta sẽ dùng pytest. Xem phần tiếp theo để biết vì sao bạn có thể muốn chọn Pytest thay vì các công cụ khác đã liệt kê.
Tại sao dùng Pytest?
Bên cạnh cộng đồng hỗ trợ rộng lớn, pytest có nhiều yếu tố khiến nó trở thành một trong những công cụ tốt nhất để triển khai bộ kiểm thử tự động trong Python. Triết lý và tính năng của Pytest được thiết kế để nâng trải nghiệm kiểm thử cho lập trình viên. Một cách mà những người tạo ra Pytest đạt được mục tiêu này là giảm đáng kể lượng mã cần thiết cho các tác vụ phổ biến và cho phép thực hiện các tác vụ nâng cao với hệ lệnh và plugin phong phú.
Một vài lý do khác để dùng Pytest gồm:
Dễ học
Pytest cực kỳ dễ học: nếu bạn hiểu từ khóa assert của Python hoạt động thế nào, thì bạn đã đi được nửa chặng đường làm chủ framework. Test trong pytest là các hàm Python có tiền tố “test_” hoặc hậu tố “_test” - dù bạn cũng có thể dùng class để nhóm nhiều test. Nhìn chung, đường cong học tập của pytest thoải hơn unittest vì bạn không cần học cấu trúc mới.
Lọc test
Bạn có thể không muốn chạy toàn bộ test mỗi lần - điều này thường xảy ra khi bộ test lớn dần. Đôi khi, bạn muốn cô lập vài test cho một tính năng mới để nhận phản hồi nhanh trong lúc phát triển, rồi chạy toàn bộ khi đã tự tin mọi thứ ổn. Pytest có ba cách để cô lập test: 1) lọc theo tên, yêu cầu pytest chỉ chạy các test có tên khớp mẫu cung cấp 2) phạm vi thư mục, là thiết lập mặc định khiến pytest chỉ chạy những test trong hoặc dưới thư mục hiện tại và 3) phân loại test, cho phép định nghĩa các nhóm test mà pytest nên bao gồm hoặc loại trừ.
Tham số hóa
Pytest có decorator tích hợp tên là parametrize cho phép tham số hóa đối số của hàm test. Do đó, nếu các hàm bạn đang kiểm thử xử lý dữ liệu hoặc thực hiện một phép biến đổi tổng quát, bạn không cần viết nhiều test tương tự nhau. Chúng ta sẽ đề cập thêm về tham số hóa ở phần sau.
Chúng ta dừng ở đây, nhưng danh sách lý do khiến pytest là lựa chọn tuyệt vời cho bộ kiểm thử tự động của bạn còn dài.
Pytest so với unittest
Dù có tất cả lý do trên, vẫn có thể có người phản biện việc dùng pytest chỉ vì đó là framework của bên thứ ba - “cài framework làm gì khi đã có sẵn cái tích hợp?” Lập luận đó có lý, nhưng để bảo toàn quan điểm, chúng tôi sẽ đưa ra vài điều để cân nhắc.
Lưu ý: Nếu bạn đã bị thuyết phục bởi pytest, hãy chuyển tới phần tiếp theo nơi chúng ta tìm hiểu cách dùng framework.
Bớt mã khuôn mẫu
Unittest yêu cầu lập trình viên tạo các lớp dẫn xuất từ module TestCase rồi định nghĩa test case như các phương thức trong lớp.
"""
An example test case with unittest.
See: https://docs.python.org/3/library/unittest.html
"""
import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
def test_isupper(self):
self.assertTrue('FOO'.isupper())
self.assertFalse('Foo'.isupper())
def test_split(self):
s = 'hello world'
self.assertEqual(s.split(), ['hello', 'world'])
# check that s.split fails when the separator is not a string
with self.assertRaises(TypeError):
s.split(2)
Pytest, ngược lại, chỉ cần bạn định nghĩa một hàm với tiền tố “test_” và dùng các điều kiện assert bên trong.
"""
An example test case with pytest.
See: https://docs.pytest.org/en/6.2.x/index.html
"""
# content of test_sample.py
def inc(x):
return x + 1
def test_answer():
assert inc(3) == 5
Hãy để ý sự khác biệt về lượng mã cần viết; unittest đòi hỏi khá nhiều mã khuôn mẫu như yêu cầu tối thiểu cho bất kỳ bài test nào. Điều này khiến bạn rất có khả năng phải lặp lại cùng một đoạn mã nhiều lần. Pytest thì có các tính năng tích hợp phong phú giúp đơn giản hóa quy trình bằng cách giảm lượng mã cần để viết test case.
Đầu ra
Đầu ra mà mỗi framework cung cấp rất khác nhau. Dưới đây là ví dụ chạy pytest:
"""
See: https://docs.pytest.org/en/6.2.x/index.html
"""
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-1.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item
test_sample.py F [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________
def test_answer():
> assert inc(3) == 5
E assert 4 == 5
E + where 4 = inc(3)
test_sample.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 4 == 5
============================ 1 failed in 0.12s =============================
Bài test trên thất bại, nhưng lưu ý mức độ chi tiết của phần phân tích lỗi. Điều này giúp lập trình viên dễ dàng xác định lỗi nằm ở đâu, rất hữu ích khi gỡ lỗi. Thêm vào đó là báo cáo trạng thái tổng thể cho bộ test, cho biết số test thất bại và thời gian chạy.
Hãy xem ví dụ một bài test thất bại với unittest.
import unittest
def square(n):
return n*n
def cube(n):
return n*n*n
class TestCase(unittest.TestCase):
def test_square(self):
self.asserEquals(square(4), 16)
def test_cube(self):
self.assertEquals(cube(4), 16)
Khi chạy script, chúng ta nhận được đầu ra sau:
---------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (errors=1)
Đầu ra trên cho biết đã chạy hai bài test trong 0,001 giây và một bài thất bại, nhưng không có nhiều thông tin khác. Tổng kết lại, pytest cung cấp phản hồi nhiều thông tin hơn, rất hữu ích khi cần gỡ lỗi.
Tựu trung, cả pytest và unittest đều là công cụ tuyệt vời để kiểm thử tự động trong Python. Nhiều lập trình viên Python có thể thiên về pytest do tính gọn nhẹ và hiệu quả. Nó cũng cực kỳ dễ áp dụng và có nhiều tính năng để bạn xây dựng một bộ test hiệu quả.
Giờ đến phần chính của bài viết. Chúng ta đã bàn về unit test là gì và vì sao pytest là công cụ tuyệt vời cho kiểm thử tự động trong Python. Bây giờ hãy xem cách sử dụng công cụ này.
Hướng dẫn Pytest
Hãy xem framework kiểm thử Python mà chúng ta vừa nói hoạt động ra sao.
Bước đầu tiên là cài đặt gói, có thể thực hiện với lệnh pip đơn giản.
Lưu ý: Những người tạo ra pytest khuyên bạn dùng venv cho phát triển và pip để cài đặt ứng dụng, phụ thuộc và chính pytest.
pip install -U pytest
Tiếp theo, kiểm tra framework đã được cài bằng lệnh sau:
>>>> pytest --version
pytest 7.1.2
Mọi thứ đã được cài đặt. Giờ bạn sẵn sàng chạy thử một vài bài test.
Tạo một bài test đơn giản
Tạo test với Pytest rất đơn giản. Để minh họa chức năng này, chúng tôi đã tạo một script tên calcualte_age.py. Script này chỉ có một hàm, get_age, chịu trách nhiệm tính tuổi người dùng dựa trên ngày sinh.
import datetime
def get_age(yyyy:int, mm:int, dd:int) -> int:
dob = datetime.date(yyyy, mm, dd)
today = datetime.date.today()
age = round((today - dob).days / 365.25)
return age
Pytest sẽ thực thi tất cả các tệp python có tiền tố test_ hoặc hậu tố _test trong tên. Cụ thể hơn, pytest tuân theo các quy ước sau để tìm test [nguồn: tài liệu]:
- Khi không chỉ định đối số, pytest sẽ bắt đầu thu thập trong testpaths nếu đã cấu hình: testpaths là danh sách thư mục pytest sẽ tìm khi không cung cấp thư mục, tệp hoặc id test cụ thể.
- Pytest sau đó sẽ đệ quy vào các thư mục trừ khi bạn thiết lập norecursedirs; Nó tìm các tệp bắt đầu bằng test_*.py hoặc kết thúc bằng *_test.py
- Trong các tệp đó, pytest sẽ thu thập test theo thứ tự sau:
- Các hàm hoặc phương thức test có tiền tố nằm ngoài class
- Các hàm hoặc phương thức test có tiền tố nằm trong các lớp bắt đầu bằng Test và không có phương thức __init__.
Chúng ta không chỉ định đối số nào, nhưng đã tạo một script khác trong cùng thư mục tên test_calculate_age.py: do đó, khi các thư mục được duyệt đệ quy, test sẽ được phát hiện. Trong script này, chúng ta có một test duy nhất, test_get_age, để xác nhận hàm hoạt động đúng.
Lưu ý: Bạn có thể quyết định đặt các test vào một thư mục riêng bên ngoài ứng dụng - ý tưởng hay nếu bạn có nhiều kiểm thử chức năng hoặc muốn tách biệt mã kiểm thử và mã ứng dụng vì lý do nào đó.
from calculate_age import get_age
def test_get_age():
# Given.
yyyy, mm, dd = map(int, "1996/07/11".split(""))
# When.
age = get_age(yyyy, mm, dd)
# Then.
assert age == 26
Để thực thi bài test, chạy lệnh sau từ command prompt:
py -m pytest

Đầu ra của một lần chạy pytest thành công.
Vậy là xong.
Nhưng nếu chúng ta cần cung cấp dữ liệu để các bài test vượt qua thì sao? Xin chào fixtures của pytest.
Pytest Fixtures
Tính năng fixtures vô cùng hữu ích của Pytest cho phép lập trình viên cấp dữ liệu vào các bài test. Về bản chất, chúng là các hàm chạy trước mỗi hàm test để quản lý trạng thái của bài test. Ví dụ, nếu có nhiều test cùng dùng một bộ dữ liệu, chúng ta có thể dùng fixture để lấy dữ liệu lặp lại đó chỉ bằng một hàm.
import pytest
from sklearn.model_selection import train_test_split
from fraud_detection_model.config.core import config
from fraud_detection_model.processing.data_manager import (
load_datasets,
load_interim_data
)
@pytest.fixture(scope="session")
def pipeline_inputs():
# import the training dataset
dataset = load_datasets(transaction=config.app_config.train_transaction, identity=config.app_config.train_identity, )
# divide train and test
X_train, X_test, y_train, y_test = train_test_split(
dataset[config.model_config.all_features], dataset[config.model_config.target],
test_size=config.model_config.test_size,
stratify=dataset[config.model_config.target],
random_state=config.model_config.random_state)
return X_train, X_test, y_train, y_test
Trong đoạn mã trên, chúng ta đã tạo một fixture bằng decorator pytest.fixture. Lưu ý phạm vi được đặt là "session" để thông báo cho pytest rằng chúng ta muốn hủy fixture khi kết thúc phiên kiểm thử.
Lưu ý: các fixture của chúng ta được lưu trong conftest.py.
Mã sử dụng một hàm được import từ module khác để nạp hai tệp csv vào bộ nhớ và gộp thành một tập dữ liệu. Sau đó, tập dữ liệu được chia thành tập huấn luyện và kiểm thử và trả về từ hàm.
Để dùng fixture này trong test, chúng ta phải truyền nó làm tham số cho hàm test. Trong ví dụ này, dùng fixture của chúng ta trong script test_pipeline.py như sau:
import pandas as pd
from pandas.api.types import is_categorical_dtype, is_object_dtype
from fraud_detection_model import pipeline
from fraud_detection_model.config.core import configfrom fraud_detection_model.processing.validation import validate_inputs
def test_pipeline_most_frequent_imputer(pipeline_inputs):
# Given
X_train, _, _, _ = pipeline_inputs assert all( x in X_train.loc[:, X_train.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
# When
X_transformed = pipeline.fraud_detection_pipe[:1].fit_transform(X_train[:50])
# Then
assert all( x not in X_transformed.loc[:, X_transformed.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
def test_pipeline_aggregate_categorical(pipeline_inputs):
# Given
X_train, _, _, _ = pipeline_inputs
assert X_train["R_emaildomain"].nunique() == 60
# When
X_transformed = pipeline.fraud_detection_pipe[:2].fit_transform(X_train[:50])
# Then
assert X_transformed["R_emaildomain"].nunique() == 2
Đừng quá bận tâm về việc đoạn mã làm gì. Điều quan trọng nhất chúng tôi muốn nhấn mạnh là chúng ta đã giảm đáng kể việc phải viết mã lặp lại vì đã tạo một fixture và truyền nó làm tham số để lấy dữ liệu vào các bài test.
Tuy nhiên, có những tình huống fixtures có thể là quá mức cần thiết. Chẳng hạn, nếu dữ liệu được đưa vào test phải được xử lý lại trong mỗi test case - điều này gần như tương đương với việc rải rác nhiều đối tượng thuần khắp mã. Nói vậy, fixtures rất có thể sẽ đóng vai trò then chốt trong bộ test của bạn, nhưng quyết định khi nào nên dùng hay tránh dùng sẽ cần thực hành và suy nghĩ kỹ.
Pytest parametrize
Fixtures rất tuyệt khi bạn có nhiều test với cùng đầu vào. Vậy nếu bạn muốn kiểm thử một hàm với những biến thể nhỏ của đầu vào thì sao? Một giải pháp là viết nhiều test khác nhau với các trường hợp đa dạng.
def test_eval_addition():
assert eval("2 + 2") == 4
def test_eval_subtraction():
assert eval("2 - 2") == 0
def test_eval_multiplication():
assert eval("2 * 2") == 4
def test_eval_division():
assert eval("2 / 2") == 1.0
Dù giải pháp này hoạt động, nó không tối ưu: trước hết, có rất nhiều mã khuôn mẫu. Giải pháp tốt hơn là dùng decorator pytest.mark.parametrize() để tham số hóa đối số cho hàm test. Điều này cho phép chúng ta định nghĩa một test duy nhất, rồi pytest sẽ kiểm thử với các tham số khác nhau mà chúng ta chỉ định.
Đây là cách chúng ta sẽ viết lại đoạn mã trên nếu dùng tham số hóa:
import pytest
@pytest.mark.parametrize("test_input, expected_output", [("2+2", 4), ("2-2", 0), ("2*2", 4), ("2/2", 1.0)])
def test_eval(test_input, expected_output):
assert eval(test_input) == expected_output
Decorator @parametrize định nghĩa bốn bộ đầu vào test và giá trị kỳ vọng cho hàm test_eval thực thi - nghĩa là hàm sẽ chạy bốn lần, mỗi lần với một bộ.

Đầu ra từ pytest cho bài test tham số hóa
Trong bài viết này, chúng ta đã đề cập:
- unit test là gì
- tại sao cần unit test
- các framework kiểm thử khác nhau trong python
- Vì sao pytest hữu ích
- cách dùng pytest và hai tính năng chủ chốt (fixtures & tham số hóa)
Bây giờ bạn đã biết đủ để bắt đầu viết test của riêng mình bằng framework pytest. Chúng tôi rất khuyến khích bạn làm điều đó để củng cố kiến thức từ bài viết. Bạn cũng nên xem khóa học về unit test cho khoa học dữ liệu bằng python để có giải thích chi tiết hơn kèm ví dụ.