🌟

Pydanticでアドホックにバリデーションを素通りさせる

2023/08/12に公開

TL;DR: Model.model_construct

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がもたらす利益については既に多くの先人達が様々な視点から言語化されているのでここでは細かいところは割愛しますが、一言でまとめるなら「オブジェクト生成時の実行時型検査と値検査を自動的に実行する」ライブラリです。

https://zenn.dev/yosemat/articles/dd69000307f468
https://zenn.dev/kumamoto/articles/609a0fd9a944d7
https://zenn.dev/hayata_yamamoto/articles/python-pydantic

いつバリデーションするのか

バリデーション(値検査)が有効なのは「自己保有のソフトウェア資産でコントロールできないデータソースからデータを引き込む時」です。例えばおそらく最も頻出と思われるユースケースはユーザ入力でしょう。Webアプリケーションのバックエンド開発においてユーザからなんら入力を受け付けないサービスも世の中には存在しますが多くのバックエンドはユーザ入力に対して応答し固有のサービス体験を提供します。この時にユーザが入力した値が直接入ってくる場合においては当然入力された値の整合性を検査することは有効であることは疑いようがありませんが、フロントエンドで値検査をかけている場合でも仕様の認識齟齬やユーザによる悪意のある入力の可能性がゼロではないため値検査する価値は十分にあるでしょう。

同様にバックエンドのロジックに外部のリソース(DB、第三者APIなど)を引き込む際にも値検査は有効です。極端な例を挙げるならORMに書いたフィールドの型の定義とそのフィールドがマッピングされたRDBMS内のテーブルのカラムとの間に齟齬がある可能性はゼロではありません。また第三者APIを利用していてある日突然レスポンスの型が変わったなどということもありえない話ではなく、ここでも値検査が有効であることは明白です。つまり「中核のサービスロジックに流入するありとあらゆる経路上のデータは値検査することには論理的整合性の観点から意義がある」と言えます。ただしいかに相対的な小さなオーバーヘッドと言えどパフォーマンスへの影響もゼロではないためどこに値検査が必要なのかは個々の要件に応じて決定されるべきです。

わざわざバリデーションしない理由って?

結論から書いてしまうとテストにおいてです。サービスロジックを外部の関心事から完全に隔離し抽象的な手続きとしての振る舞いの正しさをテストで検証する際、サービスロジックの実装内に登場するありとあらゆる変数に副作用が起こらないことを保証するために unittest.mock.sentinel が役立ちます。unittest.mock.sentinel は「関数に渡す」以外の一切のアクセスが禁じられているため unittest.mock.Mock の返り値やサービスロジックに渡す引数として活用し注入された関数やオブジェクトのメソッド呼び出しに使われたかどうかを unittest.mock.call を使って検証することができます。

例えばこんな風に(自己開発アプリケーションから一部抜粋):

user_register.py
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)
test_user_regsiter.py
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