前置き
業務の中で少し自動テストに触れたことがあったのですが、
外部勉強会にてiOSの自動テストに関するものが出てきたので、
もうちょっと自動テストについて深く理解しようかなと思い、参加してみました。
イベント概要
こちらのイベントに参加しました。
https://testnight.connpass.com/event/330064/
tarappoさん(株式会社SmartHR)の発表でした。
https://x.com/tarappo?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor
内容としては、自動テストそのもののノウハウというよりは、ミューテーションテストに関するものがメインになっていて、テストコードを例に出して「これって本当にテストケース足りてますか?カバレッジは100%になってしまうが実際は足りてないよね。その理由は〜」といった感じで説明がありました。
とはいえ、自動テストの中でミューテーションテストを行う場合、特にiOSの自動テストにおいては実行時間が課題になるが〜で工夫をすることもできるという感じで自動テストに関することも触れられていました。
どういった自動テストを用意したらいいのか、このテストは本当に価値を発揮できているかという不安を解消するための手段として、「ミューテーションテスト」というものをおススメしている内容でした。
そこで、学んだ内容を改めて自分でも整理してみたり、実際にミューテーションテストをやってみたりしてみました。
ミューテーションテストとは
「テストスイートの完全性を判定する手法の一つ。プログラムのわずかな変形(変異)をテストスイートがどの程度識別できるかを測定する。」といった内容で、テストコードの十分さを測定するための手法
この記事にも詳しく載っています
https://iikanji.hatenablog.jp/entry/2020/11/05/234912
コードカバレッジの数値でこれだけあるから問題ないかとすぐに判断してはいけないといった感じで、ミューテーションテストはここ1~2年の間で話題になっているテスト手法らしいです。
また、mutant(変異)のパターンとして以下のようなものがある
- AOR Arithmetic operator replacement
- a + b を a, b, a-b, a*b, a/b, a%b のどれかにランダムで書き換える
- LCR Logical connector replacement
- a && b を a, b, a||b, true, false のどれかにランダムで書き換える
- ROR Relational operator replacement
- a > b を a=b, true, false のどれかにランダムで書き換える
- UOI Unary operator insertion
- a を a++, a--, !a のどれかにランダムで書き換える
- SBR Statement block removal
- statement blockを空に書き換える
※引用
https://iikanji.hatenablog.jp/entry/2020/11/05/234912
説明を聞いている時の心の中
勉強会の中で変異の話が出てきた直後、「??」となりました。算術演算子や不等号とかを一時的に変えてどんなテストになるんだ、、、本来行われている処理のテストにならないんじゃないの...?なんやこれ。。。と最初は思いました。
しかし、話を聞いていると、以下のように理解できた。
「ありえない処理」を意図的に入れることで、テストケースがそれを検出できるかどうかを確認する。例えば、「age < 0」が「a <= 0」に自動で変換されたにも関わらずテストが通ってしまったら、「-1」だけでなく0を指定する場合のテストケースも追加しなきゃダメだったなと分かる。
※ちなみに、変異は手動ではなくライブラリ側で自動で入るので安心してください(安村)
ミューテーションテストをやってみる
※iOSアプリの開発で使うswiftコードのミューテーションテストとしてmuterというものがあるのですが、実行時間がかなり長いのとミューテーションスコアの結果がこれから紹介するライブラリのものよりもちょっt分かりづらいものだったというのとミューテーションスコアの表示にバグっぽいものがあったので今回は省略とさせてください。
言語
TypeScript
ライブラリ
Stryker
コード
テスト対象コード
年齢と電子マネーの残高が引数で、お酒の購入チャレンジをします。
成年年齢は18歳に引き下げられたものの、お酒は変わらず20歳からです。
0より小さい年齢や電子マネー残高は指定できなかったり、未成年なのに買おうとしたらペナルティがあったりするやばい仕様です。
function checkAgeAndBalance(age: number, balance: number): number {
const ALCOHOL_PRICE = 500;
const MIN_LEGAL_AGE = 18;
const LEGAL_DRINKING_AGE = 20;
const SENIOR_AGE = 60;
if (age < 0 || balance < 0) {
return balance;
}
if (balance < ALCOHOL_PRICE) {
return balance;
}
if (age < MIN_LEGAL_AGE) {
return 0;
}
if (age >= MIN_LEGAL_AGE && age < LEGAL_DRINKING_AGE) {
return balance / 2;
}
if (age >= SENIOR_AGE) {
return balance - (ALCOHOL_PRICE / 2);
}
return balance - ALCOHOL_PRICE;
}
export default checkAgeAndBalance;
テストコード
境界値がちょっと甘めのテストです。
import checkAgeAndBalance from './aichi';
describe('checkAgeAndBalance関数のテスト', () => {
it('年齢または残高が0未満の場合、残高をそのまま返すこと', () => {
expect(checkAgeAndBalance(-1, 500)).toBe(500);
expect(checkAgeAndBalance(20, -1)).toBe(-1);
expect(checkAgeAndBalance(-1, -1)).toBe(-1);
});
it('残高がアルコールの価格未満の場合、残高をそのまま返すこと', () => {
expect(checkAgeAndBalance(20, 400)).toBe(400);
});
it('年齢が18歳未満の場合、0を返すこと', () => {
expect(checkAgeAndBalance(17, 1000)).toBe(0);
});
it('年齢が18歳以上20歳未満の場合、残高の半分を返すこと', () => {
expect(checkAgeAndBalance(18, 1000)).toBe(500);
expect(checkAgeAndBalance(19, 1000)).toBe(500);
});
it('年齢が60歳以上の場合、シニア割引適用で残高から250を引いた値を返すこと', () => {
expect(checkAgeAndBalance(60, 1000)).toBe(750);
expect(checkAgeAndBalance(70, 1000)).toBe(750);
});
it('年齢が20歳以上59歳以下の場合、残高から500を引いた値を返すこと', () => {
expect(checkAgeAndBalance(20, 1000)).toBe(500);
expect(checkAgeAndBalance(59, 1000)).toBe(500);
});
});
ミューテーションテストの結果
実行時間が爆速だった。muterとは比べ物にならなかった。
あと、レポート結果がめっちゃ分かりやすい。
このように、ミューテーションのスコアやカバレッジと共に、変異がどれだけkillできたのかをレポートしてhtml形式で出力してくれます。
「Survived」がkillできず生き残った変異の数です。赤い丸の数になります。
宇宙人のマークしているので、これが敵ということです。(muterでゾンビのようなものでした)
この赤い丸をクリックすると、どのような変異だったのかを教えてくれます。
この例だと、「<」を「<=」に変えてみたのにテストが通ってしまった(killできなかった)ということになるので、この場合だとageに0を指定するようなケースがなかったということが分かります。
ということで、以下の2つとりあえず追加してみました
expect(checkAgeAndBalance(0, 500)).toBe(0);
expect(checkAgeAndBalance(20, 0)).toBe(0);
すると、、、スコアが「74.36」から「79.49」上がり、先ほどのif文のところにkillされていなかった変異が1つ減りました。
このように、変異を1つ1つ減らしていくことで、だんだんと網羅されたテストケースになっていきます。
Xcodeでテストした場合
ちなみに、iOSアプリ開発のための統合開発環境であるXcodeでテスト実行した場合、あえて境界値のテストを一部取り除いてもカバレッジは100%になってしまいます。
すべての命令文が少なくとも1回以上テストされれば、ステートメントカバレッジ100%を達成できてしまう
※一度もテストコード実行時にチェックしていないif文が存在していたらカバレッジは下がります。
なので、「とりあえずカバレッジが100%だから」は良くないということが分かる。
ミューテーションテストのデメリット
ミューテーションテストがなぜそこまで使われてこなかったのか、勉強会の中でその理由を知った。
ミューテーションテスト自体の歴史は浅くないが、実行時間が課題になっているようだった。
-
実行時間の長さ
- 自動テストを考えたとき、変異が入る→ビルドする→自動テストセットアップ→自動テスト実行→再び違う変異入れる→ビルド→・・・という感じで、変異の数が多いほど実行時間は増えていってしまうので、この実行時間が課題になってしまう
- iOS向けだと先ほどのmuterがあるが、muterが取っているアプローチは、ビルドとテスト実行を分離したり、起動時環境変数を使って、できるだけ早くするようにしている。それでもそこまで時間短縮にはなっていないらしい
- でも、クラウドのマシンを使うという技もありなので、使う例がポツポツ出てきているらしい。iOSに関してはミューテーションテストできなくはないが時間はめっちゃかかる。これは勉強会発表者がおっしゃっていた
-
実行時間の長さに対する対策
- テストの実行対象を絞る
- 実行対象外のファイルはテストしないように設定する(実行対象だけテストするとか)
- 設定で変異パターンを絞る
- テストの実行対象を絞る
ミューテーションテストの注意点
- お金が吹っ飛ぶことがある
- GitHub Actionsとか従量課金制だった場合、ミューテーションテストをガンガン動かしていたら、いくら便利でもお金が吹っ飛ぶ。数十万普通に飛んでくのがiOSの世界線なので、いつどこで実行するか考える必要があるとのことだった
- どんなに頑張ってもミューテーションスコアが100%にならない場合がある
- 「等価ミューテーション」といって、変異させても結果が変わらないケースがある
-
https://note.com/tarappo/n/n85122ed1d565#:~:text=%E3%81%8D%E3%81%9F%E3%81%A8%E3%81%8A%E3%82%8A%E3%81%A7%E3%81%99%E3%80%82-,%E7%AD%89%E4%BE%A1%E3%83%9F%E3%83%A5%E3%83%BC%E3%83%86%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3,-%E3%82%82%E3%81%97%E3%81%8B%E3%81%97%E3%81%9F%E3%82%89%E6%B0%97
- このような等価ミューテーションに対して対策をする例がある
- 某所では、等価ミューテーションをなるようなものを今後検知しないみたいなツールを作る場合もあるらしいが、一般的にOSSではそういうことができるわけじゃ無いから自分たちで頑張るしかないらしい
- このような等価ミューテーションに対して対策をする例がある
- ミューテーションスコアが100%だから必ずしも良いわけじゃない
- どのような変異が入るかはミューテーションテストのライブラリ側の実装に依存する
- OSSをちゃんとみる必要がある。
- どのような変異が入るかはミューテーションテストのライブラリ側の実装に依存する
ミューテーションテストのメリット
- テスト漏れに気づきやすい
感想
新人の方もテストコード作る時に役に立ちそう