pythonのunittestで自作のassertion methodを追加したときに、不要な行がスタックトレースに紛れ込まなくて良くなる方法について

pythonでassertion methodを追加したい場合はテキトウにメソッドを書いてあげれば良い。Mixinのクラスを定義してあげれば大丈夫。

組み込みのassertion methodの定義

例えば、幾つかのunittest.TestCaseに組み込みのassertion methodの定義を見て真似してあげれば良い。

たとえばunittest/case.pyをのぞくと以下の様なコードがある。

unittest/case.py

class TestCase(object):
...
    def assertTrue(self, expr, msg=None):
        """Check that the expression is true."""
        if not expr:
            msg = self._formatMessage(msg, "%s is not true" % safe_repr(expr))
            raise self.failureException(msg)

testがfailになった場合にfailreExceptionを呼んであげれば良い。

細々とした定義

ちなみに、failreExceptionは以下のような定義になっていて、fail()メソッドを使っている部分もあった。

unittest/case.py

class TestCase(object):
...
    failureException = AssertionError
...
    def fail(self, msg=None):
        """Fail immediately, with the given message."""
        raise self.failureException(msg)

自作のassertion method

以上を踏まえて必ず失敗するassertion methodを作ってみる。

testing.py

class AlwaysFailMixin:
    def assert_always_fail(self):
        self.fail("fail..")

以下のようなテストコードを書く。

00tests.py

import unittest
from testing import AlwaysFailMixin


class Tests(AlwaysFailMixin, unittest.TestCase):
    def test_it(self):
        self.assert_always_fail()


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

実行してみると、以下のようなtracebackが出力される。

