私は普段、フロントエンドをメインで開発しています。
突然ですが、この場を借りて懺悔させてください。私は今まで、ソフトウェアテストをおざなりにして生きてきました。すみませんでした。
今回機会をいただき、改めてテストについて学んだので、記事にしてみたいと思います。
間違い等ありましたら、(優しく)ご指摘いただけると嬉しいですw
基本的な知識
なぜテストが必要なのか
ソフトウェアテストでは、主に次のような項目を確認するという目的があります。
-
正当性確認(Verification)
ソフトウェアが正しく作られていることを確認する -
妥当性確認(Validation)
ソフトウェアが目的通りに作られていることを確認する
テストの種類
ソフトウェアテストは、大きく分けて次の2種類に分けられます。
単体テスト(Unit Testing)
クラスや関数など、モジュール単位で行うテスト。内部構造が正しいことを確認し、不具合を発見する。
このテストで確認すること:
- デットコードの有無
- 条件分岐やループ、例外処理の正当性
- 変数のライフサイクル
結合テスト(Integration Testing)
単体テストで検証したモジュール同士を結合して行うテスト。
ソフトウェアの機能として正しく動作するかを確認することになる場合が多いので、機能テスト(Fanctional Testing)とも呼ばれる。
このテストで確認すること:
- モジュールの呼び出し
- データの入出力
テスト方法の分類
テストを行う方法の種類も、大きく分けて2種類に分けることができます。
ホワイトボックステスト
モジュールの内部構造を把握しながらテストを行う手法。 中身が見える箱という意味で、Glass-Box Testingとも呼ばれる
この方法で行われるテスト:
ブラックボックステスト
モジュール内部の構造は考慮せず、入出力だけでテストを行う手法。
この方法で行われるテスト:
いろいろなテスト
制御フローテスト(Control Flow Testing)
一つの処理に対して、コードがどのように実行されていくかを検査する。
このテストでわかること:
- 実行されていない処理があるかどうか
カバレッジ(Coverage)
特定の条件について、コード全体のうちどれだけ網羅してテストできているかの割合。
- 命令網羅率(Statement Coverage : C0)
- 全ての「命令分」のうちテストした割合
- 分岐網羅率(Branch Coverage : C1)
- 全ての「分岐」のうちテストした割合
- 条件網羅率(Condition Coverage : C2)
- 全ての「条件パターン」のうちテストした割合
C1はC0の基準を、C2はC1の基準が含まれている(例: 全ての条件パターンを網羅する(=C2を100%にする)ためには、全ての分岐を網羅する(=C1を100%にする)必要がある)
そのため、C0よりもC1、C1よりもC2の方がテスト回数が多くなる。
データフローテスト(Data Flow Testing)
プログラムで使われるデータや変数のライフサイクルを検査するテスト。
このテストでわかること:
- 定義、利用、消滅のライフサイクルになっているかどうか
- 未使用・未消滅の場合 → メモリ不足やエラーが
- 未定義の場合 → 無駄なデータが残り、メモリ不足や予期しないエラーが発生する
境界値分析(Boundary Value Analysis)
条件が変わる境目になる値を分析して、その値を入力して検査するテスト。
主に、次の項目に当てはまる値を用いる。
- 条件にある値(境界値)
- 境界値に最も近く、かつ条件が真になる値
- 境界値に最も近く、かつ条件が偽になる値
このテストでわかること:
- 条件式などの境界が正しく指定され、動作しているかどうか
同値分割(Equivalence Partitioning)
各条件に当てはまる代表値を入力して検査するテスト。一般的に中央値を使用する。
冗長なテストケースを省き、効率良くテストを実施するための手法。
このテストでわかること
- 条件式などの境界が正しく指定され、動作しているかどうか
ランダムテスト(Random Testing)
(仕様の条件内のうち)自動生成されたランダムな値を入力して検査するテスト。
あくまで補助的なテスト手法。
実際にテストしてみる
知識は一通り並べたので、次は実際に利用してみます。
今回はNextjsを使い、普段慣れ親しんだフロントエンドに加えて、簡単なAPIを使ったサーバーサイドのテストも書いてみます。
- 今回使うパッケージ(ざっくり)
- next.js
- react
- typescript
- jest ... テスティングフレームワーク
- enzyme ... reactのテストコードをかけるテストユーティリティ
1. 関数のテスト
一般的なユニットテストがこれに該当するんだと思います。
今回は簡単な関数としてfizzbuzzの関数を作ってテストします。
/**
* fizzbuzzを返す関数
*
* @param num 引数
* @returns
*/
export const fizzbuzz = (num: number) => {
if (num % 3 === 0 && num % 5 === 0)
return "Fizz,Buzz"
else if (num % 3 === 0)
return "Fizz"
else if (num % 5 === 0)
return "Buzz"
else // numが3の倍数でも5の倍数でもない
return null
}
import { fizzbuzz } from "../../libs/fizzbuzz";
describe("Test FizzBuzz", () => {
it(`becomes Fizz: 3`, () => {
const result = fizzbuzz(3)
expect(result).toEqual(`Fizz`);
});
it(`becomes Buzz: 5`, () => {
const result = fizzbuzz(5)
expect(result).toEqual("Buzz");
});
it(`becomes Fizz,Buzz: 15`, () => {
const result = fizzbuzz(15)
expect(result).toEqual("Fizz,Buzz");
});
it(`not match: 19`, () => {
const result = fizzbuzz(19)
expect(result).toEqual(null);
});
});
そして、実際にjestを走らせてみると……
通りました!
これで、関数fizzbuzz
が正常に動作していることがわかります。
このテストを基本的な知識であげたテストの種類に当てはめるなら、境界値分析になるのだろうか? 🤔
少なくともテストに際して関数内部の構造は関係しないので、ブラックボックステストになりますね。
2. APIのテスト
サーバーサイドエンジニアの方にとっては当たり前ですかね。
流石の自分も、エンドポイントのテストは書いたことがあります。
先程のfizzbuzz関数を使ったエンドポイントを作って、テストしてみます。
import { NextApiRequest, NextApiResponse } from "next"
import { fizzbuzz } from "../../../libs/fizzbuzz"
export default (req: NextApiRequest, res: NextApiResponse) => {
const num = req.query.num
if (typeof num === 'number') {
const result = fizzbuzz(parseInt(num))
res.statusCode = 200
res.json({ result })
} else {
res.statusCode = 400
res.json({error: `Bad request`})
}
}
// @reference https://seanconnolly.dev/unit-testing-nextjs-api-routes
import { createMocks } from 'node-mocks-http';
import handleFizzBuzz from '../../../pages/api/fizzbuzz/[num]';
describe('/api/fizzbuzz/[num]', () => {
test(`becomes Fizz: 3`, async () => {
const { req, res } = createMocks({ method: 'GET', query: { num: 3 } });
await handleFizzBuzz(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({ result: 'Fizz' }),
);
});
test(`becomes Buzz: 5`, async () => {
const { req, res } = createMocks({ method: 'GET', query: { num: 5 } });
await handleFizzBuzz(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({ result: 'Buzz' }),
);
});
test(`becomes Fizz,Buzz: 15`, async () => {
const { req, res } = createMocks({ method: 'GET', query: { num: 15 } });
await handleFizzBuzz(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({ result: 'Fizz,Buzz' }),
);
});
test(`not match: 8`, async () => {
const { req, res } = createMocks({ method: 'GET', query: { num: 8 } });
await handleFizzBuzz(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({ result: null }),
);
});
test(`Bad request`, async () => {
const { req, res } = createMocks({ method: 'GET', query: { num: 'not number' } });
await handleFizzBuzz(req, res);
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({ error: 'Bad request' }),
);
});
});
これで実行してみると、、通りました!
エンドポイントのテストも、内部の構造は把握していないのでブラックボックステストに該当するのでしょうか。
3. DOMのテスト
次は、React寄りのテストをやってみます。
DOMのテストは、コンポーネントのレンダリング結果にあるDOMの要素を調べるテストです。
わかりやすい簡単なReactのコードでやっていきます。
import React from "react";
import Head from "next/head";
const index = () => (
<>
<Head>
<title>Hello Jest</title>
</Head>
<p>Test code practice</p>
</>
);
export default index;
これに対するテストコードが、これ↓
import { shallow } from "enzyme";
import React from "react";
import App from "../pages/index";
describe("Test Index.tsx", () => {
it('App shows "Test code practice" in a <p> tag', () => {
const app = shallow(<App />);
// 出力結果の中からpタグを探して、DOMのテキストが期待する値と一致するか検証する
expect(app.find("p").text()).toEqual("Test code practice");
});
});
次は条件によって出力が変化するコンポーネントをテストしてみます。
import React from 'react'
import { fizzbuzz } from '../libs/fizzbuzz'
export const FizzOrBuzz = ({num}:{num: number}) => {
const str = fizzbuzz(num) ?? '---'
return (
<p>{`${num}: ${str}`}</p>
)
}
import { shallow } from "enzyme";
import React from "react";
import {FizzOrBuzz} from "../../components/FizzOrBuzz";
describe("Test FizzOrBuzz in <p> tag", () => {
it(`becomes Fizz: 3`, () => {
const app = shallow(<FizzOrBuzz num={3} />);
expect(app.find("p").text()).toEqual(`3: Fizz`);
});
it(`becomes Buzz: 5`, () => {
const app = shallow(<FizzOrBuzz num={5} />);
expect(app.find("p").text()).toEqual(`5: Buzz`);
});
it(`becomes Fizz, Buzz: 30`, () => {
const app = shallow(<FizzOrBuzz num={30} />);
expect(app.find("p").text()).toEqual(`30: Fizz,Buzz`);
});
it(`not match: 8`, () => {
const app = shallow(<FizzOrBuzz num={8} />);
expect(app.find("p").text()).toEqual(`8: ---`);
});
});
こちらも、うまくテストを通すことができました!
今回は簡単な文字列だけでしたが、もっと細かくDOMのテストを行う場合は、出力されるDOMの構成要素をあらかじめ知っている必要があるので、ホワイトボックステストになりそうですね。
基本的な知識であげたテストの種類に当てはめるなら、制御フローテストになるのだろうか? 🤔
4. カバレッジ
Jestには、カバレッジを算出してくれる機能があります。
今まで作ったコードとテストを使ったカバレッジをみてみましょう。
Stmts
は命令網羅率(Statement Coverage): C0、Branch
は分岐網羅率: C1。ここまでは上で学んだ情報通りですね。
残りのFuncs
は、関数網羅率(Function Coverage)、定義された関数の網羅率を表しています。
Lines
は、行数網羅率(...とでもいうんでしょうか?)で、ソースコードの実行可能行の網羅率とのことです。
今まで書いたコードには全てテストを書いていたので、網羅率が100%になっていますね。
ここで、試しに使っていないコードを追加してみます。
...
// 引数に与えた数字が、fizzbuzzに一致する数字か判定する関数
export const isFizzBuzz = (num: number) => {
return fizzbuzz(num) !== null
}
追加した状態で再度テストを実施してみると……
お。変化がありましたね。
fizzbuzz.ts
のC0が91.67%になって、Funcsが50%になっています。
fizzbuzz.ts
にはfizzbuzz
とisFizzBuzz
の2つの関数が宣言されており、前者はテストコードがあるので、半分の50%だけ実施したよ、ということが書かれていますね。
Uncovered Line
には、実施されていないコードの行番号が書かれています。
今度は、isFizzBuzz
関数について追加のテストコードを書いてみましょう。
describe("Test isFizzBuzz", () => {
const trueValue = [3, 5, 6, 9, 10, 12, 15]
const falseValue = [1, 2, 4, 7, 8, 11, 13]
trueValue.forEach((v) => {
it(`becomes True: ${v}`, () => {
const result = isFizzBuzz(v)
expect(result).toEqual(true);
});
})
falseValue.forEach((v) => {
it(`becomes False: ${v}`, () => {
const result = isFizzBuzz(v)
expect(result).toEqual(false);
});
})
});
5. スナップショットテスト
フロントエンド開発に特化したテストとして、スナップショットテストというものがあります。
これはレンダリング結果をスナップショットとして記録しておき、差分がないかを比較するテストです。
フロントエンドはUI部分が重要になるので、スナップショットテストを使うことで、UIの予期せぬ変更による表示崩れを防ぐことができます。
スナップショット用のテストを追加してみます。
import { shallow } from "enzyme";
import React from "react";
import renderer from 'react-test-renderer';
...
it('Snapshot test', () => {
const tree = renderer.create(<App />).toJSON();
expect(tree).toMatchSnapshot()
});
});
初めて実行すると、スナップショットが書き出されます。
その後、ソースコードの一部を書き換えて、再度テストを実行してみました。
違いは、practices
を付けただけ。人力では見逃してしまいそうな変更でも、テストはしっかり反応しています。
Snapshotの方も、差分を検知してくれていますね。
まとめ
今回はソフトウェアテストに関しての基礎知識を勉強した上で、実際にJestを使ってJavaScriptのテストコードを書いてみました。
テストを全く書いたことがない!ってわけではないんですが、なんとなく「書けって言われたので……」と書いていた自分。
今回、なぜテストを行うのか、**テスト方法の分類**や、それぞれのテストで何がわかるのかについて調べて、ソフトウェアテストを行うことで、一つのコードでもいろいろな角度から見直すことができるんだなと感じました。
今回はサンプルコードとして簡単なコードを書きましたが、実際のプロダクトコードとなると、使われている関数やコンポーネントの量も桁違いなので、その全てにテストコードを書くことは難しいと思います。
それでも、適切な観点から適切な量のテストコードを書くことで、ソフトウェアの品質が向上する事は間違いないですし、予期せぬバグを未然に防ぐことができるはずです。
何より、テスト結果のオールグリーン画面が気持ちいいですね。
今後は会社のプロダクトでも、自分自身のプロジェクトでも、少しずつソフトウェアテストを導入していこうと思いました。
それではノシ