unittestとは、Pythonに最初から入っているテスト用の標準ライブラリで、unittest.TestCase を継承したクラスに test_ で始まるメソッドを書き、python -m unittest で実行する仕組みです。 追加のインストールが要らないので、Pythonが動く環境ならすぐにテストを書き始められます。

この記事では、最小のテストファイルを作るところから、assertメソッドの一覧(用途・書き方・FAIL時メッセージ)、setUp / tearDown の実行順、mock と patch、subTest、skip、pytestとの違いまでを順に扱います。掲載した出力はすべて Python 3.11.15(Linux)で実際に動かした結果です。ファイルのパスだけは /home/taro/sample に置き換えています(表示は環境に合わせて置き換えています)。

unittestの早見表

まず「これだけ見れば書ける」内容を1つの表にまとめます。

やりたいこと 書き方 補足
テストクラスを作る class TestCalc(unittest.TestCase): クラス名は自由。Test で始めると見分けやすい
テストを1つ書く def test_add(self): メソッド名は test で始める(始まらないと実行されない)
値が等しいか確かめる self.assertEqual(実際, 期待) いちばんよく使う
例外が出ることを確かめる with self.assertRaises(ValueError): with の中で例外が出ればOK
小数を比べる self.assertAlmostEqual(a, b, places=3) 小数点以下の桁数を指定
テストごとの準備 def setUp(self): 各テストの直前に毎回実行
テストごとの後片付け def tearDown(self): 各テストの直後に毎回実行
外部の処理を差し替える @patch("使う側のモジュール.名前") 「使われている場所」を指定する
全部実行する python -m unittest カレントフォルダから test*.py を探す
詳しく表示する python -m unittest -v テスト名ごとに ok / FAIL / ERROR を表示
フォルダを指定して実行 python -m unittest discover -s tests -p でファイル名パターンも指定可
1つだけ実行する python -m unittest test_calc.TestCalc.test_add モジュール.クラス.メソッド
名前で絞る python -m unittest -k zero 名前に zero を含むテストだけ
最初の1件で止める python -m unittest -f failfast

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Python unittestの使い方|assert一覧とsetUp・mock」

最小のテストファイルを作って実行する

テスト対象とテストコード

フォルダを1つ作り、次の2ファイルを置きます。前提は Python 3.11 以降です(3.8 以降でも同じコードが動く書き方にしています)。

# calc.py
def add(a, b):
    return a + b


def divide(a, b):
    if b == 0:
        raise ValueError("0では割れません")
    return a / b
# test_calc.py
import unittest

from calc import add, divide


class TestCalc(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)

    def test_divide(self):
        self.assertAlmostEqual(divide(1, 3), 0.333, places=3)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            divide(1, 0)


if __name__ == "__main__":
    unittest.main()

ルールは3つだけです。 ファイル名を test で始める、クラスは unittest.TestCase を継承する、メソッド名を test で始める。この3つがそろうと、unittest が自動で見つけて実行します。

python -m unittest -v の実行結果

python -m unittest -v
test_add (test_calc.TestCalc.test_add) ... ok
test_divide (test_calc.TestCalc.test_divide) ... ok
test_divide_by_zero (test_calc.TestCalc.test_divide_by_zero) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

-v を外すと、テスト1件が1文字で表示されます。

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

ファイル末尾に unittest.main() を書いてあるので、python test_calc.py でも同じ結果になります。普段は python -m unittest を使うと、複数ファイルをまとめて実行できて便利です。

実行コマンドの一覧

コマンド 動き
python -m unittest カレントフォルダ以下から test*.py を探して全部実行(discover と同じ)
python -m unittest -v テスト名と結果を1行ずつ表示
python -m unittest test_calc モジュールを指定
python -m unittest test_calc.TestCalc クラスを指定
python -m unittest test_calc.TestCalc.test_add メソッドを1つ指定
python -m unittest -k zero 名前に zero を含むテストだけ
python -m unittest -f 最初の FAIL / ERROR で止める
python -m unittest -b テストが通ったときの print 出力を隠す
python -m unittest discover -s tests -p "test_*.py" 探すフォルダとファイル名パターンを指定
python test_calc.py unittest.main() を書いたファイルを直接実行

