【django】Beautiful Soapを使ったサイトマップのテストケースの書き方

Beautiful Soupを使って、djangoのサイトマップ用テストケースの作成方法を紹介します。これにより、いつのまにかサイトマップが壊れていた。。。ということを防ぐことができます。

Beautiful Soupについて

Beautiful Soupは、HTMLやXMLを解析し、データを取り出すことができるPythonのライブラリです。このライブラリを使って、テストケース内でサイトマップをパースします。

インストール

pipを使うと簡単にインストールできます。

$ pip install beautifulsoup4

Beautiful Soupのサンプル

例えば、https://remotestance.com/blog/2740/から全てのimgタグを取り出し、そのsrc属性を取得するには以下のように書きます。

# coding: utf-8
from urllib.request import urlopen
from bs4 import BeautifulSoup

url = 'https://remotestance.com/blog/2740/'
with urlopen(url) as fp:
    soup = BeautifulSoup(fp.read(), 'html.parser')
    for img in soup.findAll('img'):
        print(img['src'])

このサンプルを実行すると、

https://remotestance.com/__wp__/wp-content/uploads/2015/06/django_authentication_eyecatch.png
https://b.st-hatena.com/images/entry-button/button-only@2x.png
...

のように、src属性が表示されます。

プロジェクト構成

今回のサンプルでは、プロジェクト名myproject、アプリ名myappで進めていきます。

$ django-admin startproject myproject
$ cd myproject
$ ./manage.py startapp myapp

テストするサイトマップについて

テスト対象のサイトマップを作成します。なお、サイトマップはdjangoを使って出力します。Djangoを使ってサイトマップを出力する方法は、昔書いた記事「[Django] サイトマップを出力する方法」が参考になれば幸いです。

テスト対象の準備

myapp/sitemaps.pyの作成

テスト用のサイトマップを作成します。

# coding: utf-8
from django.contrib.sitemaps import Sitemap
from django.core.urlresolvers import reverse


class ItemDetailSitemap(Sitemap):
    def items(self):
        return range(1, 100)

    def location(self, obj):
        return reverse('item_detail', args=[obj])

特にDBにアクセスしたりせずに、item_detailという名前のURLを100ページ返しています。

myapp/views.pyの作成

サイトマップに記述した、item_detailという名前のURLで返すページを用意します。

# coding: utf-8
from django.views.generic import View
from django.http import HttpResponse


class ItemDetail(View):
    def get(self, request, *args, **kwds):
        item_id = kwds['item_id']
        return HttpResponse('ITEM: %s' % item_id)

テンプレートは使わずに、画面に「ITEM: XXX」と表示するだけの単純なビューです。

myproject/urls.pyの修正

サイトマップとitem_detailを使えるようにするため、urls.pyを修正します。

# coding: utf-8
from django.conf.urls import url
from myapp import sitemaps as myapp_sitemaps
from myapp.views import ItemDetail

sitemaps = {
    'item_detail': myapp_sitemaps.ItemDetailSitemap,
}

urlpatterns = [
    url(r'^itemdetail/(?P<item_id>d+)/$',
        ItemDetail.as_view(), name='item_detail'),
    url(r'^sitemap.xml$', 'django.contrib.sitemaps.views.sitemap',
        {'sitemaps': sitemaps}, name='sitemap'),
]

サイトマップのURLには、sitemapという名前を付けておきます。これにより、django.core.urlresolvers.reverse('sitemap')で、サイトマップのURLを取得できるようになります。

settings.pyの修正

myappやサイトマップを使えるようにするため、settings.pyのINSTALLED_APPを修正します。

INSTALLED_APPS = (
    ...
    'myapp',  # 追加
    'django.contrib.sites',  # 追加
    'django.contrib.sitemaps',  # 追加
)

また、サイトマップを使えるようにするため、同ファイルにSITE_IDを追加しておきます。

SITE_ID = 1

テスト用サイトマップの確認

これで、「http://localhost:8000/sitemap.xml」にアクセスすると、以下のようなサイトマップが返ってくるようになりました。

<urlset>
  <url>
    <loc>http://example.com/itemdetail/1/</loc>
  </url>
  <url>
    <loc>http://example.com/itemdetail/2/</loc>
  </url>
  ...
</urlset>

テストケース作成

テスト対象のサイトマップを準備したら、テストケースを書きます。

このテストケースは、以下のような流れになっています。

  1. サイトマップのURLにリクエストを投げ、サイトマップを取得します。
  2. サイトマップを解析し、各ページのURLを取得します。
  3. 各ページのURLにリクエストを投げ、200が返ってくることを確認します。
# coding: utf-8
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import Client


