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
"""
- この記事の完全なコードはこちらのリポジトリにおいています
- rhoboro/scrapy-unittest
- クローラ先はPelicanで作っている私のブログです。
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のイニシャライザに渡されます
- これを拡張して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