ブログに戻ります

Webスクレイパーをpytestでテストする方法: 実践ガイド

Ava Wilson
Ava Wilson

Expert in Web Scraping Technologies

10-Sep-2026

TL;DR:

  • fetchparseを分離すると、解析は純粋な関数になり、HTML文字列を受け取り、レコードを出力します。ネットワークとモックライブラリなしでテスト可能です。
  • オフラインスイートは0.16秒で14のテストを実行しました。2つのライブ契約テストはデフォルトで選択されておらず、それ自体で0.88秒かかります。
  • カバレッジは85%と報告され、唯一カバーされていない行は fetch()scrape() でした。それは閉じるべきギャップではなく、意図した形です。
  • フィールドパーサーに例外を発生させるようにします。フィクスチャのコピーで1つのCSSクラスの名前を変更すると、20行のnullの代わりに名付けられた行で ValueError: missing price が生成されました。
  • フィクスチャテストは、パーサーが保存したHTMLを処理できることを証明します。実際のページに対する契約テストのみがサイトの変更を検出します。
  • グリーンスイートは、ターゲットがそのHTMLを提供し続けているか、サーバーサイドでまだレンダリングされているか、あるいはページを全く返しているかを教えてくれません。
  • Scrapelessフリープランで実際のレンダリングされたページに対してライブ部分を実行します。

スクレイパーは、ほとんどのソフトウェアがそうでないように壊れます:リポジトリ内に変更がないのに、他の誰かがページを編集したためにコードが機能しなくなるのです。それは通常の本能——テストを書く、成功するのを見守る、出荷する——を必要ですが、十分ではなく、テストが何を確認すべきかを変えます。

以下のスイートは、小さな書籍カタログスクレイパーをカバーしています。これは、パーサーが正しいかどうかを問うオフライン部分と、サイトがまだパーサーの期待に合致しているかどうかを問うライブ部分の2つの半分から成っています。

スクレイパーテストの実際の目的

3つの失敗は区別する価値があります。なぜなら、そのうちの2つだけがあなたのものだからです:

失敗 検出方法
パーサーが有効なHTMLを誤処理する オフラインユニットテスト 通貨記号のある価格がfloatではなく文字列になる
サイトがマークアップを変更した ライブ契約テスト price_colorproduct-price になる
サイトがページを提供するのを止めた どちらでもない レスポンスがチャレンジページまたは空のシェルになる

ほとんどの公開されたスクレイパーテストのアドバイスは最初の行をカバーしています。2番目の項目は、サイトに話しかけるテストが必要です。3番目は、テストスイートで全く捕まえることができないため、それを構築する前に大声で言っておく価値があります。

インストール

bash Copy
python3 -m venv .venv
./.venv/bin/pip install pytest pytest-cov responses parsel requests

このスイートが実行されたバージョン:

text Copy
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モッキングが通常の次の質問であるため、含まれています。解析テストにはそれが必要なく、その理由は構造的であってスタイル的ではありません。

パーシングをテスト可能にする分割

ネットワークに触れる関数は1つだけです。他はすべて文字列を受け取ります。

python Copy
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も時計もグローバルステートもないため、テストには全くモッキングを必要としません。標準ライブラリのモッキングツールは優れていますが、ここではほとんど不要です。引数として入力を受け取る関数は、その依存関係をパッチする必要がありません。

2つのフィールドパーサーは、Noneを返すのではなく例外を発生させることに注意してください。この単一の決定が、静かなマークアップの変更を名付けられた失敗に変えます。

実際のページをフィクスチャとして保存する

テストには、その下で変更されないHTMLが必要なので、実際のレスポンスを1回保存し、コミットします。

python Copy
import requests, pathlib

response = requests.get(CATEGORY_URL, timeout=30)
response.raise_for_status()
pathlib.Path("fixtures/mystery.html").write_bytes(response.content)
text Copy
fixture saved: 50388 bytes

セッションスコープのフィクスチャを通じて読み込むため、全体の実行のためにファイルが1回読まれます:

python Copy
# 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")

