[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

scrapyのspiderのテスト(scrapy check / contract)

なんか気がつけば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 itemsreturns 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.pySPIDER_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は覚えておくと便利かもしれません。