12
13

More than 5 years have passed since last update.

ScrapyでSpiderのテストを書く

Last updated at Posted at 2017-08-13

Scrapyのユニットテストを書こうとしたところ、ちょっと特殊かつあまり情報がなかったのでまとめました。いつHTMLが変更されてもおかしくないというクローラーの特性上、正当性チェックよりも実装時のcrawl時間を短縮するための利用をメインにするのが吉かなと思います。
(※主にSpiderのユニットテストに関する記事です)
(※Pipeline等のテストはunittestなどで普通に書けるため範囲外です)

TL;DR;

Spiders Contractsを使います

  • 公式のドキュメント
  • docstringに書く
  • scrapy check spidername で実行できる
  • 自分でサブクラスを作り拡張できる
  • ドキュメントにあるサンプルコード
    def parse(self, response):
    """ This function parses a sample response. Some contracts are mingled
    with this docstring.

    @url http://www.amazon.com/s?field-keywords=selfish+gene
    @returns items 1 16
    @returns requests 0 0
    @scrapes Title Author Year Price
    """

Spiders Contractsの基本的な使い方

下記のサンプルコードを見るのが早いと思います。(Python3.6.2, Scrapy 1.4.0)

  • docstringに書いてあるものがContracts
    • @コントラクト名 arg1 arg2 arg3... という形式
  • Scrapyで定義済みコントラクト
    • url: パース先URL
    • item: yieldされる想定item数を min( max) の順で書く
    • requests: yieldされる想定Request数を min( max) の順で書く
    • scrapes: itemにセットされる想定の要素を羅列する
myblog.py
    def parse_list(self, response):
        """一覧画面のパース処理

        @url http://www.rhoboro.com/index2.html
        @returns item 0 0
        @returns requests 0 10
        """
        for detail in response.xpath('//div[@class="post-preview"]/a/@href').extract():
            yield Request(url=response.urljoin(detail), callback=self.parse_detail)

Custom Contractsを作る

サブクラスの作成

Contractsは自分でサブクラスを作ることで拡張可能です。作成したCntractsはsetting.pyで登録します。

  • self.args
    • docstringで渡されるパラメータのリストです
  • adjust_request_args(self, kwags)メソッド
    • kwagsは下記のコードでRequestのイニシャライザに渡されます
    • scrapy/contracts/__init__.py
    • これを拡張してreturnすることでRequest内容を変更できます。
  • pre_process(self, response)メソッド
    • Requsetのcallbackで指定したメソッドが呼ばれる前に呼び出されます
  • post_process(self, output)メソッド
    • Requsetのcallbackで指定したメソッドが呼ばれた後に呼び出されます
    • outputはyieldされたもののリストです
contracts.py
# -*- coding: utf-8 -*-

from scrapy.contracts import Contract
from scrapy.exceptions import ContractFail


class ItemValidateContract(Contract):
    """Itemが期待通りかチェックする

    取得結果は常に変わる可能性があるため、
    不変な値を想定しているのところだけテストするのがいいと思います。
    要素欠け以上のチェックはPipelineでやるべきかな。
    """
    name = 'item_validate' # この名前がdocstringでの名前になる

    def post_process(self, output):
        item = output[0]
        if 'title' not in item:
            raise ContractFail('title is invalid.')


class CookiesContract(Contract):
    """リクエストに(scrapyの)cookiesを追加するContract

    @cookies key1 value1 key2 value2
    """
    name = 'cookies'

    def adjust_request_args(self, kwargs):
        # self.argsを辞書形式に変換してcookiesにいれる
        kwargs['cookies'] = {t[0]: t[1]
                             for t in zip(self.args[::2], self.args[1::2])}
        return kwargs

利用する側のコード

これを使う側のコードはこんな感じになります。

  • settings.pyで登録する必要があります。
settings.py
...
SPIDER_CONTRACTS = {
    'item_crawl.contracts.CookiesContract': 10,
    'item_crawl.contracts.ItemValidateContract': 20,
}
...
  • テストコード
myblog.py
    def parse_detail(self, response):
        """詳細画面のパース処理

        @url http://www.rhoboro.com/2017/08/05/start-onomichi.html
        @returns item 1
        @scrapes title body tags
        @item_validate
        @cookies index 2
        """
        item = BlogItem()
        item['title'] = response.xpath('//div[@class="post-heading"]//h1/text()').extract_first()
        item['body'] = response.xpath('//article').xpath('string()').extract_first()
        item['tags'] = response.xpath('//div[@class="tags"]//a/text()').extract()
        item['index'] = response.request.cookies['index']
        yield item

テストを実行する

scrapy check spidername で実行します。
当たり前ですが指定したページのみクロールするためscrapy crawl spidernameを試すより早いです。

  • 成功時
(venv) [alpaca]~/github/scrapy/crawler/crawler % scrapy check my_blog                                                                                                [master:crawler]
.....
----------------------------------------------------------------------
Ran 5 contracts in 8.919s

OK
  • 失敗時( parse_detail()の item['title'] = ... をコメントアウト)
(venv) [alpaca]~/github/scrapy/crawler/crawler % scrapy check my_blog                                                                                                [master:crawler]
...FF
======================================================================
FAIL: [my_blog] parse_detail (@scrapes post-hook)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/rhoboro/github/scrapy/venv/lib/python3.6/site-packages/scrapy/contracts/__init__.py", line 134, in wrapper
    self.post_process(output)
  File "/Users/rhoboro/github/scrapy/venv/lib/python3.6/site-packages/scrapy/contracts/default.py", line 89, in post_process
    raise ContractFail("'%s' field is missing" % arg)
scrapy.exceptions.ContractFail: 'title' field is missing

======================================================================
FAIL: [my_blog] parse_detail (@item_validate post-hook)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/rhoboro/github/scrapy/venv/lib/python3.6/site-packages/scrapy/contracts/__init__.py", line 134, in wrapper
    self.post_process(output)
  File "/Users/rhoboro/github/scrapy/crawler/crawler/contracts.py", line 18, in post_process
    raise ContractFail('title is invalid.')
scrapy.exceptions.ContractFail: title is invalid.

----------------------------------------------------------------------
Ran 5 contracts in 8.552s

FAILED (failures=2)

ちなみにエラー時はこちら。(これはsettings.pyの記載を忘れたとき。)
正直情報が少な過ぎてツライです。

(venv) [alpaca]~/github/scrapy/crawler/crawler % scrapy check my_blog                                                                                                [master:crawler]
Unhandled error in Deferred:


----------------------------------------------------------------------
Ran 0 contracts in 0.000s

OK
12
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
13