Em 7 de outubro de 2026, a Shifter tem o maior pool de IPs dos EUA ativos, mais do que a Oxylabs e a Bright Data, por uma fração do custo.A Shifter agora tem o maior pool de IPs ativos dos EUA

Ver benchmarks

Extração de dados

Testando Scrapers: Fixtures, Replay e Suítes de Regressão

Scrapers quebram quando os sites mudam, e os testes que acessam o site ao vivo quebram junto. Como testar um scraper com fixtures gravadas, replay e canaries diários.

Chris Collins

9 de outubro de 2026 · 12 min de leitura

A maioria dos scrapers é testada da mesma forma: alguém os executa contra o site ao vivo, olha para o resultado e decide que parece correto. Isso funciona até o dia em que não funciona mais. O site muda um nome de classe, o scraper retorna campos vazios, e ninguém percebe até que um relatório mais adiante saia errado.

A dificuldade é que um scraper depende de algo que você não controla. Testes que chamam o site ao vivo são lentos, instáveis e mudam suas respostas sem aviso, então as equipes param de executá-los. Testes que nunca tocam páginas reais passam para sempre enquanto a produção quebra.

A saída é dividir o problema em dois. Teste seu código contra páginas gravadas que nunca mudam, e teste o site separadamente com uma pequena verificação diária que avisa quando ele mudou. Este guia constrói os dois, com código que você pode adaptar: fixtures gravadas, uma sessão de replay que executa todo o crawler offline, testes de golden-file para o parser e um canário para o site ao vivo.

Três coisas que podem quebrar

Quando um scraper produz dados ruins, a causa é quase sempre uma de três, e cada uma precisa de um tipo diferente de teste:

O que quebrouExemploTeste que detecta
Seu parserUm refactor transforma “1,099.00” em 1.099Testes de golden-file em páginas gravadas
Sua lógica de crawlA paginação para após a página um; um 503 é tratado como uma página vaziaTestes de replay que executam o crawler offline
O siteUm nome de classe muda, um campo se move, uma página de bloqueio apareceUm canário diário contra o site ao vivo

Os dois primeiros são bugs no seu código e devem ser detectados antes de você implantar, em testes que rodam em segundos sem rede. O terceiro não é um bug de forma alguma; é o mundo mudando, e isso precisa de monitoramento em vez de um teste unitário.

Mantenha a busca e o parsing separados

Testar começa com estrutura. Se a função que faz o parsing de uma página também a busca, todo teste precisa lidar com a rede. Mantenha o parsing como uma função pura: HTML entra, registros saem. O crawler então se torna um loop fino que busca páginas e as entrega ao parser.

Aqui está o scraper que vamos testar, para um site de listagens com paginação:

from urllib.parse import urljoin

from lxml import html as lxml_html


def parse_listing(page_html):
    """Products on one listing page. Pure function: HTML in, records out."""
    doc = lxml_html.fromstring(page_html)
    items = []
    for card in doc.cssselect("li.product"):
        price = card.cssselect(".price")[0].text_content().strip()
        items.append({
            "sku": card.get("data-sku"),
            "name": card.cssselect("h2")[0].text_content().strip(),
            "price": float(price.replace("$", "").replace(",", "")),
            "in_stock": not card.cssselect(".sold-out"),
        })
    return items


def next_page(page_html, base_url):
    links = lxml_html.fromstring(page_html).cssselect("a[rel=next]")
    return urljoin(base_url, links[0].get("href")) if links else None


def crawl(session, start_url, max_pages=50):
    """Follow pagination; return the records and the URLs that failed."""
    records, failed, url = [], [], start_url
    for _ in range(max_pages):
        response = session.get(url, timeout=30)
        if response.status_code != 200:
            failed.append((url, response.status_code))
            break
        records.extend(parse_listing(response.text))
        url = next_page(response.text, response.url)
        if url is None:
            break
    return records, failed

crawl() recebe a sessão como argumento em vez de criar uma. Essa única escolha é o que torna isso testável: em produção você passa uma requests.Session, em testes você passa algo que serve páginas gravadas.

