状態のあるコードに対するテストの自動生成 その2

前回、曖昧な仕様を元に実装して、それにBLUE*を使った自動テストを掛けた結果、仕様に「open後にopenしたらエラーになるべきかどうか」「closeした後再度openできるのかどうか」が明記されていない点について、自動テスト生成が突っ込んでくることを確認した。今回は前回よりもさらに仕様が曖昧なところからスタートすることにする。

今回の初期仕様

「なんかCSVを読んでアイテムを取り出すやつ作ってよ、まずは1行分でいいから」

これは酷い…

なお、今回のエントリーではある程度実装してから「それは先に言えよ」と言いたくなるような仕様変更を入れることにする。

コードの読みやすさのために、引用符は|、バックスラッシュはbで表すことにする。

今回の初期実装

今回の最初の実装はこちら。何をしてもエラーになる!

def parse(s):
    raise NotImplementedError

今回の初期テスト

もちろん最初から完璧なテストが存在するわけがないのでこれもかなり酷い状況からスタートする。まだバックスラッシュやカンマはでてきていない。

e_plus = ['a', '||', '|a|']
e_minus = ['|']

実装を改善

とりあえず与えられたテストぐらいはパスするようにする。

def parse(s):
    in_quote = False
    for c in s:
        if c == '|':
            in_quote = not in_quote

    if in_quote: raise AsserionError

これで実行してみると「"aa"はFailしなくていいの?」と自動テスト君がつっこんでくる。それはFailしなくていいのでテストに足す。

3: for input 'aa': dfa said False, but target said True

テストを足すと長さ20以下の入力列で、もう問題は見つからない、と自動テスト君が言う。めでたしめでたし。

仕様変更

「えっと、この実装だと引用符とその他の文字だけチェックしているけど、もちろんバックスラッシュで引用符がエスケープできないとダメだよ。え?仕様に書いていなかった?CSVって言ってんだからわかるだろうが!」

ま、わかってたんだけどね。出現しうる文字にバックスラッシュ(b)を追加して再度自動テストを走らせる。

まず入力'b'に対してFailするべきなのにしない、と指摘される。実装を直そう。

3: for input 'b': dfa said False, but target said True
def parse(s):
    in_quote = False
    to_escape = False
    for c in s:
        if c == '|':
            in_quote = not in_quote
        elif c == 'b':
            to_escape = True

    if in_quote: raise AsserionError
    if to_escape: raise AsserionError

これで自動テストは「問題が見つからない」と報告する。
まあ、テストの失敗はバグの存在を示すが、テストの成功はバグの不在を示さない、ってわけだ。
今の実装ではバックスラッシュが出現すると必ずFailする。そして自動テスト君もそう振る舞うのが正しいと思っている。
それが正しくないってことは人間が教えないといけない。

バックスラッシュを含む成功例をテストに追加

成功例として'ab|a'を足してみる。これはバックスラッシュによって引用符がエスケープされるから成功しなければいけない。
このテストケースが追加されたことで、自動テスト君はいくつかテストケースを提案してくる。

5: for input 'a|': dfa said True, but target said False
6: for input 'ab': dfa said True, but target said False

これはテストに追加しよう。

14: for input 'ab|': dfa said True, but target said False

これは、自動テストくんの言うとおり、成功すべき入力。実装の側を直す必要がある。

エスケープの実装

def parse(s):
    in_quote = False
    to_escape = False
    for c in s:
        if to_escape:
            to_escape = False
            continue

        if c == '|':
            in_quote = not in_quote
        elif c == 'b':
            to_escape = True

    if in_quote: raise AssertionError
    if to_escape: raise AssertionError

実装を修正して再度テストを走らせる。3つほどテストケースが提案されたのでそれを追加する。

13: for input 'aba': dfa said False, but target said True
14: for input 'ab|': dfa said False, but target said True
15: for input 'abb': dfa said False, but target said True

1分ほど自動テストくんが走って、問題が見つけられないのでCtrl-Cした。
ここは現状「20以下の入力列を全探索」という実装になっているけど、時間で上限をつけたり、ランダムに探索したりするように改良する手が考えられる。
今回は10以下の入力列を探索するように変更することにする。長さ10以下のテストでは、6798件のテストが走っていたことがわかった。

値のテストを追加

状態遷移が割とまともに動いているようなので、値のテストを追加する。e_plusに入っている入力列は「成功するはず」なわけだから、「成功したらこの値が返るはず」をテストに書くことになる。「どういうテストを追加すべきか」というに悩まないで済む。

def parse(s):
    """
    >>> parse('a')
    ['a']
    """
    ...

もちろん今は状態遷移だけしか実装してないのでFailしまくる。順番に直していこう。

**********************************************************************
File "use_bluestar2.py", line 71, in __main__.parse
Failed example:
    parse('a')
Expected:
    ['a']
Got nothing
**********************************************************************

まず1つ目

