Pydanticでアドホックにバリデーションを素通りさせる
Model.model_construct
TL;DR: from pydantic import BaseModel
class Model(BaseModel):
s: str
Model.model_construct(s=2) # OK
Model(s=2) # ValidationError
SEO対策(というかこれで救われる命があると信じています)
- pydantic disable validation
- pydantic do not validate
- pydantic バリデーション 無効化
Pydanticとは?
Pydanticがもたらす利益については既に多くの先人達が様々な視点から言語化されているのでここでは細かいところは割愛しますが、一言でまとめるなら「オブジェクト生成時の実行時型検査と値検査を自動的に実行する」ライブラリです。
いつバリデーションするのか
バリデーション(値検査)が有効なのは「自己保有のソフトウェア資産でコントロールできないデータソースからデータを引き込む時」です。例えばおそらく最も頻出と思われるユースケースはユーザ入力でしょう。Webアプリケーションのバックエンド開発においてユーザからなんら入力を受け付けないサービスも世の中には存在しますが多くのバックエンドはユーザ入力に対して応答し固有のサービス体験を提供します。この時にユーザが入力した値が直接入ってくる場合においては当然入力された値の整合性を検査することは有効であることは疑いようがありませんが、フロントエンドで値検査をかけている場合でも仕様の認識齟齬やユーザによる悪意のある入力の可能性がゼロではないため値検査する価値は十分にあるでしょう。
同様にバックエンドのロジックに外部のリソース(DB、第三者APIなど)を引き込む際にも値検査は有効です。極端な例を挙げるならORMに書いたフィールドの型の定義とそのフィールドがマッピングされたRDBMS内のテーブルのカラムとの間に齟齬がある可能性はゼロではありません。また第三者APIを利用していてある日突然レスポンスの型が変わったなどということもありえない話ではなく、ここでも値検査が有効であることは明白です。つまり「中核のサービスロジックに流入するありとあらゆる経路上のデータは値検査することには論理的整合性の観点から意義がある」と言えます。ただしいかに相対的な小さなオーバーヘッドと言えどパフォーマンスへの影響もゼロではないためどこに値検査が必要なのかは個々の要件に応じて決定されるべきです。
わざわざバリデーションしない理由って?
結論から書いてしまうとテストにおいてです。サービスロジックを外部の関心事から完全に隔離し抽象的な手続きとしての振る舞いの正しさをテストで検証する際、サービスロジックの実装内に登場するありとあらゆる変数に副作用が起こらないことを保証するために unittest.mock.sentinel
が役立ちます。unittest.mock.sentinel
は「関数に渡す」以外の一切のアクセスが禁じられているため unittest.mock.Mock
の返り値やサービスロジックに渡す引数として活用し注入された関数やオブジェクトのメソッド呼び出しに使われたかどうかを unittest.mock.call
を使って検証することができます。
例えばこんな風に(自己開発アプリケーションから一部抜粋):
from dataclasses import dataclass
from injector import inject, singleton
from charaxiv import combinators, protocols
class UserWithEmailExistsException(Exception):
def __init__(self, email: str) -> None:
super().__init__(f"email={email}")
@singleton
@inject
@dataclass
class Combinator:
transaction_atomic: protocols.transaction_atomic.Protocol
db_user_with_email_exists: protocols.db_user_with_email_exists.Protocol
secret_token_generate: protocols.secret_token_generate.Protocol
db_registration_exists: protocols.db_registration_exists.Protocol
db_registration_delete_by_email: protocols.db_registration_delete_by_email.Protocol
db_registration_insert: protocols.db_registration_insert.Protocol
user_registration_mail_send: combinators.user_registration_mail_send.Combinator
async def __call__(self, /, *, email: str) -> None:
async with self.transaction_atomic():
if await self.db_user_with_email_exists(email=email):
raise UserWithEmailExistsException(email)
if await self.db_registration_exists(email=email):
await self.db_registration_delete_by_email(email=email)
token = self.secret_token_generate()
await self.db_registration_insert(email=email, token=token)
await self.user_registration_mail_send(email=email, token=token)
import contextlib
from unittest import mock
import pytest
from charaxiv import combinators, protocols
from charaxiv.combinators.user_register import Combinator
@pytest.mark.asyncio
async def test_user_register() -> None:
# Setup mocks
manager = mock.Mock()
manager.context_manager = mock.AsyncMock()
manager.transaction_atomic = mock.Mock(spec=protocols.transaction_atomic.Protocol, side_effect=[manager.context_manager])
manager.db_user_with_email_exists = mock.AsyncMock(spec=protocols.db_user_with_email_exists.Protocol, side_effect=[False])
manager.db_registration_exists = mock.AsyncMock(spec=protocols.db_registration_exists.Protocol, side_effect=[False])
manager.db_registration_delete_by_email = mock.AsyncMock(spec=protocols.db_registration_delete_by_email.Protocol)
manager.secret_token_generate = mock.Mock(spec=protocols.secret_token_generate.Protocol, side_effect=[mock.sentinel.token])
manager.db_registration_insert = mock.AsyncMock(spec=protocols.db_registration_insert.Protocol)
manager.user_registration_mail_send = mock.AsyncMock(spec=combinators.user_registration_mail_send.Combinator)
# Instantiate combinator
combinator = Combinator(
transaction_atomic=manager.transaction_atomic,
db_user_with_email_exists=manager.db_user_with_email_exists,
db_registration_exists=manager.db_registration_exists,
db_registration_delete_by_email=manager.db_registration_delete_by_email,
secret_token_generate=manager.secret_token_generate,
db_registration_insert=manager.db_registration_insert,
user_registration_mail_send=manager.user_registration_mail_send,
)
# Execute combinator
await combinator(email=mock.sentinel.email)
# Assert depndency calls
assert manager.mock_calls == [
mock.call.transaction_atomic(),
mock.call.context_manager.__aenter__(),
mock.call.db_user_with_email_exists(email=mock.sentinel.email),
mock.call.db_registration_exists(email=mock.sentinel.email),
mock.call.secret_token_generate(),
mock.call.db_registration_insert(email=mock.sentinel.email, token=mock.sentinel.token),
mock.call.user_registration_mail_send(email=mock.sentinel.email, token=mock.sentinel.token),
mock.call.context_manager.__aexit__(None, None, None),
]
冗長に思えるかもしれませんが、モックされている関数たちが何を受け取り何を返したかさえ検証できればあとは実際に依存注入された関数たちが十分に単体テストで正しく動作することを保証されていれば複雑な手続きでもほぼ単体テストのみでシステム全体の動作を(統合テストにより依存注入が正しく実行されているという前提で)保証できます(例外や仕様の考慮漏れがあった場合を除き)。
問題は関数が返す値がPydanticのモデルだった場合です。先述の通り unittest.mock.sentinel
はありとあらゆるアクセスパターンを禁じているので当然属性アクセスも禁止されています。なのでオブジェクトを返してその属性にアクセスするだけのコードでも容赦なく例外を投げます。そこで unittest.mock.Mock
の返り値に実際に返されるオブジェクトと同じ属性が unittest.mock.sentinel
になった状態のオブジェクトを設定する必要があります。ここで型ヒントが効いてくれると嬉しいのでPydanticのモデルを使ってテストをしたくなるわけです普通にモデルのコンストラクタに unittest.mock.sentinel
を渡すと当然値検査で弾かれ ValidationError
を投げ返されてしまいます。
これをうまく回避する方法がないのか、検索エンジンを頼りに彷徨っていたのですがなかなか答えが見つからずに諦めかけていたところで記事冒頭で紹介した Model.model_construct
メソッドを発見しました。唯一の不満は model_construct
メソッドの引数には型ヒントがついていないことなんですが、Model(a=a, b=b)
と記述して後から Model.model_construct(a=a, b=b)
と書き足せば解決するのでそこまで大きなデメリットではないのかなと感じています(フィールドとして定義されていない値も受け取れる様になっているので仕方がないっちゃ仕方がないんですが、定義されてるフィールドの型ヒントだけでもあると嬉しいですよね)。
かくして属性アクセスも unittest.mock.sentinel
を使ってテストが書けるようになり私は満足して山に帰りました。おしまいおしまい。
Discussion