Help us understand the problem. What is going on with this article?

Pythonのunittestでハマったところと、もっと早くに知りたかったこと

More than 1 year has passed since last update.

注意

Python3のunittestの記事です。

テストコードなんて書かないよ! という人へ

ちょっと前まで私も、「テストコードは書かない」「一瞬一瞬で勝負する」「過去は決して振り返らない――」という男前なプログラマーでした。しかし幾多ものレガシープログラムに苦しみ、その後でKent Beckの『テスト駆動開発』を読んでからは心を入れ替えてテストを書くようになりました。網羅的に記述する単体テストとはまた違った、開発の補助としてのテストコードは、とてもよいものです。まだテストコードを自発的に書いたことがないという人は、テスト駆動な技術書を読んで、騙されたと思って書いてみてください。

開発の補助としてのテストコードにはこんなメリットがあります。

  • 開発速度と品質が上がる
  • どうクラスを使ったら良いかを示すドキュメントになる
  • リファクタリングがしやすくなる

この記事について

この記事では「Pythonで作って」と言われたために仕事でPythonを書き始めたPython初心者の私が、unittestなどを使う上で引っかかったポイントや、もっと早くに知りたかったことをひたすら書いておきます。
unittestがうまく動かない人の一助となれば幸いです。

ファイル名・メソッド名はtestから始まらないといけない

タイトルの通りです。
各記事で書かれているコード例通りに写経しない場合、ここにハマります。「余裕っしょ~」と自分なりに翻訳して、コードだけ読んで文章をよく読まずにいきなり例と違うコードを書いていくと、なぜか動かなくて死にます。私です。

ファイル名
good👍test_hoge.py
bad👎hoge_test.py

メソッド定義
good👍def test_hoge_method(self):
bad👎def hoge_method_test(self):

私はケツに_testと書いていたせいで貴重な時間を沢山無駄にしました。
テストスイートを自分で書いたりすればhoge_test.pyなども読み込ませることはできますが、
pythonのunittestでは先頭testが普通です。郷に入っては郷に従えです。

# テストスイートを自分で書かずにこれをした時に、標準でファイル名・メソッド名はtestから始まるものを探す
$ python -m unittest discover

特にケツtestにこだわる宗教的な理由もないので、先頭testにしておきましょう。

追記

コメント欄で、discoverにオプションをつけることで、
ケツに_test.pyと書いてもテストとして認識してくれると教えて頂きました🙏
-pもしくは--patternで、任意のパターンを指定してファイル名を探索させることができます。
ただし、メソッド名などは相変わらず先頭testなので注意が必要です。

$ python -m unittest discover -p "*test.py"

テストコードが実行できない

こんなディレクトリ構成だとします。

projectroot # projectrootをcdとしてコマンドを打つ
  ┣classes
  ┃   ┣__init__.py
  ┃   ┗hoge.py
  ┗tests
      ┣__init__.py
      ┗test_hoge.py # ←これを動かしたい

test_hoge.pyだけテストを実行しようと思った時に、
下のコマンドを試しました。

※動かないよ。パスだとダメだよ。
$ python -m unittest .\tests\test_hoge.py

動きません。パッケージ名やモジュール名で指定しましょう。
-mのオプションはモジュールで実行するためにつけています。
パッケージ名は、__init__.pyがあるフォルダのフォルダ名、
モジュール名は、.pyファイルのファイル名(.pyの部分を除く)です。

__init__.pyがあるフォルダを、pythonはパッケージであると判断します。
なのでこれもダメです。少なくとも私はよくやりました。後ろの.pyは不要です。

※動かないよ。.pyもいらないよ。
$ python -m unittest tests.test_hoge.py

こうするとちゃんと動きます。

※ちゃんと動くよ。testsパッケージのtest_hogeモジュールだということを認識するよ。
$ python -m unittest tests.test_hoge

更にこうすると、特定クラスの特定メソッドだけを実行できます。
この場合はTestHogeClassクラスのhoge_method_testメソッドだけを実行していると思ってください。

※ちゃんと動くよ
$ python -m unittest tests.test_hoge.TestHogeClass.hoge_method_test

とにかくimportがうまくいかない

テストコードの実行まではできましたが、今度は想定してなかったERRORが出てきます。
これが一番の曲者です。とにかくうまくいきません。

どちらかというとunittestの問題というよりは、Pythonの挙動の理解が問題なんですが、テスト時に壁となることに違いはありません。
テスト対象のプログラムは既にちゃんと動くのに、なぜかテストコード上だとうまく動かない……。そんなことがあります。
英語のページなんですが、下記のページにとても分かりやすく解説が書いてあります。英語が読めるならばこちらを読んでもいいでしょう。
Python (relative) Imports and Unittests – Derp!

さて、こんなディレクトリ構成だとします。
__init__.pyはさっき上で説明しましたね。classesパッケージとtestsパッケージができている状態です。

