Cách Kiểm Tra Web Scrapers Với pytest: Hướng Dẫn Thực Tiễn
Expert in Web Scraping Technologies
TL;DR:
- Tách
fetchkhỏiparsevà việc phân tích trở thành một hàm thuần túy — chuỗi HTML vào, bản ghi ra — có thể kiểm tra mà không cần mạng và thư viện giả lập. - Bộ kiểm tra ngoại tuyến đã chạy 14 bài kiểm tra trong 0.16 giây; hai bài kiểm tra hợp đồng trực tiếp bị bỏ qua theo mặc định và mất 0.88 giây riêng.
- Độ bao phủ báo cáo 85%, và các dòng duy nhất không được bao phủ là
fetch()vàscrape(). Đó là hình dạng dự kiến thay vì một khoảng trống cần lấp đầy. - Làm cho các bộ phân tích trường tăng trưởng. Đổi tên một lớp CSS trong một bản sao của tài liệu đã tạo ra
ValueError: missing pricetại một dòng đã đặt tên thay vì 20 hàng null. - Một bài kiểm tra tài liệu chứng minh rằng bộ phân tích xử lý được HTML mà bạn đã lưu. Chỉ có một bài kiểm tra hợp đồng chống lại trang sống mới phát hiện rằng trang web đã thay đổi.
- Một bộ kiểm tra xanh không thể cho bạn biết mục tiêu vẫn phục vụ HTML đó, vẫn kết xuất phía máy chủ, hoặc vẫn trả về một trang.
- Chạy nửa sống chống lại các trang đã được kết xuất thực trên gói miễn phí Scrapeless.
Các trình thu thập dữ liệu bị hỏng theo cách mà hầu hết phần mềm không bị: không có gì trong kho lưu trữ thay đổi, và mã ngừng hoạt động vì ai đó chỉnh sửa một trang. Điều đó khiến cho bản năng thông thường — viết các bài kiểm tra, theo dõi chúng trở nên xanh, vận chuyển — là cần thiết nhưng không đủ, và nó thay đổi những gì mà các bài kiểm tra nên kiểm tra.
Bộ kiểm tra dưới đây bao gồm một trình thu thập dữ liệu danh mục sách nhỏ. Nó được viết thành hai nửa trả lời các câu hỏi khác nhau: một nửa ngoại tuyến hỏi xem bộ phân tích có chính xác không, và một nửa trực tiếp hỏi xem trang web có vẫn khớp với những gì bộ phân tích mong đợi không.
Những Gì Bài Kiểm Tra Trình Thu Thập Dữ Liệu Thực Sự Được Dùng Để
Ba lỗi đáng được tách biệt, vì chỉ có hai trong số đó là của bạn:
| Lỗi | Phát hiện bởi | Ví dụ |
|---|---|---|
| Bộ phân tích xử lý sai HTML hợp lệ | bài kiểm tra đơn vị ngoại tuyến | một giá với ký hiệu tiền tệ trở thành một chuỗi, không phải là số thực |
| Trang web đã thay đổi mã của nó | bài kiểm tra hợp đồng trực tiếp | price_color trở thành product-price |
| Trang web ngừng phục vụ trang | không có | phản hồi là một trang thách thức hoặc một.shell trống |
Hầu hết các lời khuyên về kiểm tra trình thu thập dữ liệu đã xuất bản bao gồm hàng đầu tiên. Cái thứ hai cần một bài kiểm tra mà trò chuyện với trang web; cái thứ ba không thể bị phát hiện bởi bất kỳ bộ kiểm tra nào, điều này đáng được nói ra trước khi xây dựng một cái.
Cài Đặt
bash
python3 -m venv .venv
./.venv/bin/pip install pytest pytest-cov responses parsel requests
Các phiên bản mà bộ kiểm tra này đã chạy:
text
pytest 9.1.1
pytest-cov 7.1.0
responses 0.26.3
parsel 1.11.0
requests 2.34.2
lxml 6.1.3
responses được bao gồm vì việc giả lập HTTP là câu hỏi tiếp theo thông thường. Các bài kiểm tra phân tích không cần gì cả, và lý do là do cấu trúc chứ không phải do phong cách.
Sự Tách Biệt Giúp Phân Tích Được Kiểm Tra
Một hàm chạm vào mạng. Mọi thứ khác nhận một chuỗi.
python
import requests
from parsel import Selector
CATEGORY_URL = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def fetch(url: str = CATEGORY_URL) -> str:
"""The only function that touches the network."""
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.content.decode("utf-8")
def parse_price(raw: str | None) -> float:
if not raw:
raise ValueError("missing price")
return float(raw.replace("£", "").strip())
def parse_rating(css_class: str | None) -> int:
word = (css_class or "").replace("star-rating", "").strip()
if word not in RATINGS:
raise ValueError(f"unknown rating: {word!r}")
return RATINGS[word]
def parse(html: str) -> list[dict]:
"""Pure function: HTML in, records out."""
sel = Selector(text=html)
return [{
"title": card.css("h3 a::attr(title)").get(),
"price": parse_price(card.css("p.price_color::text").get()),
"rating": parse_rating(card.css("p.star-rating::attr(class)").get()),
"in_stock": bool(card.css("p.instock.availability").get()),
} for card in sel.css("article.product_pod")]
parse không có I/O, không có đồng hồ và không có trạng thái toàn cầu, vì vậy việc kiểm tra nó không cần bất kỳ sự giả lập nào. Các công cụ giả lập của thư viện chuẩn rất xuất sắc và chủ yếu là không cần thiết ở đây — một hàm đã nhận đầu vào của nó dưới dạng một đối số không cần các phụ thuộc của nó được vá.
Lưu ý rằng hai bộ phân tích trường tăng thay vì trả về None. Quyết định đơn lẻ đó chính là thứ biến một thay đổi mã im lặng thành một lỗi đã đặt tên.
Lưu Một Trang Thực Làm Tài Liệu
Các bài kiểm tra cần HTML không thay đổi dưới chúng, vì vậy hãy lưu một phản hồi thực một lần và cam kết nó.
python
import requests, pathlib
response = requests.get(CATEGORY_URL, timeout=30)
response.raise_for_status()
pathlib.Path("fixtures/mystery.html").write_bytes(response.content)
text
fixture saved: 50388 bytes
Tải nó qua một tài liệu theo phiên làm cho tệp được đọc một lần cho toàn bộ vòng chạy:
python
# tests/conftest.py
import pathlib
import pytest
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
@pytest.fixture(scope="session")
def mystery_html() -> str:
return (FIXTURES / "mystery.html").read_text(encoding="utf-8")
Cam kết tài liệu. Nó là bản ghi về những gì trang trông như thế nào khi bộ phân tích được viết, và một bản so sánh với một bản sao mới là cách nhanh nhất để xem trang web đã thay đổi gì.
Viết Các Khẳng Định Đáng Có
Khẳng định về giá trị và các bất biến, không phải về thực tế là một cái gì đó đã trở lại.
python
import pytest
from bookscraper import parse, parse_price, parse_rating
def test_parse_returns_every_card(mystery_html):
assert len(parse(mystery_html)) == 20
def test_record_shape(mystery_html):
record = parse(mystery_html)[0]
assert set(record) == {"title", "price", "rating", "in_stock"}
assert record["title"] == "Sharp Objects"
assert record["price"] == 47.82
assert record["rating"] == 4
assert record["in_stock"] is True
def test_every_price_is_positive(mystery_html):
assert all(r["price"] > 0 for r in parse(mystery_html))
@pytest.mark.parametrize("raw,expected", [("£47.82", 47.82), ("£9.99", 9.99), ("£100.00", 100.0)])
def test_parse_price(raw, expected):
assert parse_price(raw) == expected
def test_parse_price_rejects_missing():
with pytest.raises(ValueError):
parse_price(None)
def test_parse_rating_rejects_unknown():
with pytest.raises(ValueError, match="unknown rating"):
parse_rating("star-rating Eleven")
def test_empty_html_yields_no_records():
assert parse("<html><body></body></html>") == []
Ba loại khẳng định đang thực hiện công việc riêng biệt. Các giá trị chính xác gắn một bản ghi đã biết. Các bất biến (all prices > 0, xếp hạng từ 1 đến 5) giữ cho các bản ghi mà tài liệu chưa chứa. Và các trường hợp pytest.raises gắn hành vi lỗi, là phần mà một thay đổi mã thực hiện.
Giữ Các Bài Kiểm Tra Trực Tiếp Ra Khỏi Chạy Mặc Định
Các bài kiểm tra hợp đồng ảnh hưởng đến trang web thực, vì vậy chúng chậm và phụ thuộc vào thời gian hoạt động của người khác. Một dấu hiệu giữ cho chúng ra khỏi vòng lặp nhanh mà không cần xóa chúng.
python
# tests/test_selector_contract.py
import pytest
from bookscraper import fetch, parse
pytestmark = pytest.mark.live
@pytest.fixture(scope="module")
def live_html():
return fetch()
def test_live_page_still_yields_records(live_html):
assert len(parse(live_html)) == 20
def test_live_selectors_match_fixture_shape(live_html, mystery_html):
live, saved = parse(live_html), parse(mystery_html)
assert {r["title"] for r in live} == {r["title"] for r in saved}
ini
[pytest]
pythonpath = .
testpaths = tests
markers =
live: hits the real site; excluded from the default run
addopts = -m "not live"
Đăng ký dấu hiệu trong cấu hình là điều ngăn hệ thống đánh dấu của pytest cảnh báo về một dấu hiệu không xác định, và addopts tạo ra việc loại trừ mặc định thay vì điều gì đó mà mọi người phải nhớ.
text
$ pytest -q
.............. [100%]
14 passed, 2 deselected in 0.16s
$ pytest -q -m live
.. [100%]
2 passed, 14 deselected in 0.88s
Việc phân chia là quan trọng vì hai bộ kiểm thử thuộc về các lịch trình khác nhau. Bộ kiểm thử ngoại tuyến 14 chạy mỗi khi có cam kết. Bộ kiểm thử trực tiếp 2 chạy theo một bộ hẹn giờ, và sự thất bại của chúng có nghĩa là trang đã di chuyển thay vì mã — đây là ranh giới kim tự tháp kiểm thử thực tế vạch ra giữa các bài kiểm thử nhanh và cách ly và số lượng nhỏ mà vượt qua một ranh giới thực tế.
Đọc Độ Che Phủ như một Kiểm Tra Kiến Trúc
text
$ pytest -q --cov=bookscraper --cov-report=term-missing
Name Stmts Miss Cover Missing
----------------------------------------------
bookscraper.py 26 4 85% 14-16, 47
----------------------------------------------
TOTAL 26 4 85%
14 passed, 2 deselected in 0.50s
Các dòng 14-16 là phần thân của fetch; dòng 47 là scrape, kết hợp cả hai. Mỗi dòng logic phân tích đều được bao phủ và mỗi dòng không được bao phủ là một dòng nói chuyện với mạng.
Đó là số cần phải có. Theo đuổi 100% ở đây có nghĩa là mô phỏng requests để chứng minh rằng requests.get đã được gọi, điều này kiểm tra mô phỏng. Việc đọc hữu ích của một báo cáo độ che phủ trên một trình thu thập dữ liệu là những dòng nào bị thiếu, và liệu chúng có phải là những dòng mà bạn cố tình giữ ở rìa hay không.
Kiểm tra một trình thu thập dữ liệu chống lại một trang được kết xuất phía bên khách? Kế hoạch miễn phí Scrapeless bao phủ đủ phiên để bắt một món trang được kết xuất đáng giá để cam kết.
Một Thay Đổi Đánh Dấu Trông Như Thế Nào
Lấy món trang đã lưu, đổi tên một lớp theo cách mà thiết kế lại một trang sẽ làm, và chạy bộ phân tích chống lại nó:
python
html = pathlib.Path("fixtures/mystery.html").read_text(encoding="utf-8")
drifted = html.replace("price_color", "product-price")
pathlib.Path("fixtures/mystery_drifted.html").write_text(drifted, encoding="utf-8")
print("price_color occurrences:", html.count("price_color"), "->", drifted.count("price_color"))
text
price_color occurrences: 20 -> 0
text
raw = None
def parse_price(raw: str | None) -> float:
if not raw:
> raise ValueError("missing price")
E ValueError: missing price
bookscraper.py:21: ValueError
=========================== short test summary info ============================
FAILED tests/test_drift_demo.py::test_parse_survives_price_class_rename - Val...
1 failed in 0.11s
Sự thất bại chỉ ra trường và dòng. Nếu parse_price trả lại None trên một lần khớp bị thiếu, quá trình sẽ hoàn thành và viết hai mươi bản ghi với giá bằng không — và đường ống sẽ báo cáo thành công. Thuộc tính lớp của đặc tả HTML hoàn toàn không có bảo đảm ổn định nào; nó là trình bày, và việc coi tên lớp như một hợp đồng có nghĩa là bộ phân tích phải kêu to khi hợp đồng bị vi phạm.
Vì lý do tương tự, việc xác nhận hình dạng của bản ghi sau khi phân tích là đáng để kết hợp với những bài kiểm tra này — hướng dẫn của chúng tôi về xác nhận dữ liệu thu thập bao gồm nửa thời gian chạy của cùng một vấn đề.
Nơi Bộ Kiểm Thử Ngừng Lại
Một bộ xanh nghĩa là bộ phân tích xử lý HTML trong fixtures/. Nó không nói gì về ba điều làm hỏng các trình thu thập dữ liệu trong sản xuất:
- Trang giờ đây được kết xuất ở phía khách. HTML mà một khách hàng đơn giản nhận được là một vỏ bọc; các bộ chọn thì đúng và không khớp với gì cả.
- Phản hồi không phải là trang. Một thử thách hoặc tạm thời đến với HTTP 200, và một khẳng định chỉ có nội dung có thể vượt qua trên mã hóa mà không chứa bản ghi nào.
- Món trang đã cũ. Nó vẫn phân tích một cách sạch sẽ vì nó là một tệp, điều này chính xác là lý do tại sao nó không thể cho bạn biết rằng trang đã di chuyển.
Hai điều đầu tiên cần một trình duyệt thực chứ không phải một yêu cầu thực. Việc ghi lại món trang thông qua Scrapeless Scraping Browser có nghĩa là HTML đã lưu là DOM mà trình duyệt đã lắp ráp, vì vậy bộ kiểm thử ngoại tuyến kiểm tra cùng một tài liệu mà lần chạy trực tiếp sẽ thấy. Một bộ kiểm thử hợp đồng là một vài phiên trên một bộ hẹn giờ thay vì một chi phí trên mỗi cam kết, và giá cả cho biết số lượng phiên đó tăng lên. Điều thứ ba được trả lời bởi bài kiểm tra hợp đồng so sánh tiêu đề trực tiếp với của món trang — cảnh báo sớm rẻ nhất có sẵn, và lý do mà hai bài kiểm tra này tồn tại.
Khắc Phục Sự Cố
fixture 'mystery_html' not found — món trang sống trong tests/conftest.py, và pytest chỉ phát hiện conftest.py trong thư mục kiểm thử hoặc bên trên nó.
ModuleNotFoundError: No module named 'bookscraper' — đặt pythonpath = . trong pytest.ini, hoặc cài đặt gói ở chế độ có thể chỉnh sửa. Các bài kiểm tra chạy từ rootdir, không phải từ tests/.
PytestUnknownMarkWarning: Unknown pytest.mark.live — đăng ký dấu hiệu trong phần markers của cấu hình.
Các bài kiểm thử trực tiếp thất bại trong khi bộ kiểm thử ngoại tuyến vượt qua — đó là bài kiểm tra hợp đồng đang thực hiện nhiệm vụ của nó. So sánh một bản sao mới của trang với món trang đã cam kết trước khi chạm vào bộ phân tích.
Kết Luận
Quyết định thiết kế khiến một trình thu thập dữ liệu có thể kiểm thử không phải là khung kiểm thử, mà là sự phân chia: fetch trả về một chuỗi, parse nhận một cái, và mọi thứ thú vị xảy ra trong một hàm thuần túy. Độ che phủ xác nhận hình dạng — 85%, với fetch và scrape là những dòng duy nhất không được bao phủ.
Ngoài ra, hai thói quen mang lại giá trị lớn nhất. Làm cho các trình phân tích trường nâng lên, để một lớp được đặt tên lại tạo ra ValueError: missing price tại một dòng đã đặt tên thay vì hai mươi giá trị null. Và giữ một bộ hợp đồng nhỏ sống phía sau một dấu hiệu, vì bài kiểm tra chỉ có thể cho bạn biết trình phân tích vẫn hoạt động trên trang mà bạn đã lưu.
Sẵn sàng để kiểm tra một bộ thu thập dữ liệu trên các trang hiển thị trước khi bạn phân tích chúng? Bắt đầu với gói miễn phí của Scrapeless và chụp một bài kiểm tra từ DOM thật.
Câu hỏi thường gặp
Q: Làm thế nào để bạn kiểm tra đơn vị một bộ thu thập dữ liệu web mà không va chạm vào trang web?
Tách biệt quá trình lấy dữ liệu ra khỏi phân tích và kiểm tra quá trình phân tích. Nếu parse nhận một chuỗi HTML và trả về các bản ghi, một tệp bài kiểm tra đã lưu là toàn bộ cài đặt kiểm tra — không có thư viện mô phỏng, không có sự chặn HTTP. 14 bài kiểm tra ngoại tuyến ở trên chạy trong 0.16 giây vì không có bài nào mở một socket.
Q: Tôi có cần một thư viện mô phỏng như responses hay unittest.mock không?
Chỉ đối với mã gọi trực tiếp vào mạng. Khi phân tích nhận một đối số chuỗi, không có gì để vá. Tìm đến việc mô phỏng HTTP khi bạn muốn kiểm tra hành vi của lớp lấy dữ liệu — xử lý trạng thái, thời gian chờ, cấu trúc tiêu đề — thay vì để kiểm tra quá trình phân tích.
Q: Làm thế nào để tôi phát hiện rằng một trang web đã thay đổi các bộ chọn của tôi?
Một bài kiểm tra hợp đồng lấy trang sống và so sánh nó với bài kiểm tra đã được cam kết. test_live_selectors_match_fixture_shape ở trên khẳng định rằng tập hợp các tiêu đề khớp; khi nó ngừng khớp, trang web đã di chuyển. Giữ nó phía sau một dấu hiệu để nó chạy theo lịch thay vì trên mỗi lần cam kết.
Q: Các bài kiểm tra bộ thu thập dữ liệu có nên chạy trong CI không?
Các bài kiểm tra ngoại tuyến, trên mỗi lần cam kết — chúng có tính quyết định và nhanh chóng. Các bài kiểm tra hợp đồng sống không nên là rào cản cho một lần gộp, vì một thất bại có nghĩa là trang web của người khác đã thay đổi và yêu cầu kéo là vô tội. Chạy chúng theo thời gian và cảnh báo về kết quả thay vào đó.
Q: Mức độ bao phủ mà một bộ thu thập dữ liệu nên nhắm tới là gì?
Nhìn vào các dòng nào đang thiếu thay vì tỷ lệ phần trăm. 85% với fetch và scrape bị bỏ sót là một bộ kiểm tra hình dáng tốt; 85% như vậy với các nhánh phân tích bị bỏ sót thì không. Thúc đẩy đến 100% thường có nghĩa là khẳng định rằng một mô phỏng đã được gọi, điều đó không chứng minh gì về dữ liệu.
Q: Một trình phân tích có nên trả về None hay nâng lên khi một trường bị thiếu?
Nâng lên. Một None được lan truyền vào cơ sở dữ liệu như một null và báo cáo chạy thành công, vì vậy thất bại xuất hiện sau nhiều ngày như dữ liệu thiếu. Nâng lên xác định tên trường và dòng ngay khi đánh dấu thay đổi, điều này đã biến một lớp được đặt tên lại thành ValueError: missing price ở trên.
Tại Scrapless, chúng tôi chỉ truy cập dữ liệu có sẵn công khai trong khi tuân thủ nghiêm ngặt các luật, quy định và chính sách bảo mật trang web hiện hành. Nội dung trong blog này chỉ nhằm mục đích trình diễn và không liên quan đến bất kỳ hoạt động bất hợp pháp hoặc vi phạm nào. Chúng tôi không đảm bảo và từ chối mọi trách nhiệm đối với việc sử dụng thông tin từ blog này hoặc các liên kết của bên thứ ba. Trước khi tham gia vào bất kỳ hoạt động cạo nào, hãy tham khảo ý kiến cố vấn pháp lý của bạn và xem xét các điều khoản dịch vụ của trang web mục tiêu hoặc có được các quyền cần thiết.



