何かを書き留める何か

数学や読んだ本について書く何かです。最近は社会人として生き残りの術を学ぶ日々です。

Hypothesisとpytestを使ってDjangoのユニットテストを書く

Hypothesisとは何か、プロパティベーステストとは何か

Hypothesisは、Python向けのプロパティベーステストのライブラリである。 プロパティベーステストは、生成された多数の入力データに対してプロパティ(性質)が満たされるかどうかをテストする手法である。 HaskellのQuickCheckライブラリが初出で、現在は各プログラミング言語に移植されている。 従来のユニットテストは、ある程度固定したテストデータを指定してテストを行っていた。 その際、境界値分析などで妥当なパラメータを決定していた。 しかし、境界値分析が必ず通用するとは限らないし、人間が行う以上、ミスも発生する。 プロパティベーステストはデータを固定する代わりにそのデータが満たすプロパティを指定してテストを行う。 実際のテストケースはHypothesisがプロパティを満たすパラメータを決めて生成してくれる。 人力では発見しにくいバグが見つかりやすくなるのだ。

今回は、Hypothesisを使ってDjangoユニットテストを書くのがこのエントリの主旨である。 また、普段はpytestを使っているので、三者を組み合わせたテストの書き方を調べるのも目的である。 なお、僕は『ロバストPython』でHypothesisの存在を知った。

www.oreilly.co.jp

とても良い本なので、是非読んでみてほしい(監訳しました)。

Modelのユニットテスト

まずはModelから。 前回のエントリで作成した、説明のためだけに存在するCardモデルに再登場していただく。 なお、Number クラスは実際のトランプを意識してサイズアップした。

from django.db import models


class Number(models.IntegerChoices):
    ACE = 1
    TWO = 2
    THREE = 3
    FOUR = 4
    FIVE = 5
    SIX = 6
    SEVEN = 7
    EIGHT = 8
    NINE = 9
    TEN = 10
    JACK = 11
    QUEEN = 12
    KING = 13


class Suit(models.TextChoices):
    DIAMOND = "♢", "ダイヤ"
    SPADE = "♠", "スペード"
    HEART = "♡", "ハート"
    CLUB = "♣", "クラブ"


class Card(models.Model):
    number = models.IntegerField(choices=Number)
    suit = models.CharField(max_length=1, choices=Suit)

    class Meta:
        constraints = [
            models.CheckConstraint(check=models.Q(number__in=Number.values), name="number_check"),
            models.CheckConstraint(check=models.Q(suit__in=Suit.values), name="suit_check"),
        ]

    def __str__(self) -> str:
        return f"{self.suit}{self.number}"

前回書いたユニットテストを再掲する。

import pytest
from django.db.utils import DatabaseError

from ..models import Card, Number, Suit


@pytest.mark.django_db
class TestCardModel:
    @pytest.mark.parametrize("number", Number)
    @pytest.mark.parametrize("suit", Suit)
    def test_card_creation(self, number: Number, suit: Suit):
        card = Card.objects.create(number=number, suit=suit)

        assert card.number == number
        assert card.suit == suit
        assert str(card) == f"{suit}{number}"

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_argument(self, number: Number, suit: Suit):
        with pytest.raises(DatabaseError):
            Card.objects.create(number=number, suit=suit)

Modelのユニットテストを観察してみよう。

  • 正常ケースでは、数値およびスートの組み合わせをすべてテストしている。これはテストケースが高々52通りなのですべてチェックできる。
  • 失敗ケースでは、数値が不正の例、スートが不正の例をそれぞれ1ケースしかテストできていない。
    • 数値の境界値は0と14だが、本当にこれだけだろうか。
    • スートとして入る文字列は多数にある。すべての文字などテストできるはずもない。

事前にデータを固定する従来のユニットテストの手法だけでは、想定していない失敗ケースを見過ごしている可能性が生じる。 ここで、プロパティベーステストの出番である。 「想定していない入力が満たすプロパティ」を定義して、実際のテストケースはライブラリに任せよう。

まずは、「想定していないスート」に関するプロパティを考える。 スートの場合は単純で、 今回のケースだと「 ("♢", "♠", "♡", "♣") 以外の文字」である。 また、「想定する数値」に関するプロパティも考えておこう。 こちらも単純で「1から13」である。

これをHypothesisで書くと次のようになる。

