Como Testar Scrapers da Web Com pytest: Um Guia Prático
Expert in Web Scraping Technologies
TL;DR:
- Separe
fetchdeparsee a análise se torna uma função pura — string HTML de entrada, registros de saída — testável sem rede e sem biblioteca de simulação. - O conjunto offline executou 14 testes em 0,16 s; os dois testes de contrato ao vivo estão desmarcados por padrão e levam 0,88 s por conta própria.
- A cobertura reportou 85%, e as únicas linhas não cobertas foram
fetch()escrape(). Essa é a forma pretendida em vez de uma lacuna a ser fechada. - Faça os analisadores de campo levantarem exceções. Renomear uma classe CSS em uma cópia do fixture produziu
ValueError: missing priceem uma linha nomeada ao invés de 20 linhas nulas. - Um teste de fixture prova que o analisador lida com o HTML que você salvou. Apenas um teste de contrato em relação à página ao vivo detecta que o site mudou.
- Um conjunto verde não pode dizer que o alvo ainda serve esse HTML, ainda renderiza no lado do servidor ou ainda retorna uma página.
- Execute a metade ao vivo contra páginas renderizadas reais no plano gratuito do Scrapeless.
Scrapers quebram de uma maneira que a maioria dos softwares não faz: nada no repositório muda, e o código para de funcionar porque outra pessoa editou uma página. Isso torna o instinto usual — escrever testes, ver eles ficarem verdes, enviar — necessário, mas não suficiente, e altera o que os testes devem estar verificando.
O conjunto abaixo cobre um scraper de catálogo de livros pequeno. Ele é escrito em duas metades que respondem a perguntas diferentes: uma metade offline que pergunta se o analisador está correto, e uma metade ao vivo que pergunta se o site ainda corresponde ao que o analisador espera.
Para Que os Testes de Scraper São Realmente
Três falhas merecem ser separadas, porque apenas duas delas são suas:
| Falha | Detectada por | Exemplo |
|---|---|---|
| O analisador manipula HTML válido incorretamente | teste unitário offline | um preço com um símbolo de moeda se torna uma string, não um float |
| O site mudou sua marcação | teste de contrato ao vivo | price_color se torna product-price |
| O site parou de servir a página | nenhum | a resposta é uma página de desafio ou uma shell vazia |
A maioria dos conselhos publicados sobre testes de scrapers cobre a primeira linha. A segunda precisa de um teste que se comunique com o site; a terceira não pode ser capturada por um conjunto de testes, o que vale a pena dizer em voz alta antes de construir um.
Instalar
bash
python3 -m venv .venv
./.venv/bin/pip install pytest pytest-cov responses parsel requests
As versões que este conjunto executou:
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 está incluído porque a simulação HTTP é a próxima pergunta usual. Os testes de análise não precisam de nada disso, e a razão é estrutural em vez de estilística.
A Separação que Torna a Análise Testável
Uma função toca a rede. Tudo o mais recebe uma string.
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 não tem I/O, não tem relógio e não tem estado global, portanto testá-lo não precisa de simulação. As ferramentas de simulação da biblioteca padrão são excelentes e na maioria das vezes desnecessárias aqui — uma função que já recebe sua entrada como argumento não precisa que suas dependências sejam alteradas.
Note que os dois analisadores de campo levantam exceções em vez de retornar None. Essa decisão única é o que transforma uma alteração silenciosa na marcação em uma falha nomeada.
Salvar uma Página Real como Fixture
Os testes precisam de HTML que não mude por baixo deles, então salve uma resposta real uma vez e comprometa-a.
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
Carregue-a através de um fixture com escopo de sessão para que o arquivo seja lido uma vez durante toda a execução:
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")
Comprometa o fixture. Ele é o registro de como a página parecia quando o analisador foi escrito, e uma diferença em relação a uma cópia nova é a maneira mais rápida de ver o que um site mudou.
Escreva Aserções Que Valem a Pena
Afirme valores e invariantes, não o fato de que algo foi devolvido.
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>") == []
Três tipos de asserção estão realizando trabalhos distintos. Valores exatos fixam um registro conhecido. Invariantes (all prices > 0, classificações entre 1 e 5) se aplicam a registros que o fixture ainda não contém. E os casos pytest.raises fixam o comportamento de falha, que é a parte que uma alteração de marcação exerce.
Mantenha os Testes Ao Vivo Fora da Execução Padrão
Os testes de contrato atingem o site real, portanto são lentos e dependem do tempo de atividade de outra pessoa. Um marcador os mantém fora do loop rápido sem excluí-los.
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"
Registrar o marcador na configuração é o que impede que o sistema de marcação do pytest avise sobre uma marca desconhecida, e addopts torna a exclusão o padrão em vez de algo que todos têm que lembrar.
text
$ pytest -q
.............. [100%]
14 passed, 2 deselected in 0.16s
$ pytest -q -m live
.. [100%]
2 passed, 14 deselected in 0.88s
A divisão é importante porque os dois conjuntos pertencem a cronogramas diferentes. O offline 14 é executado a cada commit. O vivo 2 é executado em um cronômetro, e sua falha significa que o site mudou em vez do código — este é o limite a pirâmide de testes prática que diferencia entre testes isolados rápidos e o pequeno número que atravessa um limite real.
Leia a Cobertura como uma Verificação de Arquitetura
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
As linhas 14-16 são o corpo de fetch; a linha 47 é scrape, que compõe os dois. Cada linha da lógica de análise está coberta e cada linha não coberta é uma que fala com a rede.
Esse é o número que se deseja. Buscar 100% aqui significa simular requests para provar que requests.get foi chamado, o que testa o mock. A leitura útil de um relatório de cobertura em um scraper é quais linhas estão faltando e se são aquelas que você deliberadamente manteve na borda.
Testando um scraper contra uma página que renderiza no lado do cliente? O plano gratuito do Scrapeless cobre sessões suficientes para capturar uma fixture renderizada digna de comprometimento.
Como é uma Mudança de Marcação
Pegue a fixture salva, renomeie uma classe da maneira como um redesign de site faria e execute o analisador contra ela:
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
A falha nomeia o campo e a linha. Se parse_price tivesse retornado None em uma correspondência ausente, a execução teria sido concluída e escrito vinte registros com um preço nulo — e o pipeline teria relatado sucesso. O atributo de classe da especificação HTML não oferece nenhuma garantia de estabilidade; ele é apresentacional, e tratar um nome de classe como um contrato significa que o analisador deve ser barulhento quando o contrato é quebrado.
Pela mesma razão, validar a forma do registro após a análise vale a pena emparelhar com esses testes — nosso guia para validar dados raspados cobre a metade em tempo de execução do mesmo problema.
Onde o Conjunto de Testes Para
Um conjunto verde significa que o analisador lida com o HTML em fixtures/. Não diz nada sobre três coisas que quebram scrapers em produção:
- A página agora renderiza no lado do cliente. O HTML que um cliente comum recebe é uma casca; os seletores estão corretos e não correspondem a nada.
- A resposta não é a página. Um desafio ou intersticial chega com HTTP 200, e uma afirmação apenas de conteúdo pode passar em uma marcação que não contém registros.
- A fixture envelheceu. Ela ainda analisa limpidamente porque é um arquivo, que é exatamente o motivo pelo qual não pode te dizer que o site mudou.
Os dois primeiros precisam de um navegador real em vez de um pedido real. Capturar a fixture através do Scrapeless Scraping Browser significa que o HTML salvo é o DOM que o navegador montou, então o conjunto offline testa o mesmo documento que a execução ao vivo verá. Um conjunto de contrato é uma mão cheia de sessões em um cronômetro em vez de um custo por commit, e preços lista a que esse ritmo chega. O terceiro é respondido pelo teste de contrato comparando títulos ao vivo com os da fixture — o alerta precoce mais barato disponível, e a razão pela qual esses dois testes existem.
Solucionando Problemas
fixture 'mystery_html' not found — a fixture vive em tests/conftest.py, e o pytest só descobre conftest.py no diretório de teste ou acima dele.
ModuleNotFoundError: No module named 'bookscraper' — defina pythonpath = . em pytest.ini, ou instale o pacote em modo editável. Os testes são executados a partir do rootdir, não a partir de tests/.
PytestUnknownMarkWarning: Unknown pytest.mark.live — registre o marcador na seção markers da configuração.
Os testes ao vivo falham enquanto o conjunto offline passa — esse é o teste de contrato fazendo seu trabalho. Compare uma cópia fresquinha da página contra a fixture comprometida antes de mexer no analisador.
Conclusão
A decisão de design que torna um scraper testável não é o framework de teste, é a divisão: fetch retorna uma string, parse aceita uma, e tudo que é interessante acontece em uma função pura. A cobertura confirma a forma — 85%, com fetch e scrape como as únicas linhas não cobertas.
Além disso, dois hábitos carregam a maior parte do valor. Faça os analisadores de campo levantarem erros, para que uma classe renomeada produza ValueError: missing price em uma linha nomeada em vez de vinte preços nulos. E mantenha um pequeno conjunto de contratos ativos atrás de um marcador, porque o fixture só pode te dizer que o analisador ainda funciona na página que você salvou.
Pronto para testar um scraper contra páginas que renderizam antes de você analisá-las? Comece com o plano gratuito do Scrapeless e capture um fixture do DOM real.
FAQ
P: Como você testa unitariamente um web scraper sem acessar o site?
Separe a busca da análise e teste a análise. Se parse recebe uma string HTML e retorna registros, um arquivo fixture salvo é toda a configuração do teste — sem biblioteca de mocking, sem interceptação HTTP. Os 14 testes offline acima rodaram em 0.16 s porque nenhum deles abre um socket.
P: Eu preciso de uma biblioteca de mocking como responses ou unittest.mock?
Somente para código que chama a rede em si. Uma vez que a análise aceita um argumento de string, não há nada para corrigir. Procure por mocking HTTP quando você quiser testar o comportamento da própria camada de busca — manuseio de status, timeouts, construção de cabeçalhos — em vez de testar a análise.
P: Como eu detecto que um site mudou meus seletores?
Um teste de contrato que busca a página ao vivo e a compara com o fixture comprometido. test_live_selectors_match_fixture_shape acima afirma que o conjunto de títulos corresponde; quando parar de corresponder, o site mudou. Mantenha-o atrás de um marcador para que ele seja executado em um cronograma em vez de em cada commit.
P: Os testes de scraper devem rodar no CI?
Os offline, em cada commit — eles são determinísticos e rápidos. Testes de contrato ao vivo não devem bloquear um merge, porque uma falha significa que o site de outra pessoa mudou e o pull request é inocente. Execute-os em um timer e avise sobre o resultado em vez disso.
P: Que cobertura um scraper deve almejar?
Olhe para quais linhas estão faltando em vez da porcentagem. 85% com fetch e scrape sem coberturas é um conjunto bem estruturado; os mesmos 85% com ramificações de análise descobertas não são. Empurrar para 100% geralmente significa afirmar que um mock foi chamado, o que não prova nada sobre os dados.
P: Um analisador deve retornar None ou levantar erro quando um campo está faltando?
Levante um erro. Um None se propaga para o banco de dados como nulo e a execução relata sucesso, então a falha aparece dias depois como dados faltando. Levantar um erro nomeia o campo e a linha no momento em que a marcação muda, que é o que transformou uma classe renomeada em ValueError: missing price acima.
Na Scorretless, acessamos apenas dados disponíveis ao público, enquanto cumprem estritamente as leis, regulamentos e políticas de privacidade do site aplicáveis. O conteúdo deste blog é apenas para fins de demonstração e não envolve atividades ilegais ou infratoras. Não temos garantias e negamos toda a responsabilidade pelo uso de informações deste blog ou links de terceiros. Antes de se envolver em qualquer atividade de raspagem, consulte seu consultor jurídico e revise os termos de serviço do site de destino ou obtenha as permissões necessárias.



