なんか気がつけば2017年8月から始めたBlogも1万アクセスを突破してました。めでたい。
さて、最近はDjangoよりもScrapyに触ってる事が多いです。結構日本語の情報も増えてきましたが、Django等に比べるとまだまだ利用者が少ないのか情報が少ない部分もあります。
このBlogではScrapyにはじめて触れますが、いきなりテストについてメモ代わりにまとめておきます。ScrapyではSpiderというコンポーネントをクロール対象ごとに作成します。このSpiderのテストをどうするかについてです。
サンプルの構成
今回のプロジェクトは以下のような構成です。といってもscrapy startproject
してdenzowblog
というspiderを一つ追加しただけの状態です。
. ├── scrapy.cfg └── testscrapy ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py └── spiders ├── __init__.py └── denzowblog.py
とりあえず、このブログのPythonカテゴリの記事のタイトルとURLを取得するようにdenzowblog.py
を変更しておきます。
# -*- coding: utf-8 -*- import scrapy from bs4 import BeautifulSoup class BlogItem(scrapy.Item): title = scrapy.Field() url = scrapy.Field() class DenzowBlogSpider(scrapy.Spider): name = 'denzowblog' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def start_requests(self): yield scrapy.Request(url='http://www.denzow.me/archive/category/Python', callback=self.parse) def parse(self, response): soup = BeautifulSoup(response.text, 'html.parser') for node in soup.select('.entry-title-link'): title = node.get_text() url = node.attrs.get('href') yield BlogItem( title=title, url=url )
本来Item
は別ファイルに定義すべきですが、簡略化のためまとめてしまっています。この状態でクロールしてみます。
$ scrapy crawl denzowblog
結果例です。
2018-02-26 23:42:19 [scrapy.core.scraper] DEBUG: Scraped from <200 http://www.denzow.me/archive/category/Python> {'title': 'Djangoのtemplateでifを省略する(yesnoフィルター)', 'url': 'http://www.denzow.me/entry/2018/02/11/151826'} 2018-02-26 23:42:19 [scrapy.core.scraper] DEBUG: Scraped from <200 http://www.denzow.me/archive/category/Python> {'title': 'DynamodbのLimitが思った動きでなくて嵌った話(Dynamodbの基本的なクエリ等)', 'url': 'http://www.denzow.me/entry/2018/02/04/130419'} 2018-02-26 23:42:19 [scrapy.core.scraper] DEBUG: Scraped from <200 http://www.denzow.me/archive/category/Python> {'title': 'Pythonでシングルトン(Singleton)を実装してみる', 'url': 'http://www.denzow.me/entry/2018/01/28/171416'} 2018-02-26 23:42:19 [scrapy.core.scraper] DEBUG: Scraped from <200 http://www.denzow.me/archive/category/Python> {'title': 'DjangoのATOMIC_REQUESTSの挙動について', :
ちゃんととれていますね。さて、ではこのSpiderをテストしていきます。
Contractによるテスト
SpiderのテストはPythonのUnittestモジュールとは少し毛色が違い、Docstringに書きます。イメージ的にはdoctestが近いでしょうか。
先程のparse
にテストを追加すると以下のようになります。
def parse(self, response): """ @url http://www.denzow.me/archive/category/Python @returns items 30 30 @returns requests 0 0 """ soup = BeautifulSoup(response.text, 'html.parser') for node in soup.select('.entry-title-link'): title = node.get_text() url = node.attrs.get('href') yield BlogItem( title=title, url=url )
Docstring部分の意味はhttp://www.denzow.me/archive/category/Python
にアクセスして生成されたresponse
オブジェクトを使用してparse
を実行した際に、何らかのItemオブジェクトが30〜30生成
され、何らかのRequestオブジェクトが0〜0生成
されることを確認するものです。数値を1つのみ指定した場合はその数値以上がもどればテスト通過とみなされます。
実行する際は以下のようcheck
を実行します。
$ scrapy check .. ---------------------------------------------------------------------- Ran 2 contracts in 0.493s OK
Ran 2
になっているのはreturns items
とreturns requests
がそれぞれ異なるテストとしてカウントされているためです。Unittest実行時と違いRan 2 contracts
となっているのはScrapyでのテストがContractsと呼ばれているためです。細かい部分はドキュメントにあります。
また例えば、@returns items 30 30
を@returns items 29 29
にすると以下のように失敗します。
$ scrapy check F. ====================================================================== FAIL: [denzowblog] parse (@returns post-hook) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/scrapy/contracts/__init__.py", line 134, in wrapper self.post_process(output) File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/scrapy/contracts/default.py", line 74, in post_process (occurrences, self.obj_name, expected)) scrapy.exceptions.ContractFail: Returned 30 items, expected 29 ----------------------------------------------------------------------
内容のテスト
先程の例ではparse
が戻すItemやRequestの数のみがテストされていました。場合によってはItemの中身をチェックしたいケースもあります。その場合はCustom Contracts
を使用します。以下のファイルをproject.contracts.py
として作成します。(ファイル名は任意です)
from scrapy.contracts import Contract from scrapy.exceptions import ContractFail class ItemValidator(Contract): # テスト対象側ではこの名前を指定する name = 'item_validator' def post_process(self, output): latest_blog = output[0] expected_title = 'Djangoのtemplateでifを省略する(yesnoフィルター)' if latest_blog['title'] != expected_title: raise ContractFail('{} != {}'.format(latest_blog['title'], expected_title))
post_process
の引数output
にはテスト対象のメソッド(今回はparse
)が戻したItemやResponseがリストで渡されます。今回のケースではoutput[0]
が最新のBlog記事なのでそのタイトルが期待したものであるかをチェックし、失敗した場合はContractFail
例外を送出させています。
続いて、ItemValidator
を使用するように設定を変更します。settings.py
にSPIDER_CONTRACTS
を追加します。
SPIDER_CONTRACTS = { 'testscrapy.contracts.ItemValidator': 10, }
10
は実行順序の制御のための値です。複数のcontractsを指定し、それらの順序が重要である場合は調整します。これでプロジェクト内のSpiderでitem_validator
というCustom Contractsが使用できるようになったためparse
に指定します。
def parse(self, response): """ @url http://www.denzow.me/archive/category/Python @returns items 30 30 @returns requests 0 0 @item_validator """ soup = BeautifulSoup(response.text, 'html.parser') for node in soup.select('.entry-title-link'): title = node.get_text() url = node.attrs.get('href') yield BlogItem( title=title, url=url )
これでテストを再度実行してみます。
$ scrapy check ... ---------------------------------------------------------------------- Ran 3 contracts in 0.355s
Ran 3
と一つ増えています。もちろんテストケースを少しいじって失敗されるとちゃんと検知されます。
$ scrapy check ..F ====================================================================== FAIL: [denzowblog] parse (@item_validator post-hook) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/scrapy/contracts/__init__.py", line 134, in wrapper self.post_process(output) File "/Users/denzow/work/scrapy/testscrapy/testscrapy/contracts.py", line 13, in post_process raise ContractFail('{} != {}'.format(latest_blog['title'], expected_title)) scrapy.exceptions.ContractFail: Djangoのtemplateでifを省略する(yesnoフィルター) != DUMMY ----------------------------------------------------------------------
クロール先の情報は変化する可能性もあるので、どの程度内容のテストをするかは悩むところですが機能として知っておいて損はないかと思います。
テストコード以外でのエラー
scrapy check
では@returns
等のcontractsで失敗した箇所はもちろん表示してくれるのですが、テスト対象のメソッドに入る前の箇所で例外が送出されると以下のように何も詳細が出ません。
class DenzowBlogSpider(scrapy.Spider): name = 'denzowblog' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) raise Exception('test') # init時にわざと失敗させる : def parse(self, response): """ @url http://www.denzow.me/archive/category/Python @returns items 30 30 @returns requests 0 0 @item_validator """
$ scrapy check Unhandled error in Deferred: <-- 何も詳細が出ない ---------------------------------------------------------------------- Ran 0 contracts in 0.000s OK
当初、これを解決できずに苦労したのですが以下のようにするとちゃんとスタックトレースがでますので簡単に問題箇所を特定できます。
(rst36) denzownoMacBook-Pro:testscrapy denzow$ scrapy check --loglevel=CRITICAL Unhandled error in Deferred: 2018-02-27 00:13:22 [twisted] CRITICAL: Unhandled error in Deferred: 2018-02-27 00:13:22 [twisted] CRITICAL: Traceback (most recent call last): File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/twisted/internet/defer.py", line 1386, in _inlineCallbacks result = g.send(result) File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/scrapy/crawler.py", line 79, in crawl self.spider = self._create_spider(*args, **kwargs) File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/scrapy/crawler.py", line 102, in _create_spider return self.spidercls.from_crawler(self, *args, **kwargs) File "/Users/denzow/miniconda3/envs/rst36/lib/python3.6/site-packages/scrapy/spiders/__init__.py", line 51, in from_crawler spider = cls(*args, **kwargs) File "/Users/denzow/work/scrapy/testscrapy/testscrapy/spiders/denzowblog.py", line 17, in __init__ raise Exception('test') Exception: test ---------------------------------------------------------------------- Ran 0 contracts in 0.000s OK
当たり前といえば当たり前なんですが、--loglevel=CRITICAL
が指定できるのが盲点でした・・・。実際にScrapyを書いていくと複数のミドルウェアを追加したりとテスト対象のparse
部分以外のレイヤーで例外が発生するケースが多いので、scrapy check --loglevel=CRITICAL
は覚えておくと便利かもしれません。