import hypothesis
import hypothesis.strategies as st
import pytest
from django.db.utils import DatabaseError

from ..models import Card, Number, Suit


@pytest.mark.django_db
class TestCardModel:
    @hypothesis.given(
        number=st.integers(min_value=1, max_value=13),
        suit=st.characters(exclude_characters=Suit.values) | st.just(None),
    )
    def test_invalid_suit(self, number: Number, suit: str):
        with pytest.raises(DatabaseError):
            Card.objects.create(number=number, suit=suit)

ここで着目してほしいのは、@hypothesis.givenデコレータである。 Hypothesisは@hypothesis.givenデコレータでストラテジを指定することから始まる。 st.integers()は整数を生成するストラテジである。 最大値や最小値はオプションであるが、今回は「1から13」が欲しいので両方指定した。 st.characters()は長さ1の文字列(つまり文字)を生成するストラテジである。 オプションで細かく制御できるが、今回はSuit.valuesを除外した文字列を生成する設定を行った。 これで「 ("♢", "♠", "♡", "♣") 以外の文字」のプロパティが実現できた。 st.just()は単一の値を生成するプロパティである。 ここで面白いのが、プロパティ同士を|で連結できることだ。 st.characters(exclude_characters=Suit.values) | st.just(None) で「("♢", "♠", "♡", "♣") 以外の文字、またはNone」を満たすプロパティが生成できる。

次に、「想定していない数値」に関するプロパティとそのテストを書く。

    @hypothesis.given(
        number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(None),
        suit=st.sampled_from(Suit.values),
    )
    def test_invalid_number(self, number: int, suit: Suit):
        with pytest.raises(DatabaseError):
            Card.objects.create(number=number, suit=suit)

st.sampled_from() は引数で渡したイテラブルから取得するプロパティである。 小さいイテラブルオブジェクトならこちらが楽である。 st.integers()単体では「0以下の整数、または14以上の整数」は指定できないので、 | で連結して実現している。

実際にテストを走らせてみよう。 結果を抜粋する。

E           self = <django.db.backends.sqlite3.base.SQLiteCursorWrapper object at 0x000002819441C050>
E           query = 'INSERT INTO "sample_card" ("number", "suit") VALUES (?, ?) RETURNING "sample_card"."id"', params = (-9223376507952189107, '♢')
E
E               def execute(self, query, params=None):
E                   if params is None:
E                       return super().execute(query)
E                   # Extract names if params is a mapping, i.e. "pyformat" style is used.
E                   param_names = list(params) if isinstance(params, Mapping) else None
E                   query = self.convert_query(query, param_names=param_names)
E           >       return super().execute(query, params)
E           E       OverflowError: Python int too large to convert to SQLite INTEGER
E
E           .tox\test\Lib\site-packages\django\db\backends\sqlite3\base.py:329: OverflowError

トランプの数値として-9223376507952189107 を入れた結果、SQLiteで扱える数値を超えてしまった例外が発生した。 これは境界値分析が足りない例ともいえるし、データベース(今回はSQLite)の制約を想定していないケースを想定していなかったともいえる。 いずれにせよ、従来の固定されたテストケースだけでは発見できないバグがプロパティベーステストで発見できたのは素晴らしいことである。

Formのユニットテスト

続いてFormのテストを書いていく。 こちらも前回のエントリからの再登板である。

from django.forms import ModelForm

from .models import Card


class CardModelForm(ModelForm):
    class Meta:
        model = Card
        fields = ["number", "suit"]

従来のユニットテストの手法で書いたテストは以下の通りである。

import pytest

from ..forms import CardForm
from ..models import Number, Suit


