概要
テスト駆動 でテスト書いてる場合とくに、自動テストは 動く仕様書 でありたいと思うわけですが、将来参入するエンジニアに仕様を伝えやすくするためには、テストから仕様以外の 便宜的な 実装を徹底的に省くべきだと考えています。
今回は試行錯誤のなかで効果的であると感じた方法をいくつか紹介します。
なお、言語は JavaScript、テストフレームワークは Jest を使います。
it.each の書き方
紆余曲折の中、下記の書き方に落ち着きました。
describe('地点Aから地点Bの【距離】をユーザごとに取得できる', () => {
it.each([
// タイトル, ユーザId, 期待値(距離)
['user1 は 3', user1, 3],
['user2 は 5', user2, 5],
])('%s', (title, userId, expected) => {
// 実行
const distance = MyModule.getDistanceAToB(userId);
// 確認
expect(distance).toBe(expected);
});
});
-
it.each
でまとめることで 網羅性の担保 を評価しやすい。 -
it.each
のまとまりでグルーピングされないので上位にdescribe
** 書いた方が結果の出力が グルーピング されて解りやすい。describe
で 評価概要を読んだ後で case を見る 方が理解しやすいというメリットもある。 -
each
のcase の 各カラムが何なのかを冒頭で コメントで示しておくと case が頭に入りやすい。 - 各 case の 第一要素はテストタイトル にして、
it
の第一引数で'%s'
とすると、テスト結果が見やすくなる。
関数化
そもそも関数というものは、構造化の文脈なら中身を読まなくても良いように切り出すのが優れているものだが、この場合はまず読んでもらう前提。関数の内容を1度覚えてもらうことで後続の表現に冗長さが無くなって読みやすいものになる。
describe('地点Aから地点Bまでの【移動時間】を全件取得できる', () => {
const A = sec => ({place: 'A', time: Date.now() + sec, hogehoge: 'fugafuga'});
const B = sec => ({place: 'B', time: Date.now() + sec, hogehoge: 'fugafuga'});
const other = sec => ({place: 'C', time: Date.now() + sec, hogehoge: 'fugafuga'});
it.each([
// title, records, expected
['全件取れる', [
A(0), B(3),
A(13), B(17),
], [3, 4]],
['地点A,B以外の地点が間にあったら間の部屋は無視される', [
A(0), other(3), B(5),
A(13), other(15), B(17),
], [5, 4]],
['全件取れる', [
{place: 'A', time: Date.now(), hogehoge: 'fugafuga'},
{place: 'B', time: Date.now() + 3, hogehoge: 'fugafuga'},
{place: 'A', time: Date.now() + 13, hogehoge: 'fugafuga'},
{place: 'B', time: Date.now() + 17, hogehoge: 'fugafuga'},
], [3, 4]],
['地点A,B以外の地点が間にあったら間の部屋は無視される', [
{place: 'A', time: Date.now(), hogehoge: 'fugafuga'},
{place: 'C', time: Date.now() + 3, hogehoge: 'fugafuga'},
{place: 'B', time: Date.now() + 5, hogehoge: 'fugafuga'},
{place: 'A', time: Date.now() + 13, hogehoge: 'fugafuga'},
{place: 'C', time: Date.now() + 15, hogehoge: 'fugafuga'},
{place: 'B', time: Date.now() + 17, hogehoge: 'fugafuga'},
], [5, 4]],
- 下記のように配列で表現する方法もあるが、解釈ロジックがどこにあるのか不明瞭になるので、明示的に関数として知らせる方が良い。
['全件取れる', [
['A', 0], ['B', 0],
['A', 13], ['B', 17],
], [3, 4]],
DB を使ったテストなら fabricate
fixture でデータ生成は冗長だし、テスト読んでも fixture の中を読んでも仕様は解りづらく、メンテ困難に陥りやすい。一方で、fabricate 方式では、テストに関係ある列だけを it 句の中で指定して生成するので解りやすい。
// data1.fixture.js
module.exports = [
{
user_id: '11111111111111111111111111111111',
visit_time: '2019-09-01T00:00:00Z',
place_name: 'A',
deleted_flag: false,
created_at: '2019-09-01T00:00:00Z',
updated_at: '2019-09-01T00:00:00Z',
created_by: '11111111111111111111111111111111',
updated_by: '11111111111111111111111111111111'
},
{
user_id: '11111111111111111111111111111111',
visit_time: '2019-09-01T00:00:03Z',
place_name: 'B',
deleted_flag: false,
created_at: '2019-09-01T00:00:00Z',
updated_at: '2019-09-01T00:00:00Z',
created_by: '11111111111111111111111111111111',
updated_by: '11111111111111111111111111111111'
},
{
user_id: '11111111111111111111111111111111',
visit_time: '2019-09-01T00:00:15Z',
place_name: 'A',
deleted_flag: false,
created_at: '2019-09-01T00:00:00Z',
updated_at: '2019-09-01T00:00:00Z',
created_by: '11111111111111111111111111111111',
updated_by: '11111111111111111111111111111111'
},
// ...以下略
];
// test.spec.js
describe('データベース関連の何らかのテスト', () => {
it('fixtureを使ったテスト', () => {
const fixture1 = require('./fixtures/data1.fixture');
MyTestUtil.setUpFixture(fixture1); // insert
const userId = '11111111111111111111111111111111';
const actual = AnyModule.doSomething(userId);
expect(actual).toBe(12345);
});
});
describe('データベース関連の何らかのテスト', () => {
it('fabricateを使ったテスト', () => {
const user_id = '11111111111111111111111111111111';
const now = Date.now();
MyTestUtil.fabricate.table1([
{user_id, visit_time: now + 0, place_name: 'A'},
{user_id, visit_time: now + 3, place_name: 'B'},
{user_id, visit_time: now + 15, place_name: 'A'},
// ...以下略
]);
const actual = AnyModule.doSomething(user_id);
expect(actual).toBe(12345);
});
});
-
テストに関係ない列がないことではるかに読みやすくなる。
-
テスト別ファイルに切り出さず、複数 it で共有もせず、 it の中に記載することでメンテしやすい。
-
fabricate の機能は下記のような実装で簡単に作れる。
const db = require('../db');
const __orDefVal = (value, defVal) => value === undefined ? defVal : value;
const __fabricate = async (tableName, fabricateRecords) => {
return async records => {
await db.query(`TRUNCATE TABLE ${tableName}`);
for (const record of records) {
await fabricateRecords(record);
}
};
};
const fabricate = {
table1: __fabricate('table1', async record => {
await db.query(
`INSERT INTO table1 (user_id, visit_time, place_name, deleted_flag, created_at, updated_at, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
__orDefVal(record['user_id'], '1'),
__orDefVal(record['visit_time'], Date.now()),
__orDefVal(record['place_name'], 'A'),
__orDefVal(record['deleted_flag'], false),
__orDefVal(record['created_at'], new Date().toISOString()),
__orDefVal(record['updated_at'], new Date().toISOString()),
__orDefVal(record['created_by'], '1'),
__orDefVal(record['updated_by'], '1'),
]
);
}),
};
jest の toMatchObject
配列は完全一致、オブジェクトはサブセットとして一致。
つまり関係ないプロパティを無視してくれる。
関係ないプロパティを省いて仕様を示せる。
// 配列は順番も件数もチェックするが、
// オブジェクトは expected にないプロパティは検査対象外にしてくれる。
// つまり下記で actual の要素が place 以外のプロパティをもってたとしても問題はない。
expect(actual).toMatchObject([
{place: 'A'},
{place: 'B'},
{place: 'A'},
]);
jest の toHaveProperty でパスが使える
const actual = {
aaa: {
bbb: 123
}
};
expect(actual).toHaveProperty('aaa.bbb', 123);
expect(actual).not.toHaveProperty('ccc.bbb', 123); // 中間がなくてもエラーにならない
jest で複雑なマッチング
const actual = {
aaa: [
{ bbb: 123, ccc: '1-asdfasdf' },
{ bbb: 456, ccc: '1-sdfgsdfg' },
],
};
// 上記の ccc がランダムっぽい値の場合、一定の基準で評価したいかもしれない。
expect(actual).toEqual({
aaa: [
{ bbb: 123, ccc: expect.stringMatching(/^1-/) },
{ bbb: 456, ccc: expect.stringMatching(/^1-/) },
}]
});
Jest の toBe, toEqual 系の使い分け
-
toBe
:===
で比較される。 -
toEqual
: Deep にチェックされる。配列の要素同士、Objectのプロパティ同士が比較される。ただし、Objectの値が undefined のキーは比較されない。型はチェックされない。 -
toStrictEqual
: Object比較の場合に undefined のキーも比較される。型が一致している必要あり。(同一インスタンスでなくてもいい。) -
toMatchObject
: 前述の通り。Deep にチェックされる。Object の場合、Expected 側に無いプロパティは無視してくれる。型はチェックされない。 -
toBeClosed
: 浮動小数同士の比較に。誤差を許す比較。