-k zero を実際に付けると、3件のうち該当する1件だけが動きました。

test_divide_by_zero (test_calc.TestCalc.test_divide_by_zero) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

OK・FAIL・ERRORの違いを出力で確かめる

unittest の結果は大きく3種類です。FAIL は「assert の期待と結果が違った」、ERROR は「assert 以外の場所で例外が出た」という違いがあります。3つが同時に出るテストを用意しました。

# test_result.py
import unittest

from calc import add, divide


class TestResult(unittest.TestCase):
    def test_ok(self):
        self.assertEqual(add(2, 3), 5)

    def test_fail(self):
        self.assertEqual(add(2, 3), 6)

    def test_error(self):
        self.assertEqual(divide(1, 0), 0)
python -m unittest -v test_result
test_error (test_result.TestResult.test_error) ... ERROR
test_fail (test_result.TestResult.test_fail) ... FAIL
test_ok (test_result.TestResult.test_ok) ... ok

======================================================================
ERROR: test_error (test_result.TestResult.test_error)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/taro/sample/test_result.py", line 14, in test_error
    self.assertEqual(divide(1, 0), 0)
                     ^^^^^^^^^^^^
  File "/home/taro/sample/calc.py", line 7, in divide
    raise ValueError("0では割れません")
ValueError: 0では割れません

======================================================================
FAIL: test_fail (test_result.TestResult.test_fail)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/taro/sample/test_result.py", line 11, in test_fail
    self.assertEqual(add(2, 3), 6)
AssertionError: 5 != 6

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1, errors=1)
表示 1文字表示 意味 次にすること
ok . 期待どおり そのまま進む
FAIL F assert の期待と結果が違う(AssertionError) 実際 != 期待 の行を読み、実装か期待値を見直す
ERROR E テスト中に AssertionError 以外の例外が出た Traceback の最後の行(例外の種類)から原因をたどる
skipped s skip 指定で実行しなかった 理由の文字列を確認
expected failure x 通らないと分かっているテストが、予想どおり通らなかった 直ったら expectedFailure を外す

読み方のポイントは次の3つです。

  • テストはメソッド名のアルファベット順に実行されます(test_error → test_fail → test_ok)。書いた順ではありません。
  • AssertionError: 5 != 6 は「実際の値 != 期待した値」の順です。assertEqual(実際, 期待) の順に書いておくと読みやすくなります。
  • 1件でも FAIL か ERROR があると、終了コードが 1 になります(上の実行では echo $? が 1 でした)。CI はこの終了コードで合否を判断します。

assertメソッド一覧(用途・書き方・FAIL時メッセージ)

よく使う assert メソッドを、わざと通らない値で実行して、表示されるメッセージを集めました。メッセージの形を知っておくと、テストが止まったときに原因をすぐ読み取れます。