Grave fixtures uma vez

Uma fixture é uma resposta real, salva em disco com metadados suficientes para reproduzi-la exatamente: a URL, a URL final após redirecionamentos, o código de status e a codificação. Grave o corpo como bytes brutos, não texto decodificado, para que problemas de codificação sejam reproduzidos em vez de escondidos.

import hashlib
import json
from pathlib import Path


def _name(url):
    return hashlib.sha256(url.encode()).hexdigest()[:16]


def record(session, url, directory):
    """Fetch a page once and save everything a test needs to replay it."""
    response = session.get(url, timeout=30)
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    name = _name(url)
    (directory / f"{name}.html").write_bytes(response.content)
    (directory / f"{name}.json").write_text(json.dumps({
        "url": url,
        "final_url": response.url,
        "status": response.status_code,
        "encoding": response.encoding,
        "headers": {"Content-Type": response.headers.get("Content-Type", "")},
    }, indent=2))
    return name


class Replayed:
    def __init__(self, meta, body):
        self.url = meta["final_url"]
        self.status_code = meta["status"]
        self.headers = meta["headers"]
        self.content = body
        self.text = body.decode(meta["encoding"] or "utf-8", errors="replace")


class ReplaySession:
    """Stands in for requests.Session: serves recorded pages, never the network."""

    def __init__(self, directory):
        self.directory = Path(directory)
        self.requested = []

    def get(self, url, **kwargs):
        self.requested.append(url)
        name = _name(url)
        meta_path = self.directory / f"{name}.json"
        if not meta_path.exists():
            raise LookupError(f"no fixture recorded for {url}")
        meta = json.loads(meta_path.read_text())
        return Replayed(meta, (self.directory / f"{name}.html").read_bytes())

ReplaySession tem o mesmo método get() que requests.Session, então crawl() não consegue perceber a diferença. Ela gera um erro para qualquer URL que não tenha sido gravada, o que importa: um teste que silenciosamente recorresse à rede seria rápido no seu laptop e lento e instável no CI.

Grave mais do que páginas de sucesso. Os casos que quebram scrapers em produção são os que vale a pena manter: a última página da paginação, um resultado de busca vazio, um produto esgotado, um 503, uma página de bloqueio. Cada um se torna um caso de teste permanente. Se você arquiva respostas brutas em produção, como nosso guia sobre arquivar páginas raspadas em WARC descreve, esses arquivos são uma fonte pronta de fixtures a partir de falhas reais.

Valide o que qualquer crawl correto deve satisfazer

Golden files verificam a saída exata. A validação verifica regras que devem valer independentemente de como o conteúdo do site muda: todo registro tem um ID, os IDs são únicos, os nomes não estão vazios, os preços são números plausíveis, e o crawl retornou aproximadamente tantos registros quanto esperado.

def validate(records, min_records=1):
    """Checks that hold for any correct crawl, whatever the site shows today."""
    problems = []
    if len(records) < min_records:
        problems.append(f"only {len(records)} records, expected at least {min_records}")
    seen = set()
    for i, r in enumerate(records):
        if not r.get("sku"):
            problems.append(f"record {i}: missing sku")
        elif r["sku"] in seen:
            problems.append(f"record {i}: duplicate sku {r['sku']}")
        seen.add(r.get("sku"))
        if not r.get("name"):
            problems.append(f"record {i}: empty name")
        if not isinstance(r.get("price"), float) or not 0 < r["price"] < 100_000:
            problems.append(f"record {i}: implausible price {r.get('price')!r}")
    return problems


def canary(session, start_url, crawl, min_records):
    """Run the real crawl against the live site and list anything wrong.

    Schedule it daily: an empty list means the scraper still works today.
    """
    records, failed = crawl(session, start_url)
    return validate(records, min_records) + [f"fetch failed: {url} ({status})" for url, status in failed]

A mesma validate() roda em três lugares: na suíte de testes, no canário diário e, idealmente, em cada execução de produção antes dos dados serem gravados. Nosso guia sobre schema drift em dados raspados vai mais fundo em verificações no nível de campo ao longo do tempo.

