Thử nghiệm production grade code là một công việc khó khăn. Đôi khi bạn có thể mất gần như toàn bộ thời gian trong quá trình phát triển tính năng này. Hơn nữa, ngay cả khi bạn có phạm vi phủ sóng 100% và vượt qua các bài thử nghiệm, bạn vẫn có thể không cảm thấy tự tin về tính năng mới sẽ hoạt động bình thường trong quá trình sản xuất.
Hướng dẫn này sẽ cho bạn biết quá trình phát triển ứng dụng bằng Test-Driven Development (TDD). Bài viết đề cập đến cách thức và những thứ bạn nên kiểm tra. Chúng tôi sẽ sử dụng pytest để thử nghiệm, pydantic để xác thực dữ liệu và giảm số lượng thử nghiệm cần thiết và Flask để cung cấp giao diện cho khách hàng thông qua API RESTful. Cuối cùng, bạn sẽ có một mẫu mô hình vững chắc có thể sử dụng cho bất kỳ dự án Python nào để bạn có thể tự tin vượt qua các bài thử nghiệm đúng nghĩa là một phần mềm hoạt động được.
>>> Tổng hợp 5 Phần mềm Lập trình Python phổ biến nhất năm 2022
Mục tiêu
Sau bài viết này, bạn sẽ có thể:
1. Giải thích cách bạn nên thử nghiệm phần mềm của mình
2. Định cấu hình pytest và thiết lập cấu trúc dự án để thử nghiệm
3. Xác định các mô hình cơ sở dữ liệu với pydantic
4. Sử dụng đồ đạc pytest để quản lý trạng thái thử nghiệm và thực hiện các tác dụng phụ
5. Xác minh các phản hồi JSON dựa trên các định nghĩa của Lược đồ JSON
6. Tổ chức các hoạt động cơ sở dữ liệu bằng các lệnh (sửa đổi trạng thái, thêm hiệu ứng phụ tác động) và truy vấn (chỉ đọc, không có hiệu ứng phụ tác động)
7. Viết Unit test, kiểm thử tích hợp và kiểm thử end-to-end với pytest
8. Giải thích tại sao cần tập trung nỗ lực thử nghiệm vào hành vi thử nghiệm hơn là chi tiết triển khai
Tôi nên kiểm thử phần mềm của mình như thế nào?
Các lập trình viên phần mềm có xu hướng rất độc đoán về kiểm thử. Do đó, họ có những ý kiến khác nhau về tầm quan trọng của kiểm thử và ý tưởng về cách thực hiện nó. Vậy nên, chúng ta hãy xem xét ba nguyên tắc mà (hy vọng) hầu hết các lập trình viên sẽ đồng ý giúp bạn viết các bài kiểm thử có giá trị:
1. Kiểm thử sẽ cho biết unit test có đáp ứng yêu cầu mong đợi. Do đó, bạn nên code ngắn gọn và tập trung vào trọng tâm. Cấu trúc GIVEN, WHEN, THEN có thể giúp thực hiện điều này:
- GIVEN - điều kiện ban đầu cho bài kiểm thử là gì?
- KHI NÀO - điều gì đang xảy ra cần được kiểm thử?
- SAU ĐÓ – kết quả mong đợi là gì?
Vì vậy, bạn nên chuẩn bị môi trường kiểm thử, kiểm thử behavior, cuối cùng, hãy kiểm tra xem đầu ra có đáp ứng mong đợi hay không.
2. Mỗi behavior nên được kiểm thử một lần - và chỉ một lần. Kiểm thử cùng một behavior nhiều lần không có nghĩa là phần mềm của bạn có khả năng hoạt động cao hơn. Các bài kiểm thử cũng cần được duy trì. Nếu bạn thực hiện một thay đổi nhỏ đối với cơ sở mã của mình và sau đó hai mươi kiểm thử bị hỏng, làm thế nào để bạn biết chức năng nào bị hỏng? Nếu chỉ một lần kiểm thử không thành công thì việc tìm ra lỗi sẽ dễ dàng hơn nhiều.
3. Mỗi bài kiểm thử phải độc lập với các bài kiểm thử khác. Nếu không, bạn sẽ gặp khó khăn trong việc duy trì và chạy test suite.
Hướng dẫn này cũng mang tính chủ quan. Đừng coi bất cứ điều gì là giải pháp duy nhất. Vui lòng liên hệ trên Twitter (@jangiacomelli) để thảo luận bất kỳ điều gì liên quan đến hướng dẫn này.
> Nếu bạn đã cài đặt thành công phần mềm lập trình Python thì bắt đầu TỰ HỌC PYTHON ngay. Hoặc tham gia KHÓA HỌC PYTHON tại NIIT - ICT Hà Nội để được hướng dẫn với lộ trình bài bản hơn.
Thiết lập cơ bản
Hãy chăm chỉ và sẵn sàng để xem tất cả những điều này có ý nghĩa gì trong thế giới thực. Bài kiểm thử đơn giản nhất với pytest như hình dưới đây:
def another_sum(a, b):
return a + b
def test_another_sum():
assert another_sum(3, 2) == 5
Đó là ví dụ mà bạn có thể đã thấy ít nhất một lần. Trước hết, bạn sẽ không bao giờ viết các bài kiểm thử bên trong cơ sở mã, vì vậy hãy chia nó thành hai file và package.
Tạo một thư mục mới cho dự án này và chuyển vào đó:
$ mkdir testing_project
$ cd testing_project
Tiếp theo, tạo (và kích hoạt) một môi trường ảo.
Để biết thêm về cách quản lý dependencies và môi trường ảo, hãy xem Môi trường Python hiện đại.
Thứ ba, cài đặt pytest:
(venv)$ pip install pytest
Sau đó, tạo một thư mục mới tên là "sum". Thêm an __init__.py vào thư mục mới, để biến nó thành package, cùng với tệp a another_sum.py :
def another_sum(a, b):
return a + b
Thêm một thư mục khác đặt tên là "tests" and them các file và thư mục dưới đây:
└── tests
├── __init__.py
└── test_sum
├── __init__.py
└── test_another_sum.py
Bây giờ, bạn sẽ có:
├── sum
│ ├── __init__.py
│ └── another_sum.py
└── tests
├── __init__.py
└── test_sum
├── __init__.py
└── test_another_sum.py
Trong test_another_sum.py thêm:
from sum.another_sum import another_sum
def test_another_sum():
assert another_sum(3, 2) == 5
Tiếp theo, thêm một tệp trống conftest.py, được sử dụng để lưu trữ pytest fixtures, bên trong thư mục “tests”.
Cuối cùng, thêm pytest.ini – một tệp cấu hình pytest – vào thư mục "tests", cũng có thể đang là một thư mục trống.
Cấu trúc dự án đầy đủ bây giờ sẽ như sau:
├── sum
│ ├── __init__.py
│ └── another_sum.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_sum
├── __init__.py
└── test_another_sum.py
Để các test trong một package duy nhất cho phép bạn:
1. Sử dụng lại cấu hình pytest trong tất cả các test
2. Sử dụng lại fixtures trong tất cả các test
3. Đơn giản hóa việc chạy test
Bạn có thể chạy tất cả các test bằng lệnh này:
(venv)$ python -m pytest tests
Bạn sẽ thấy kết quả của các test, trong trường hợp này là test_another_sum:
============================== test session starts ==============================
platform darwin -- Python 3.10.1, pytest-7.0.1, pluggy-1.0.0
rootdir: /testing_project/tests, configfile: pytest.ini
collected 1 item
tests/test_sum.py/test_another_sum.py . [100%]
=============================== 1 passed in 0.01s ===============================
Ứng dụng thực tế Test-Driven Development
Bây giờ bạn đã có ý tưởng cơ bản về cách thiết lập và cấu trúc các test, hãy xây dựng một ứng dụng blog đơn giản. Chúng tôi sẽ thiết lập nó bằng TDD để xem test đang hoạt động. Chúng tôi sẽ sử dụng Flask cho frawework web của mình và để tập trung vào kiểm thử thì SQLite được sử dụng cho cơ sở dữ liệu của chúng tôi.
Ứng dụng của chúng tôi sẽ có các yêu cầu sau:
-
các article có thể được tạo
-
các article có thể được tìm nạp
-
các article có thể được liệt kê
Đầu tiên, hãy tạo một dự án mới:
$ mkdir blog_app
$ cd blog_app
Thứ hai, tạo (và kích hoạt) một môi trường ảo.
Thứ ba, cài đặt pytest và pydantic, một thư viện phân tích cú pháp và xác thực dữ liệu:
(venv)$ pip install pytest && pip install "pydantic[email]"
pip install "pydantic[email]" cài đặt pydantic cùng với email-validator, sẽ được sử dụng để xác thực địa chỉ email.
Tiếp đó, tạo các file và thư mục dưới đây:
blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ └── models.py
└── tests
├── __init__.py
├── conftest.py
└── pytest.ini
Thêm code dưới đây vào models.py to để xác định mô hình mới Article với pydantic:
import os
import sqlite3
import uuid
from typing import List
from pydantic import BaseModel, EmailStr, Field
class NotFound(Exception):
pass
class Article(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
@classmethod
def get_by_id(cls, article_id: str):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles WHERE id=?", (article_id,))
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
@classmethod
def get_by_title(cls, title: str):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles WHERE title = ?", (title,))
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
@classmethod
def list(cls) -> List["Article"]:
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles")
records = cur.fetchall()
articles = [cls(**record) for record in records]
con.close()
return articles
def save(self) -> "Article":
with sqlite3.connect(os.getenv("DATABASE_NAME", "database.db")) as con:
cur = con.cursor()
cur.execute(
"INSERT INTO articles (id,author,title,content) VALUES(?, ?, ?, ?)",
(self.id, self.author, self.title, self.content)
)
con.commit()
return self
@classmethod
def create_table(cls, database_name="database.db"):
conn = sqlite3.connect(database_name)
conn.execute(
"CREATE TABLE IF NOT EXISTS articles (id TEXT, author TEXT, title TEXT, content TEXT)"
)
conn.close()
Đây là một mô hình kiểu Active Record, cung cấp các phương pháp lưu trữ, tìm nạp một article và liệt kê tất cả các article. Bạn có thể tự hỏi tại sao chúng tôi không viết test bao gồm mô hình. Chúng tôi sẽ tìm hiểu lý do tại sao trong thời gian ngắn.
Tạo một article mới với Python
Tiếp theo, hãy trình bày logic kinh doanh của chúng ta. Chúng tôi sẽ viết một số lệnh và truy vấn của trình trợ giúp để tách logic của chúng tôi khỏi mô hình và API. Vì chúng tôi đang sử dụng pydantic, chúng tôi có thể dễ dàng xác thực dữ liệu dựa trên mô hình của chúng tôi.
Tạo package "test_article" trong thư mục "tests". Sau đó, thêm một tệp gọi là test_commands.py vào đó.
blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ └── models.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_article
├── __init__.py
└── test_commands.py
Thêm test dưới đây vào test_commands.py:
import pytest
from blog.models import Article
from blog.commands import CreateArticleCommand, AlreadyExists
def test_create_article():
"""
GIVEN CreateArticleCommand with valid author, title, and content properties
WHEN the execute method is called
THEN a new Article must exist in the database with the same attributes
"""
cmd = CreateArticleCommand(
author="john@doe.com",
title="New Article",
content="Super awesome article"
)
article = cmd.execute()
db_article = Article.get_by_id(article.id)
assert db_article.id == article.id
assert db_article.author == article.author
assert db_article.title == article.title
assert db_article.content == article.content
def test_create_article_already_exists():
"""
GIVEN CreateArticleCommand with a title of some article in database
WHEN the execute method is called
THEN the AlreadyExists exception must be raised
"""
Article(
author="jane@doe.com",
title="New Article",
content="Super extra awesome article"
).save()
cmd = CreateArticleCommand(
author="john@doe.com",
title="New Article",
content="Super awesome article"
)
with pytest.raises(AlreadyExists):
cmd.execute()
Các test này bao gồm các trường hợp sử dụng kinh doanh sau:
• các article nên được tạo ra để có dữ liệu hợp lệ
• tiêu đề article phải là duy nhất
Chạy các test từ thư mục dự án của bạn để thấy rằng chúng không thành công:
(venv)$ python -m pytest tests
Bây giờ chúng ta có thể thực hiện lệnh.
Thêm một tệp a commands.py vào thư mục "blog":
from pydantic import BaseModel, EmailStr
from blog.models import Article, NotFound
class AlreadyExists(Exception):
pass
class CreateArticleCommand(BaseModel):
author: EmailStr
title: str
content: str
def execute(self) -> Article:
try:
Article.get_by_title(self.title)
raise AlreadyExists
except NotFound:
pass
article = Article(
author=self.author,
title=self.title,
content=self.content
).save()
return article
Test Fixtures
Chúng ta có thể sử dụng pytest fixtures để xóa cơ sở dữ liệu sau mỗi lần test và tạo một cơ sở dữ liệu mới trước mỗi lần test. Fixtures là các chức năng được trang trí bằng trình trang trí @ pytest.fixture. Chúng thường nằm bên trong conftest.py nhưng chúng cũng có thể được thêm vào các tệp test thực tế. Các chức năng này được thực thi mặc định trước mỗi lần test.
Có một tùy chọn là sử dụng các giá trị trả về của chúng bên trong các test. Ví dụ:
import random
import pytest
@pytest.fixture
def random_name():
names = ["John", "Jane", "Marry"]
return random.choice(names)
def test_fixture_usage(random_name):
assert random_name
Vì vậy, để sử dụng giá trị trả về từ fixture bên trong test, bạn chỉ cần thêm tên của chức năng fixture làm tham số cho chức năng test.
Một tùy chọn khác là thực hiện một hiệu ứng phụ, như tạo cơ sở dữ liệu hoặc mocking một mô hình.
Bạn cũng có thể chạy một phần của fixture trước và một phần sau test bằng cách sử dụng yield thay vì return. Ví dụ:
@pytest.fixture
def some_fixture():
# do something before your test
yield # test runs here
# do something after your test
Bây giờ, hãy thêm fixture sau vào conftest.py, tạo cơ sở dữ liệu mới trước mỗi lần test và sau đó xóa:
import os
import tempfile
import pytest
from blog.models import Article
@pytest.fixture(autouse=True)
def database():
_, file_name = tempfile.mkstemp()
os.environ["DATABASE_NAME"] = file_name
Article.create_table(database_name=file_name)
yield
os.unlink(file_name)
Cờ autouse được đặt thành True để nó tự động được sử dụng mặc định trước (và sau) mỗi test trong test suite. Vì chúng tôi đang sử dụng cơ sở dữ liệu cho tất cả các test nên sử dụng cờ này là hợp lý. Bằng cách đó, bạn không phải thêm chính xác tên cố định vào tất cả các test dưới dạng tham số.
Nếu bạn thực sự không cần quyền truy cập vào cơ sở dữ liệu để test ở mọi mơi, bạn có thể tắt tính năng autouse với điểm test. Bạn có thể xem một ví dụ dưới đây.
(venv)$ python -m pytest tests
Test thành công.
Như bạn có thể thấy, test của chúng tôi chỉ kiểm tra lệnh CreateArticleCommand. Chúng tôi không kiểm tra mô hình Article thực tế vì nó không chịu trách nhiệm về logic kinh doanh. Chúng tôi biết rằng lệnh đã hoạt động như mong đợi. Do đó, không cần phải viết thêm bất kỳ test nào khác.
Danh sách tất cả Articles
Yêu cầu tiếp theo là liệt kê tất cả các article. Tại đây, chúng tôi sẽ sử dụng một truy vấn thay vì lệnh, vì vậy hãy thêm một tệp mới có tên là test_queries.py vào thư mục "test_article":
from blog.models import Article
from blog.queries import ListArticlesQuery
def test_list_articles():
"""
GIVEN 2 articles stored in the database
WHEN the execute method is called
THEN it should return 2 articles
"""
Article(
author="jane@doe.com",
title="New Article",
content="Super extra awesome article"
).save()
Article(
author="jane@doe.com",
title="Another Article",
content="Super awesome article"
).save()
query = ListArticlesQuery()
assert len(query.execute()) == 2
Chạy các test:
(venv)$ python -m pytest tests
Test thất bại.
Thêm một tệp queries.py vào thư mục "blog":
blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ ├── commands.py
│ ├── models.py
│ └── queries.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_article
├── __init__.py
├── test_commands.py
└── test_queries.py
Bây giờ chúng ta có thể triển khai truy vấn:
from typing import List
from pydantic import BaseModel
from blog.models import Article
class ListArticlesQuery(BaseModel):
def execute(self) -> List[Article]:
articles = Article.list()
return articles
Mặc dù không có tham số nào ở đây, nhưng để đảm bảo tính nhất quán, chúng tôi đã dùng lại từ BaseModel.
Chạy lại các test:
(venv)$ python -m pytest tests
Bây giờ các test chạy thành công.
Lấy Article theo ID
Lấy article đơn lẻ theo ID của nó theo cách tương tự như liệt kê tất cả các article. Thêm một test mới cho GetArticleByIDQuery vào test_queries.py.
from blog.models import Article
from blog.queries import ListArticlesQuery, GetArticleByIDQuery
def test_list_articles():
"""
GIVEN 2 articles stored in the database
WHEN the execute method is called
THEN it should return 2 articles
"""
Article(
author="jane@doe.com",
title="New Article",
content="Super extra awesome article"
).save()
Article(
author="jane@doe.com",
title="Another Article",
content="Super awesome article"
).save()
query = ListArticlesQuery()
assert len(query.execute()) == 2
def test_get_article_by_id():
"""
GIVEN ID of article stored in the database
WHEN the execute method is called on GetArticleByIDQuery with an ID
THEN it should return the article with the same ID
"""
article = Article(
author="jane@doe.com",
title="New Article",
content="Super extra awesome article"
).save()
query = GetArticleByIDQuery(
id=article.id
)
assert query.execute().id == article.id
Chạy test để đảm bảo chúng thất bại:
(venv)$ python -m pytest tests
Tiếp theo, thêm GetArticleByIDQuery vào queries.py:
from typing import List
from pydantic import BaseModel
from blog.models import Article
class ListArticlesQuery(BaseModel):
def execute(self) -> List[Article]:
articles = Article.list()
return articles
class GetArticleByIDQuery(BaseModel):
id: str
def execute(self) -> Article:
article = Article.get_by_id(self.id)
return article
Bây giờ các test thành công:
(venv)$ python -m pytest tests
Tuyệt. Chúng tôi đáp ứng tất cả các yêu cầu được đề cập ở trên:
-
articles có thể được tạo
-
articles có thể được tìm nạp
-
articles có thể được liệt kê
Tất cả chúng đều được test. Vì chúng tôi đang sử dụng pydantic để xác thực dữ liệu trong thời gian chạy, chúng tôi không cần nhiều test để bao quát logic kinh doanh. Nếu author không phải là một email hợp lệ, pydantic sẽ phát sinh lỗi. Tất cả những gì cần thiết là đặt thuộc tính author thành loại EmailStr. Chúng tôi cũng không cần phải test nó vì nó đã được các nhà bảo trì pydantic kiểm thử.
Vậy nên, chúng tôi đã sẵn sàng giới thiệu chức năng này với thế giới thông qua API RESTful của Flask.
Tạo API với Flask
Chúng tôi sẽ giới thiệu ba endpoint đáp ứng yêu cầu:
-
/create-article/ - tạo một article mới
-
/article-list/ - lấy lại tất cả articles
-
/article/<article_id>/ - fetch article
Đầu tiên, tạo một thư mục có tên "schemas" bên trong "test_article", and thêm hai JSON schemas vào đó, Article.json và ArticleList.json.
Article.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Article",
"type": "object",
"properties": {
"id": {
"type": "string"
},
"author": {
"type": "string"
},
"title": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": ["id", "author", "title", "content"]
}
ArticleList.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ArticleList",
"type": "array",
"items": {"$ref": "file:Article.json"}
}JSON Schemas được sử dụng để xác định phản hồi từ API endpoints. Trước khi tiếp tục, cài đặt thư viện Python jsonschema, được sử dụng để xác thực JSON payloads against the defined schemas, and Flask:
(venv)$ pip install jsonschema Flask
Tiếp theo, hãy viết các test tích hợp cho API.
Thêm một tệp mới gọi là test_app.py cho "test_article":
import json
import pathlib
import pytest
from jsonschema import validate, RefResolver
from blog.app import app
from blog.models import Article
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def validate_payload(payload, schema_name):
"""
Validate payload with selected schema
"""
schemas_dir = str(
f"{pathlib.Path(__file__).parent.absolute()}/schemas"
)
schema = json.loads(pathlib.Path(f"{schemas_dir}/{schema_name}").read_text())
validate(
payload,
schema,
resolver=RefResolver(
"file://" + str(pathlib.Path(f"{schemas_dir}/{schema_name}").absolute()),
schema # it's used to resolve the file inside schemas correctly
)
)
def test_create_article(client):
"""
GIVEN request data for new article
WHEN endpoint /create-article/ is called
THEN it should return Article in json format that matches the schema
"""
data = {
'author': "john@doe.com",
"title": "New Article",
"content": "Some extra awesome content"
}
response = client.post(
"/create-article/",
data=json.dumps(
data
),
content_type="application/json",
)
validate_payload(response.json, "Article.json")
def test_get_article(client):
"""
GIVEN ID of article stored in the database
WHEN endpoint /article/<id-of-article>/ is called
THEN it should return Article in json format that matches the schema
"""
article = Article(
author="jane@doe.com",
title="New Article",
content="Super extra awesome article"
).save()
response = client.get(
f"/article/{article.id}/",
content_type="application/json",
)
validate_payload(response.json, "Article.json")
def test_list_articles(client):
"""
GIVEN articles stored in the database
WHEN endpoint /article-list/ is called
THEN it should return list of Article in json format that matches the schema
"""
Article(
author="jane@doe.com",
title="New Article",
content="Super extra awesome article"
).save()
response = client.get(
"/article-list/",
content_type="application/json",
)
validate_payload(response.json, "ArticleList.json")Vậy thì điều gì đang xảy ra ở đây?
Đầu tiên, chúng tôi đã xác định Flask test client như một fixture để nó có thể được sử dụng trong các test.
1. Sau đó, chúng tôi thêm một chức năng để xác thực payloads. Nó có hai tham số:
-
payload – phản hồi JSON từ API
-
schema_name – tên của tệp lược đồ bên trong thư mục "schemas
2.Cuối cùng, mỗi endpoint có 3 test. Bên trong mỗi test có một lệnh gọi tới API và xác thực payload được trả về.
Chạy test để đảm bảo chúng thất bại tại thời điểm này:
(venv)$ python -m pytest tests
Bây giờ chúng ta có thể viết API.
Cập nhật app.py như dưới đây:
from flask import Flask, jsonify, request
from blog.commands import CreateArticleCommand
from blog.queries import GetArticleByIDQuery, ListArticlesQuery
app = Flask(__name__)
@app.route("/create-article/", methods=["POST"])
def create_article():
cmd = CreateArticleCommand(
**request.json
)
return jsonify(cmd.execute().dict())
@app.route("/article/<article_id>/", methods=["GET"])
def get_article(article_id):
query = GetArticleByIDQuery(
id=article_id
)
return jsonify(query.execute().dict())
@app.route("/article-list/", methods=["GET"])
def list_articles():
query = ListArticlesQuery()
records = [record.dict() for record in query.execute()]
return jsonify(records)
if __name__ == "__main__":
app.run()Các trình xử lý định tuyến khá đơn giản vì tất cả logic được bao phủ bởi các lệnh và truy vấn. Các action có sẵn với các hiệu ứng phụ (như mutations) được biểu thị bằng các lệnh - ví dụ: tạo một article mới. Mặt khác, các action không có hiệu ứng phụ, những action chỉ đọc trạng thái hiện tại, được bao phủ bởi các truy vấn.
Mẫu lệnh và truy vấn được sử dụng trong bài đăng này là phiên bản đơn giản hóa của mô hình CQRS. Chúng tôi đang kết hợp CQRS và CRUD.
Phương thức .dict() ở trên được cung cấp bởi BaseModel từ pydantic, thứ mà tất cả các mô hình của chúng tôi đều kế thừa từ đó.
Các bài test sẽ thành công:
(venv)$ python -m pytest tests
Chúng tôi đã đề cập đến các kịch bản mặc định không có lỗi. Trong thế giới thực, khách hàng không phải lúc nào cũng sử dụng API như chúng ta dự kiến. Ví dụ, khi thực hiện yêu cầu tạo một article mà không có titlethì ValidationError sẽ xuất hiện bởi lệnh CreateArticleCommand , điều này sẽ dẫn đến lỗi máy chủ nội bộ và trạng thái HTTP 500. Đó là điều mà chúng tôi muốn tránh. Do vậy, chúng tôi cần xử lý các lỗi như vậy để thông báo cho người dùng về bad request một cách hợp lý.
Hãy viết các test bao gồm các trường hợp đó. Thêm như dưới đây vào test_app.py:
@pytest.mark.parametrize(
"data",
[
{
"author": "John Doe",
"title": "New Article",
"content": "Some extra awesome content"
},
{
"author": "John Doe",
"title": "New Article",
},
{
"author": "John Doe",
"title": None,
"content": "Some extra awesome content"
}
]
)
def test_create_article_bad_request(client, data):
"""
GIVEN request data with invalid values or missing attributes
WHEN endpoint /create-article/ is called
THEN it should return status 400
"""
response = client.post(
"/create-article/",
data=json.dumps(
data
),
content_type="application/json",
)
assert response.status_code == 400
assert response.json is not None
Chúng tôi đã sử dụng tùy chọn tham số của pytest, giúp đơn giản hóa đầu vào khác nhau cho một test duy nhất.
Test sẽ không thành công tại thời điểm này vì chúng tôi chưa xử lý ValidationError :
(venv)$ python -m pytest tests
Vậy nên hãy thêm trình xử lý lỗi vào ứng dụng Flask bên trong app.py:
from pydantic import ValidationError
# Other code ...
app = Flask(__name__)
@app.errorhandler(ValidationError)
def handle_validation_exception(error):
response = jsonify(error.errors())
response.status_code = 400
return response
# Other code ...
ValidationError có một phương thức errors trả về danh sách tất cả các lỗi cho từng trường bị thiếu hoặc do chuyển một value không qua xác thực. Chúng ta có thể đơn giản trả lại lỗi này trong phần thân và đặt trạng thái của phản hồi thành 400.
Bây giờ lỗi đã được xử lý thích hợp, tất cả các test sẽ qua:
(venv)$ python -m pytest tests
Độ bao phủ mã (Code Coverage)
Bây giờ, với ứng dụng đã được test, đã đến lúc kiểm tra độ phủ của mã. Vì vậy, hãy cài đặt một plugin pytest cho phạm vi bao phủ được gọi là pytest-cov:
(venv)$ pip install pytest-cov
After the plugin is installed, we can check code coverage of our blog application like this:
Sau khi plugin được cài đặt, chúng tôi có thể kiểm tra mức độ phủ mã của ứng dụng blog như sau:
(venv)$ python -m pytest tests --cov=blog
Bạn sẽ thấy điều tương tự như sau:
---------- coverage: platform darwin, python 3.10.1-final-0 ----------
Name Stmts Miss Cover
--------------------------------------
blog/__init__.py 0 0 100%
blog/app.py 25 1 96%
blog/commands.py 16 0 100%
blog/models.py 57 1 98%
blog/queries.py 12 0 100%
--------------------------------------
TOTAL 110 2 98%
Độ che phủ 98% có đủ tốt không? Có lẽ là được. Tuy nhiên, hãy nhớ một điều: Tỷ lệ bao phủ cao là rất tốt nhưng chất lượng các bài test của bạn quan trọng hơn nhiều. Nếu chỉ có 70% mã hoặc ít hơn như thế được bao phủ, bạn nên nghĩ đến việc tăng tỷ lệ bao phủ. Nhưng nhìn chung không có ý nghĩa khi viết các bài kiểm tra từ 98% đến 100%. (Một lần nữa, các test cần được duy trì giống như logic kinh doanh của bạn!)
End-to-end Tests
We have a working API at this point that's fully tested. We can now look at how to write some end-to-end (e2e) tests. Since we have a simple API we can write a single e2e test to cover the following scenario:
Tại thời điểm này, chúng tôi có một API đang hoạt động đã được test đầy đủ. Bây giờ chúng ta có thể xem cách viết một số end-to-end test (e2e). Vì chúng tôi có một API đơn giản, chúng tôi có thể viết một test e2e duy nhất để giải quyết tình huống sau:
-
Tạo một article mới
-
Liệt kê các articles
-
Lấy article đầu tiên từ danh sách
Thứ nhất, hãy cài đăt requests library (thư viện yêu cầu):
(venv)$ pip install requests
Thứ hai, thêm một test mới vào test_app.py:
import requests
# other code ...
@pytest.mark.e2e
def test_create_list_get(client):
requests.post(
"http://localhost:5000/create-article/",
json={
"author": "john@doe.com",
"title": "New Article",
"content": "Some extra awesome content"
}
)
response = requests.get(
"http://localhost:5000/article-list/",
)
articles = response.json()
response = requests.get(
f"http://localhost:5000/article/{articles[0]['id']}/",
)
assert response.status_code == 200
Có hai thứ bạn cần làm trước khi chạy test này:
Đầu tiên, đăng ký một marker gọi là e2e với pytest bằng cách thêm code dưới đây vào pytest.ini:
[pytest]
markers =
e2e: marks tests as e2e (deselect with '-m "not e2e"')
Pytest markers được sử dụng để loại trừ một số test chạy hoặc bao gồm các test độc lập với vị trí của chúng.
Để chạy mỗi test e2e, hãy chạy:
(venv)$ python -m pytest tests -m 'e2e'
Để chạy tất cả các test ngoại trừ e2e:
(venv)$ python -m pytest tests -m 'not e2e'
Chạy các test e2e tốn kém hơn và yêu cầu ứng dụng phải được thiết lập và chạy, vì vậy bạn có thể luôn luôn không muốn chạy chúng.
Vì test e2e gặp một máy chủ trực tiếp, chúng tôi sẽ cần mở rộng ứng dụng. Điều hướng đến dự án trong terminal window, kích hoạt môi trường ảo và chạy ứng dụng:
(venv)$ FLASK_APP=blog/app.py python -m flask run
Bây giờ chúng ta có thể chạy test e2e:
(venv)$ python -m pytest tests -m 'e2e'
Bạn sẽ thấy lỗi 500. Tại sao? Các unit test không qua? Đúng vậy. Vấn đề là chúng tôi đã không tạo bảng cơ sở dữ liệu. Chúng tôi đã sử dụng fixtures trong các test. Vì vậy, hãy tạo một bảng và một cơ sở dữ liệu.
Thêm một tệp init_db.py vào thư mục "blog":
if __name__ == "__main__":
from blog.models import Article
Article.create_table()
Chạy tập lệnh mới và khởi động lại máy chủ:
(venv)$ python blog/init_db.py
(venv)$ FLASK_APP=blog/app.py python -m flask run
Nếu bạn gặp bất kỳ sự cố nào khi chạy init_db.py , bạn có thể cần đặt đường dẫn Python: export PYTHONPATH=$PYTHONPATH:$PWD.
Bây giờ test sẽ qua:
(venv)$ python -m pytest tests -m 'e2e'
Kim tự tháp kiểm thử (Testing Pyramid)
Chúng tôi bắt đầu với các unit test (để kiểm tra các lệnh và truy vấn), sau đó là các test tích hợp (để kiểm tra các API endpoint) và kết thúc bằng các test e2e. Trong các ứng dụng đơn giản, như trong ví dụ này, bạn có thể kết thúc với một số test tích hợp và unit test tương tự. Nói chung, độ phức tạp càng lớn, bạn càng có thể thấy một hình dạng giống như kim tự tháp về mối quan hệ giữa các unit test, tích hợp và e2e. Đó là nơi bắt nguồn của thuật ngữ "test pyramid".
Test Pyramid là framework có thể giúp các lập trình viên tạo ra phần mềm chất lượng cao.
Sử dụng Kim tự tháp kiểm thử làm hướng dẫn, bạn thường muốn 50% các test trong test suite là các unit test, 30% là test tích hợp và 20% là test e2e.
Định nghĩa:
-
Unit test - một loại kiểm thử phần mềm trong đó các đơn vị hay thành phần riêng lẻ của phần mềm được kiểm thử
-
Test tích hợp - một giai đoạn trong kiểm thử phần mềm mà mỗi môđun phần mềm riêng biệt được kết hợp lại và thử nghiệm theo nhóm
-
e2e - một phương pháp kiểm thử để kiểm tra luồng hoạt động của ứng dụng từ đầu đến cuối
Càng lên cao trong kim tự tháp, các test của bạn càng dễ bị tác động và ít dự đoán hơn. Hơn nữa, cho đến nay, chạy các test e2e vẫn là chậm nhất nên mặc dù chúng có thể mang lại niềm tin rằng ứng dụng của bạn đang làm những gì được mong đợi nhưng bạn không có nhiều test e2e như các unit test hoặc test tích hợp.
Unit là gì?
Test tích hợp và e2e trông khá đơn giản. Mọi người thảo luận nhiều hơn về các unit test vì trước tiên phải xác định "unit" thực sự là gì. Hầu hết các hướng dẫn test hiển thị một ví dụ unit test kiểm tra một chức năng hoặc phương pháp. Production code không bao giờ đơn giản.
Điều đầu tiên, trước khi xác định unit là gì, chúng ta hãy xem xét test nói chung là gì và những thứ gì nên được test.
Tại sao phải Test?
Chúng ta viết test để
- Đảm bảo code hoạt động như mong đợi
- Bảo vệ phần mềm tránh bị hồi quy
Tuy nhiên, khi chu kỳ phản hồi quá dài, các lập trình viên có xu hướng bắt đầu suy nghĩ nhiều hơn về các test cần viết vì thời gian là một hạn chế lớn trong phát triển phần mềm. Đó là lý do tại sao chúng tôi muốn có nhiều unit test hơn các loại bài kiểm tra khác. Chúng tôi muốn tìm và sửa lỗi càng nhanh càng tốt.
Test những gì?
Vậy là bạn đã biết tại sao chúng ta nên test, bây giờ chúng ta phải xem xét nên test những gì.
We should test the behavior of our software. (And, yes: This still applies to TDD, not just BDD.) This is because you shouldn't have to change your tests every time there's a change to the code base.
Think back to the example of the real world application. From a testing perspective, we don't care where the articles are stored. It could be a text file, some other relational database, or a key/value store -- it doesn't matter. Again, our app had the following requirements:
Chúng ta nên test behavior của phần mềm. (Và Điều này vẫn áp dụng cho TDD, không chỉ BDD.) Vì khi test behavior, bạn không cần phải thay đổi các test của mình mỗi khi có thay đổi đối với cơ sở mã.
Hãy nghĩ lại ví dụ về ứng dụng trong thế giới thực. Từ góc độ thử nghiệm, chúng tôi không quan tâm nơi articles được lưu trữ. Nó có thể là một tệp văn bản, một số cơ sở dữ liệu quan hệ khác hoặc một kho lưu trữ khóa/ giá trị - điều đó không quan trọng. Ứng dụng của chúng tôi vẫn có các yêu cầu sau:
-
articles có thể được tạo
-
Fetch articles
-
articles có thể được liệt kê
Miễn là các yêu cầu đó không thay đổi, thay đổi đối với phương tiện lưu trữ sẽ không phá vỡ các test của chúng tôi. Tương tự như vậy, chúng tôi biết rằng miễn là các test vượt qua thì phần mềm của mình đáp ứng các yêu cầu đó - vì vậy nó đang hoạt động.
Vậy cuối cùng Unit là gì?
Về mặt kỹ thuật, mỗi chức năng / phương pháp là một unit, nhưng chúng ta vẫn không nên test từng chức năng / phương thức trong số chúng. Thay vào đó, hãy tập trung sức lực của bạn vào việc test các chức năng và phương pháp được hiển thị công khai từ một mô hình / package.
Trong trường hợp của chúng tôi, đây là các phương thức execute. Chúng tôi không mong đợi gọi mô hình Article trực tiếp từ API Flask, vì vậy đừng tập trung nhiều (nếu có) năng lượng vào việc test nó. Nói chính xác hơn, trong trường hợp của chúng ta, các "unit", cần được kiểm tra, là các phương thức execute từ các lệnh và truy vấn. Nếu một số phương pháp không nhằm mục đích được gọi trực tiếp từ các phần khác của phần mềm hoặc từ người dùng cuối, thì đó có thể là chi tiết triển khai. Do đó, các test của chúng tôi có khả năng chống cấu trúc lại các chi tiết triển khai, đây là một trong những ưu điểm tuyệt vời của các test.
Ví dụ: các bài kiểm tra của chúng tôi vẫn sẽ vượt qua nếu chúng tôi bọc logic cho get_by_id và get_by_title trong một phương thức "được bảo vệ" được gọi là _get_by_attribute:
# other code ...
class Article(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
@classmethod
def get_by_id(cls, article_id: str):
return cls._get_by_attribute("SELECT * FROM articles WHERE id=?", (article_id,))
@classmethod
def get_by_title(cls, title: str):
return cls._get_by_attribute("SELECT * FROM articles WHERE title = ?", (title,))
@classmethod
def _get_by_attribute(cls, sql_query: str, sql_query_values: tuple):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute(sql_query, sql_query_values)
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
# other code ..
Mặt khác, nếu bạn thực hiện một thay đổi đột ngột bên trong Article, các test sẽ không thành công. Và đây chính xác là những gì chúng tôi muốn. Trong tình huống đó, chúng ta có thể hoàn nguyên breaking change hoặc thích ứng với nó trong lệnh hoặc truy vấn.
Bởi vì có một điều mà chúng tôi đang cố gắng làm được: Vượt qua các test có nghĩa là phần mềm hoạt động.
Khi nào bạn nên sử dụng Mocks?
Chúng tôi đã không sử dụng bất kỳ mock nào trong các test của mình, bởi vì chúng tôi không cần chúng. Các phương pháp mocking hoặc lớp mocking bên trong các mô hình hoặc package tạo ra các test không chống lại việc tái cấu trúc bởi vì chúng được kết hợp với các chi tiết triển khai. Những test như vậy thường xuyên bị hỏng và rất tốn kém để bảo trì. Mặt khác, khi tốc độ là vấn đề thì mocking các tài nguyên bên ngoài rất có lợi. (lệnh gọi tới các API bên ngoài, gửi email, quy trình không đồng bộ kéo dài, v.v.).
Ví dụ: chúng tôi có thể kiểm tra mô hình Article riêng biệt và mock nó bên trong các test của chúng tôi cho CreateArticleCommand như sau:
def test_create_article(monkeypatch):
"""
GIVEN CreateArticleCommand with valid properties author, title and content
WHEN the execute method is called
THEN a new Article must exist in the database with same attributes
"""
article = Article(
author="john@doe.com",
title="New Article",
content="Super awesome article"
)
monkeypatch.setattr(
Article,
"save",
lambda self: article
)
cmd = CreateArticleCommand(
author="john@doe.com",
title="New Article",
content="Super awesome article"
)
db_article = cmd.execute()
assert db_article.id == article.id
assert db_article.author == article.author
assert db_article.title == article.title
assert db_article.content == article.content
Vâng, làm điều đó là hoàn toàn tốt, nhưng chúng tôi hiện có nhiều test hơn để duy trì – tất cả các test từ trước đó cộng với tất cả các test mới cho các phương pháp trong Article. Bên cạnh đó, điều duy nhất hiện được test bởi test_create_article là một article được trả về từ save giống với artivle được trả về bởi execute. Khi chúng tôi phá vỡ một cái gì đó bên trong Article , test này sẽ vẫn vượt qua vì chúng tôi đã mock nó. Và đó là điều chúng tôi muốn tránh: Chúng tôi muốn test behavior của phần mềm để đảm bảo rằng nó hoạt động như mong đợi. Trong trường hợp này, behavior bị phá vỡ nhưng test của chúng tôi sẽ không hiển thị điều đó.
Tóm lại những cần nắm được
-
Không có cách đúng duy nhất để test phần mềm. Tuy nhiên, test logic sẽ dễ dàng hơn khi nó không được kết hợp với cơ sở dữ liệu của bạn. Bạn có thể sử dụng mẫu Active Record (mẫu bản ghi hoạt đông) với các lệnh và truy vấn (CQRS) để trợ giúp cho việc này.
-
Tập trung vào giá trị kinh doanh của code.
-
Đừng kiểm thử các phương pháp chỉ để nói rằng chúng đã được test. Bạn cần phần mềm làm việc chứ không phải phương pháp thử nghiệm. TDD chỉ là một công cụ để cung cấp phần mềm tốt hơn, nhanh hơn và đáng tin cậy hơn. Tương tự đối với độ phủ của mã: Cố gắng giữ nó ở mức cao nhưng đừng thêm các test chỉ để có độ phủ 100%.
-
Test chỉ có giá trị khi nó bảo vệ tránh khỏi sự hồi quy, cho phép bạn cấu trúc lại và cung cấp phản hồi nhanh chóng. Do đó, bạn nên cố gắng để các test của mình giống hình kim tự tháp (50% unit test, 30% test tích hợp, 20% e2e test). Mặc dù, trong các ứng dụng đơn giản, rất tốt neeys nó có thể trông giống một ngôi nhà hơn (40% unit test, 40% test tích hợp, 20% test e2e).
-
Các hồi quy càng nhanh, bạn càng có thể chặn và sửa chúng nhanh hơn. Bạn sửa chúng càng nhanh thì chu kỳ phát triển càng ngắn. Để tăng tốc độ phản hồi, bạn có thể sử dụng pytest marker để loại trừ e2e và các test chậm khác trong quá trình phát triển. Bạn có thể ít chạy chúng hơn.
-
Chỉ sử dụng mocks khi cần thiết (như đối với các API HTTP của bên thứ ba). Chúng làm cho việc thiết lập kiểm thử của bạn trở nên phức tạp hơn và nhìn chung các test của bạn có ít khả năng tái cấu trúc hơn. Thêm vào đó, chúng có thể dẫn đến kết quả dương tính giả.
-
Một lần nữa, các test của bạn là một khoản nợ không phải là một tài sản; chúng sẽ bao gồm behavior của phần mềm nhưng bạn đừng test quá mức.
Kết luận
Có rất nhiều thứ bạn có thể học được ở đây. Hãy nhớ rằng đây chỉ là những ví dụ được sử dụng để thể hiện các ý tưởng. Bạn có thể sử dụng các ý tưởng tương tự với Domain-driven design (DDD), Behavior-driven design (BDD) và nhiều cách tiếp cận khác. Hãy nhớ rằng các test phải được xử lý giống như bất kỳ mã nào khác: Chúng là một khoản nợ phải trả chứ không phải tài sản. Viết các test để bảo vệ phần mềm của bạn tránh khỏi các lỗi nhưng đừng để nó đốt thời gian của bạn.