何かを書き留める何か

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

Curriculum Vitae of XaroCydeykn

Curriculum Vitaeと呼ぶには何かが欠けている気がするが気にしない。

各種アカウント

  • Twitter 何だかんだ学生時代から続けている。ある意味20代の歴史である。
  • Mastodon 流行りだしたので作って放置していたが、一応生きている。
  • misskey.io にわかに流行りだしていたので作ってみた。

Twitterに限らず、複数アカウントを同時に使ったことがないので、どうやって使い分けたらいいのかがわからない。

翻訳

監訳

共著

技術書査読

2016年

2017年

2018年

2019年

2020年

2021年

2022年

2023年

2024年

外部発表

2016年

  • PyConJP で感じる私の成長」PyCon JP 2016 Day1 Lightning Talk

受付後にLTの募集枠が空いていたので深く考えずに登録した。 卒論・修論発表で培った(?)勢い重視の発表で中身の薄さを乗り越えた。 最初のPyCon JPは怖い、という話はある程度共感を得たようである。

2017年

当初は話を聞くだけのつもりであったが、Python 3.6の新機能を調べるうちにメタクラスの部分の置き換えができることに気づいたので発表した。

中身のない概要から如何に内容を絞り出すか、と苦戦した発表。 技術書に書かれていることだけでは難しく、実践を伴わないと意味のある発表にするのが難しい。

  • Respect the Built-in Names」PyCon JP 2017 Day1 Lightning Talk

Reject Conから評判の良かった内容を抽出して膨らませたLT。 意外と琴線に触れる内容だったらしく、アンサーLTまで登場した。

  • 技術書査読・校正の現場から」BPStudy #123 Lightning Talk

間違い探しLT。 筆者も意外といい加減なことを書いているのでそれを検証しつつなんとかするのが査読や校正の役割である。

2018年

  • レガシーDjangoアプリケーションの現代化」DjangoCongress JP 2018 Talk

2017年8月から参画したプロジェクトの経験を元に架空のプロジェクトという見立てを用いて話を作った。

SymPyで学んだことを整理するために発表した。 題材として学部1,2年でやるような数学を選んだつもりだったが、気付いたら整数論も入っていた。

2019年

正しくはQuerySetではなくDjango ORMの失敗談。 PCを持たないのにその場で登壇を決めてしまったので会社の同僚にPCを借りて急ごしらえでスライドを作成した。

ピタゴラス数と無限降下法について勉強して話した。 無限降下法の実装は手探りで証明を理解しつつやったので中々に面白かったのだが、それが発表者に伝わったのかは神のみぞ知る。

2020年

  • 君はcmathを知っているか」PyCon mini Shizuoka 2020 Talk

cmathモジュールの可能性を模索した。 当初の予定はマンデルブロ集合が到達点であったが、冷静に考えてcmathである必要性を感じなかったので、離散Fourier変換と信号処理という電通大の学部3年でやる基本的なテーマを引っ張り出した。

ioモジュールのインメモリーストリーム(StringIo, BytesIO)の実用例を発表した。 BytesIOの中でさらにZipfileを開くという込み入った実装がちょっと気に入っている。

2021年

完全なる一発ネタ。 気軽にPythonを使っていいんだよ、とか気軽にLTやっていいんだよ、というのがテーマであった。 PyCon JP 2021のTwitterでこのLTを元にしたツイートがあって感動した。

  • 組み込み関数powの知られざる進化」PyCon JP 2021 Talk

pow関数に突然追加された機能について数学的な背景を説明した。 数学科でも、情報系でも扱うようなテーマなので、案外みんな知っているのかもしれない。

2022年

  • 残念ながら、1回も登壇せず。書籍作業を優先していたので発表にまで手が回らない状況であった。

2023年

  • 堅牢なPythonコードを書く方法」 BPStudy#189

監訳した『ロバストPython』の概略を説明した。 Pythonの良さと静的型付け言語の良さを良いとこどりして文字通り良いコードを書いていきたい。 「型が形無し」は邦訳にあるジョークだが、それに合わせたか「継承に警鐘を鳴らす」という言い回しが咄嗟に出た。

役に立たないオブジェクトを作る発表。 英語資料を作るのはよいのだが、英語発表は非常に大変だった。

  • Python Distilled』試飲会」 BPStudy#195