class TestSitemap(TestCase):
    def test_sitemap(self):
        client = Client()

        # サイトマップを取得し、200が返ってくることを確認します。
        path = reverse('sitemap')
        res = client.get(path)
        self.assertEqual(res.status_code, 200)
        print('checked %s' % path)

        soup = BeautifulSoup(res.content, 'lxml')

        # サイトマップから、各ページのURLを取得します。
        for urlset in soup.findAll('urlset'):
            for loc in urlset.findAll('loc'):
                # 「http://example.com/itemdetail/1/」のようなURLから「/itemdetail/1/」を取り出します。
                o = urlparse(loc.text)
                path = '%s?%s' % (o.path, o.query) if o.query else o.path

                # 取り出したpathを使ってリクエストを投げます。
                res = client.get(path)

                # レスポンスが200であることを確認します。
                self.assertEqual(res.status_code, 200)
                print('checked %s' % path)

テストを実行するとOKになります。

$ ./manage.py test
checked /sitemap.xml
checked /itemdetail/1/
checked /itemdetail/2/
...
.
----------------------------------------------------------------------
Ran 1 test in 0.058s

OK

分割されたサイトマップ

サイトマップには、別のサイトマップへのURLを含めることができます。そのような分割されたサイトマップだと、上記のテストケースでは対応できません。その場合、テストケースを修正する必要があります。

そういったサイトマップに対応させるため、urls.pyとsitemaps.pyを修正します。

テスト対象の修正

myproject/settings.pyを修正

settings.pyを修正して、サイトマップを分割します。

# coding: utf-8
from django.conf.urls import url
from myapp import sitemaps as myapp_sitemaps
from myapp.views import ItemDetail

sitemaps = {
    'item_detail': myapp_sitemaps.ItemDetailSitemap,
}

urlpatterns = [
    url(r'^itemdetail/(?P<item_id>d+)/$',
        ItemDetail.as_view(), name='item_detail'),
    url(r'^sitemap.xml$', 'django.contrib.sitemaps.views.index',
        {'sitemaps': sitemaps}, name='sitemap'),
    url(r'^sitemap-(?P<section>.+).xml$',
        'django.contrib.sitemaps.views.sitemap',
        {'sitemaps': sitemaps}),
]

myapp/sitemaps.pyの修正

sitemaps.pyを修正して、10URLごとに分割されるようにします。

# coding: utf-8
from django.contrib.sitemaps import Sitemap
from django.core.urlresolvers import reverse


class ItemDetailSitemap(Sitemap):
    limit = 10
    def items(self):
        return range(1, 100)

    def location(self, obj):
        return reverse('item_detail', args=[obj])

テスト用サイトマップの確認

settings.pyとsitemaps.pyを修正すると、サイトマップは以下のように分割されます。

sitemap.xml

<sitemapindex>
  <sitemap>
    <loc>http://example.com/sitemap-item_detail.xml</loc>
  </sitemap>
  <sitemap>
    <loc>http://example.com/sitemap-item_detail.xml?p=2</loc>
  </sitemap>
  ...
</sitemapindex>

sitemap-item_detail.xml

<urlset>
  <url>
    <loc>http://example.com/itemdetail/1/</loc>
  </url>
  <url>
    <loc>http://example.com/itemdetail/2/</loc>
  </url>
  ...
</urlset>

テストケース作成

分割されたサイトマップに対応できるように、テストケースを修正します。

下記テストケースでは、サイトマップの中にサイトマップが見つかったら、再帰的に「check_url」という関数を呼ぶようにしています。なお、「check_url」は引数で指定したpathを使ってサイトマップを解析し、各URLをチェックする関数です。

# coding: utf-8
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import Client


def url_to_path(url):
    o = urlparse(url)
    return '%s?%s' % (o.path, o.query) if o.query else o.path


class TestSitemap(TestCase):
    def test_sitemap(self):
        client = Client()

        def check_url(client, path):
            # 引数のpathを使ってリクエストを投げます。
            res = client.get(path)

            # レスポンスが200であることを確認します。
            self.assertEqual(res.status_code, 200)
            print('checked %s' % path)

            soup = BeautifulSoup(res.content, 'lxml')

            # サイトマップの中に記述されている、
            # 別のサイトマップのURLを処理します。
            for sitemap in soup.findAll('sitemap'):
                for loc in sitemap.findAll('loc'):
                    # サイトマップのpathを取得し、check_urlを再帰的に呼びます。
                    path = url_to_path(loc.text)
                    check_url(client, path)

            # サイトマップの中に記述されている、
            # 各ページのURLを処理します。
            for url in soup.findAll('urlset'):
                for loc in url.findAll('loc'):
                    # 各ページのURLが200を返すことを確認します。
                    path = url_to_path(loc.text)
                    res = client.get(path)
                    self.assertEqual(res.status_code, 200)
                    print('checked %s' % path)

        # 「/sitemap.xml」をチェックします。
        check_url(client, reverse('sitemap'))

これを実行すると、

checked /sitemap.xml
checked /sitemap-item_detail.xml
checked /itemdetail/1/
...
checked /sitemap-item_detail.xml?p=2
checked /itemdetail/11/
...
...
.
----------------------------------------------------------------------
Ran 1 test in 0.274s

OK

のようにOKになります。

この記事が役に立った場合、シェアしていただけると励みになります!!