वेब स्क्रैपर्स को pytest के साथ कैसे परखें: एक व्यावहारिक मार्गदर्शिका
Expert in Web Scraping Technologies
TL;DR:
fetchऔरparseको अलग करें और पार्सिंग एक शुद्ध फ़ंक्शन बन जाती है — HTML स्ट्रिंग इन, रिकॉर्ड आउट — नेटवर्क और कोई मॉकिंग लाइब्रेरी के बिना परीक्षण योग्य।- ऑफ़लाइन सूट ने 0.16 सेकंड में 14 परीक्षण चलाए; दो लाइव कॉन्ट्रैक्ट परीक्षण डिफ़ॉल्ट रूप से अस्वीकृत हैं और अपने आप में 0.88 सेकंड लेते हैं।
- कवरेज ने 85% रिपोर्ट किया, और केवल अव्यवस्थित पंक्तियाँ
fetch()औरscrape()थीं। यह एक इच्छित आकार है न कि बंद करने के लिए एक अंतर। - फील्ड पार्सर को उठाने दें। एक फिक्स्चर की कॉपी में एक CSS वर्ग का नाम बदलने से
ValueError: missing priceएक नामित पंक्ति पर उत्पन्न हुआ, न कि 20 शून्य पंक्तियाँ। - एक फिक्स्चर परीक्षण साबित करता है कि पार्सर उस HTML को संभालता है जो आपने सहेजा था। केवल एक लाइव पृष्ठ के खिलाफ एक कॉन्ट्रैक्ट परीक्षण यह पता लगाता है कि साइट बदली है।
- एक हरा सूट आपको नहीं बता सकता कि लक्ष्य अभी भी उस HTML को परोसता है, अभी भी सर्वर साइड में रेंडर करता है, या अभी भी एक पृष्ठ लौटाता है।
- वास्तविक रेंडर किए गए पृष्ठों के खिलाफ लाइव आधे को Scrapeless मुफ्त योजना पर चलाएँ।
स्क्रैपर्स एक ऐसे तरीके से टूटते हैं जो अधिकतर सॉफ़्टवेयर नहीं होते: भंडार में कुछ भी नहीं बदलता है, और कोड काम करना बंद कर देता है क्योंकि किसी और ने एक पृष्ठ को संपादित कर दिया। यह सामान्य प्रवृत्ति को बनाता है — परीक्षण लिखें, उन्हें हरा होते हुए देखें, भेजें — आवश्यक लेकिन अपर्याप्त, और इससे यह बदलता है कि परीक्षणों को किस चीज़ की जांच करनी चाहिए।
नीचे प्रस्तुत सूट एक छोटे पुस्तक-कैटलॉग स्क्रैपर को कवर करता है। यह दो भागों में लिखा गया है जो अलग-अलग प्रश्नों का उत्तर देते हैं: एक ऑफ़लाइन भाग जो पूछता है कि क्या पार्सर सही है, और एक लाइव भाग जो पूछता है कि क्या साइट अभी भी उस पर क्या उम्मीद करता है।
स्क्रैपर टेस्ट वास्तव में किस लिए हैं
तीन असफलताओं को अलग करना उचित है, क्योंकि इनमें से केवल दो आपकी हैं:
| असफलता | पता लगाया गया | उदाहरण |
|---|---|---|
| पार्सर मान्य HTML को गलत तरीके से संभालता है | ऑफलाइन यूनिट टेस्ट | एक मूल्य जिसमें मुद्रा का प्रतीक होता है, एक स्ट्रिंग बन जाता है, न कि एक फ्लोट |
| साइट ने अपना मार्कअप बदल दिया | लाइव कॉन्ट्रैक्ट टेस्ट | price_color product-price बन जाता है |
| साइट ने पृष्ठ को परोसना बंद कर दिया | कोई नहीं | प्रतिक्रिया एक चुनौती पृष्ठ या एक खाली खोल है |
प्रकाशित स्क्रैपर-टेस्टिंग सलाह का अधिकांश भाग पहले पंक्ति को कवर करता है। दूसरे को साइट से बात करने वाले परीक्षण की आवश्यकता होती है; तीसरे को तो एक परीक्षण सूट द्वारा बिल्कुल भी पकड़ा नहीं जा सकता, जिसे एक बार निर्माण करने से पहले जोर से कहना महत्वपूर्ण है।
इंस्टॉल
bash
python3 -m venv .venv
./.venv/bin/pip install pytest pytest-cov responses parsel requests
यह सूट जिन संस्करणों के खिलाफ चला:
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 को शामिल किया गया है क्योंकि HTTP मॉकिंग सामान्य अगला प्रश्न है। पार्सिंग परीक्षणों को इसकी आवश्यकता नहीं होती, और इसका कारण संरचनात्मक है न कि शैलीय।
वह विभाजन जो पार्सिंग को परीक्षण योग्य बनाता है
एक फ़ंक्शन नेटवर्क को छूता है। बाकी सब एक स्ट्रिंग लेता है।
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 में कोई I/O, कोई घड़ी, और कोई वैश्विक राज्य नहीं है, इसलिए इसका परीक्षण करने के लिए कोई मॉकिंग की आवश्यकता नहीं है। मानक पुस्तकालय के मॉकिंग उपकरण उत्कृष्ट हैं और यहाँ ज्यादातर अनावश्यक हैं — एक फ़ंक्शन जो पहले से ही अपने इनपुट को एक तर्क के रूप में लेता है, उसे अपनी निर्भरताओं को पैच करने की आवश्यकता नहीं होती।
ध्यान दें कि दोनों फील्ड पार्सर उठाते हैं न कि None लौटाते हैं। यह एकल निर्णय एक मौन मार्कअप परिवर्तन को एक नामित विफलता में बदल देता है।
एक वास्तविक पृष्ठ को फ़िक्स्चर के रूप में सहेजें
परीक्षणों को HTML की आवश्यकता होती है जो उनके नीचे नहीं बदलती है, इसलिए एक वास्तविक प्रतिक्रिया को एक बार सहेजें और इसे समिति में डालें।
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
इसे एक सत्र-व्यापी फ़िक्स्चर के माध्यम से लोड करें ताकि फ़ाइल पूरी रन के लिए एक बार पढ़ी जाए:
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")
फ़िक्स्चर को कमिट करें। यह उस पृष्ठ की रिकॉर्डिंग है जैसा कि पार्सर लिखा गया था, और ताजा कॉपी के खिलाफ एक डिफ़ का उपयोग यह देखने का सबसे तेज़ तरीका है कि साइट ने क्या बदला।
मूल्यांकन लिखें जो रखने लायक हों
मूल्यों और अपरिवर्तनीयताओं पर जोर दें, न कि इस पर कि कुछ वापस आया।
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>") == []
तीन प्रकार का आक्षेप अलग-अलग कार्य कर रहे हैं। सटीक मूल्य एक ज्ञात रिकॉर्ड को इंगित करते हैं। अपरिवर्तनीयताएँ (all prices > 0, 1 और 5 के बीच रेटिंग) उन रिकॉर्ड के लिए होती हैं जो फ़िक्स्चर में अभी तक नहीं हैं। और pytest.raises मामले विफलता व्यवहार को इंगित करते हैं, जो वह हिस्सा है जिस पर एक मार्कअप परिवर्तन व्यायाम करता है।
डिफ़ॉल्ट रन से लाइव परीक्षणों को बाहर रखें
कॉन्ट्रैक्ट परीक्षण वास्तविक साइट पर जाते हैं, इसलिए वे धीमे होते हैं और किसी और के अपटाइम पर निर्भर करते हैं। एक मार्कर उन्हें तेज़ लूप से बाहर रखता है बिना उन्हें हटाए।
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"
कॉन्फ़िग में मार्कर को पंजीकरण करने से pytest के मार्कर सिस्टम को एक अज्ञात मार्क के बारे में चेतावनी देने से रोकता है, और addopts को बाहर करने को डिफ़ॉल्ट बनाता है न कि कुछ ऐसा जो सभी को याद रखना पड़े।
text
$ pytest -q
.............. [100%]
14 passed, 2 deselected in 0.16s
$ pytest -q -m live
.. [100%]
2 passed, 14 deselected in 0.88s
विभाजन महत्वपूर्ण है क्योंकि दो सूट विभिन्न शेड्यूल पर होते हैं। ऑफ़लाइन 14 हर कमिट पर चलती हैं। लाइव 2 एक टाइमर पर चलती हैं, और उनकी विफलता का मतलब है कि साइट ने कोड के बजाय स्थानांतरित किया — यह सीमा है व्यावहारिक परीक्षण पिरामिड जो तेज़ अलग परीक्षणों और उस छोटे संख्या के बीच खींचती है जो वास्तविक सीमा को पार करते हैं।
आर्किटेक्चर जांच के रूप में पढ़ें कवरेज
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
लाइन 14-16 fetch का शरीर हैं; लाइन 47 scrape है, जो दोनों को संयोजित करता है। पार्सिंग लॉजिक की प्रत्येक लाइन कवर है और हर अनकवर्ड लाइन वह है जो नेटवर्क से बात करती है।
यह वह संख्या है जिसे चाहना है। यहाँ 100% का पीछा करने का मतलब है requests को मॉक करना ताकि यह साबित हो सके कि requests.get को बुलाया गया था, जो मॉक का परीक्षण करता है। एक स्क्रैपर पर कवरेज रिपोर्ट का उपयोगी पढ़ने का मतलब है कौन सी लाइनें गायब हैं, और क्या वे वही हैं जिन्हें आपने जानबूझकर किनारे पर रखा था।
एक पृष्ठ के खिलाफ स्क्रैपर का परीक्षण करना जो क्लाइंट-साइड पर रेंडर होता है? स्क्रैपलेस फ्री प्लान पर्याप्त सत्रों को कवर करता है ताकि एक रेंडर किया गया फ़िक्सचर कैप्चर किया जा सके जिसे कमिट करने के लायक है।
मार्कअप बदलाव कैसा दिखता है
बचाए गए फ़िक्स्चर को लें, एक क्लास का नाम बदलें जैसे कि किसी साइट के डिजाइन में बदलाव होता है, और इसके खिलाफ पार्सर चलाएँ:
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
विफलता क्षेत्र और लाइन का नाम देती है। यदि parse_price ने एक गायब मैच पर None लौटाया होता, तो रन पूरा हो गया होता और एक अमान्य मूल्य के साथ बीस रिकॉर्ड लिखे गए होते — और पाइपलाइन ने सफल होने की सूचना दी होती। HTML विनिर्देशन का वर्ग विशेषता किसी भी स्थिरता की गारंटी नहीं देता; यह प्रस्तुतिकरणात्मक है, और एक क्लास नाम को अनुबंध के रूप में मानना मतलब है कि पार्सर को तब शोर मचाना होगा जब अनुबंध टूटता है।
इसी कारण से, पार्सिंग के बाद रिकॉर्ड के आकार को मान्य करना इन परीक्षणों के साथ जोड़ा जाना चाहिए — स्क्रैप किए गए डेटा को मान्य करने के लिए हमारा गाइड उसी समस्या के रनटाइम आधे को कवर करता है।
परीक्षण सूट कहाँ रुकता है
एक हरा सूट का मतलब है कि पार्सर HTML को fixtures/ में संभालता है। यह उस तीन चीजों के बारे में कुछ नहीं कहता जो उत्पादन में स्क्रैपर को तोड़ती हैं:
- पृष्ठ अब क्लाइंट-साइड पर रेंडर होता है। एक सामान्य क्लाइंट को प्राप्त HTML एक शेल है; चयनकर्ता सही हैं और कोई मेल नहीं खा रहे हैं।
- उत्तर पृष्ठ नहीं है। एक चुनौती या इंटरस्टीशियल HTTP 200 के साथ आती है, और केवल सामग्री के आधार पर एक पुष्टिकरण उस मार्कअप पर पास हो सकता है जिसमें कोई रिकॉर्ड नहीं है।
- फ़िक्स्चर का समय बीत चुका है। यह अभी भी साफ़ बैठता है क्योंकि यह एक फ़ाइल है, यही कारण है कि यह आपको यह नहीं बता सकता कि साइट हिल गई है।
पहले दो को एक वास्तविक अनुरोध के बजाय एक वास्तविक ब्राउज़र की आवश्यकता है। स्क्रैपलेस स्क्रैपिंग ब्राउज़र के माध्यम से फ़िक्स्चर कैप्चर करना मतलब है कि सहेजा गया HTML वह DOM है जिसे ब्राउज़र ने असेंबल किया, इसलिए ऑफ़लाइन सूट वही दस्तावेज़ टेस्ट करता है जो लाइव रन देखेगा। एक अनुबंध सूट एक टाइमर पर सत्रों का एक मुट्ठी भर है, प्रति-कमिट लागत के बजाय, और मूल्य निर्धारण यह दर्शाता है कि वह लय क्या हो रही है। तीसरे का उत्तर अनुबंध परीक्षण द्वारा लाइव शीर्षकों की तुलना के माध्यम से दिया जाता है — सबसे सस्ता प्रारंभिक चेतावनी उपलब्ध, और वहीं कारण है कि ये दो परीक्षण मौजूद हैं।
समस्या निवारण
fixture 'mystery_html' not found — फ़िक्स्चर tests/conftest.py में रहता है, और pytest केवल conftest.py को परीक्षण निर्देशिका में या उससे ऊपर खोजता है।
ModuleNotFoundError: No module named 'bookscraper' — pythonpath = . को pytest.ini में सेट करें, या पैकेज को संपादन योग्य मोड में स्थापित करें। परीक्षण rootdir से चलते हैं, tests/ से नहीं।
PytestUnknownMarkWarning: Unknown pytest.mark.live — config के markers अनुभाग में मार्कर को पंजीकृत करें।
लाइव परीक्षण विफल होते हैं जबकि ऑफ़लाइन सूट पास करता है — यह अनुबंध परीक्षण है जो अपना काम करता है। पार्सर को छूने से पहले कमिट की गई फ़िक्स्चर के खिलाफ एक ताज़ा पृष्ठ की डिफ़ समझें।
निष्कर्ष
डिज़ाइन निर्णय जो एक स्क्रैपर को परीक्षण योग्य बनाता है वह परीक्षण ढांचा नहीं है, यह विभाजन है: fetch एक स्ट्रिंग लौटाता है, parse एक लेता है, और सब कुछ रोचक एक शुद्ध फ़ंक्शन में होता है। कवरेज आकार की पुष्टि करता है — 85%, जिसमें fetch और scrape केवल अनकवर्ड लाइनें हैं।
उसके परे, दो आदतें अधिकांश मूल्य ले जाती हैं। फ़ील्ड पार्सरों को उठाना सुनिश्चित करें, ताकि एक नामांकित पंक्ति पर ValueError: missing price उत्पन्न किया जाए, बजाय इसके कि बीस शून्य कीमतें उत्पन्न हों। और एक छोटे जीवनकाल वाले अनुबंध सूट को एक मार्कर के पीछे रखें, क्योंकि फिक्स्चर आपको केवल यह बता सकता है कि पार्सर उस पृष्ठ पर अभी भी काम करता है जिसे आपने सहेजा है।
क्या आप उन्हें पार्स करने से पहले पृष्ठों के खिलाफ एक स्क्रैपर का परीक्षण करने के लिए तैयार हैं? सक्रिय फ्री प्लान के साथ शुरू करें और वास्तविक DOM से एक फिक्स्चर कैप्चर करें।
अक्सर पूछे जाने वाले प्रश्न
प्रश्न: मैं बिना साइट को हिट किए एक वेब स्क्रैपर का यूनिट टेस्ट कैसे करूंगा?
फ़ेच को पार्स से अलग करें और पार्स का परीक्षण करें। यदि parse एक HTML स्ट्रिंग लेता है और रिकार्ड लौटाता है, तो एक सहेजी गई फिक्स्चर फ़ाइल पूरे परीक्षण सेटअप है — कोई मॉकिंग लाइब्रेरी नहीं, कोई HTTP इंटरसेप्शन नहीं। उपरोक्त 14 ऑफ़लाइन परीक्षण 0.16 सेकंड में चलाए गए क्योंकि उनमें से कोई भी एक सॉकेट नहीं खोलता है।
प्रश्न: क्या मुझे प्रतिक्रियाएँ या unittest.mock जैसी मॉकिंग लाइब्रेरी की आवश्यकता है?
केवल उस कोड के लिए जो नेटवर्क को स्वयं कॉल करता है। एक बार जब पार्सिंग एक स्ट्रिंग तर्क ले लेती है, तो पैच करने के लिए कुछ नहीं है। HTTP मॉकिंग का उपयोग करें जब आप फ़ेच परत के अपने व्यवहार का परीक्षण करना चाहते हैं — स्थिति प्रबंधन, टाइमआउट, हेडर निर्माण — बजाय इसके कि पार्सिंग का परीक्षण किया जाए।
प्रश्न: मैं कैसे पता करूँगा कि एक साइट ने मेरे चयनकर्ताओं को बदल दिया?
एक अनुबंध परीक्षण जो लाइव पृष्ठ को फ़ेच करता है और इसे प्रतिबद्ध फिक्स्चर के खिलाफ तुलना करता है। ऊपर test_live_selectors_match_fixture_shape यह सुनिश्चित करता है कि शीर्षकों का सेट मेल खाता है; जब यह मेल करना बंद कर देता है, तो साइट स्थानांतरित हो गई। इसे एक मार्कर के पीछे रखें ताकि यह हर प्रतिबद्धता पर नहीं बल्कि एक समयसूची पर चले।
प्रश्न: क्या स्क्रैपर परीक्षण CI में चलने चाहिए?
ऑफ़लाइन वाले, हर प्रतिबद्धता पर — वे निर्धारीय और तेज़ होते हैं। लाइव अनुबंध परीक्षणों को एक मर्ज को गेट नहीं करना चाहिए, क्योंकि एक विफलता का मतलब है कि किसी और की साइट बदल गई और पुल अनुरोध निर्दोष है। इसके बजाय, उन्हें एक टाइमर पर चलाएँ और परिणाम पर अलर्ट करें।
प्रश्न: एक स्क्रैपर को कितनी कवरेज हासिल करनी चाहिए?
दर्शाएँ देखें कि कौन सी पंक्तियाँ मिसिंग हैं, प्रतिशत के बजाय। 85% के साथ fetch और scrape बिना कवर की गई एक अच्छी आकार वाली सूट है; उसी 85% के साथ पार्सिंग शाखाएँ बिना कवर की गई नहीं हैं। 100% तक पहुँचने का मतलब आमतौर पर यह है कि यह सुनिश्चित करना कि एक मॉक को कॉल किया गया था, जो डेटा के बारे में कुछ भी साबित नहीं करता है।
प्रश्न: क्या एक पार्सर को None लौटाना चाहिए या जब एक फ़ील्ड गायब हो तो उठाना चाहिए?
उठाना चाहिए। एक None डेटाबेस में एक शून्य के रूप में फैलता है और रन सफलता की सूचना देता है, इसलिए विफलता कई दिनों बाद गायब डेटा के रूप में उभरती है। उठाना फ़ील्ड का नाम रखता है और मार्कअप बदलने के क्षण पर पंक्ति को दर्शाता है, जो एक नामांकित कक्षा को ऊपर ValueError: missing price में बदलने का कारण बना।
स्क्रैपलेस में, हम केवल सार्वजनिक रूप से उपलब्ध डेटा का उपयोग करते हैं, जबकि लागू कानूनों, विनियमों और वेबसाइट गोपनीयता नीतियों का सख्ती से अनुपालन करते हैं। इस ब्लॉग में सामग्री केवल प्रदर्शन उद्देश्यों के लिए है और इसमें कोई अवैध या उल्लंघन करने वाली गतिविधियों को शामिल नहीं किया गया है। हम इस ब्लॉग या तृतीय-पक्ष लिंक से जानकारी के उपयोग के लिए सभी देयता को कोई गारंटी नहीं देते हैं और सभी देयता का खुलासा करते हैं। किसी भी स्क्रैपिंग गतिविधियों में संलग्न होने से पहले, अपने कानूनी सलाहकार से परामर्श करें और लक्ष्य वेबसाइट की सेवा की शर्तों की समीक्षा करें या आवश्यक अनुमतियाँ प्राप्त करें।