フィクスチャをコミットします。これは、パーサーが書かれたときのページがどのように見えたかの記録であり、新しいコピーに対するdiffが、サイトの変更を最も早く把握する方法です。

持っている価値のあるアサーションを書く

何かが戻ってきたという事実ではなく、値と不変量に対してアサートします。

python Copy
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>") == []

3種類のアサーションが異なる作業をしています。正確な値は1つの既知のレコードを固定します。不変量(all prices > 0、1から5の間の評価)は、フィクスチャがまだ含んでいないレコードに対して保持されます。そして、pytest.raises ケースは失敗動作を固定し、マークアップの変更が行う部分です。

ライブテストをデフォルトの実行から外す

契約テストは実際のサイトにアクセスするため、遅く、他の誰かの稼働時間に依存します。マーカーを使うことで、削除せずに高速ループから外します。

python Copy
# 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 Copy
[pytest]
pythonpath = .
testpaths = tests
markers =
    live: hits the real site; excluded from the default run
addopts = -m "not live"

設定でマーカーを登録することが、pytestのマーカーシステムが未知のマークについて警告するのを止める方法であり、addoptsは除外をデフォルトにすることで、全員が覚えておかなければならない何かにするのではなくなります。

text Copy
$ pytest -q
..............                                    [100%]
14 passed, 2 deselected in 0.16s

$ pytest -q -m live
..                                                [100%]
2 passed, 14 deselected in 0.88s

分割は重要です。なぜなら、2つのスイートが異なるスケジュールに属しているからです。オフラインの14は、すべてのコミットで実行されます。ライブの2はタイマーで実行され、その失敗はコードではなくサイトが変更されたことを意味します — これは境界です 実用的なテストピラミッドが高速の孤立したテストと、実際の境界を越える少数のテストの間に引くものです。

アーキテクチャチェックとしてのカバレッジの読み取り

text Copy
$ 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であり、2つを構成します。すべての解析ロジックの行がカバーされており、カバーされていない行はネットワークに話しかけるものです。

それが求めるべき数です。ここで100%を追求することは、requestsをモックしてrequests.getが呼び出されたことを証明することを意味し、モックをテストします。スクレイパーのカバレッジレポートの有用な読み取りは、どの行が欠落しているか、そしてそれらが意図的にエッジに留まっている行かどうかです。

ページがクライアントサイドでレンダリングされるスクレイパーのテスト? Scrapelessの無料プランは、コミットする価値のあるレンダリングされたフィクスチャをキャッチするために十分なセッションをカバーします。

マークアップ変更の例

保存されたフィクスチャを取り、サイトのデザインリニューアルのように1つのクラス名を変更し、それに対してパーサーを実行します:

python Copy
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 Copy
price_color occurrences: 20 -> 0
text Copy
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を返していた場合、実行は完了し、価格がnullの20レコードが書き込まれたでしょう — そしてパイプラインは成功を報告していました。HTML仕様のクラス属性には、まったく安定性の保証がありません。これは見た目のものであり、クラス名を契約として扱うことは、契約が破られたときにパーサーが騒がなければならないことを意味します。

同じ理由から、解析後にレコードの形状を検証することは、これらのテストと組み合わせる価値があります — 私たちのスクレイピングデータを検証するガイドは、同じ問題の実行時の半分をカバーしています。

テストスイートが停止する場所

グリーンスイートは、パーサーがfixtures/のHTMLを処理していることを意味します。生産環境でスクレイパーを壊す3つのことについては何も言っていません:

  • ページは現在クライアントサイドでレンダリングされています。 平凡なクライアントが受け取るHTMLはシェルであり、セレクタは正しく、何も一致しません。
  • レスポンスはページではありません。 チャレンジやインタースティシャルがHTTP 200と共に届き、コンテンツのみのアサーションは、レコードを含まないマークアップで通過することができます。
  • フィクスチャが経年劣化しています。 それはファイルであるため、クリーンにパースされ続けるだけです。これは、サイトが移動したことを伝えられない正確な理由です。