メソッド 用途 書き方の例 FAIL時メッセージ(実行結果)
assertEqual 等しい assertEqual(1 + 1, 3) AssertionError: 2 != 3
assertNotEqual 等しくない assertNotEqual(2, 2) AssertionError: 2 == 2
assertTrue 真と評価される assertTrue([]) AssertionError: [] is not true
assertFalse 偽と評価される assertFalse("a") AssertionError: 'a' is not false
assertIsNone None である assertIsNone(0) AssertionError: 0 is not None
assertIs 同じオブジェクト assertIs([1], [1]) AssertionError: [1] is not [1]
assertIn 含まれる assertIn("d", ["a", "b", "c"]) AssertionError: 'd' not found in ['a', 'b', 'c']
assertIsInstance 型が合う assertIsInstance("1", int) AssertionError: '1' is not an instance of <class 'int'>
assertAlmostEqual 小数がほぼ等しい assertAlmostEqual(0.1 + 0.2, 0.31) AssertionError: 0.30000000000000004 != 0.31 within 7 places (0.009999999999999953 difference)
assertGreater より大きい assertGreater(1, 2) AssertionError: 1 not greater than 2
assertLessEqual 以下 assertLessEqual(3, 2) AssertionError: 3 not less than or equal to 2
assertRaises 例外が出る with assertRaises(ValueError): int("10") AssertionError: ValueError not raised
assertRegex 正規表現に合う assertRegex("abc", r"\d+") AssertionError: Regex didn't match: '\\d+' not found in 'abc'
assertCountEqual 順番を無視して要素が同じ assertCountEqual([1, 2, 2], [2, 1, 1]) AssertionError: Element counts were not equal:(続けて差分)

対になるメソッドとして、assertIsNotNone / assertIsNot / assertNotIn / assertNotIsInstance / assertNotAlmostEqual / assertLess / assertGreaterEqual / assertNotRegex もあります。使い方は上の表と同じです。

assertEqualはリストや辞書の差分も見せてくれる

assertEqual は型に合わせて差分を表示します。リストと辞書で試した結果です。

self.assertEqual([1, 2, 3], [1, 2, 4])
AssertionError: Lists differ: [1, 2, 3] != [1, 2, 4]

First differing element 2:
3
4

- [1, 2, 3]
?        ^

+ [1, 2, 4]
?        ^
self.assertEqual({"id": 1, "name": "tanaka"}, {"id": 1, "name": "suzuki"})
AssertionError: {'id': 1, 'name': 'tanaka'} != {'id': 1, 'name': 'suzuki'}
- {'id': 1, 'name': 'tanaka'}
?                    ^^^^ ^

+ {'id': 1, 'name': 'suzuki'}
?                    ^^^^ ^

どの要素が違うかまで出るので、assertTrue(a == b) より assertEqual(a, b) を選ぶと原因を追いやすくなります。 assertTrue(a == b) だと、表示は False is not true だけになります。

小数はassertAlmostEqualで比べる

0.1 + 0.2 は 2進数の都合で 0.30000000000000004 になります。assertEqual だと次のように止まります。

AssertionError: 0.30000000000000004 != 0.3

assertAlmostEqual(0.1 + 0.2, 0.3) に変えると通ります。既定では小数点以下7桁に丸めて比べ、places=3 のように桁数を、delta=0.01 のように許容する差を指定できます。

msg引数で「何を確かめているか」を添える

すべての assert メソッドは msg を受け取れます。

self.assertEqual(add_tax(100), 110, msg="税込み計算を確認")
AssertionError: 108 != 110 : 税込み計算を確認

元のメッセージの後ろに : 区切りで追加されます。

例外・ログ・警告を確かめる

import logging
import unittest
import warnings


class TestContext(unittest.TestCase):
    def test_raises_message(self):
        with self.assertRaises(ValueError) as cm:
            int("x")
        self.assertIn("invalid literal", str(cm.exception))

    def test_raises_regex(self):
        with self.assertRaisesRegex(ValueError, "invalid literal"):
            int("x")

    def test_logs(self):
        with self.assertLogs("app", level="INFO") as cm:
            logging.getLogger("app").info("started")
        self.assertEqual(cm.output, ["INFO:app:started"])

    def test_warns(self):
        with self.assertWarns(DeprecationWarning):
            warnings.warn("old", DeprecationWarning)
メソッド 確かめること
assertRaises(例外) with の中でその例外が出る。cm.exception で例外オブジェクトを取り出せる
assertRaisesRegex(例外, 正規表現) 例外の種類に加えて、メッセージが正規表現に合う
assertLogs(ロガー名, level) 指定レベル以上のログが1件以上出る。cm.output は レベル:ロガー名:本文 のリスト
assertWarns(警告) その警告が出る

