0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[学習メモ] テストコードを書いてみる(unittestとpytest)

0
Last updated at Posted at 2026-02-01

Pythonの学習のアウトプットで学んだことや気づいたこと、詰まったことなどのメモとして書いています。
今回は、以前に作成した読書記録アプリにGoogle Cloud FUnctionsとslackを使って読了通知をする機能を実装しています。その過程でテストコードを書いたことについてのメモです。

unittestとpytest

unittestとは

公式で提供されている標準のテスト機能

  • Pythonに標準で含まれているフレームワークで、インストール不要で使える
  • JUnit(Java)の考え方を取り入れたクラスベースの構造が特徴

メリット

  • Python本体に含まれているので追加の依存が不要
  • 伝統的なxUnit系の習慣に基づいているので、多言語テストと共通の考え方

デメリット

  • クラスベースのため記述が冗長になりがち
  • assertはメソッド中心なので書き方がやや重い

pytestとは

モダンで柔軟なテスト

  • 実務でも使われることが多い
  • 標準のassert文をそのまま使えるので、テストの記述がシンプル
  • 関数単位でテストを書けるので、テストコードが読みやすい

メリット

  • テストが短く直感的に書ける
  • テスト実行時の絞り込み・詳細レポートなど機能が豊富

デメリット

  • 標準ライブラリではないのでインストールが必要
  • fixtureを多用すると学習コストが上がる(慣れが必要)

unittestとpytestの違い比較表

項目   unittest pytest
標準 / 外部 標準ライブラリ サードパーティ
書き方 クラス+メソッド ベース 関数ベースでシンプル
assert 専用メソッド(self.assert) Python の assert
fixture 基本的には手動 強力な仕組みがある
学習の簡単さ 公式+伝統的 初心者でも簡単だが機能多い

テストコード例

テスト対象コード

アプリ側からfunctionsにユーザー名と書籍名をJSONにしてデータをPOSTするコードです。

myapp.py
import os
import requests

def send_read_notification(user, book):
    user_name = user.name
    book_title = book.title

    data = {
        "user_name": user_name,
        "book_title": book_title
    }
    CLOUD_FUNCTION_URL=os.environ['CLOUD_FUNCTION_URL']
    requests.post(CLOUD_FUNCTION_URL, json=data)

unittestのテストコードの場合

test.py
import unittest
from unittest.mock import patch, MagicMock
from myapp import send_read_notification

class TestSendReadNotification(unittest.TestCase):
    @patch("notifier.requests.post")
    @patch("notifier.os.environ", {"CLOUD_FUNCTION_URL": "https://example.com/fake"})
    def test_send_read_notification_calls_post(self, mock_post):
        # --- ダミー user / book を用意 ---
        user = MagicMock()
        user.name = "Taro"

        book = MagicMock()
        book.title = "Python入門"

        # --- 実行 ---
        send_read_notification(user, book)

        # --- 検証:requests.post が正しい引数で呼ばれたか ---
        mock_post.assert_called_once_with(
            "https://example.com/fake",
            json={"user_name": "Taro", "book_title": "Python入門"}
        )

pytestのテストコードの場合

test.py
import pytest
from myapp import send_read_notification

def test_send_read_notification(mocker, monkeypatch):
    user = mocker.MagicMock()
    user.name = "Taro"

    book = mocker.MagicMock()
    book.book.title = "Python入門"

    monkeypatch.setenv("CLOUD_FUNCTION_URL", "https://example.com/fake")
    mock_post = mocker.patch("myapp.requests.post")
    send_read_notification(user, book)

    mock_post.assert_called_once_with(
        "https://example.com/fake",
        json={"user_name":"Taro", "book_title":"Python入門"}
        )

インストール

以下のコマンドでpytestpytest-mockを追加

poetry add --group dev pytest pytest-mock

--group dev は「開発用の依存関係」として追加するということです。テストツールは本番には不要なので、開発用に分けるのが一般的です。

実行コマンド(poetryで実行)

  • unittest
    一部のファイルを実行
poetry run python -m unittest test_myapp.py

すべてのテストを実行

poetry run python -m unittest
  • pytest
    一部のファイルを実行
poetry run pytest test_myapp.py

すべてのテストを実行

poetry run pytest

使い分けの基準

unittestが向いているケース

  • 標準ライブラリだけでテストを完結させたい
  • 既存のコードがunittestベース