projectroot # projectrootをcdとしてコマンドを打つ
  ┣classes
  ┃   ┣__init__.py
  ┃   ┣hoge.py
  ┃   ┗hoge_caller.py
  ┗tests
      ┣__init__.py
      ┣test_hoge.py
      ┗test_hoge_caller.py

なんてことはないHogeクラスを書きます。

hoge.py
class Hoge:
    def hoge_method(self):
        return "hogehoge"

if __name__ == '__main__':
    print(Hoge().hoge_method())

実行します。

※実行する↓
$ python .\classes\hoge.py # パスで指定する場合
$ python -m classes.hoge # module指定の場合

※出力↓
hogehoge

当たり前ですね。
そしてまた、なんてことはない、HogeCallerクラスを書きます。

hoge_caller.py
# 同じパッケージ内のhogeモジュールをimportしている。(ネタバレ:後から出てくるエラーはコイツが元凶)
from hoge import Hoge 

class HogeCaller:
    def call_hoge(self):
        return 'called: ' + Hoge().hoge_method()

if __name__ == '__main__':
    print(HogeCaller().call_hoge())

同じく実行します。

※実行する↓
$ python .\classes\hoge_caller.py

※出力↓
called: hogehoge

いいですね。ちゃんとcalled: hogehogeと出ています。何も問題はありません。
ではテストコードを書いてみましょう。2つのクラスとも、もう動くことは分かってるので安心ですね。
(※これはunittestの記事であるためにこの順で書いています。テストコードにある程度慣れてきたら、最初にテストコードを書いてから実際の処理を書いても構いません。テスト駆動開発しましょう。)

test_hoge.py
import unittest
from classes.hoge import Hoge # from hoge import Hogeは不可。

class TestHoge(unittest.TestCase):

    def test_hoge_method(self):
        hoge = Hoge()
        expected = 'hogehoge'
        actual = hoge.hoge_method()
        self.assertEqual(expected, actual)

コード中にも書いたように、フォルダが違うのでfrom hogeではうまくいきません。
from ..\classes\hoge.py import Hogeのように書いてもいけません。パスではなく、パッケージです。
もうHogeクラスは実際に自分で動かしているので結果は分かりきっていますが、テスト結果を見てみましょう。

※実行する↓
$ python -m unittest discover

※出力↓
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

余裕ですね。はぁ。なんだ。なんてことないじゃないか。
それじゃあ同じ風にHogeCallerのテストも書いてみよう。

test_hoge_caller.py
import unittest
from classes.hoge_caller import HogeCaller

class TestHogeCaller(unittest.TestCase):

    def test_hoge_method(self):
        hoge_caller = HogeCaller()
        expected = 'called: hogehoge'
        actual = hoge_caller.call_hoge()
        self.assertEqual(expected, actual)
※実行する↓
$ python -m unittest discover