翻訳した『Python Distilled』の宣伝。 Pythonの公式ドキュメントは充実しているが、読みやすいとは必ずしも言えない。 『Python Distilled』はPythonの重要な要素を抽出した本であり、1冊でPythonに必要な事柄をカバーできる。 ウイスキーのストレートのように、最初は大変かもしれないが、読めば読むほど価値がわかる本である。

資料を作成する際に、Pythonのwith文についてちゃんと書かれている箇所を探すのは大変であることに気付いた。 最初にwith文の使い方を知ったのはどこだろうか。

2024年に読んだ本

雑な読書記録

買っても読まず、読んでも特に記録を残さずに思い出に残らないので、年単位で読んだ本と簡単な感想を残しておくことにしよう。 いつも、書評を書こうと思い立つもすぐに断念してしまうので「簡単な感想」にとどめてそのハードルを下げるのが目的である、と言っておきながら5年目である。 過去のリストは以下の通り。

自分が読み返して「こんなの読んだのか」と感慨に耽るのが目的なので、気楽に読み流してほしい。

犯罪学教室のかなえ先生 『世の中の8割はどうでもいい。』

www.shogakukan.co.jp

人生をバランスよく生きてくための「テキトー術」を、『人生がクソゲーだと思ったら読む本』で話題を呼んだ自称・日本一テキトーなVTuberが説きます!

大学の同級生が編集を担当した、ということで購入。 筆者はVTuverで、学生時代や法務教官時代の経験を元に「テキトー」に生きる方法を描いたエッセイ。 8割はどうでもいいとかテキトーという言葉から投げやりな生き方をなんとなく想起してしまうがそうではなく、自分でどうしようもない部分に悩む必要はない、というのは面白い視点であった。 語りかける文体ではありつつも読みやすいというも面白い。語りかける文体は得てして読みづらい代物になるのだが、読みやすかった。 エッセイという体歳上、個人の経験がベースであり、何らかの論文や研究がベースになっている主張ではないことは一応気にしておいてもいいかもしれない。 なお、肩書に「日本初の元国家公務員の男性VTuber」とあるが、あまくだり氏の方が早い気がする。 もっとも、現在のあまくだり氏はVTuberではなくYouTuberかつカードショップオーナーである。