$ python 00tests.py
F
======================================================================
FAIL: test_it (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "00tests.py", line 7, in test_it
    self.assert_always_fail()
  File "$HOME/venvs/my/individual-sandbox/daily/20190616/example_unittest/testing.py", line 3, in assert_always_fail
    self.fail("fail..")
AssertionError: fail..

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

FAILED (failures=1)

動いてはいるのだけれど、この行が残ってしまうのがちょっと不服。

  File "$HOME/venvs/my/individual-sandbox/daily/20190616/example_unittest/testing.py", line 3, in assert_always_fail
    self.fail("fail..")

これを消す方法が存在するのでひと工夫すると良いという話。

__unittest = True

実は __unittest = True という値をモジュールグローバルに定義してあげると良い感じに動く。

--- testing.py   2019-06-17 03:43:04.748776798 +0900
+++ testing2.py   2019-06-17 03:43:45.248823695 +0900
@@ -1,3 +1,6 @@
+__unittest = True  # <- this!!
+
+
 class AlwaysFailMixin:
     def assert_always_fail(self):
         self.fail("fail..")

実行結果は以下の様になる(すばらしい)。

$ python 01tests.py
F
======================================================================
FAIL: test_it (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "01tests.py", line 7, in test_it
    self.assert_always_fail()
AssertionError: fail..

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

FAILED (failures=1)

このあたりドキュメントに書いてあっても良さそうな気がするけれど。書いていない。

どこか情報源になるものは?

少し調べるとstack overflowなどでの情報は見つかる。

あるいはdjangoのtest用のhelperコード的なものにも含まれている。

実際どこで取り除かれているのか?

実際中のコードを覗いてみると以下の部分が対応している。

unittest/result.py

class TestResult(object):
...
    def _is_relevant_tb_level(self, tb):
        return '__unittest' in tb.tb_frame.f_globals

    def _exc_info_to_string(self, err, test):
        """Converts a sys.exc_info()-style tuple of values into a string."""
        exctype, value, tb = err
        # Skip test runner traceback levels
        while tb and self._is_relevant_tb_level(tb):
            tb = tb.tb_next
...

テストも存在しているので信用しても良いものだと思う。

test/test_result.py

class Test_TestResult(unittest.TestCase):
    def testStackFrameTrimming(self):
        class Frame(object):
            class tb_frame(object):
                f_globals = {}
        result = unittest.TestResult()
        self.assertFalse(result._is_relevant_tb_level(Frame))

        Frame.tb_frame.f_globals['__unittest'] = True
        self.assertTrue(result._is_relevant_tb_level(Frame))

おしまい。

jqfpyが--input-format=rawをサポートすればsed,awk的な処理にも対応できるのでは?と思ったので実装してみた。けっこう便利だった。

github.com

以前にもちょっとしたjqfpyの活用方法の記事は書いていた。

今回もそれらと似たような話。

--input-format=raw

0.6.0からformatにrawが追加された。とくにJSONYAMLなどフォーマットを気にせずそのまま文字列として返される(正確には readline().rstrip())。

なので、利用者としては単なる改行区切りの文字列と認識してテキトーにコードを書いてあげれば良い。

function gen() { (for i in $(seq 5); do echo $i; done) }
$ gen | jqfpy -i raw
"1"
"2"
"3"
"4"
"5"

クォートされるのが邪魔なら -r, --raw-output

$ gen | jqfpy -i raw -r
1
2
3
4
5

1つにまとめたければ --slurp

$ gen | jqfpy -i raw -r --slurp -c
["1", "2", "3", "4", "5"]

もちろん、 -u, --unbuffered にも対応している。少しずつ進行するようなコマンドでも大丈夫。

$ function gen2() { (for i in $(seq 5); do echo $i; sleep 1; done) }
$ gen2 | jqfpy -u -i raw 'import time; [time.time(), get()]' -c
[1560638479.4626064, "1"]
[1560638480.2339017, "2"]
[1560638481.2378538, "3"]
[1560638482.2408028, "4"]
[1560638483.2435904, "5"]

# -u なし
$ gen2 | jqfpy -i raw 'import time; [time.time(), get()]' -c
[1560638535.1345422, "1"]
[1560638535.1405966, "2"]
[1560638535.1406453, "3"]
[1560638535.1406796, "4"]
[1560638535.1407096, "5"]

Noneを返すものは無視される

ついでにNoneを返すものは無視するようにした。これは過去にもこのブログで言及していた(jqfpyにloadfile(),dumpfile()を追加していた で dev/nullへのリダイレクトは邪魔だよねという形で)。

$ function gen3() { printf 'foo\nbar\n\nboo'; }
$ gen3 | jqfpy -i raw
"foo"
"bar"
""
"boo"

$ gen3 | jqfpy -i raw 'get() or None'
"foo"
"bar"
"boo"

無視しないようにするには --show-none を付ける。

$ gen3 | jqfpy --show-none -i raw 'get() or None'
"foo"
"bar"
null
"boo"

nullと表示されている通り、デフォルトではJSONとしての出力される。それを止めたければ -o, --output-format を付けてあげる。

$ gen3 | jqfpy --show-none -i raw -o raw 'get() or None'
'foo'
'bar'
None
'boo'

dictknife の各種コマンドは -i, --input-format, -o, --output-format の他にそれらを同時に指定する f, --format があるのだけれど、こちらにも付けてあげても良いかもしれない。

sedの代わり

例えばsedは以下の様な使いかたをする。oを@に変える。一番シンプルな例かも。

$ echo foo | sed 's/o/@/g'
f@@

ふつうのpythonの文字列処理。もちろんpythonでやる分長くはなるけれど。

$ echo foo | jqfpy -i raw 'get().replace("o","@")' -r
f@@

正規表現でのマッチなどをやる分には3.8に期待。

awkの代わり

awkはtsvやcsvになりきれていないようなスペース区切りの文字列の一部分だけを取り出すのに使ったりする。個人的にはcutコマンドの上位版というくらいの認識(それ以上詳しくはない)。

$ echo 'foo 20 F' | awk '{printf "%s(%s)さん\n", $1, $2, "\n"}'
foo(20)さん

この辺りもまぁsplit()とかと組み合わせた文字列処理。

$ echo 'foo 20 F' | jqfpy -i raw 'row = get().split(" "); f"{row[0]}({row[1]})さん"' -r
foo(20)さん

ちなみにちょっと形を変えてJSONで出力するのも楽といえば楽。zip()との組み合わせはありと言えばあり。

行区切りの文字列をJSONとして

$ echo 'foo 20 F' | jqfpy -i raw 'row = get().split(" "); props = ["name","age","nickname"];  dict(zip(props, row))' -r
{
  "name": "foo",
  "age": "20",
  "nickname": "F"
}

スクリプトに持っていくときにpythonの関数に

ちなみに --show-code を実行してあげれば、ただのpythonの関数としてけっこう手軽に取り出せると思う。オプションが残ったままでも大丈夫(-i raw とか -r の話)

$ echo 'foo 20 F' | jqfpy --show-code -i raw 'row = get().split(" "); props = ["name","age","nickname"];  dict(zip(props, row))' -r
def _transform(get, h=None):
    row = get().split(" ")
    props = ["name","age","nickname"]
    return dict(zip(props, row))

ちょこっと書き換えればこういう感じに変更するだけなので。

def _transform(line):
    row = line.split(" ")
    props = ["name","age","nickname"]
    return dict(zip(props, row))

おわりに

もう少し複雑な例があれば翻訳してみても良いかも。それなりに冗長にはなるもののpythonがネイティブの人には新しくunix系のコマンドのshell芸的なテクニックを覚えるよりも心理的な負担が少なくなるかも?

ちなみによりawkなどに近づけたものとしてはtseなどがあったりします。

github.com