0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pytestのはじめかた【後編】fixtureとモックを理解する

0
Posted at

はじめに

前編ではPytestの基本・pytest.raises・parametrize・カバレッジを扱いました。

この記事では、少し難しいけど覚えると一気にテストが書きやすくなる2つのテクニックを解説します。

  • fixture → テストの「前準備」を共通化する仕組み
  • モック → 外部への依存を「ニセモノ」に差し替える仕組み

どちらも「なぜ必要か」から丁寧に説明するので、初めての方でも大丈夫です。


fixture とは?

まず「困る場面」を想像する

たとえば、ショッピングカートを管理するクラスがあるとします。

# cart.py

class Cart:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

    def total(self, prices):
        return sum(prices[item] for item in self.items)

このクラスに対して複数のテストを書くと、こうなります:

# test_cart.py

def test_add_item():
    cart = Cart()          # ← 毎回書く
    cart.add("apple")
    assert "apple" in cart.items

def test_total():
    cart = Cart()          # ← 毎回書く
    cart.add("apple")
    cart.add("banana")
    prices = {"apple": 100, "banana": 150}
    assert cart.total(prices) == 250

def test_empty_cart():
    cart = Cart()          # ← 毎回書く
    assert cart.items == []

cart = Cart() が毎回登場していて冗長です。テストが増えるほど、この繰り返しが積み重なります。

fixture で解決する

@pytest.fixture を使うと、前準備を1か所にまとめられます。

# test_cart.py

import pytest
from cart import Cart

@pytest.fixture
def cart():
    return Cart()   # ← ここに前準備をまとめる

def test_add_item(cart):       # 引数に fixture 名を書くだけで使える
    cart.add("apple")
    assert "apple" in cart.items

def test_total(cart):
    cart.add("apple")
    cart.add("banana")
    prices = {"apple": 100, "banana": 150}
    assert cart.total(prices) == 250

def test_empty_cart(cart):
    assert cart.items == []

ポイント:

  • @pytest.fixture を付けた関数を定義する
  • テスト関数の引数に fixture 名(cart)を書くと、自動で注入される
  • 各テストは独立した Cart() インスタンスを受け取る(テスト間で状態が混ざらない)

fixture に後片付けも書ける

データベース接続など「使い終わったら閉じたい」ものは yield で書きます。

@pytest.fixture
def db_connection():
    conn = connect_to_db()   # 前準備
    yield conn               # ← テストにこれを渡す
    conn.close()             # テスト終了後に後片付け

yield の前が「前準備」、後ろが「後片付け」です。

fixture のスコープ

デフォルトでは各テスト関数ごとに fixture が作り直されます。重い前準備(DBの初期化など)を毎回やりたくない場合はスコープを指定できます。

@pytest.fixture(scope="module")   # このファイル内で1回だけ実行
def heavy_setup():
    ...
スコープ タイミング
function(デフォルト) テスト関数ごと
class テストクラスごと
module テストファイルごと
session pytest 実行全体で1回

モック とは?

まず「困る場面」を想像する

たとえば、天気予報APIを叩いて結果を返す関数があるとします。

# weather.py

import requests

def get_weather(city):
    response = requests.get(f"https://api.example.com/weather?city={city}")
    data = response.json()
    return data["condition"]

この関数のテストを書こうとすると、困ります:

  • テストのたびに外部APIへの通信が発生する
  • APIが落ちているとテストも失敗してしまう
  • テスト環境からインターネットに出られないこともある
  • APIの返す値が変わるとテストが不安定になる

テストしたいのは「自分のコードのロジック」であって、外部APIの動作ではない。 そこでモックの出番です。

モックで解決する

unittest.mock の patch を使って、requests.get をニセモノに差し替えます。

# test_weather.py

from unittest.mock import patch, MagicMock
from weather import get_weather

def test_get_weather():
    # requests.get の返り値をニセモノにする
    mock_response = MagicMock()
    mock_response.json.return_value = {"condition": "sunny"}

    with patch("weather.requests.get", return_value=mock_response):
        result = get_weather("Tokyo")

    assert result == "sunny"

何が起きているか:

  1. patch("weather.requests.get", ...) → weather.py の中で使われている requests.get をニセモノに差し替える
  2. MagicMock() → 何でも受け付けるニセオブジェクト。.json() が呼ばれたら {"condition": "sunny"} を返すよう設定
  3. with ブロックを抜けると元に戻る

実際の通信は一切発生せず、テストが安定して速く動きます。

patch のデコレータ版

with の代わりにデコレータとして書くこともできます。好みで使い分けてください。

from unittest.mock import patch, MagicMock

@patch("weather.requests.get")
def test_get_weather(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"condition": "rainy"}
    mock_get.return_value = mock_response

    result = get_weather("Osaka")
    assert result == "rainy"

モックが呼ばれたか確認する

「この関数は1回だけ呼ばれるはず」という検証もできます。

mock_get.assert_called_once()                          # 1回だけ呼ばれた
mock_get.assert_called_once_with("https://api.example.com/weather?city=Tokyo")  # 実際に渡されるURLを指定する
mock_get.assert_called()                               # 1回以上呼ばれた
mock_get.assert_not_called()                           # 呼ばれていない

どこをモックすべきか?

よくある迷いポイントです。

差し替える対象 使うパス
weather.py 内の requests.get "weather.requests.get"
weather.py 内の requests モジュール "weather.requests"

「モックするのは、テスト対象のファイルからみたパス」 が鉄則です。requests 本体のパスではなく、weather.py がインポートしている場所を指定します。


fixture × モックの組み合わせ

実際のコードでは fixture の中でモックを設定することもよくあります。

import pytest
from unittest.mock import patch, MagicMock
from weather import get_weather

@pytest.fixture
def mock_weather_api():
    mock_response = MagicMock()
    mock_response.json.return_value = {"condition": "cloudy"}
    with patch("weather.requests.get", return_value=mock_response) as mock:
        yield mock

def test_get_weather_cloudy(mock_weather_api):
    result = get_weather("Sapporo")
    assert result == "cloudy"

fixture の yield で with patch(...) を組み合わせると、テスト終了時に自動でパッチが元に戻ります。


まとめ

概念 一言で言うと 使いどころ
fixture テストの前準備を共通化する仕組み 毎回同じセットアップが必要なとき
モック 外部依存をニセモノに差し替える仕組み API・DB・ファイルI/Oを含む関数をテストするとき

fixture は「準備の共通化」、モックは「外部依存の切り離し」と覚えておけば使いどころに迷いません。


前編・後編のまとめ

トピック 記事
Pytestの基本・assert・失敗の見かた 前編
pytest.raises・parametrize・カバレッジ 前編
fixture・モック 後編(本記事)

さらに先へ

  • conftest.py → fixture を複数ファイルで共有する
  • pytest-mock → mocker fixture でよりシンプルにモックを書く
  • CI連携 → GitHub ActionsでPushのたびに自動テストを走らせる
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?