プロのように Django シグナルをテストする方法
ハキ・ベニタ著
より良い読書体験を得るには、私の Web サイトにあるこの記事をご覧ください。
Django シグナルは、モジュールを分離するのに非常に役立ちます。これにより、低レベルの Django アプリが、直接の依存関係を作成せずに、他のアプリが処理できるようにイベントを送信できるようになります。
シグナルのセットアップは簡単ですが、テストは困難です。そこでこの記事では、Django シグナルをテストするためのコンテキスト マネージャーの実装を段階的に説明します。
使用例
チャージ機能を備えた決済モジュールがあるとします。 (私は支払いについてよく書いているので、このユースケースをよく知っています。) 請求が行われたら、合計請求カウンターをインクリメントする必要があります。
信号を使用するとどうなるでしょうか?
まず、信号を定義します。
# signals.py
from django.dispatch import Signal
charge_completed = Signal(providing_args=['total'])
次に、充電が正常に完了したら信号を送信します。
# payment.py
from .signals import charge_completed
@classmethoddef process_charge(cls, total):
# Process charge…
if success: charge_completed.send_robust( sender=cls, total=total, )
概要アプリなどの別のアプリは、合計料金カウンターをインクリメントするハンドラーに接続できます。
# summary.py
from django.dispatch import receiver
from .signals import charge_completed
@receiver(charge_completed)def increment_total_charges(sender, total, **kwargs): total_charges += total
支払いモジュールは、完了した料金を処理する概要モジュールやその他のモジュールを知る必要はありません。 支払いモジュールを変更せずに、 多くの受信者を追加できます。
たとえば、次のような受信者が適しています。
- 取引ステータスを更新します。
- ユーザーに電子メール通知を送信します。
- クレジット カードの最終使用日を更新します。
信号のテスト
基本を説明したので、process_charge
のテストを作成しましょう。充電が正常に完了したときに、信号が正しい引数とともに送信されることを確認したいと考えています。
シグナルが送信されたかどうかをテストする最良の方法は、シグナルに接続することです。
# test.py
from django.test import TestCase
from .payment import chargefrom .signals import charge_completed
class TestCharge(TestCase):
def test_should_send_signal_when_charge_succeeds(self): self.signal_was_called = False self.total = None
def handler(sender, total, **kwargs): self.signal_was_called = True self.total = total
charge_completed.connect(handler)
charge(100)
self.assertTrue(self.signal_was_called) self.assertEqual(self.total, 100)
charge_completed.disconnect(handler)
ハンドラーを作成し、シグナルに接続し、関数を実行して引数を確認します。
ハンドラー内で self
を使用してクロージャを作成します。 self
を使用しなかった場合、ハンドラー関数はローカル スコープ内の変数を更新するため、変数にアクセスできなくなります。これについては後でもう一度説明します。
充電が失敗した場合にシグナルが呼び出されないことを確認するためのテストを追加しましょう。
def test_should_not_send_signal_when_charge_failed(self): self.signal_was_called = False
def handler(sender, total, **kwargs): self.signal_was_called = True
charge_completed.connect(handler)
charge(-1)
self.assertFalse(self.signal_was_called)
charge_completed.disconnect(handler)
これは機能していますが、 それは定型文がたくさんあります。もっと良い方法があるはずです。
コンテキストマネージャーに入る
これまでに行ったことを詳しく見てみましょう。
- シグナルを何らかのハンドラーに接続します。
- テスト コードを実行し、ハンドラーに渡された引数を保存します。
- ハンドラーをシグナルから切断します。
このパターンは見覚えがあるような気がします…
(ファイル) オープン コンテキスト マネージャーが何をするかを見てみましょう:
- ファイルを開きます。
- ファイルを処理します。
- ファイルを閉じます。
そして、データベース トランザクション コンテキスト マネージャー:
- トランザクションを開きます。
- いくつかの操作を実行します。
- トランザクションを閉じます (コミット/ロールバック)。
どうやらコンテキストマネージャーはシグナルに対しても機能するようです。
始める前に、コンテキスト マネージャーを使用して信号をテストする方法を考えてください。
with CatchSignal(charge_completed) as signal_args: charge(100)
self.assertEqual(signal_args.total, 100)
わかりました、試してみましょう:
class CatchSignal: def __init__(self, signal): self.signal = signal self.signal_kwargs = {}
def handler(sender, **kwargs): self.signal_kwrags.update(kwargs)
self.handler = handler
def __enter__(self): self.signal.connect(self.handler) return self.signal_kwrags
def __exit__(self, exc_type, exc_value, tb): self.signal.disconnect(self.handler)
ここにあるもの:
- 「キャッチ」したい信号を使用してコンテキストを初期化しました。
- コンテキストは、シグナルによって送信された引数を保存するハンドラー関数を作成します。
- クロージャを作成するには、
self
上の既存のオブジェクト (signal_kwargs
) を更新します。 - ハンドラーをシグナルに接続します。
__enter__
と__exit__
の間で一部の処理が (テストによって) 行われます。- ハンドラーをシグナルから切断します。
コンテキスト マネージャーを使用して、charge 関数をテストしてみましょう。
def test_should_send_signal_when_charge_succeeds(self): with CatchSignal(charge_completed) as signal_args: charge(100) self.assertEqual(signal_args[‘total’], 100)
これは良いことですが、 では陰性の検査結果はどのようなものになるでしょうかでしょうか。
def test_should_not_send_signal_when_charge_failed(self): with CatchSignal(signal) as signal_args: charge(100) self.assertEqual(signal_args, {})
ヤク、それはダメだよ。
ハンドラーをもう一度見てみましょう。
- ハンドラー関数が呼び出されたことを確認したいと考えています。
- ハンドラー関数に送信される引数をテストしたいと考えています。
待ってください… この機能はすでに知っています!
モックを入力してください
ハンドラーをモックに置き換えてみましょう。
from unittest import mock
class CatchSignal: def __init__(self, signal): self.signal = signal self.handler = mock.Mock()
def __enter__(self): self.signal.connect(self.handler) return self.handler
def __exit__(self, exc_type, exc_value, tb): self.signal.disconnect(self.handler)
そしてテスト:
def test_should_send_signal_when_charge_succeeds(self): with CatchSignal(charge_completed) as handler: charge(100) handler.assert_called_once_with( total=100, sender=mock.ANY, signal=charge_completed, )
def test_should_not_send_signal_when_charge_failed(self): with CatchSignal(charge_completed) as handler: charge(-1) handler.assert_not_called()
かなり良くなりました!
モックを使用すべき目的に正確に使用したため、スコープとクロージャーについて心配する必要はありません。
これでうまくいくようになりましたが、さらに改善することはできますか?
contextlib を入力してください
Python には、contextlib と呼ばれるコンテキスト マネージャーを処理するためのユーティリティ モジュールがあります。
contextlib
を使用してコンテキストを書き換えましょう。
from unittest import mockfrom contextlib import contextmanager
@contextmanagerdef catch_signal(signal): """Catch django signal and return the mocked call.""" handler = mock.Mock() signal.connect(handler) yield handler signal.disconnect(handler)
私はこのアプローチのほうが好きです。なぜなら、従うのが簡単だからです。
- 歩留まりにより、テスト コードが実行される場所が明確になります。
- セットアップ コード (開始と終了) は同じスコープ内にあるため、
self
にオブジェクトを保存する必要はありません。
以上です。4 行のコードですべてを制御できます。利益!