@pytest.mark.django_db
class TestCardForm:
    @pytest.mark.parametrize("number", Number)
    @pytest.mark.parametrize("suit", Suit)
    def test_valid_form(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardForm(data=form_data)

        assert form.is_valid()

        card = form.save()
        assert card.number == number
        assert card.suit == suit

    def test_blank_data(self):
        form = CardForm(data={})

        assert not form.is_valid()
        assert len(form.errors) == 2

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_data(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardForm(data=form_data)

        assert not form.is_valid()
        assert len(form.errors) == 1

    @pytest.mark.parametrize("number,suit", ((None, Suit.SPADE), (Number.ACE, None)))
    def test_invalid_data_with_none(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardForm(data=form_data)

        assert not form.is_valid()
        assert len(form.errors) == 1

これもまた、Modelのテストケースと同じ問題点を抱えている。 同じようにプロパティベーステストに書き換えてみよう。

import hypothesis
import hypothesis.strategies as st
import pytest

from ..forms import CardModelForm
from ..models import Number, Suit


@pytest.mark.django_db
class TestCardForm:
    @hypothesis.given(
        number=st.sampled_from(Number.values),
        suit=st.sampled_from(Suit.values),
    )
    def test_valid_form(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardModelForm(data=form_data)

        assert form.is_valid()

        card = form.save()
        assert card.number == number
        assert card.suit == suit

    def test_blank_data(self):
        form = CardModelForm(data={})

        assert not form.is_valid()
        assert len(form.errors) == 2

    @hypothesis.given(
        number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(None),
        suit=st.sampled_from(Suit.values),
    )
    def test_invalid_number(self, number: int, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardModelForm(data=form_data)

        assert not form.is_valid()
        assert len(form.errors) == 1

    @hypothesis.given(
        number=st.integers(min_value=1, max_value=13),
        suit=st.characters(exclude_characters=Suit.values) | st.just(None),
    )
    def test_invalid_suit(self, number: Number, suit: str):
        form_data = {"number": number, "suit": suit}
        form = CardModelForm(data=form_data)

        assert not form.is_valid()
        assert len(form.errors) == 1

元々のサンプルが単純なので、書き換える手間も少ない。 ModelとFormは似たようなテストを行っているが、エラーとなるのはModelの方だけである。 この差はどこにあるのだろうか。

ちなみに、Hypothesisは、実行するたびにテストケースが異なるので、数回テストを実行すると思わぬ発見があるかもしれない。 デフォルトではテストケース1つにつき100通りのテストを生成している。 この値は@hypothesis.settingsデコレータで変更可能だが、生成するテスト数が多すぎるとHypothesisが遅すぎるぞ!とエラーを吐くので注意が必要である。 なお、そのエラーも抑制する方法が存在する。

Viewのテスト

最後に、Viewのテストである。 やはり、前回のエントリから使いまわす。

from django.http.response import HttpResponse
from django.urls import reverse_lazy
from django.views import generic

from .forms import CardModelForm
from .models import Card


class CardListView(generic.ListView):
    context_object_name = "card_list"
    template_name = "sample/card_list.html"
    model = Card


class CardCreateView(generic.FormView):
    form_class = CardModelForm
    template_name = "sample/card_create.html"
    success_url = reverse_lazy("sample:list")

    def form_valid(self, form: CardModelForm) -> HttpResponse:
        form.save()
        return super().form_valid(form)

さて、ここまでの例だけ眺めると、「Hypothesisとpytestを使ってDjangoユニットテストを書くのは簡単ですね、楽勝だ」と思うかもしれない。 しかし、ViewのテストをHypothesisとpytestを使って書く際に必ず陥る落とし穴が存在する。 pytestの関数スコープのfixtureとHypothesisの相性が悪いのである。 実際、Hypothesisの開発者もそれを認識しているものの、最新版のHypothesisでも同様の事象が発生する。

hypothesis.works

今回の場合、pytest-djangoにある client Fixtureが使えなくなる。 回避方法は複数あるが、clientの場合はおとなしく from django.test import Client すれば回避できる。

また、Djangoとpytestを組み合わせる際に必ず登場するデコレータ @pytest.mark.django_db も、Viewのテストでは機能しないのでは、と踏んでいる。 こちらは、 hypothesis.extra.django.TestCase を使うことで解決できる。 上記の例では@pytest.mark.django_db を使っているが、Hypothesisを使う場合はhypothesis.extra.django.TestCaseに揃えたほうが良さそうである。

それを踏まえた、落とし穴をすべて塞いだパターンのテストは以下のとおりである。

import hypothesis
import hypothesis.extra.django
import hypothesis.strategies as st
import pytest
from django.test import Client
from django.urls import reverse

from ..models import Card, Number, Suit
from .factory import CardFactory


class TestCardListView(hypothesis.extra.django.TestCase):
    @hypothesis.given(
        number=st.integers(min_value=1, max_value=13),
        suit=st.sampled_from(Suit.values),
    )
    def test_card_list_view(self, number: int, suit: str):
        client = Client()
        CardFactory(number=number, suit=suit)

        response = client.get(reverse("sample:list"))

        assert response.status_code == 200
        assert len(response.context["card_list"]) == 1


class TestCardCreateView(hypothesis.extra.django.TestCase):
    def test_get_request(self):
        client = Client()
        response = client.get(reverse("sample:create"))

        assert response.status_code == 200

    @hypothesis.given(
        number=st.integers(min_value=1, max_value=13),
        suit=st.sampled_from(Suit.values),
    )
    def test_valid_post_request(self, number: Number, suit: Suit):
        client = Client()
        form_data = {"number": number, "suit": suit}
        response = client.post(reverse("sample:create"), data=form_data)

        assert response.status_code == 302
        assert Card.objects.count() == 1

        card = Card.objects.first()
        assert card.number == number
        assert card.suit == suit
        assert str(card) == f"{suit}{number}"

    @hypothesis.given(
        number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(""),
        suit=st.sampled_from(Suit.values),
    )
    def test_invalid_post_by_number(self, number: int, suit: str):
        client = Client()
        form_data = {"number": number, "suit": suit}
        response = client.post(reverse("sample:create"), data=form_data)

        assert response.status_code == 200
        assert Card.objects.count() == 0

    @hypothesis.given(
        number=st.integers(min_value=1, max_value=13),
        suit=st.characters(exclude_characters=Suit.values) | st.just(""),
    )
    def test_invalid_post_by_suit(self, number: int, suit: str):
        client = Client()
        form_data = {"number": number, "suit": suit}
        response = client.post(reverse("sample:create"), data=form_data)

        assert response.status_code == 200
        assert Card.objects.count() == 0

感覚的には、パラメータテストをさらに進化させたような感覚がある。 Hypothesisを使って「想定していませんでした」を減らせると良いなと感じている。 とはいえ、プロパティベーステストは従来のユニットテストを完全に置き換えるものではない。 今回のケースだと、Card生成の成功パターン、Formに空データを入れるパターンでは不要である。 境界値が曖昧なケースや想定するパターンが複雑なケースで真価を発揮するはずだ。

もちろんフルパワーであなたと戦う気はありませんからご心配なく…

Hypothesisには他にもステートフルテストや凝ったストラテジの生成など、機能がたくさんある。 Djangoの場合、FormのテストをHypothesisで強化するところから始めると導入しやすいかもしれない。 想定していない入力をバンバンHypothesisが生成してくれるだろう。

ちなみに、pytestを実行する際に--hypothesis-show-statisticsオプションをつけると、Hypothesisが生成したテストケースの量などがわかる。

test: commands[0]> pytest --hypothesis-show-statistics --hypothesis-explain .
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.1.1, pluggy-1.4.0
cachedir: .tox/test/.pytest_cache
Using --randomly-seed=3654462177
django: version: 5.0.4, settings: sampleproject.settings (from ini)
rootdir: /home/runner/work/my_playground/my_playground/django_playground
configfile: pytest.ini
plugins: randomly-3.15.0, Faker-24.11.0, hypothesis-6.100.1, django-4.8.0
collected 12 items

sample/tests/test_models.py ...                                          [ 25%]
sample/tests/test_views.py .....                                         [ 66%]
sample/tests/test_forms.py ....                                          [100%]
============================ Hypothesis Statistics =============================

sample/tests/test_models.py::TestCardModel::test_invalid_suit:

  - during generate phase (0.20 seconds):
    - Typical runtimes: ~ 1ms, of which < 1ms in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


sample/tests/test_models.py::TestCardModel::test_card_creation:

  - during generate phase (0.15 seconds):
    - Typical runtimes: ~ 1ms, of which < 1ms in data generation
    - 71 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because nothing left to do


sample/tests/test_models.py::TestCardModel::test_invalid_number:

  - during generate phase (0.20 seconds):
    - Typical runtimes: ~ 1ms, of which < 1ms in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


sample/tests/test_views.py::TestCardCreateView::test_valid_post_request:

  - during generate phase (0.26 seconds):
    - Typical runtimes: ~ 3-4 ms, of which < 1ms in data generation
    - 56 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because nothing left to do


sample/tests/test_views.py::TestCardCreateView::test_invalid_post_by_suit:

  - during generate phase (0.81 seconds):
    - Typical runtimes: ~ 6-9 ms, of which < 1ms in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


sample/tests/test_views.py::TestCardCreateView::test_invalid_post_by_number:

  - during generate phase (0.81 seconds):
    - Typical runtimes: ~ 6-9 ms, of which < 1ms in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


sample/tests/test_views.py::TestCardListView::test_card_list_view:

  - during generate phase (0.14 seconds):
    - Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
    - 56 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because nothing left to do


sample/tests/test_forms.py::TestCardForm::test_invalid_suit:

  - during generate phase (0.27 seconds):
    - Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


sample/tests/test_forms.py::TestCardForm::test_invalid_number:

  - during generate phase (0.22 seconds):
    - Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


sample/tests/test_forms.py::TestCardForm::test_valid_form:

  - during generate phase (0.17 seconds):
    - Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
    - 72 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because nothing left to do


============================== 12 passed in 4.02s ==============================
  test: OK (15.39=setup[9.37]+cmd[6.02] seconds)
  congratulations :) (15.45 seconds)

pytestのログだとテスト数が少なく思えるが、実際は各ケースごとに100回テストを行っている(はずだ)。

別の話題:OverflowError: Python int too large to convert to SQLite INTEGER の解決方法

さて、想定していない入力として、巨大な数値(具体的には、int64の範囲を超える整数)が投入されたケースはどうやって直せばよいか。

CheckConstraint

CHECK制約は、データベースに設定するものである。 つまり、チェックする前にSQLiteのINTEGERの範囲を超える整数を投入されたらどうしようもない。

Modelのバリデーション

ModelのFieldにバリデーションを設定できる。

class Card(models.Model):
    number = models.IntegerField(
        choices=Number,
        validators=(
            MaxValueValidator(Number.KING.value),
            MinValueValidator(Number.ACE.value),
        ),
    )
    suit = models.CharField(max_length=1, choices=Suit)

しかし、設定してもバリデーションは効かない。 何故なのか。 その秘密はDjangoのドキュメントに書かれている。

docs.djangoproject.com

雑に要約すると、

  • Modelのfull_clean()メソッドを使うと、モデルに設定してあるバリデーションが実行される。
  • ModelFormを使っている場合、is_valid()メソッドを実行するとバリデーションが実行される。
  • Modelsave()メソッドを呼んだ際、 full_clean()clean() は自動的に呼ばれない。

つまり、Card.objects.create()card.save()など、Cardから直接レコードを作成すると、ModelのFieldにバリデーションが短絡されてしまう。 そのため、巨大な数値を投入された際にはCHECK制約しか機能せず、その防御壁も突破されてしまったのだ。 Modelのテストに失敗してFormのテストは通過したのは、この性質によるものだ。

よって、Modelのテストを次のように書き換える。 明示的にfull_clean()を呼ぶしかない。

import hypothesis
import hypothesis.extra.django
import hypothesis.strategies as st
import pytest
from django.core.validators import ValidationError

from ..models import Card, Number, Suit


class TestCardModel(hypothesis.extra.django.TestCase):
    @hypothesis.given(
        number=st.integers(min_value=Number.ACE.value, max_value=Number.KING.value),
        suit=st.sampled_from(Suit.values),
    )
    def test_card_creation(self, number: Number, suit: Suit):
        card = Card(number=number, suit=suit)
        card.full_clean()
        card.save()

        assert card.number == number
        assert card.suit == suit
        assert str(card) == f"{suit}{number}"

    @hypothesis.given(
        number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(None),
        suit=st.sampled_from(Suit.values),
    )
    def test_invalid_number(self, number: int, suit: Suit):
        with pytest.raises(ValidationError):
            card = Card(number=number, suit=suit)
            card.full_clean()

    @hypothesis.given(
        number=st.integers(min_value=Number.ACE.value, max_value=Number.KING.value),
        suit=st.characters(exclude_characters=Suit.values) | st.just(None),
    )
    def test_invalid_suit(self, number: Number, suit: str):
        with pytest.raises(ValidationError):
            card = Card(number=number, suit=suit)
            card.full_clean()

これで、バリデーションやCHECK制約がすべて機能する状態にできた。

このサンプルコード自体は何も意味のないものだが、HypothesisのおかげでDjangoのModelバリデーションの振る舞いについて少しだけ詳しくなれた。