A suíte de testes

Com fixtures e validação em vigor, os testes são curtos. Eles usam apenas a biblioteca padrão:

import json
import os
import unittest
from pathlib import Path

from fixtures import ReplaySession
from shop import crawl, parse_listing
from validate import canary, validate

FIXTURES = Path(__file__).parent / "fixtures"
GOLDEN = Path(__file__).parent / "golden"
UPDATE = os.environ.get("UPDATE_GOLDEN") == "1"


class ParserTests(unittest.TestCase):
    def test_every_fixture_matches_its_golden_file(self):
        for page in sorted(FIXTURES.glob("*.html")):
            meta = json.loads(page.with_suffix(".json").read_text())
            if meta["status"] != 200:
                continue
            with self.subTest(url=meta["url"]):
                parsed = parse_listing(page.read_text())
                golden = GOLDEN / f"{page.stem}.json"
                if UPDATE:
                    golden.write_text(json.dumps(parsed, indent=2))
                self.assertEqual(parsed, json.loads(golden.read_text()))


class CrawlTests(unittest.TestCase):
    def test_crawl_follows_pagination_offline(self):
        session = ReplaySession(FIXTURES)
        records, failed = crawl(session, "https://shop.example/laptops?page=1")
        self.assertEqual(len(session.requested), 2)
        self.assertEqual(failed, [])
        self.assertEqual(validate(records, min_records=4), [])

    def test_crawl_reports_a_failed_page(self):
        session = ReplaySession(FIXTURES)
        records, failed = crawl(session, "https://shop.example/monitors?page=1")
        self.assertEqual(failed, [("https://shop.example/monitors?page=2", 503)])
        self.assertEqual(len(records), 2)


class ValidationTests(unittest.TestCase):
    def test_catches_broken_records(self):
        bad = [{"sku": "A1", "name": "", "price": 0.0}, {"sku": "A1", "name": "x", "price": "12"}]
        self.assertEqual(validate(bad), [
            "record 0: empty name",
            "record 0: implausible price 0.0",
            "record 1: duplicate sku A1",
            "record 1: implausible price '12'",
        ])

    def test_canary_flags_a_partial_crawl(self):
        problems = canary(ReplaySession(FIXTURES), "https://shop.example/monitors?page=1", crawl, min_records=4)
        self.assertEqual(problems, [
            "only 2 records, expected at least 4",
            "fetch failed: https://shop.example/monitors?page=2 (503)",
        ])


if __name__ == "__main__":
    unittest.main()
  • O teste de golden-file faz o parsing de cada página gravada e compara o resultado com um arquivo JSON armazenado. Na primeira vez, execute-o com UPDATE_GOLDEN=1 para escrever os golden files, depois revise-os manualmente: eles são a especificação do que o parser deve produzir. Depois disso, qualquer mudança na saída do parser falha o teste até que alguém olhe e decida se é uma correção ou um bug.
  • Os testes de replay executam o crawl() real offline. Um verifica que a paginação é seguida até o fim; o outro verifica que um 503 na página dois é reportado em vez de silenciosamente tratado como o fim dos resultados.
  • Os testes de validação verificam se as regras realmente detectam registros quebrados, e se o canário reporta um crawl parcial.

Executando a suíte contra fixtures gravadas de uma pequena loja de teste:

test_crawl_follows_pagination_offline (test_shop.CrawlTests) ... ok
test_crawl_reports_a_failed_page (test_shop.CrawlTests) ... ok
test_every_fixture_matches_its_golden_file (test_shop.ParserTests) ... ok
test_canary_flags_a_partial_crawl (test_shop.ValidationTests) ... ok
test_catches_broken_records (test_shop.ValidationTests) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.019s

OK

Cinco testes em uma fração de segundo, sem rede. Essa velocidade é o ponto principal: testes tão rápidos rodam a cada commit. Nosso guia sobre executar scrapers em CI/CD cobre o pipeline ao redor deles, incluindo por que testes de fixture não precisam de nenhuma credencial de proxy.

