状態のあるコードに対するテストの自動生成 その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
全探索で見つかるミスマッチに関しては、興味深いものも含まれているけども、そうでないものも混ざっている印象。
まずは「失敗するだろう」と思っているところ抜きで探索して、それが見つからないときにだけ別途小さいサイズの全探索をするとかがいいのかぁ。
前回との違い
ああ、そうか。前回は入力列がメソッドの呼び出しで、成功失敗判定が「例外が飛ぶかどうか」だったので「ある入力列で例外が飛ぶのであれば、それに何を付け加えた文字列でも例外が飛ぶ」という性質が合ったのだな。