assertRaisesRegex・assertLogs・assertWarns は、同じ形のコードを Python 3.11.15 で動かして通ることを確かめています。

setUp・tearDown・setUpClassの実行順

テストの前後に行う準備と後片付けを、unittest ではフィクスチャと呼びます。どの順番で呼ばれるかを print で確かめます。

# test_order.py
import unittest


def setUpModule():
    print("setUpModule")


def tearDownModule():
    print("tearDownModule")


class TestOrder(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print("  setUpClass")

    @classmethod
    def tearDownClass(cls):
        print("  tearDownClass")

    def setUp(self):
        print("    setUp")
        self.items = ["pen"]
        self.addCleanup(print, "    cleanup")

    def tearDown(self):
        print("    tearDown")

    def test_a(self):
        print("      test_a", self.items)
        self.items.append("note")

    def test_b(self):
        print("      test_b", self.items)
python -m unittest test_order
setUpModule
  setUpClass
    setUp
      test_a ['pen']
    tearDown
    cleanup
.    setUp
      test_b ['pen']
    tearDown
    cleanup
.  tearDownClass
tearDownModule

----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK

出力の途中にある . は、テスト1件が通ったことを表す印です。この結果から次のことが分かります。

  • setUp と tearDown は、テストメソッド1つごとに毎回呼ばれます。
  • test_a で self.items に "note" を足しても、test_b では ['pen'] に戻っています。テストごとに新しいインスタンスが作られ、setUp がやり直されるので、テスト同士が影響し合いません。
  • setUpClass / tearDownClass はクラスにつき1回、setUpModule / tearDownModule はファイルにつき1回です。
  • addCleanup で登録した処理は、tearDown の後に実行されます。
flowchart TD
    A[setUpModule] --> B[setUpClass]
    B --> C[setUp]
    C --> D[test_a]
    D --> E[tearDown]
    E --> F[addCleanupで登録した処理]
    F --> G[setUp]
    G --> H[test_b]
    H --> I[tearDown]
    I --> J[addCleanupで登録した処理]
    J --> K[tearDownClass]
    K --> L[tearDownModule]

どのフィクスチャを使うかの判断表

フィクスチャ 呼ばれる回数 向いている用途
setUp / tearDown テストメソッドごと テスト用データの用意、一時ファイル、毎回まっさらにしたいもの
setUpClass / tearDownClass クラスごとに1回 作るのに時間がかかり、読み取りだけに使うもの
setUpModule / tearDownModule ファイルごとに1回 ファイル全体で共有する準備
addCleanup 登録した数だけ setUp の途中で確保したものを確実に片付けたいとき

迷ったら setUp を選ぶと、テスト同士が独立して保てます。 setUpClass で作ったものをテストの中で書き換えると、実行順によって結果が変わることがあるためです。

setUpで例外が出るとtearDownは呼ばれない

# test_setup_err.py
import unittest


class TestSetupError(unittest.TestCase):
    def setUp(self):
        print("setUp")
        raise RuntimeError("準備でエラー")

    def tearDown(self):
        print("tearDown")

    def test_a(self):
        print("test_a")
setUp
E
======================================================================
ERROR: test_a (test_setup_err.TestSetupError.test_a)
----------------------------------------------------------------------
Traceback (most recent call last):
    raise RuntimeError("準備でエラー")
RuntimeError: 準備でエラー

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

(Traceback のうち File の行は省略しています。)tearDown も test_a も出力されていません。setUp が途中で止まると tearDown は呼ばれないので、片付けが必要なものは確保した直後に addCleanup で登録しておくと安心です。

import tempfile
import unittest
from pathlib import Path


class TestFile(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)  # 確保した直後に片付けを登録
        self.path = Path(self.tmp.name) / "memo.txt"

    def test_write(self):
        self.path.write_text("hello", encoding="utf-8")
        self.assertEqual(self.path.read_text(encoding="utf-8"), "hello")

mockとpatchの使い方

外部API・現在時刻・メール送信のように、テストのたびに本物を動かしにくい処理は、unittest.mock で差し替えます。これも標準ライブラリです。

Mockの基本(戻り値と呼び出しの記録)

from unittest.mock import Mock, call

m = Mock(return_value=10)
print(m(1, key="x"), m.call_count, m.call_args)
# 10 1 call(1, key='x')

m = Mock(side_effect=[1, 2, ValueError("3回目")])
print(m(), m())
# 1 2   (3回目の呼び出しでは ValueError が出る)

m = Mock()
m("a")
m("b")
m.assert_has_calls([call("a"), call("b")])
print(m.call_args_list)
# [call('a'), call('b')]
属性・メソッド 役割
return_value 呼ばれたときに返す値
side_effect リストなら順番に返す。例外を置くとその回で送出。関数を渡すと代わりに実行
call_count 呼ばれた回数
call_args / call_args_list 最後の呼び出しの引数 / すべての呼び出しの引数
assert_called() 1回以上呼ばれた
assert_called_once() ちょうど1回呼ばれた
assert_called_with(引数) 最後の呼び出しがその引数
assert_called_once_with(引数) ちょうど1回、その引数で呼ばれた
assert_not_called() 一度も呼ばれていない
assert_has_calls([call(...), ...]) その並びの呼び出しが含まれる

patchは「使われている場所」を指定する

patch でいちばんつまずきやすいのが、どこをパッチするかです。次の構成で再現します。

sample/
├── shop/
│   ├── __init__.py
│   ├── api.py
│   └── order.py
└── tests/
    ├── __init__.py
    ├── test_order_ng.py
    └── test_order_ok.py
# shop/api.py
def fetch_price(item_id):
    raise ConnectionError("外部APIには接続しません")
# shop/order.py
from shop.api import fetch_price


def total(item_id, qty):
    return fetch_price(item_id) * qty

total をテストしたいので、fetch_price を 120 を返す Mock に差し替えます。まず、定義されている場所(shop.api)をパッチした例です。

# tests/test_order_ng.py(うまくいかない例)
import unittest
from unittest.mock import patch

from shop.order import total


class TestTotalNg(unittest.TestCase):
    @patch("shop.api.fetch_price", return_value=120)
    def test_total(self, mock_fetch):
        self.assertEqual(total("A-1", 3), 360)
python -m unittest tests.test_order_ng
E
======================================================================
ERROR: test_total (tests.test_order_ng.TestTotalNg.test_total)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python3.11/unittest/mock.py", line 1378, in patched
    return func(*newargs, **newkeywargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/taro/sample/tests/test_order_ng.py", line 10, in test_total
    self.assertEqual(total("A-1", 3), 360)
                     ^^^^^^^^^^^^^^^
  File "/home/taro/sample/shop/order.py", line 5, in total
    return fetch_price(item_id) * qty
           ^^^^^^^^^^^^^^^^^^^^
  File "/home/taro/sample/shop/api.py", line 2, in fetch_price
    raise ConnectionError("外部APIには接続しません")
ConnectionError: 外部APIには接続しません

----------------------------------------------------------------------
Ran 1 test in 0.003s

FAILED (errors=1)

パッチしたはずなのに、本物の fetch_price が呼ばれています。理由は、shop/order.py が from shop.api import fetch_price を実行した時点で、order モジュールの中に fetch_price という名前が別に作られているからです。shop.api.fetch_price を差し替えても、shop.order.fetch_price は元の関数を指したままになります。

直し方は、使われている場所(shop.order)を指定することです。

# tests/test_order_ok.py(直した後)
import unittest
from unittest.mock import patch

from shop.order import total


class TestTotalOk(unittest.TestCase):
    @patch("shop.order.fetch_price", return_value=120)
    def test_total(self, mock_fetch):
        self.assertEqual(total("A-1", 3), 360)
        mock_fetch.assert_called_once_with("A-1")
test_total (tests.test_order_ok.TestTotalOk.test_total) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
使う側の import の書き方 patch に書く文字列
from shop.api import fetch_price(order.py 内) "shop.order.fetch_price"
import shop.api として shop.api.fetch_price(...) と呼ぶ "shop.api.fetch_price"
from shop import api として api.fetch_price(...) と呼ぶ "shop.api.fetch_price"

「テスト対象のコードが、その名前をどこから探すか」を考えると決まります。

patchの3つの書き方

from unittest.mock import patch

# 1. デコレータ:テストメソッド全体で差し替える
@patch("shop.order.fetch_price", return_value=120)
def test_total(self, mock_fetch):
    ...

# 2. with 文:ブロックの中だけ差し替える
def test_context_manager(self):
    with patch("shop.order.fetch_price") as mock_fetch:
        mock_fetch.return_value = 50
        print(total("B-2", 2))   # 100
    mock_fetch.assert_called_once_with("B-2")

# 3. setUp で開始し、addCleanup で戻す:クラスの全テストで使う
def setUp(self):
    patcher = patch("shop.order.fetch_price", return_value=120)
    self.mock_fetch = patcher.start()
    self.addCleanup(patcher.stop)

3つとも実行して通ることを確認しています(2の print は 100 でした)。デコレータを複数重ねたときは、下に書いたデコレータの Mock が先の引数になります。

呼び出しの検証で見るメッセージ

引数が期待と違うと、期待と実際が並んで表示されます。

with patch("shop.order.fetch_price", return_value=50) as mock_fetch:
    total("B-2", 2)
mock_fetch.assert_called_once_with("B-9")
AssertionError: expected call not found.
Expected: fetch_price('B-9')
  Actual: fetch_price('B-2')

メソッド名を打ち間違えた場合も、Python 3.11 では教えてくれます。

m = Mock()
m("a")
m.assert_called_once_wiht("zzz")   # with の綴りが違う
AttributeError: 'assert_called_once_wiht' is not a valid assertion. Use a spec for the mock if 'assert_called_once_wiht' is meant to be an attribute.

autospecで引数の数も確かめる

Mock はどんな引数でも受け取るので、本物と違う呼び方をしても通ってしまいます。autospec=True を付けると、本物の関数と同じ引数の形を守るようになります。

with patch("shop.order.fetch_price", autospec=True) as mock_fetch:
    mock_fetch("A-1", "extra")   # 本物は引数1つ
TypeError: too many positional arguments

subTestで複数の入力をまとめて確かめる

同じ確認を入力を変えて繰り返すときは subTest が便利です。for 文だけで書くと最初に止まった入力しか分かりませんが、subTest で囲むと最後まで回り、通らなかった入力がすべて表示されます。

# tests/test_sub.py
import unittest


def is_even(n):
    return n % 2 == 0


class TestSub(unittest.TestCase):
    def test_even(self):
        for n in [0, 2, 3, 4, 7]:
            with self.subTest(n=n):
                self.assertTrue(is_even(n))
FF
======================================================================
FAIL: test_even (tests.test_sub.TestSub.test_even) (n=3)
----------------------------------------------------------------------
AssertionError: False is not true

======================================================================
FAIL: test_even (tests.test_sub.TestSub.test_even) (n=7)
----------------------------------------------------------------------
AssertionError: False is not true

----------------------------------------------------------------------
Ran 1 test in 0.003s

FAILED (failures=2)

(Traceback の行は省略しています。)見出しに (n=3) (n=7) と付くので、どの入力で止まったかがひと目で分かります。テストの数は 1 件のまま数えられます。

skipで一時的に実行を外す

# tests/test_skip.py
import sys
import unittest


class TestSkip(unittest.TestCase):
    @unittest.skip("外部サービスの準備ができてから実行")
    def test_later(self):
        self.fail("ここは実行されません")

    @unittest.skipIf(sys.platform.startswith("linux"), "Linuxでは対象外")
    def test_not_linux(self):
        pass

    @unittest.skipUnless(sys.version_info >= (3, 11), "3.11以降が必要")
    def test_new_python(self):
        pass

    @unittest.expectedFailure
    def test_known_issue(self):
        self.assertEqual(0.1 + 0.2, 0.3)

    def test_runtime_skip(self):
        self.skipTest("実行中に条件を見てスキップ")
python -m unittest -v tests.test_skip
test_known_issue (tests.test_skip.TestSkip.test_known_issue) ... expected failure
test_later (tests.test_skip.TestSkip.test_later) ... skipped '外部サービスの準備ができてから実行'
test_new_python (tests.test_skip.TestSkip.test_new_python) ... ok
test_not_linux (tests.test_skip.TestSkip.test_not_linux) ... skipped 'Linuxでは対象外'
test_runtime_skip (tests.test_skip.TestSkip.test_runtime_skip) ... skipped '実行中に条件を見てスキップ'

----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK (skipped=3, expected failures=1)
書き方 動き
@unittest.skip("理由") 必ずスキップ
@unittest.skipIf(条件, "理由") 条件が真ならスキップ
@unittest.skipUnless(条件, "理由") 条件が偽ならスキップ
self.skipTest("理由") テストの途中でスキップ
@unittest.expectedFailure 通らないことを想定。通らなければ全体は OK のまま

理由の文字列は実行結果にそのまま出るので、「いつ戻すか」が分かる言葉にしておくと後で助かります。

discoverでフォルダごと実行する

テストが増えたら tests フォルダにまとめ、discover で一括実行します。

python -m unittest discover -s tests -p "test_o*.py" -v

今回の構成では、test_order_ng.py と test_order_ok.py の2ファイルが対象になり、末尾は次のようになりました。

----------------------------------------------------------------------
Ran 2 tests in 0.002s

FAILED (errors=1)
オプション 意味 既定値
-s 探し始めるフォルダ .(カレント)
-p テストファイル名のパターン test*.py
-t import の基準にする最上位フォルダ -s と同じ
-v 詳細表示 なし

discover でテストが見つからないときは、次の表を上から確認します。

確認すること 直し方
ファイル名が test で始まっているか calc_test.py のような名前なら -p "*_test.py" を付ける
メソッド名が test で始まっているか check_add → test_add に変える
クラスが unittest.TestCase を継承しているか 継承を追加する
サブフォルダに __init__.py があるか 空のファイルを置くと確実に見つかる
テスト対象を import できる場所で実行しているか 最上位のフォルダで実行する。ModuleNotFoundError が出たらここを見直す

非同期の関数をテストする

async def の関数は unittest.IsolatedAsyncioTestCase でテストできます。

# test_async.py
import asyncio
import unittest


async def fetch_name():
    await asyncio.sleep(0.01)
    return "tanaka"


class TestAsync(unittest.IsolatedAsyncioTestCase):
    async def asyncSetUp(self):
        self.prefix = "user:"

    async def test_fetch(self):
        self.assertEqual(self.prefix + await fetch_name(), "user:tanaka")
test_fetch (test_async.TestAsync.test_fetch) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.018s

OK

unittestとpytestの違い

Python のテストでは、外部ライブラリの pytest もよく使われます。pytest は unittest で書いたテストをそのまま実行できるので、どちらかを選んだら戻れない、という関係ではありません。上の test_calc.py と test_result.py を pytest 9.1.1 で実行した結果の末尾です。

python -m pytest test_calc.py test_result.py -q
    def test_fail(self):
>       self.assertEqual(add(2, 3), 6)
E       AssertionError: 5 != 6

test_result.py:11: AssertionError
=========================== short test summary info ============================
FAILED test_result.py::TestResult::test_error - ValueError: 0では割れません
FAILED test_result.py::TestResult::test_fail - AssertionError: 5 != 6
2 failed, 4 passed in 0.05s

pytest では FAIL と ERROR の区別がなく、どちらも failed と表示されました。

比べる点 unittest pytest
入手 標準ライブラリ(追加インストールなし) pip install pytest が必要
テストの書き方 TestCase を継承したクラスのメソッド 関数でもクラスでも書ける
確認の書き方 self.assertEqual(a, b) などのメソッド assert a == b
準備と後片付け setUp / tearDown / setUpClass fixture(関数の引数で受け取る)
入力を変えて繰り返す subTest @pytest.mark.parametrize
モック unittest.mock(標準) unittest.mock をそのまま使える。monkeypatch もある
スキップ @unittest.skip @pytest.mark.skip
実行コマンド python -m unittest pytest
命名の決まり メソッドは camelCase(setUp、assertEqual) snake_case
こんなとき 選び方の目安
外部ライブラリを入れずに始めたい unittest
標準ライブラリだけで配布するツールのテスト unittest
既に unittest のテストがたくさんある そのまま続ける。pytest で実行することもできる
短く書きたい、プラグインを使いたい pytest

unittest で覚えた考え方(準備・実行・確認、モック、スキップ)は、pytest でもそのまま生きます。

つまずきやすい点と直し方

起きること 理由 直し方
Ran 0 tests と表示される ファイル名・メソッド名が test で始まっていない 名前を test_ で始める
テストの順番が書いた順と違う メソッド名のアルファベット順に実行される 順番に頼らず、各テストを独立させる
patch したのに本物が呼ばれる 定義側をパッチしている 使われている側のモジュール名を指定する
小数の比較が通らない 2進数の誤差 assertAlmostEqual を使う
assertTrue(a == b) の表示が False is not true だけ 比較の中身が渡っていない assertEqual(a, b) に変える
setUp で止まると後片付けが動かない tearDown は setUp が成功したときだけ呼ばれる addCleanup を使う
前のテストの影響で結果が変わる setUpClass で作ったものを書き換えている setUp に移して毎回作り直す

よくある質問

Q. unittest.main() は書いたほうがよいですか? A. python -m unittest で実行するなら、なくても動きます。python test_calc.py のようにファイルを直接実行したい場合に書いておくと便利です。

Q. assertEqual の引数は、どちらが期待値ですか? A. unittest としての決まりはありません。メッセージは 1つ目 != 2つ目 の順で出るので、「実際の値, 期待する値」の順にそろえると読みやすくなります。チームで順番を決めておくと、表示の読み違いが減ります。

Q. テストの実行に時間がかかるものを見つけるには? A. Python 3.12 以降には --durations オプションがあり、時間のかかったテストを表示できます(この記事の検証環境は 3.11 のため未実行です)。3.11 以前では、pytest の --durations=5 で同じことができます。

Q. print の出力が邪魔なときは? A. python -m unittest -b を付けると、通ったテストの標準出力を隠せます。上の test_order.py に -b を付けて実行すると、print の行が消えて、.. と Ran 2 tests、OK だけが表示されました。

まとめ

  • unittest は標準ライブラリ。unittest.TestCase を継承し、test で始まるメソッドを書いて python -m unittest -v で実行する
  • FAIL は assert の期待と違ったとき、ERROR はそれ以外の例外。1件でもあると終了コードは 1
  • assertEqual は差分まで表示する。小数は assertAlmostEqual、例外は assertRaises
  • setUp / tearDown はテストごとに毎回、setUpClass はクラスに1回。片付けは addCleanup が確実
  • patch は「使われている場所」を指定する。from A import f と書いたモジュール側の名前を差し替える
  • subTest で通らなかった入力を全部表示、skip は理由つきで一時的に外す
  • pytest は unittest のテストをそのまま実行できるので、後から移ることもできる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Python unittestの使い方|assert一覧とsetUp・mock」

株式会社RJCでは、Pythonのテストの書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?