O canário: testando o site, não o seu código

Fixtures congelam o site como ele era. Para descobrir quando ele muda, execute o crawl real contra o site ao vivo em um cronograma e valide o resultado. É isso que a função canary() acima faz: ela retorna uma lista vazia quando tudo está bem e uma lista de problemas específicos quando não está.

Execute-a uma vez por dia, ou antes de cada crawl grande, com uma URL inicial pequena e uma contagem mínima de registros realista. Quando ela falhar:

  1. Grave a página que quebrou como uma nova fixture.
  2. Observe o teste de golden-file ou de replay falhar nela, o que confirma que o teste reproduz o problema.
  3. Corrija o parser, revise a nova saída golden, e faça commit da fixture, da correção e do golden file juntos.

A mudança do site se torna um teste de regressão permanente, então a mesma quebra nunca mais pode voltar despercebida. Esse ciclo, canário para fixture para correção, é o que transforma um scraper frágil em um mantido.

Um canário também avisa sobre falhas que não têm nada a ver com parsing: uma página de bloqueio que retorna status 200, um redirecionamento de região, ou uma barreira de consentimento no lugar do conteúdo. Nosso estudo sobre a taxa de falha silenciosa mostra quão comuns são respostas que parecem bem-sucedidas mas carregam o conteúdo errado. Para monitoramento contínuo de todo um pipeline, veja como monitorar um pipeline de web scraping.

Regras práticas

  • Nunca chame a rede a partir de testes unitários. Faça de fixtures ausentes um erro, como ReplaySession faz.
  • Mantenha as fixtures pequenas e reais. Uma página por caso é suficiente. Não corte nada do HTML: o ruído que você cortaria é frequentemente o que quebra um parser.
  • Revise golden files como código. Um golden file não revisado só prova que o parser faz o que fazia ontem.
  • Nomeie o caso, não a data. Uma fixture chamada “produto esgotado” é útil daqui a um ano; uma chamada “página de terça-feira” não é.
  • Atualize fixtures deliberadamente. Quando o site muda, adicione novas fixtures ao lado das antigas em vez de substituí-las, a menos que o layout antigo nunca possa voltar.
  • Mantenha segredos fora das fixtures. Páginas gravadas podem conter tokens de sessão ou dados pessoais. Remova cookies e verifique os corpos antes de fazer commit deles.

Perguntas frequentes

Como testar um web scraper sem acessar o site ao vivo?

Grave respostas reais uma vez como fixtures, depois substitua a sessão HTTP por uma que serve essas gravações. O parser e o crawler rodam exatamente como em produção, mas offline, em milissegundos e com a mesma resposta todas as vezes.

O que é um teste de golden file?

Um teste que compara a saída de uma função com uma cópia armazenada e revisada da saída esperada. Para um scraper, o golden file é a lista de registros que uma página gravada deveria produzir. Qualquer mudança na saída falha o teste até que alguém a aceite ou corrija.

Como sei quando uma mudança no site quebra meu scraper?

Execute um canário: um pequeno crawl real contra o site ao vivo, em um cronograma, com regras de validação que qualquer resultado correto deve satisfazer. Quando ele reporta problemas, grave a nova página como uma fixture, corrija o parser e mantenha a fixture como um teste de regressão.

Os testes de scraper devem usar proxies?

Testes unitários e de replay não devem usar a rede de forma alguma, então não precisam de proxies ou credenciais. O canário roda contra o site ao vivo e deve usar a mesma configuração de proxy que a produção, para que veja o que a produção vê.

Conclusão

Teste seu código e teste o site separadamente. Fixtures gravadas e uma sessão de replay permitem testar o parser e o crawler offline, rapidamente e de forma determinística, a cada commit. Um canário diário com regras de validação avisa quando o site muda. Transforme cada quebra que o canário encontra em uma fixture, e o scraper fica mais confiável a cada vez que o site muda, não menos.

Pronto para começar?

Experimente os proxies residenciais da Shifter, mais de 205M IPs, mais de 195 países, a partir de $ 0,75/GB.

Começar