マウンティングポリス『人生が整うマウンティング大全』(技術評論社

gihyo.jp

人間関係あるところにマウントあり,マウンティングを制する者こそが人生を制する。

全体の三分の二はカタログ的にマウンティングの事例を集めたもの、残りは人生に基づく人生訓、といった構成である。 人はマウントしたがるものである、自分に秘めるマウント欲も否定せず、相手のマウント欲を尊重しつつコミュニケーションするとうまくいきますよ、というのが主題だろうか。 とはいえ、かなり穿ちすぎな本である。 「マウンティング枕詞」も、無意識にマウントする人には有効かもしれないが、意識して、つまりメタメッセージにマウントを織り込んで話す人々には逆にそれを見透かされる気がしてならない。 「マウンティングエクスペリエンス(MX)」の考察も雑で楽しい読み物の域を超えない。

もっとも、こういう書評めいた感想を書く行為こそ筆者からすればマウンティングである、と言われるのだろう。

岡奈津子『新版 〈賄賂〉のある暮らし』(白水社

www.hakusuisha.co.jp

ソ連崩壊後、独立して計画経済から市場経済に移行したカザフスタン。国のありかたや人びとの生活はどのような変化を遂げたのか。

市場経済化したカザフスタンの生活実態に迫った研究書。 JETROの『アジ研ワールド・トレンド』の記事をまとめ、一般の人にも読みやすくしたものである。 〈賄賂〉と括弧つきなのは、賄賂とお礼の区別が厳密に定義できないという故である。

カザフスタンという国をあまり知らなくても、そこまで興味が無くてもものすごく面白く読める本である。 何をするにも賄賂、賄賂と日本では考えにくい状況が展開される。 賄賂が横行するのは給与が低いせいだ、という言説もカザフスタンの実態の前には不十分である。 まず、仕事を得るにも賄賂が必要であり、しかもその職を維持するにも上司に上納金を送るなど、構造と賄賂が一体化しているため、単に給与を上げても解決しない。 また、仕事を得る際に賄賂を払っても、市民からわいろを受け取ればペイできる、つまりある種の投資でもある、という主張にもびっくりした。 なお、新版で追加された解説によるとカザフスタン全国民が賄賂を使っているわけではなく、旧ソ連の国々と比較するとそこまでひどい国ではないらしい。

一度絶版になった本だが、やはりちゃんとした本は復刊するのである。 手に入りやすくなったので、カザフスタン中央アジアにそこまで興味が無くても読んでみてほしい。

ジェフ・ホワイト『ラザルス』(草思社

soshisha.com

北朝鮮はなぜミサイルを撃ち続けられるのか? 警察庁等が名指しで非難したハッカー集団の痕跡を追跡。もはや軍事組織と呼ぶべき北朝鮮サイバー部隊の実態とは?

英国BBCの系列であるBBCワールドサービスのポッドキャスト『The Lazarus Heist』の書籍版、という位置づけの調査報道ノンフィクション。 偽米100ドル札の「スーパーノート」から韓国、ハリウッドへの企業や金融機関へのハッキング、バングラディシュ中央銀行への搾取、ランサムウェアまで、 北朝鮮が国家的に関わっていると強く疑われる犯罪、特にハッカー集団による攻撃についての調査報道である。 僕の脳内にある北朝鮮像は本書第2章「破産国家」で描写される過酷な状況だったり、国営放送の特徴的なアナウンサー、将軍様などハッカー集団やサイバー部隊といった言葉とは結び付かないイメージだが、 本書に描かれているのは(第2章「破産国家」はともかく)北朝鮮が何としても生き残るために手段を選ばず外貨を獲得する姿である。 「ハッカー集団の痕跡を追跡」とあるが、本書で大部分を占めるバングラディシュ中央銀行への攻撃に関する記述は資金洗浄に関する話が大半である。 本書に登場する実行犯の大半は検挙されておらず、資金洗浄のいわゆる末端作業を担っていた人物が逮捕され、何らかの裁判を受けたにすぎず、北朝鮮サイバー部隊は依然として検挙されずにいる。 北朝鮮のサイバー部隊は、全員が全員ではないと思うが、過酷な身分制を乗り越える手段として自分の才能をサイバー部隊に向けているため、多少平和な日本の若い人とは置かれている状況が全く違う。 第2章「破産国家」で描写される過酷さが、洗練された犯罪手法に昇華しているのだろうか。

警視庁にサイバー警察なる部署があったり、各種自衛隊の情報機関など、日本にはこのようなサイバー部隊はいるのだろうか。 我々のような一般的な読者は変なメールを開かないなど、自衛するしかないのだろうか。

読んでいて気になった箇所。 P.104にDDoS(分散型サービス拒否攻撃)の説明があるが、説明されているのはDos(サービス拒否攻撃)に留まる内容で分散型という言葉に関する説明がない。 P.243に「東京の北にある茨城県」とあるが、北東だと思う。 P.255に以下の記述がある。

私の協力者の話だと、≪ササキタダシ≫という日本名は”吹き出しそうになるほどありきたり”な名前で、欧米で言うなら"ジョン・スミス”のようなものだと教えてもらった。

著名なのは近江源氏の佐々木氏などあり、昔からある日本の苗字(氏?カバネ?)だが、偽名として笑うほどありきたりだろうか。 「タダシ」も正なのか忠なのか忠司わからないが、偽名として笑うほどありきたりだろうか。 偽名ならば、という前提で、「サトウタロウ」や「タナカイチロウ」の方が”吹き出しそうになるほどありきたり”な名前だと思う。 むしろ、「ササキタダシ」は偽名か本名か判断が難しいラインではないか。 実際、≪ササキタダシ≫なる人物は実在し、原著者がインタビューしている。

田川建三『イエスという男 第二版[増補改訂版]』(作品社)

www.sakuhinsha.com

エスキリスト教の先駆者ではない。歴史の先駆者である。歴史の本質を担った逆説的反逆者の生と死!

新約聖書学者の田川先生のイエス論。 初版は三一書房で1980年、第二版は作品社から2004年に刊行。 昨年読んだ、清水俊史『ブッダという男』(筑摩書房)のタイトルのインスパイヤ元でもある。

他のイエス論を読んだことがないので比較は難しいが、福音書やラビ関係の書籍からナザレのイエスの人物像に迫った本、というべきだろうか。 『新約聖書 訳と註』とは異なり、田川先生が考えるイエス像による想像というか推測もあるが、それでも福音書やラビ関係の書籍を根拠にどういう人物であったを描写している。 同業者に向ける容赦ない批判はやはりすごいというか、自分が同じ目にあったら泣いてしまいそうである。

田川先生と言えば、『新約聖書概論』など、未刊行の書籍がいくつかあるが、果たしてそれは刊行されるのだろうか。 ホームページは3年ほど更新されていない。 そもそも、どこから刊行されるのだろうか?作品社か勁草書房ぐらいしか思いつかないが。

言語技術の会(編)『実践・言語技術入門』(朝日新聞社

publications.asahi.com

あいまいな日本語表現を再検討し、国際化時代に通用する論理的な文章と話し方のための技術。 これまでの作文教育では不十分だった「事実を伝える」「物事を説明する」「自分の考えを述べる」ことに慣れるための練習問題付き。

Twitterで書名を見かけたので入手。

巻末の経歴によると、「言語技術の会」は1977年発足の学習院教育問題調査会の国語教育分科会が前身で、1983年に改称した。 要は学習院の教員からなる団体である。 着目すべき点は、著者陣に『理科系の作文技術』で有名な木下是雄がいることである。 ちなみに学習院大学名誉教授、元学習院大学学長と書かれている。 その他のメンバは学習院の小中高の教員や学習院女子の教員からなる。

冒頭に次のような文言がある。

この本の主眼は、具体例を通して情報・意見・意図の伝達に必要な心得を浮かび上がらせることなのです。 いちばんのポイントは、事実(実見したこと)と意見(自分が考えたこと)とをはっきり区別して扱うことです。

これだけで、この本はいい本であるな、と感じた。 記述に関するページ数は100ページ前後にコンパクトにまとまっており、個人的には『理科系の作文技術』よりも簡潔で読みやすい。 『実践・言語技術入門』を読んだ後に、とある本を読んだら根拠のない意見の羅列で唖然としたぐらい『実践・言語技術入門』は良い。

朝日新聞出版はこの本を今すぐ復刊するべきだと思うが、『理科系の作文技術』や『レポートの組み立て方』に同じような記述があるかもしれない。 ちなみに、僕が学部生の頃に読んだのは『理科系の作文技術』ではなくて『レポートの組み立て方』である。

また、現在の学習院はこのような取り組みは行っているのだろうか。 論理的な文章というか、読みやすい文章を書く技術は必ず役に立つので、取り組み続けていていたら良いなと思う。

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バリデーションの振る舞いについて少しだけ詳しくなれた。

DjangoのModelとChoices

まとめ

  • Choices クラスの登場により、ModelのFieldにある choices を指定しやすくなった。
  • しかし、Choices クラスを使っても、ModelのFieldにある choices では、データベースに入る値を制限できない。
  • CHECK制約を使えばデータベースに入る値を制限できる、Choices クラスの属性を活用することもできる。
  • Choices クラスに __empty__ 属性を指定した場合は少し注意が必要かもしれない。
  • ModelのFieldにある choices を指定した状態でModelFormを実装すると、期待するバリデーションを備えたFormができる。

愚かなる者よ、何故に『戦い』に身を置く?

  • 何かを守るためだ
  • そういう話は……興味ない

選択肢を出したのだから選択肢から選んでよ

Django 3.0から、Modelを定義する際に Choices クラスが使えるようになった。 以前から、Modelの Field クラスには choices 引数を指定できた。 choices 引数の渡し方は基本的にタプルのリストであり、単純な構造ながら面倒であった。 Choices クラスは実質的に列挙型であり、Fieldの選択肢を列挙型で指定できる。 タプルのリストと比較して、選択肢同士の関係が列挙型でより密接になり、扱いやすくなったと言える。

次のような例を考える。

from django.db import models


class Number(models.IntegerChoices):
    ACE = 1
    JACK = 11
    QUEEN = 12
    KING = 13

    __empty__ = "選択してください"


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

    __empty__ = "選択してください"


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

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

トランプのカードを想定した(説明するためだけに存在する) Card Modelである。 Card モデルはフィールドとして数値とスートを持つ。

Fieldchoices に期待していたもの

number = models.IntegerField(choices=Number) と書いたからには、 Card モデルのレコードの number には1 , 11, 12, 13以外の数字は入って欲しくない、と思うだろう。 1 , 11, 12, 13以外の数字が指定された場合はよしなに弾いてくれる、と思うだろう。 しかし、思うだけでは何も進まない。 本当にそうなのか、ユニットテストを書いてみよう。

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)

奇妙なデータは作られないと信じているが、果たしてどうだろうか。

(.venv) $ tox -e test
test: commands[0]> pytest .

...
___________________________________________________________ TestCardModel.test_invalid_argument[1-\u2605] ____________________________________________________________ 

self = <sample.tests.test_models.TestCardModel object at 0x000001E547E5E060>, number = Number.ACE, suit = '★'

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_argument(self, number: Number, suit: Suit):
>       with pytest.raises(DatabaseError):
E       Failed: DID NOT RAISE <class 'django.db.utils.DatabaseError'>

sample\tests\test_models.py:20: Failed
___________________________________________________________ TestCardModel.test_invalid_argument[14-\u2660] ___________________________________________________________ 

self = <sample.tests.test_models.TestCardModel object at 0x000001E547E5DF70>, number = 14, suit = Suit.SPADE

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_argument(self, number: Number, suit: Suit):
>       with pytest.raises(DatabaseError):
E       Failed: DID NOT RAISE <class 'django.db.utils.DatabaseError'>

sample\tests\test_models.py:20: Failed

期待していた動作は、想定していない値を入れたら弾く、である。 しかし、ユニットテストの結果からわかるのは、Card Modelが想定していない値を受け入れていることである。

CHECK制約

choices は何をするのか。 Djangoの公式ドキュメントを確認してみよう。

choices が指定された場合、 モデルのバリデーション によって強制的に、デフォルトのフォームウィジェットが通常のテキストフィールドの代わりにこれらの選択肢を持つセレクトボックスになります。

つまり、デフォルトのフォームウィジェットはセレクトボックスになるが、Modelのフィールドに渡せる値を制限することはできない。 我々は ドキュメントを碌に読まず、choices を過信していたようだ。

それではどうすればよいのか。 CHECK制約である。

Card モデルを次のように変更しよう。

class Card(models.Model):
    number = models.IntegerField(choices=Number)
    suit = models.CharField(max_length=2, 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}"

このように、Choices をCHECK制約に渡せば、Modelが受け入れる値の種類を制限できる。

注意点

注意点として、 Choices クラスに __empty__ 属性を指定した状態でCHECK制約に渡してしまうと、制約の条件としてNoneを許容してしまう事象が発生する。

        migrations.AddConstraint(
            model_name="card",
            constraint=models.CheckConstraint(
                check=models.Q(("number__in", [None, 1, 11, 12, 13])),
                name="number_check",
            ),
        ),
        migrations.AddConstraint(
            model_name="card",
            constraint=models.CheckConstraint(
                check=models.Q(("suit__in", [None, "♢", "♠", "♡", "♣"])),
                name="suit_check",
            ),
        ),

今回のケースだと、 numbersuit いずれのフィールドもNOT NULLであるため、None を渡しても期待通りエラーが発生するが、NULLを許容する場合は素通りしてしまう。 実害は(おそらく)ないが、念のため気を付けたほうが良いだろう。

ChoicesModelForm

以上の例だけだと、Modelのフィールドにchoicesを渡すのはそれほど有益ではない、と感じるかもしれない。 choicesChoices の真価はModelFormにある。

Card Modelに対応するModelFormを書く。

from django.forms import ModelForm

from .models import Card


class CardForm(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のフィールドのchoices引数にChoicesクラス(または従来からあるリストのタプル)を渡した上で、Modelに対応するModelFormを書くと、我々が想像していたchoicesのバリデーションが手に入る。 適切なModelFormが手に入るのだ。 ユーザから入力を受け取るような場面では役に立つだろう。 ただし、上記の例の通り、CHECK制約を設定しない限り、フォームを使わずにCard.objects.create()を使うと想定していない値を持つCardのレコードが生成できてしまう点には注意が必要である。

まとめ(2回目)

  • Choices クラスの登場により、ModelのFieldにある choices を指定しやすくなった。
  • しかし、Choices クラスを使っても、ModelのFieldにある choices では、データベースに入る値を制限できない。
  • CHECK制約を使えばデータベースに入る値を制限できる、Choices クラスの属性を活用することもできる。
  • Choices クラスに __empty__ 属性を指定した場合は少し注意が必要かもしれない。
  • ModelのFieldにある choices を指定した状態でModelFormを実装すると、期待するバリデーションを備えたFormができる。