def parse(s):
    """
    >>> parse('a')
    ['a']
    """
    in_quote = False
    to_escape = False
    result = []
    buf = []
    for c in s:
        if to_escape:
            to_escape = False
            continue

        if c == '|':
            in_quote = not in_quote
        elif c == 'b':
            to_escape = True
        else:
            buf.append(c)

    if in_quote: raise AssertionError
    if to_escape: raise AssertionError
    result.append(''.join(buf))
    return result

全部足した

def parse(s):
    """
    >>> parse('a')
    ['a']
    >>> parse('||')
    ['']
    >>> parse('|a|')
    ['a']
    >>> parse('ab|a')
    ['a|a']
    >>> parse('aba')
    ['aa']
    >>> parse('ab|')
    ['a|']
    >>> parse('abb')
    ['ab']
    """
    in_quote = False
    to_escape = False
    result = []
    buf = []
    for c in s:
        if to_escape:
            buf.append(c)
            to_escape = False
            continue

        if c == '|':
            in_quote = not in_quote
        elif c == 'b':
            to_escape = True
        else:
            buf.append(c)

    if in_quote: raise AssertionError
    if to_escape: raise AssertionError
    result.append(''.join(buf))
    return result

テストも全部通っている。

そろそろカンマを

CSVって言ったのに、なんでカンマのこと無視してるの?え?まずは1要素から?のんびりしてないでさっさとカンマの対応やってよ」

はいはい、じゃあ出現しうる文字にカンマを加えて再度テストを走らせますか。

自動テスト君がまたいくつか質問をしてくる。','は有効な入力か?'a,'は?'ab,'は?

4: for input ',': dfa said False, but target said True
8: for input 'a,': dfa said False, but target said True
24: for input 'ab,': dfa said False, but target said True

それに答えると「長さ10以下の入力でミスマッチは見つからなかったよ、ちなみに16万件くらいテストして5秒くらい掛かったよ」と言ってくる。

no mismatch found in len <= 10 (161404 tests)
python use_bluestar2.py  3.98s user 0.34s system 96% cpu 4.483 total

カンマを含む値のテスト

テストを書き足す。

    >>> parse(',')
    ['', '']
    >>> parse('a,')
    ['a', '']
    >>> parse('ab,')
    ['a,']

それがパスするように実装を書き足す。

        elif c == ',':
            result.append(''.join(buf))
            buf = []

無事テストが通るようになった。めでたしめでたし。

欠けているテスト

もう気づいている勘の良い人もいるかもしれないけど、この実装は'|,|'を入力した時に['', '']を返す。in_quoteのフラグをカンマの無効化に使っていないからね。
「parseに文字列を入れた時に例外が起きるかどうか」に注目して作られたDFAでは、その入力をテストすることが重要だということに気付けないようだ。だって値が間違っているけど成功はちゃんとするから。

def is_target_ok(s):
    try:
        parse(s)
    except:
        return False
    return True

そこで、この述語関数を「パースが成功して、返ってきたリストの長さは1」というものに取り替えてやり直してみよう。実際は今までに作ったテストも走り続けるようにした方がいいんだけど、今回は実験ということで今までのテストを捨てて新しい述語でスタートする。

def is_target_ok(s):
    try:
        assert len(parse(s)) == 1
    except:
        return False
    return True

e_plus = []
e_minus = []

でもこれだとやっぱり'|,|'は発見できないようだ。今までのデータから、自動テスト君は「|の後にaが来なかったらエラーだ」と思い込んでいて、実際'|,'はエラーなので「やっぱりね」と言って先を探索しない。その結果'|,|'が成功することに気付けない。

全探索をさせるといくつか新しいミスマッチを拾ってくる。'a||'とか'|b||'とかいやらしい攻撃をされているけど、期待した'|,|'は来なかったなぁ。

13: for input 'ba': dfa said False, but target said True
3: for input 'b': dfa said True, but target said False
26: for input 'a||': dfa said False, but target said True
182: for input '|ba|': dfa said False, but target said True
46: for input '|b|': dfa said True, but target said False
181: for input '|baa': dfa said True, but target said False
186: for input '|b||': dfa said False, but target said True
190: for input '|bb|': dfa said False, but target said True
194: for input '|b,|': dfa said False, but target said True
no mismatch found in len <= 10 (1398100 tests)
python use_bluestar2.py  38.29s user 2.09s system 84% cpu 47.662 total

全探索で見つかるミスマッチに関しては、興味深いものも含まれているけども、そうでないものも混ざっている印象。
まずは「失敗するだろう」と思っているところ抜きで探索して、それが見つからないときにだけ別途小さいサイズの全探索をするとかがいいのかぁ。

前回との違い

ああ、そうか。前回は入力列がメソッドの呼び出しで、成功失敗判定が「例外が飛ぶかどうか」だったので「ある入力列で例外が飛ぶのであれば、それに何を付け加えた文字列でも例外が飛ぶ」という性質が合ったのだな。

終わりに

本当はおおよそCVSパーサが出来上がってから「え、引用符は引用符でエスケープできるって仕様書に書かなかったっけ?」「先に言えよ!」をやろうと思ったのだが、そこまでたどり着かなかった。