はじめに
js,tsで単体テストを書くときにJestがテスティングフレームワークとして候補に上がります。
そしてJestならばオプション一つでカバレッジも取得でき、下記のような出力を得られます。
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 85.71 | 62.5 | 100 | 85.71 | |
tester.ts | 85.71 | 62.5 | 100 | 85.71 | 62 |
-----------|----------|----------|----------|----------|-------------------|
が、このカバレッジレポートを見方についてよく理解できていません。
また結果をどのように評価すればよいのかよくわからないまま数値が低いらしい、高いらしいみたいな感想を持っていたりします。
たとえば、StmtsとLinesって何が違うのか?であったあり、Stmtsが85.71であればどうなんだろうか?と疑問になったりしまう。
ということで、coverageとその評価について整理していきたいとおもいます。
要約
-
Coverage指標について
- Stmtsは、C0(ステートメントテスト)を表す指標
- Branchは、C1(デシジョン/ブランチテスト)を表す指標
- Funcsは、定義関数を呼び出してるかどうかの指標
- Linesは、Stmtsで代替でき、後方互換性のための残った指標
-
基本的には、Branches <= Stmts <= Lines <= Funcsが成立する
-
Stmts > Funcsの場合、テストされていない関数が存在すること表す
カバレッジ用語整理
JSTQBテクニカルアナリストシラバスによれば、以下のように整理されます。
用語 | 意味 |
---|---|
ステートメントテスト | コード内のすべての実行可能ステートメントを1回以上テストする |
条件テスト | 全体の判定(判定条件)に注目するのではなく、個々の判定で真偽両方の判定が行われたかどうかに着目 |
デシジョン(ブランチ)テスト | 全体の判定(判定条件)に着目し、それらが真偽両方のパターンを網羅しているかどうかに着目する |
判定条件テスト | 条件テストのカバレッジを満たしながらも、同時にデシジョンカバレッジも満たす |
改良条件判定カバレッジ(MC/DC)テスト | 判定条件テストを満たしながら、個々の条件が真または偽になるときに判定条件が変わるテストも1つ以上含む |
複合条件テスト | 個々の判定に含まれるすべての組合せのテストを行う |
以下、ひとつひとつについて説明します。
-
ステートメントテスト
コード内のすべての実行可能ステートメントを1回以上テストする。if文のような条件文について着目はせず、処理文(命令文)に着目する。
C0
というカバレッジ名で呼ばれる。たとえば、以下の処理を想定する。
if (条件1) { 処理1 } if (条件2) { 処理2 }
処理1と処理2が実行されることを確認すれば良いので、必要なケースは、以下の通り。
条件1 条件2 処理1 処理2 case T T 実行 実行 -
条件テスト
全体の判定に注目するのではなく、個々の判定で真偽両方の判定が行われたかどうかに着目する。
C2
というカバレッジ名で呼ばれる。たとえば、以下のような処理があったとき
if ( A && B ) { 処理1 }
A && B
というif文の全体の条件文に着目せず、AとBという個々の条件についてT/Fの両方をテストされているのかどうかを確認する。
よって、表に整理すると以下のケースを用意する。A B A and B case1 F T F case2 T F F if文の条件文(A && B)のことは、
判定条件
と呼ばれ、一方判定条件を構成する各々の条件(AやB)は、個々の条件
と呼ばれる。条件テスト は、判定条件の真偽両方を確認することはせず、個々の条件について真偽両方のケースを網羅できるようにテストする。
-
デシジョン(ブランチ)テスト
条件テストと異なり、個々の条件ではなく、
判定条件
がT/Fの両方をカバーしてるのか確認するのが、デシジョンテストである。C1
というカバレッジ名で呼ばれる。先と同じ処理で整理すると、以下のケースを用意する。
A B A and B case1 T T T case2 T F F デシジョン(ブランチ)テストでは、
判定条件
で真偽両方を確認しており、個々の条件であるAではTしか確認していない。 -
判定条件テスト
全体の判定(判定条件)に着目し、それらが真偽両方のパターンを網羅しているかどうかに着目する。つまり、
条件テスト
とデシジョン(ブランチ)テスト
の両方を満たす必要があるテストのことである。こちらも先の処理を利用してテストケースを整理すると、以下の通り
A B A and B case1 T T T case2 F F F A and Bという判定条件だけではなく、AやBといった個々の条件でも真偽両方を確認している。
-
複合条件テスト
わかりやすいので、さきに複合条件テストについて説明する。個々の判定に含まれるすべての組合せのテストを行い、個々のケースがn個あるとすると2n個ケースが存在する。
以下のようなケースを考える。
if ( (A || B) && C ) { 処理1 }
個々の条件には、AとB,CがありそれぞれT/Fの2パターン存在するので、23 = 8つのケースが存在する。
A B C (A or B) and C case1 T T T T case2 T T F F case4 T F T T case3 T F F F case4 F T T F case5 F T F F case6 F F T F case7 F F F F 全通りを調べる数学みたいな話ですね。しかしこれはケース数が莫大になるために、もう少し負担を下げるテストとして、
改良条件判定カバレッジ(MC/DC)
がある。 -
改良条件判定カバレッジ(MC/DC)テスト
判定条件テストを満たしながら、個々の条件が真または偽になるときに判定条件が変わるテストも1つ以上含む。個々の条件について、真あるいは偽にあることで判定条件も変わるようなテストを含むテストのこと。
複合条件で利用した処理について考えると、以下のようにケースを整理できる。
A B C (A or B) and C case1 T F T T case2 F T T T case3 F F T F case4 T F F F case1とcase3をみると、case1では、A = Tで、case3では、A = F とAを変更するのに対して、それ以外の条件(B, C)は固定する。それによって、判定条件も変更してるケースである。
同じようにcase2とcase3で、B以外を固定し、またcase1とcase4で、C以外を固定し、判定条件の真偽両方を確認している。
通常,N個の個々の条件が存在するとき、N+1個のテストケースができる。2 ^ N >= N + 1 (n = 1のとき同値)となるので、 MC/DC は複合条件テストよりテストケースを削減できる。
Jestのcoverageの整理
では、いよいよJestをつかって先ほどまで整理したcoverageの用語の何に対応するのかを見ていきます。
Stmtsはなんなのか?
名前から分かる通りステートメントカバレッジつまりC0のことを指していると推測できる。
ホワイトボックステストにおけるカバレッジ(C0/C1/C2/MCC)についてで紹介されている関数を利用してカバレッジをみる。
export function t(a1, a2, b1, b2) {
let result = [];
if(a1 || a2){ // 判定条件A
result.push('A'); // 命令1
}
if(b1 || b2){ // 判定条件B
result.push('B'); // 命令2
} else{
result.push('~B'); // 命令3
}
return result;
}
C0を満たすには、参考サイトに従って次のパターンでよい。
case | a1 | a2 | b1 | b2 | 命令1 | 命令2 | 命令3 |
---|---|---|---|---|---|---|---|
1 | T | F | T | F | T | T | F |
2 | T | F | F | F | T | F | T |
describe('t関数のStmts100%', () => {
it('Case1', () => {
assert.deepEqual(['A', 'B'], t(1, 0, 1, 0));
});
it('Case2', () => {
assert.deepEqual(['A', '~B'], t(1, 0, 0, 0));
});
});
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 100 | 75 | 100 | 100 | |
tester.ts | 100 | 75 | 100 | 100 | 58 |
-----------|----------|----------|----------|----------|-------------------|
たしかにStmtsが100%になってる。
次にC0満たさない場合も同様にStmtsが満たさないことも確認してみる。
describe('t関数のStmts not 100%', () => {
it('Case1', () => {
assert.deepEqual(['A', 'B'], t(1, 0, 1, 0));
});
it('Case2', () => {
assert.deepEqual(['A', 'B'], t(1, 0, 0, 1));
});
});
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 87.5 | 62.5 | 100 | 87.5 | |
tester.ts | 87.5 | 62.5 | 100 | 87.5 | 65 |
-----------|----------|----------|----------|----------|-------------------|
こちらもC0を満たさないならば、Stmtsも100%に達しない。
つまり、Stmtsとは名前のとおり、Statement Coverage = C0のカバレッジを表してるといえる。
stmtsのパーセントはどのように導かれただろうか。生成されたhtmlでは下図を確認できる。
全部で8つの宣言があり、達成できなかった1つというのは、 result.push(~B)
の命令だとわかる。
では残り7つはなんだろうか?
export function t(a1, a2, b1, b2) { // 1 exportされた場合カウントする
let result = []; // 2 let, var, const
if(a1 || a2){ // 3 個々の条件はカウントしない
result.push('A'); // 4
}
if(b1 || b2){ // 5 個々の条件はカウントしない
result.push('B'); // 6
} else{
result.push('~B'); // 7(通過していない)
}
return result; // 8
}
Stmtでカウントされるのは、通常の命令文以外に,変数宣言、exportした関数定義、return文、条件文が含まれる。
後述するが、exportしない関数は、stmtとしてカウントされない。
Branchとはなにか?
Branchも整理した用語と同じ意味ならば、デシジョン/ブランチテスト(C1)のことを指すであろうということでしらべてみる。
先程同様にC1に必要なテストケースは以下通り。
case | a1 | a2 | b1 | b2 | 判定条件A | 判定条件B | 命令1 | 命令2 | 命令3 |
---|---|---|---|---|---|---|---|---|---|
1 | T | F | F | F | T | F | 実行 | - | 実行 |
2 | F | F | T | F | F | T | - | 実行 | - |
describe('t関数のBranchカバレッジ100%', () => {
it('Case1', () => {
assert.deepEqual(t(1, 0, 0, 0), ['A', '~B']);
});
it('Case2', () => {
assert.deepEqual( t(0, 0, 1, 1), ['B']);
});
});
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 100 | 100 | 100 | 100 | |
tester.ts | 100 | 100 | 100 | 100 | |
-----------|----------|----------|----------|----------|-------------------|
予想通り、Branchは100%となった。
仮に 条件テスト
を意味すると考えてみれば、テストケースは次の通り。
case | a1 | a2 | b1 | b2 | 判定条件A | 判定条件B | 命令1 | 命令2 | 命令3 |
---|---|---|---|---|---|---|---|---|---|
1 | F | T | F | T | T | T | 実行 | 実行 | - |
2 | T | F | T | F | T | T | 実行 | 実行 | - |
describe('t関数の条件テスト', () => {
it('Case1', () => {
assert.deepEqual(t(1, 0, 1, 0), ['A', 'B']);
});
it('Case2', () => {
assert.deepEqual(t(0, 1, 0, 1), ['A', 'B']);
});
});
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 87.5 | 75 | 100 | 87.5 | |
tester.ts | 87.5 | 75 | 100 | 87.5 | 65 |
-----------|----------|----------|----------|----------|-------------------|
条件テストが100%でも、Branchは100%にならない。よって、Branchが指すカバレッジも予想通り、C1となった。
しかし、今回は気になるところが登場する。Stmtsのときのようにhtmlの結果をみてみる。
6/8とはなにか?
分母の8は、a1 ~ b2まで4つの個々の条件におけるT/Fであろうと想像できる。
が分子はどうやってカウントしたのだろうか?
この数値の意味は未だ不明なので、istabulのソースを読んで理解してみようと思う。
Functionとはなにか?
exportされてるクラス、関数を考える。以下4パターンを考える
- export function
- export function + not export function
- export class
- export function + not export class
いずれのパターンにせよ、ファイルで定義された関数、メソッドがFuncsとしてカウントされる。exportの有無によって、Stmtsのカウント数が異なってくる。
-
export functionパターン
export const h = () => { return 'h'; }; export function g() { return 'g' }
describe("func", () => { it('関数宣言したhを呼び出し', () => { assert(func.h() === 'h'); }); it('関数定義したgを呼び出し', () => { assert(func.g() === 'g'); }); });
----------|----------|----------|----------|----------|-------------------| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | ----------|----------|----------|----------|----------|-------------------| All files | 100 | 100 | 100 | 100 | | func.ts | 100 | 100 | 100 | 100 | | ----------|----------|----------|----------|----------|-------------------|
もしひとつの関数を呼び出さないと、
Funcs
は下がる。describe("func", () => { it('関数宣言したhを呼び出し', () => { assert(func.h() === 'h'); }); });
----------|----------|----------|----------|----------|-------------------| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | ----------|----------|----------|----------|----------|-------------------| All files | 75 | 100 | 50 | 75 | | func.ts | 75 | 100 | 50 | 75 | 8 | ----------|----------|----------|----------|----------|-------------------|
全部で2つの関数があって、そのうちひとつの関数しか呼び出していないので、1/2 = 50%となる。
また75%であるStmtsに注目すると、exportをひとつの宣言としてカウントし、また呼び出してはいないが通過したと判定されている。
- export function + not export function
次は、exportされていない関数を含む場合、どうなるか考えてみる。
export const h = () => {
return 'h';
};
function g() {
return 'g'
}
describe("func", () => {
it('関数宣言したhを呼び出し', () => {
assert(func.h() === 'h');
});
});
----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files | 66.67 | 100 | 50 | 66.67 | |
func.ts | 66.67 | 100 | 50 | 66.67 | 8 |
----------|----------|----------|----------|----------|-------------------|
今回Stmtsは66%になってる。exportしていないと1Stmtsとしてカウントされないので、分母も3と減っている。またimport時にg関数は呼びだされることもないので、分子側も減る。こういうわけで、exportの有無によってStmtsの値が変わってくる。
次のようにすると、100%となる。
export const h = () => {
const result = g();
return `${result} and h`;
};
function g() {
return 'g'
}
----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files | 100 | 100 | 100 | 100 | |
func.ts | 100 | 100 | 100 | 100 | |
----------|----------|----------|----------|----------|-------------------|
-
export class
つぎにclassとそのメソッドについて調べてみる
export class Person { private name; public constructor(name: string) { this.name = name; } public callName(): string { return this.name; } }
describe("import class", () => { it("constructor", () => { const p = new Person("adam"); assert(p instanceof Person); }); it("call", () => { const p = new Person("adam"); assert(p.callName() === "adam"); }); });
----------|----------|----------|----------|----------|-------------------| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | ----------|----------|----------|----------|----------|-------------------| All files | 100 | 100 | 100 | 100 | | func.ts | 100 | 100 | 100 | 100 | | ----------|----------|----------|----------|----------|-------------------|
下図を確認すると、コンストラクタとメソッドを足した数ががFunctionsの分母となる。
-
export class and not export class
まったく意味はないが、exportされないクラスManをつくってみる。そして、先程とおなじテストを実行する。
export class Person { private name; public constructor(name: string) { this.name = name; } public callName(): string { return this.name; } } class Man extends Person { private sex; public constructor(name: string) { super(name); this.sex = 'M'; } }
ご覧の通り、PrivateであってもManクラスのコンストラクタが呼びだれないので、100%にならない。
----------|----------|----------|----------|----------|-------------------| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | ----------|----------|----------|----------|----------|-------------------| All files | 60 | 100 | 66.67 | 60 | | func.ts | 60 | 100 | 66.67 | 60 | 18,19 | ----------|----------|----------|----------|----------|-------------------|
そしてfuncitonのときと同様、exportされていないのっで、Manクラスは、Stmtとしてカウントされない。よって、Stmtsの値もexportありなしで変わってくる。
Linesとはなにか?
Linesは、行数しかみないのに対して、Stmtは宣言数をみる。ほぼほぼLines = Stmtとなるが、以下の場合、異なる。
function test() {
const a = 1; const b = 2; // Line:1, Stmt: 2
return a * b;
}
a, bの2つの変数定義してるので、Stmtは2となるが、コードでは1行で記述されているので、Linesは1となるだから、Stmtを使ったほうがより正確なカバレッジ値となる。
なぜLinesがあるのかというとLines指向カバレッジツール(lcov)と相互運用させるため、YUIカバレッジとの下位互換性を保つためである。
https://github.com/gotwarlost/istanbul/issues/639
カバレッジ結果の評価について
カバレッジについて調べると、C1(Branch)が100%になれば、C0(Stmts)もかならず100%となる。C0 >= C1という関係がある。
よって、Jestの場合、Stmts >= Branchという関係になる。さらに、Linesで説明したとり、Lines >= Stmtsという関係になる。
ということで、 Lines >= Stmts >= Branch
がカバレッジ結果で必ず成り立つわけだ。たとえば、これを利用してStmtsが60%であることを品質基準として設定することができる。逆にStmtsを60%に、Branchを70%になるように目指すというの品質基準を設定するというのはできない。
ではFunctionは上3つとの間でどのような関係をもつのだろうか?基本的には、 Function >= Lines >= Stmts >= Branch
がなりたつ。なぜならば、定義してる関数を1回呼び出だすだけで、Functionは1カウントされるが、
Line(Stmts)は条件文があると複数回関数呼び出す必要があるためである。
逆にもしFunction <= Stmtsとなるときというのは、一度もテストされていない関数があるということを意味している。
上記であげたテクニカルアナリストによれば、各カバレッジの適用基準について以下のような説明がある。
これらはすべてC1以上のレベルを要求されるテストである
テスト | 適用 |
---|---|
判定条件テスト | 重要ではあるが、クリティカルではないとき |
改良条件判定カバレッジ(MC/DC)テスト | 航空宇宙ソフトウェア業界、およびその他多くのセーフティクリティカルなとき |
複合条件テスト | 組み込みソフトウェアで使用されてきたが、MC/DCで置き換えられる |
また知識ゼロから学ぶソフトウェアテスト では、カバレッジ基準を60~90%程度と説明されています。この数値は、StmtsとBranchの平均値なのでしょうかね?
いずれにせよクリティカルであったりリスクが高い場合はカバレッジ値を高めにせよということであろう。残念ながら、一般化できるような明確な基準はないのであろう。
さいごに
Jestが出力するカバレッジレポートの見方がわからなかっため、まず一般的なカバレッジ用語の整理しました。
そしてどの用語がJestのレポートに対応するのかを整理しました。
基本的には、 Function >= Lines >= Stmts >= Branch
がなりたち、各々状況に応じて基準を設ければよいのであろう。
Branchの分母分子の算出方法について不明なところは、今後しらべる。
個人的にはカバレッジは、以前のmasterよりも相対的に低下した場合をキャッチできればよいのかなと感じてます。
100%達成なんてコストが高すぎるし、基準も60%以上のように曖昧だったりします。
なのでカバレッジを導入していくには、機能追加や保守においてカバレッジを下げないかどうかを判断するのに使っていくのがよいのかなと感じてます。
その先に落ち着く平均値が組織ごとに見えてくるのではないかなと感じてます。
カバレッジを高めても、仕様通りに実装されてるかどうかは確認できないというのがホワイトボックスの弱点です。
それを補うのがブラックボックスにテストなので、両方を目的とリソース状況にバランス良く取り入れていくのがテストには必要なんでしょう。
ともあれ、テストでは同じような言葉が登場するので、その言葉が指している意味がなんなのかを理解しないと混乱するなとときに感じます。
ひとや組織によって、結合テストといっても、何を指してるのか違ったりします。
今回もC0, C1といった言葉や様々なカバレッジの用語が出てきましたので、パッと理解できなくて困ったものです。
そういうときは、具体例を示して考えることが重要なんだなと感じてます。
以上最後までありがとうございます。