最初の2つは、実際のリクエストではなく、実際のブラウザが必要です。Scrapeless Scraping Browserを通じてフィクスチャをキャッチすることで、保存されたHTMLはブラウザが組み立てたDOMであり、オフラインスイートはライブ実行が見る同じドキュメントをテストします。契約スイートは、コミットあたりのコストではなく、タイマーでのセッションのいくつかです。価格設定では、そのリズムが何に該当するかがリストされています。3つ目は、契約テストがライブタイトルとフィクスチャの比較によって回答されます — 利用可能な最も安価な早期警告であり、これら2つのテストが存在する理由でもあります。

トラブルシューティング

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 — コンフィグのmarkersセクションでマーカーを登録します。

ライブテストが失敗し、オフラインスイートがパスする — それは契約テストがその仕事をしていることを意味します。パーサーに触れる前に、コミットされたフィクスチャに対してページの新しいコピーを差を取ります。

結論

スクレイパーをテスト可能にする設計上の決定は、テストフレームワークではなく、分割です:fetchは文字列を返し、parseは1つを受け取り、すべての興味深いことは純粋な関数で発生します。カバレッジは形状を確認します — 85%、fetchscrapeが唯一のカバーされていない行です。
それに加えて、2つの習慣が大部分の価値をもたらします。フィールドパーサーを例外を発生させるようにし、名前が変更されたクラスがValueError: missing priceを特定の行で生成するようにします。さらに、マーカーの背後に小さなライブコントラクトスイートを保持してください。なぜなら、フィクスチャはあなたが保存したページでパーサーがまだ動作しているかどうかを教えてくれるだけだからです。

スクレイパーを解析する前にレンダリングされるページに対してテストする準備はできていますか? Scrapelessの無料プランから始める と、実際のDOMからフィクスチャをキャプチャしましょう。

FAQ

Q: Webスクレイパーをサイトに接触せずにユニットテストする方法は?

フェッチをパースから分離し、パースをテストします。もしparseがHTML文字列を受け取り、レコードを返す場合、保存したフィクスチャファイルがテスト全体のセットアップになります — モッキングライブラリもHTTPインターセプションも不要です。上記の14のオフラインテストは、いずれもソケットを開かないため、0.16秒で実行されました。

Q: responsesやunittest.mockのようなモッキングライブラリは必要ですか?

ネットワーク自体を呼び出すコードのみです。パースが文字列引数を取るようになると、パッチを当てるべきものは何もありません。フェッチ層の動作 — ステータス処理、タイムアウト、ヘッダー構築 — をテストしたい場合にHTTPモッキングを利用してください。パースをテストするためではありません。

Q: サイトが私のセレクターを変更したことを検出するにはどうすればよいですか?

ライブページをフェッチしてコミットされたフィクスチャと比較するコントラクトテストです。上記のtest_live_selectors_match_fixture_shapeはタイトルのセットが一致することを確認しています。一致しなくなったら、サイトが移動したことを示しています。マーカーの後ろに保持して、すべてのコミット時ではなくスケジュール通りに実行されるようにしてください。

Q: スクレイパーテストはCIで実行するべきですか?

オフラインのものは、すべてのコミット時に実行してください — それらは決定論的で速いです。ライブコントラクトテストはマージを制御すべきではありません。なぜなら、失敗した場合は他の誰かのサイトが変更されたことを意味し、プルリクエストは無実だからです。定期的に実行し、その結果に基づいてアラートを出すようにしてください。

Q: スクレイパーはどの程度のカバレッジを目指すべきですか?

割合よりもどの行が欠けているかを見てください。fetchscrapeが未カバーの状態で85%を達成しているのは、形の良いスイートですが、パーシングブランチが未カバーの同じ85%はそうではありません。100%を目指すことは通常、モックが呼び出されたことを確認することを意味し、それはデータについて何も証明しません。

Q: フィールドが欠けているとき、パーサーはNoneを返すべきか、例外を発生させるべきか?

例外を発生させてください。Noneはデータベースにnullとして伝播し、実行は成功を報告します。したがって、失敗は数日後に欠損データとして表面化します。例外を発生させることで、マークアップが変更されたときにフィールド名と行を示すことができ、これが上記のValueError: missing priceに名前変更されたクラスの理由です。

Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。

最も人気のある記事

カタログ