pytestが向いているケース

  • テストコードを短く直感的に書きたい
  • fixtureやパラメータ化などの機能を活かしたい
  • コードの品質を継続的に高めたい

モックについて

unittest.mock の特徴

unittest.mockはモックオブジェクトを生成し、以下のことができる機能

  • オブジェクトの振る舞いを置き換えられる
    → ネットワーク通信やファイルI/Oなど実際に実行したくない処理をモック化できる

  • 呼び出しの記録や検証ができる
    assert_called_with()のように、どう呼ばれたかチェックできる

  • デコレータやコンテキストマネージャとして使える
    @patchwith patch()を使ってテスト対象の名前空間のみモックに差し替える

公式ガイドでは「モックはテスト対象の部分を置き換えて、呼び出しや返り値を検証するためのもの」という基本概念から丁寧に解説されています。

pytest におけるモックの仕組み

pytest自体はモックのための独自オブジェクトを持っているわけではないが、次のような方法がある

pytest の monkeypatch という仕組み

  • pytest のfixtureとして提供される機能で、属性や環境変数、辞書キーなどを一時的に書き換えることができる

  • monkeypatchは単純な値の置き換えにはとても便利で、テストのスコープ外で元の状態に戻してくれるので、セットアップと片付けを自分で書かなくてよくなる

pytest-mockのようなプラグイン

  • これはpytest向けにunittest.mockのパッチ機能をより扱いやすい形にしたラッパー

理解のしやすさ

  • unittest.mockの方が「公式の標準機能」「概念が明確」なので、初心者が最初に学ぶにはわかりやすいという側面はある
  • たとえばpatch()という仕組みを使う場合、「どこを置き換えるか?」という対象の名前空間を意識するというポイントがあり、これを理解すると「モックとは何か」という概念がクリアになる

一方で pytest 側の特徴

  • pytest固有のmonkeypatchは、最初は単純な値の差し替えとして直感的だが、
    • 何をどう patch すればよいか
    • どのスコープで元に戻るのか
      という「対象の名前空間」や「戻るタイミング」が少しわかりにくいこともある
  • またpytestに慣れてくるとfixtureとモックを組み合わせて使うパターンが増え、そこから「抽象的な理解」が必要になる

conftest.py

conftest.pyとは

pytsetが自動で読み込む特別な設定ファイルです。
ここに*fixtureを定義すると、そのファイルが置かれたディレクトリおよびサブディレクトリの全てのテストファイルで使えるようになります。
fixtureを毎回テストファイルで書いたりインポートする必要はありません。

*fixture・・・テストを実行する前に必要な「準備(セットアップ)」を用意して、テスト後に「片付け」もしてくれる仕組み

なぜ必要か

今回テストを実行する際に、本番環境用のデータベースURLが呼ばれエラーが起きる事象が発生したので、すべてのテストで毎回テスト用のデータベースURLを書かなくてもいいようにconftest.pyにテスト用のデータベースURLを定義しました。

データベースURLをfixture

conftest.py
import pytest
import os

os.environ["DATABASE_URL"] =  "postgres://xxxx:xxxx@xxxx/xxxx"

コード説明

os.environ["DATABASE_URL"] =  "postgres://xxxx:xxxx@xxxx/xxxx"

ここではテスト実行時に、ローカルのデータベースに接続するためのURLを環境変数に設定しています。
test.pyでテストを実行した際、from myapp import send_read_notificationをインポートした時点でmyapp.pyの処理が走ります。そうすると、デバック用の処理ではなく本番環境用の処理が走り、エラーになります。

myapp.py
if app.debug:
    app.config["SECRET_KEY"] = os.urandom(24)
    DB_INFO = {
        'user':'xxxx',
        'password':'xxxx',
        'host':'xxxx',
        'name':'xxxx',
    }
    SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg://{user}:{password}@{host}/{name}'.format(**DB_INFO)
else:
    app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY")
    SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL").replace("postgres://", "postgresql+psycopg://")

それを回避するため、テスト用のDATABASE_URLを定義することで、テスト実行時にmyapp.pyの処理が走る前にconftest.pyのDATABASE_URLが先に呼ばれ、エラーを回避できます。

まとめ

今回は実務での使用を意識してpytestでテストコードを書くことにしました。
そしてテスト方法にも複数種類があることを知ることができました。モックについての概念や使い方など、テストコードについて今後の学習でもっと理解を深めていきたいと思います。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?