※出力↓
.E
======================================================================
ERROR: tests.test_hoge_caller (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: tests.test_hoge_caller
  ※中略
  File "C:\workspace\projectroot\classes\hoge_caller.py", line 1, in <module>
    from hoge import Hoge
ModuleNotFoundError: No module named 'hoge'
※エラー訳:hoge_caller.pyに書かれてるhogeモジュールなんてないよ。

ほ?

いけるだろと思った矢先にこれです。一体何が起こってしまったのか……。
テストクラスではないテスト対象であるhoge_caller.pyの方で、importのエラーが起きています。
だって……だってさっきまで動いてたじゃん! どうして! さっきまであんなに元気に走り回っていたのに……。

先にネタバレはしていましたが、hoge_caller.pyでこう書けば解決します。

hoge_caller.py
from classes.hoge import Hoge

class HogeCaller:
    def call_hoge(self):
        hoge = Hoge()
        return 'called: ' + hoge.hoge_method()

if __name__ == '__main__':
    print(HogeCaller().call_hoge())
※実行する↓
$ python -m unittest discover

※出力↓
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

よしオッケー。
その代償として、最初に動いてたコマンドは動かなくなります。

※実行する↓
$ python .\classes\hoge_caller.py

※出力↓
Traceback (most recent call last):
  File ".\classes\hoge_caller.py", line 1, in <module>
    from classes.hoge import Hoge
ModuleNotFoundError: No module named 'classes'
※hoge_caller.pyに書かれてるclassesモジュールなんてないよ。

人は何かの犠牲なしに何も得ることはできない。鋼の錬金術師はそう教えてくれました。
……でも何か、気持ち悪いですね。一体何が起こっているのでしょう。

from classes.hoge import Hoge

class HogeCaller:
    def call_hoge(self):
        hoge = Hoge()
        return 'called: ' + hoge.hoge_method()

# 動かないので消す。コメントアウトじゃなくていいよ。
# if __name__ == '__main__':
#     print(HogeCaller().call_hoge())

とりあえずこのファイルを起点として実行するのはやめちゃいましょう。ifからを消します。これで完成。
そもそもここで定義しているのはクラスなので、外部からインスタンス化して呼び出すことを想定しています。消しても問題ないでしょう。
でも何か、気持ち悪いですね。

さて。なぜこのようなimportエラーが発生するのでしょうか。
それにはまず、Pythonがどんなモジュールをimportできるかを知る必要があります。
そこで大事なのがモジュール探索パスです。

モジュール探索パスについて

上で作成したclassesパッケージのように自分で作成したパッケージや、pipでインストールしたパッケージを使うということは、そのパッケージがある場所をPythonが知っている必要があるということです。Pythonはパッケージを探索するパスのリストを持っており、そのリストのパスの一覧からパッケージのimportを解決しようとします。そのパスを、モジュール検索パス(Module Search Path)といいます。
モジュール検索パスは、sys.pathに格納されているため、ここで確認することができます。

モジュール探索パスについて、詳しくは下記の記事を参照してください。
note.nkmk.me - Pythonでimportの対象ディレクトリのパスを確認・追加(sys.pathなど)

ここで大事なのは、モジュール探索パスには実行したpyファイルのディレクトリが含まれるということです。

つまり、

# この二つは同じもの
$ python .\classes\hoge_caller.py # パスの場合
$ python -m classes.hoge_caller # module指定の場合

のように実行した時は、(未定義)/projectroot/classesが、モジュール探索パスに含まれます。なので、最初に実行したhoge_caller.pyは正しく実行できました。
一方で、

$ python -m unittest discover

を実行した時は、(未定義)/projectrootがモジュール探索パスに含まれます。このコマンドはprojectrootで実行しているためです。
なので、hoge_caller.pyでfrom hoge import Hogeのように書いてしまうと、projectrootにhogeなんてパッケージはないのでPythonに怒られるわけです。

今回やった以外にも、エラーを回避する方法はあります。
hoge_callerのimportは相対インポートだとPythonに明示的に教えてやればいいのです。

hoge_caller.py
from . import hoge

class HogeCaller:
    def call_hoge(self):
        return 'called: ' + hoge.Hoge().hoge_method()

setUpとtearDown

unittestでは、テストの共通の初期処理をsetUp()に書いて、共通の終了処理をtearDown()に書くことができます。
これに気付かなかったがために、最初は初期処理を書くために、こんなコードを書きました。

class TestHogeClass(unittest.TestCase):

  def __init__(self, *args, **kwargs):
    super(TestHogeClass, self).__init__(*args, **kwargs)
    self.generate_stubs()

  def generate_stubs(self):
    # なんか初期処理

しかしsetUp()という便利なものがあります。
たいていのテストフレームワークには、こういうものはついています。
Pythonのunittestにもありました。もっと早くに気付きたかった……。
なんでset_upではなくsetUpなのかというと、unittestライブラリは規約:PEP8ができる前にできたものだったかららしいです。

class TestHogeClass(unittest.TestCase):

  def setUp(self):
    self.something_mock = SomethingMock() # なんか最初に走らせたい共通の処理

  def test_hoge_method(self):
    # 省略
    self.assertEqual(expected, actual)

  def tearDown(self):
    self.something_mock = None # なんか終了時に走らせたい共通の処理

unittest出力結果をカラフルしたい

テストのコンソール出力を手軽にカラフルにしたい。そう思った時期が私には数日ほどありました。
手軽にカラフルにしてくれるパッケージ、colour-runnerはWindows非対応で、greenは日本語や漢字のコンソール出力が中国のピンインみたいに変換されてしまいます。おおう……。
unittestをカラフルにしたいのであれば、少なくともunittestを使用する限りは、自分でオープンソースのパッケージにコントリビュートするか、自分で作るかしかなさそうです。もしくは、他のテストフレームワークを使うという手もあります。

もしかしたら未来には(もしくは、探し足りないだけで既に)手軽にカラフルにできるものがあるかもしれません。
もしwindows環境かつunittestで手軽に動くものがあったら教えてください。

CleanCut/greenの2.14.0(2019/05/16リリース)以降で、日本語→ピンイン変換を行っていたunidecodeを無効化するオプションが追加されている旨をコメント欄で教えて頂きました。greenの-Uもしくは--disable-unidecodeオプションを付ければ、日本語もそのまま出るとのこと! うれしい! greenを使えばunittestをカラフルにできるぞ!

asyncioの非同期処理のテストにはaiounittestが使える

シングルスレッドでの非同期処理を実現するライブラリ、asyncioを使ったasyncなメソッドなどは、同期処理を書くのと同じようにはテストできません。unittestでは非同期処理がサポートされていないためです。
公式のコードをそのまま載せてしまいますが、unittestと同じように書けるため、非同期処理のことさえ分かっていれば、学習コストが低くてとても助かります。
aiounittest 1.1.0

# unittestと同じ感覚で書ける!
class MyTest(aiounittest.AsyncTestCase):

    async def test_async_add(self):
        ret = await add(5, 6)
        self.assertEqual(ret, 11)
jesus_isao
ぜんぶ壺のせい
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away