ScrapyでSpiderのテストを書く

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

## TL;DR;

Spiders Contractsを使います

  * [公式のドキュメント](https://doc.scrapy.org/en/latest/topics/contracts.html)
  * docstringに書く
  * `scrapy check spidername` で実行できる
  * 自分でサブクラスを作り拡張できる
  * ドキュメントにあるサンプルコード
 
  ```python
    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](https://github.com/rhoboro/scrapy-unittest)
    * クローラ先は[Pelican](https://github.com/getpelican/pelican)で作っている私の[ブログ](http://www.rhoboro.com/)です。

# 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
]( https://github.com/scrapy/scrapy/blob/129421c7e31b89b9b0f9c5f7d8ae59e47df36091/scrapy/contracts/__init__.py#L55:L60)
  * これを拡張して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を試すより早いです。

* 成功時

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

OK
```

* 失敗時( `parse_detail()の item['title'] = ...` をコメントアウト)

```bash
(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
```