- トップページ
- カオスなテストコード
- 改善の第一歩
- 深い悩み
- 問題はここだ
- 先人に学ぶ(体系編)
- 先人に学ぶ(ノウハウ編)
- テストの戦略
- そして脱却へ
- おわりに
06.先人に学ぶ(ノウハウ編)
世の中のフロントエンドエンジニアの人たちはどんなテストコードを書いているんでしょうか? それを参考にしたら何かヒントが得られるかもしれません。そう思って GitHub のテストコードを眺めてみました。
企業で作っているアプリケーションは Private リポジトリで管理していると思われるので、Public リポジトリで管理されている有名どころの npm パッケージを中心に見た結果をまとめました。
※ 下記で紹介するコードは説明の簡略化のため、シンタックスを Jasmine に統一し行削除などの編集を加えていますのでご留意ください。
機能ごとにフォルダとファイルを分ける
まずフォルダ構造から見てみます。ほとんどのリポジトリが 機能ごとにファイルが分けられスッキリ整理整頓 されていました。分け方はオブジェクト単位だったりメソッド単位だったりいろいろですが、テスト対象がそのままファイル名になったものが多いです。
tj/commnader.js
# コマンドのオプションごとにファイルが分かれている
test
├── test.options.args.js
├── test.options.equals.js
├── test.options.regex.js
└── test.options.version.js
// test.options.regex.js
// 正規表現のオプションが渡された時のテスト
program
.version('0.0.1')
.option('-s, --size <size>', 'Pizza Size', /^(large|medium|small)$/i, 'medium')
.option('-d, --drink [drink]', 'Drink', /^(Coke|Pepsi|Izze)$/i)
program.parse('node test -s big -d coke'.split(' '));
expect(program.size).toBe('medium');
expect(program.drink).toBe('coke');
仕様の列挙とその検証
個々のファイルの中身を見てみると、そのほとんどが テストケースとして仕様を列挙し、挙動が仕様に沿ったものかどうかを検証するスタイル でした。ネストは深くなく、だいたい2階層程度に収まっています。
lodash/lodash
https://github.com/lodash/lodash/blob/dce7fccbb68c469565ced5ccf41fd5028e1ab730/test/at.js
describe('at', () => {
it('should return an empty array when no keys are given', () => {
expect(at(array)).toBe([]);
expect(at(array, [], [])).toBe([]);
});
it('should accept multiple key arguments', () => {
var actual = at(['a', 'b', 'c', 'd'], 3, 0, 2);
expect(actual).toBe(['d', 'a', 'c']);
});
});
フィクスチャを使う
パーサーやテンプレートエンジンなどでは フィクスチャフォルダにサンプルデータを配置しテストから呼び出して使うスタイル もちらほら見られました。
expressjs/express
test
├── fixtures
│ ├── email.tmpl
│ └── user.tmpl
├── test
│ ├── app.render.js
│ └── app.router.js
it('should support absolute paths', (done) => {
var app = createApp();
app.locals.user = { name: 'tobi' };
app.render(path.join(__dirname, 'fixtures', 'user.tmpl'), (err, str) => {
if (err) return;
expect(str).toBe('<p>tobi</p>');
done();
})
})
it('should support absolute paths with "view engine"', (done) => {
var app = createApp();
app.set('view engine', 'tmpl');
app.locals.user = { name: 'tobi' };
app.render(path.join(__dirname, 'fixtures', 'user'), (err, str) => {
if (err) return;
expect(str).toBe('<p>tobi</p>');
done();
})
})
工夫その1)手法(目的)を示すファイル・フォルダ
ちらほら見られたのが smork/unit/integration などテスト方法を表したフォルダやファイル です。
「スモークテスト」って聞き慣れないかもしれませんが、結合テストやユースケーステストを実施してみたらコンポーネントの生成でエラーが発生してそもそもテストが開始できなかった、なんて事態を避けるために存在するものです。本格的にテストを開始する前に「基本的な機能はちゃんと動きます」という事を保証しておくものなんですね。
ファイル名に smork
と入れる事で「最低限の動作保証を書いたものですよ」という作者の意図がはっきり伝わりますよね。これにはなるほど!と思いました。
├── smork-test.js
└── integration
├── click-test.js
└── drag-test.js
工夫その2)コンポーネントをワンライナーで作る
私が普段使っているフレームワーク Angular のテストにも工夫を見つけました。下記はフォームのインテグレーションテスト(クリックや入力などユーザーとの対話をメインとしたテスト)です。
angular/angular
it('should support standalone fields', () => {
const fixture = createComponent(Standalone);
expect(fixture.instance.value).toEqual('updated');
});
it('should be possible to use native validation', () => {
const fixture = createComponent(Validation);
expect(fixture.find('form').hasAttribute('novalidate')).toEqual(false);
});
...
@Component({
selector: 'standalone',
template: `<form><input></form>`
});
class Standalone {}
@Component({
selector: 'validation',
template: `<form><input [required]="required"></form>`
});
class Validation {}
...
単体の要素を持つフォーム、バリデーションが指定されたフォームなど、いろいろなフォームのパターンが用意されています。そして、それぞれをワンライナーで Angular のコンポーネントとしてセットアップするようになっています。いわばコンポーネントのフィクスチャですね。テストケースがすっきりしてとても読みやすいです。
工夫その3)シナリオで効率的に
シナリオテスト(動作を組み合わせて動線を作り、ポイントとなる箇所で検証を行うテスト)を使ったスタイルもありました。
octkit/rest.js
it('issues.*', () => {
it('octokit.issues.*', () => {
return octokit.issues.listLabelsForRepo({...})
.then((result) => {
expect(result.data).toBe('array')
return octokit.issues.createLabel({
name: 'test-label',
color: '663399'
})
})
.then((result) => {
expect(result.data.name).toEqual('test-label')
return octokit.issues.getLabel({
name: 'test-label'
})
})
...
});
このパッケージは GitHub の API Client を提供するものです。上記のテストは issue ラベルを対象にしたもので、リストを取得 > 1件追加 > 1件削除、のようなシナリオをメソッドチェインで作り出しています。サービス生成は先頭の1回だけで済みますし、シナリオの対象を issue の正常パターンだけに限定する事で記述がとてもシンプルです。
# テストのフォルダ構造
test
├── integration
│ ├── authentication-test.js
│ └── pagination-test.js
├── scenarios
│ ├── branch-protection-test.js
│ └── lock-issue-test.js
├── unit
│ └── upload-assert-test.js
このパッケージのテストのフォルダ構成は、ラベル/プルリクエスト/ブランチといった基本的な機能は scenarios
フォルダに、ページネーション/認証/レスポンスエラーなど外部要因の機能は integration
フォルダにまとめられています。
このリポジトリはテスト全般が本当にキレイに整頓されていて感動しました。 個人的にピカイチのテストです。
完璧なテストを発見
ここまで GitHub を眺めていて思ったのですが、異常パターンのテストがあんまりないんですよね。正常パターンがスモークテスト的に書かれているリポジトリが大多数で、異常値をあれこれ渡して検証するテストは見当たりませんでした。でも異常値が原因のバグが発見された場合、テストの追加場所に困りますよね。
そしてあれこれ探してみたところ、ありました!正常パターンも異常パターンも細やかに完璧にテストしているリポジトリ。
tc39/test262
// Array.lengthのテスト
/*---
esid: sec-properties-of-array-instances-length
info: |
If the length property is changed, every property whose name
is an array index whose value is not smaller than the new length is automatically deleted
es5id: 15.4.5.2_A3_T4
description: >
If new length greater than the name of every property whose name
is an array index
---*/
//CHECK#1
var x = [0, 1, 2];
x[4294967294] = 4294967294;
x.length = 2;
//CHECK#1
if (x[0] !== 0) {
$ERROR('#1: x = [0,1,2]; x[4294967294] = 4294967294; x.length = 2; x[0] === 0. Actual: ' + (x[0]));
}
//CHECK#2
if (x[1] !== 1) {
$ERROR('#2: x = [0,1,2]; x[4294967294] = 4294967294; x.length = 2; x[1] === 1. Actual: ' + (x[1]));
}
tc39 は ECMAScript を策定している専門委員会です。ECMAScript では新しい機能が仕様として策定される時にテストを書く事を義務付けています。テストのフォーマットもきちんと決められています。
Array.length
を例に取ると、テストファイルの数はすでに 24 個もあり、完璧なリグレッションテスト(新たに加えられた変更によって既存機能が破壊されない事を確認するもの)が実施されています。フロントエンドの完璧なテストを求めると、たぶんこのような形に行き着くんでしょうね。