ややこしい処理ほどTDDで少しずつ書くのに向くよ、という話
はじめに
再帰処理のような、ややこしい処理を一発でちゃんと動くように実装するのは難しいです。そのような場合こそ、シンプルなケースから始めて、レッド・グリーン・リファクターのサイクルで少しずつコードを進化させていくテスト駆動開発(TDD)の出番です。
本稿ではサンプルの(とはいえ少しだけ実用的な)コードをTDDで開発していく様子を再現します。細かい手順もほぼ忠実に再現した(というかコーディング過程でそのまま貼り付けた)ため、かなり長くなっています。お時間に余裕のあるときに読んで頂ければ幸いです。
テストファーストは知っている(あるいはすでに実践している)けど、TDDの具体的な進め方がまだよくわからない、という方にとって何かヒントになれば。
サンプルコードはJavaScript、テスティングライブラリにはJestを利用しています。
解決したい課題
アプリケーションを開発しているとき、何かの配列をソートしたいという場面はしばしばあります。
JavaScriptで配列をソートするのに最も基本的な関数はArray.prototype.sort
ですが、非常に使いづらいです。
const array = ['dog', 'pig', 'cat'];
array.sort();
// ['cat', 'dog', 'pig']
上記のコードでは期待どおりにarray
の要素がソートされますが、array
自体の内容が書き換わってしまう、いわゆる破壊的操作です。
元の配列はそのまま変更せずにソートされた新しい配列を得たい場合、例えば以下のように書かねばなりません。
const sorted = [...array].sort(); // array自体は変更されない
また、以下のコードは一見正しくnumber
の配列をソートできているようですが、
[2, 3, 1].sort();
// [1, 2, 3]
実際は偶然そのような結果となっているだけで、以下のソートは期待どおりの結果を得られません。
[2, 3, 11, 1].sort();
// [1, 11, 2, 3] ...あれ、 11 < 2?
これはsort
関数に比較関数を指定しなかった場合、各要素を文字列評価したコード値での昇順ソートが行われるためです。
数値の大小によるソートを行うには比較関数を渡す必要があります。
[2, 3, 11, 1].sort((a, b) => a - b);
// [1, 2, 3, 11]
また、undefined
は末尾に来るのに対し、null
は'null'
と文字列評価されるといった仕様のクセもあります。
['xyz', 'abc', null, undefined, 'null'].sort();
// ['abc', null, 'null', 'xyz', undefined];
これらの理由からArray.prototype.sort
は使いづらいので、実際のアプリケーション開発ではlodash
などのライブラリの関数を使用することが多くなります。
lodashのソート関数を用いれば、先ほど挙げたArray.prototype.sort
は解消されます。
_.sortBy([2, 3, 11, 1]);
// [1, 2, 3, 11] number配列をソート可能。元の配列は不変
_.sortBy(['xyz', 'abc', null, undefined, 'null']);
// ['abc', 'null, 'xyz', null, undefined] nullは文字列評価されない。null, definedの順で末尾に並ぶ
また、_.orderBy
を利用するとオブジェクト配列のソートを柔軟に行うことができます。
const users = [
{name: 'John', bloodType: 'B', birthday: {month: 5, day: 20}},
{name: 'Mary', bloodType: 'A', birthday: {month: 6, day: 12}},
{name: 'Karl', bloodType: 'B', birthday: {month: 7, day: 7}},
{name: 'David', bloodType: 'A', birthday: {month: 8, day: 13}},
];
const sortedUsers = _.orderBy(users, ['bloodType', 'birthday.day'], ['asc', 'desc']);
// 血液型の昇順、誕生日(日)の降順でソートされる
ソートに使う項目を指定でき、昇順/降順も各々に指定できます。ネストされたオブジェクトにも対応できていて便利ですね!
強いて難点を挙げるなら以下です。
- 項目や順序をそれぞれ文字列で指定しないといけない
- 項目と順序をそれぞれ別の配列で指定しないといけない
これを例えば以下のように書けたらどうでしょう?
const sortedUsers = _.orderBy(users,
user => user.bloodType, // 血液型の昇順
user => user.birthday.day, desc()); // 誕生日(日)の降順
前置きが少し長くなりましたが、これが今回の課題です。
最終的なコード
lodashの_.orderBy
をラップするような以下の関数を実装しました。
50行程度のコードなのですが、そこそこややこしいです。再帰処理って、書いているうちに頭がごちゃごちゃしてきますよね? よっぽど頭脳に自信のある方をのぞいて、一発で正しく動くプログラムが書けるとは思わないほうがよいです。
「困難は分割せよ」という格言に従い、TDDのテクニックを使って少しずつ実装していきましょう。
const _ = require('lodash');
function sort (array, ...fns) {
if (!Array.isArray(array)) {
return array;
}
if (array.length === 0) {
return [];
}
const keys = [];
const orders = [];
if (fns.length > 0) {
const model = createModel(array[0]);
// fn(model)の返却値はBuilder
fns.map(fn => fn(model))
.forEach(builder => {
keys.push(builder.propName);
orders.push(builder.order);
});
}
return _.orderBy(array, keys, orders);
}
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, createBuilder(key, obj[key], null)]);
return Object.fromEntries(entries);
}
function createBuilder (key, value, parentKey) {
const currentKey = parentKey ? `${parentKey}.${key}` : key;
const builder = {
asc: () => ({propName: currentKey, order: 'asc'}),
desc: () => ({propName: currentKey, order: 'desc'}),
propName: currentKey,
order: 'asc'
};
if (typeof value === "object") {
Object.keys(value).forEach(k => {
const b = createBuilder(k, value[k], currentKey);
builder[k] = b;
});
}
return builder;
}
module.exports.createBuilder = createBuilder;
module.exports.createModel = createModel;
module.exports.sort = sort;
TDDで実装を進める
では実際に始めていきます。
はじめの一歩
関数のシグネチャは既に決まっているので、スケルトン実装を用意します。
複数項目でソートできるよう、第2引数は残余引数を使って配列で受け取ります(...fns
)。
const _ = require('lodash');
function sort (array, ...fns) {
return null;
}
module.exports.sort = sort
最初のテストケースを作成します。lodashの_.orderBy
に配列以外の引数を指定しなかった場合、元の配列と等しい配列が返されるので、そのように期待値を書きます。このテストは失敗するはずです。
const sort = require('../sort').sort;
describe('sort:', () => {
const array = [
{name: 'Bob', age: 25},
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
]
it('ソート順指定の関数をに引数を指定しない場合、元の配列と等しい配列が得られる', () => {
// Act
const sorted = sort(array);
// Assert
expect(sorted).not.toBe(array);
expect(sorted).toEqual([
{name: 'Bob', age: 25},
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
])
});
});
同一の配列ではなく等しい配列を期待するので、not.ToBe(array)
により元の配列の参照ではないことを確認しています。
実装コードを修正してテストが通ることを確認します。
function sort (array, ...fns) {
return _.orderBy(array);
}
続けて、基本的なテストケースとその実装を書いていきましょう。
配列でないものを第1引数に渡した場合、エラーとしてもよいですが、今回はそのままそれを返却する仕様とします。
it('配列でないオブジェクトを指定した場合、それがそのまま返される', () => {
// Assert
const user = {name: 'any', age: 0};
// Act
const sorted = sort(user, user => user.name);
// Assert
expect(sorted).toBe(user);
});
テストは失敗します。渡されたものが配列であることを確認するガード条件を入れて、テストが通るようにしましょう。
function sort (array, ...fns) {
if (!Array.isArray(array)) {
return array;
}
return _.orderBy(array);
}
空の配列を渡した場合は、返却値も(別の)空配列とします。
it('空の配列を指定した場合、空配列が返却される', () => {
// Assert
const empty = [];
// Act
const sorted = sort(empty, user => user.name);
// Assert
expect(sorted).not.toBe(empty);
expect(sorted).toEqual([]);
});
テストコードを書いたら実装コードを書きます。
function sort (array, ...fns) {
if (!Array.isArray(array)) {
return array;
}
if (array.length === 0) {
return [];
}
return _.orderBy(array);
}
最も単純なパターンの実装
さて、ここからが本番です。まずはひとつのプロパティでのソートができるように実装していきます。
it('ひとつのプロパティ(name)を指定してソートできる', () => {
// Act
const sorted = sort(array, user => user.name);
// Assert
expect(sorted).toEqual([
{name: 'Alice', age: 25},
{name: 'Bob', age: 25},
{name: 'Bob', age: 37},
{name: 'John', age: 20},
]);
});
テストが失敗するのを確認したら、実装コードを書きます。テストが通る最もシンプルな実装を行うというTDDの格言に従って、以下のように書いてみます。(え、そんなベタな実装でいいの? と思うかもしれませんが、いいんです!)
function sort (array, ...fns) {
//...
return _.orderBy(array, ['name'], ['asc']);
}
今書いたテストは通ったものの、別のテストが失敗してしまいました。以下はそのエラーメッセージです。
● sort: › ソート順指定の関数をに引数を指定しない場合、元の配列と等しい配列が得られる
expect(received).toEqual(expected) // deep equality
このように、実装済みの振舞いを壊してしまったら、すぐにフィードバックが得られるのがTDDの醍醐味です。
実装を修正しましょう。第2引数以降が指定された場合(残余引数でfns
に格納された配列の長さが0より大きい場合)、キー・順序を指定するように修正します。
function sort (array, ...fns) {
//...
const keys = [];
const orders = [];
if (fns.length > 0) {
keys.push('name');
orders.push('asc');
}
return _.orderBy(array, keys, orders);
}
すべてのテストケースが通るようになりました。
name
という値がべた書きされているのがやはり気になりますね。では次に別のプロパティでソートできるか検証するテストケースを書きましょう。
it('ひとつのプロパティ(age)を指定してソートできる', () => {
// Act
const sorted = sort(array, user => user.age);
// Assert
expect(sorted).toEqual([
{name: 'John', age: 20},
{name: 'Bob', age: 25},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
]);
});
もちろんテストは失敗します。
ここからは少し設計に頭を悩める必要が出てきます。実装コードの以下の部分で、name
というべた書きの値ではなく、適切な値を使用しなくてはなりません。
if (fns.length > 0) {
keys.push('name');
orders.push('asc');
}
その適切な値は、sort
関数の引数で渡されたuser => user.age
というアロー関数で表現された関数オブジェクトから導出します。
試しに、第1引数で受け取った配列の先頭要素を使って、この関数を呼び出して得た結果をkeys
にセットしてみましょう。
if (fns.length > 0) {
const obj = array[0];
const key = fns[0](obj);
keys.push(key);
orders.push('asc');
}
これは期待どおり動作しません。試しに console.log(key)
を出力してみると25
という値が出力されます。
これはテストデータの配列の先頭要素に対して、user => user.age
という関数を適用すると、age
プロパティの値が返されるためです。
const array = [
{name: 'Bob', age: 25}, // このオブジェクトのageプロパティの値が25
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
]
テストが通るようにするには、user.age
の返す値が25
ではなく'age'
という文字列であればよいのです。
そのためにはuser => user.age
という関数に渡す引数を、本当のUserオブジェクト(Userクラスを定義してはいないので、実際には無名のオブジェクトなのですが便宜上このように呼びます)ではなく、Userオブジェクトと同じような形状をした別のオブジェクトを渡すことになりそうです。
そのオブジェクトをModel
と呼ぶことにし、それを生成する関数を作成します。
function sort (array, ...fns) {
//...
const keys = [];
const orders = [];
if (fns.length > 0) {
const model = createModel(array[0]);
const key = fns[0](model);
keys.push(key);
orders.push('asc');
}
return _.orderBy(array, keys, orders);
}
function createModel (obj) {
return null;
}
しかしこのcreateModel
の実装は難しそうです。すぐには実装のイメージが湧きません。
createModel
関数をTDDで実装する
難しいことを一発でクリアしようとしない、ということで、この関数もTDDで少しずつ作っていきましょう。
この関数が完成するまで元のテストケースが失敗し続けるのが気になるようであれば、テストケースに.skip
を付けてしばらく実行対象外としましょう。
it.skip('ひとつのプロパティ(name)を指定してソートできる', () => {
//...
});
it.skip('ひとつのプロパティ(age)を指定してソートできる', () => {
//...
})
createModel
関数は内部でのみ使用する関数ですが、テストコードを書くために一時的にエクスポートします。
module.exports.createModel = createModel;
createModel
関数に対するテストは完成後消す予定なので、describe
をネストしてまとめておきましょう。インポートもブロック内にて行います。
describe('sort:', () => {
//...
describe('createModel関数', () => {
const createModel = require('../sort').createModel;
});
});
例によって簡単なテストケースから始めます。
it('渡したオブジェクトと同じプロパティを持つ', () => {
// Assert
const obj = {name: 'any', age: 0}
// Act
const model = createModel(obj);
// Assert
expect(model).not.toBe(obj);
expect(model).toHaveProperty('name');
expect(model).toHaveProperty('age');
})
createModel
関数を実装します。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, '']);
return Object.fromEntries(entries);
}
キーと値のペア(長さ2の配列)の配列を作成し、Object.fromEntries
を呼び出すことで新しいオブジェクトを作成するようにしました。
プロパティの値には暫定的に空文字列をセットしています。
次のテストですが、先ほどのテストケースを修正して、プロパティの値も確認するようにします。
it('渡したオブジェクトと同じプロパティを持ち、その値はプロパティ名である', () => {
// Assert
const obj = {name: 'any', age: 0}
// Act
const model = createModel(obj);
// Assert
expect(model).not.toBe(obj);
expect(model).toHaveProperty('name', 'name');
expect(model).toHaveProperty('age', 'age');
});
createModel
関数の実装の修正も簡単です。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, key]);
return Object.fromEntries(entries);
}
この時点で、元のテストケースも通るようになっているはずなので、.skip
していたのを外して全てのテストケースが通ることを確認します。
sort
関数本体のテストに戻りましょう。複数のプロパティを指定したソートも難しくなさそうなのでさくっと実装しましょう。
it('複数のプロパティ(age, name)を指定してソートできる', () => {
// Act
const sorted = sort(array, user => user.age, user => user.name);
// Assert
expect(sorted).toEqual([
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 25},
{name: 'Bob', age: 37},
]);
});
テストは失敗します(準備したテストデータの問題で、name
age
の順だと偶然通ってしまうため逆の順序にしています)。
実装コードを修正します。
function sort (array, ...fns) {
//...
const keys = [];
const orders = [];
if (fns.length > 0) {
const model = createModel(array[0]);
fns.map(fn => fn(model)).forEach(key => keys.push(key));
orders.push('asc');
}
return _.orderBy(array, keys, orders);
}
引数で受け取った関数の配列(fns
)それぞれを呼び出し、keys
配列に結果を格納するようにしています。
テストも通りました!
次はどこに向かうか、やらなければならないことを整理してみましょう。書籍『テスト駆動開発』にもあるとおり、TODOリストを使って作業を進めていくのはTDDにおける役立つプラクティスの一つです。
-
asc()
で昇順指定できるようにする -
desc()
で降順指定できるようにする - ネストされたオブジェクトのプロパティも使えるようにする
昇順・降順指定に対応する
まずは昇順によるソートに対応しましょう。テストケースを書きます。
it('単一のプロパティを昇順指定でソートできる', () => {
// Act
const sorted = sort(array, user => user.age.asc());
// Assert
expect(sorted).toEqual([
{name: 'John', age: 20},
{name: 'Bob', age: 25},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
])
});
asc
という関数が見つからない、という旨のエラーが発生するはずです。
なぜならば、user => user.age.asc()
という関数の、user.age
が指し示すものは単なる文字列でしかないからです。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, key]);
// { name: 'name', age: 'age'} のようなオブジェクト
return Object.fromEntries(entries);
}
期待されるのは、user.age
が返すオブジェクトが、asc
という関数を持つことです。
それを踏まえて、createModel
が返すオブジェクトの各プロパティを、単なる文字列からオブジェクトへ拡張したいと思います。
このオブジェクトをBuilder
と呼び、それを生成する関数を用意します。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, createBuilder(key)]);
return Object.fromEntries(entries);
}
function createBuilder (key) {
return key;
}
この関数もややこしくなりそうなので、TDDで少しずつ実装していきたいと思います。テストのためエクスポートします。
module.exports.createBuilder = createBuilder;
createBuilder
関数に対する最初のテストケースを作成します。
describe('createBuilder関数', () => {
const createBuilder = require('../sort').createBuilder;
it('ascとdescという関数をプロパティに持つ', () => {
// Act
const builder = createBuilder('name');
// Assert
expect(builder).toHaveProperty('asc');
expect(typeof builder.asc).toEqual('function');
expect(builder).toHaveProperty('desc');
expect(typeof builder.desc).toEqual('function');
});
});
シンプルな実装を書きます。
function createBuilder (key) {
return {
asc: () => { },
desc: () => { }
};
}
テストは通りますが、既存のテストケースがいくつか失敗するようになってしまいました。
今までModel
オブジェクトのプロパティ値はプロパティ名を表す文字列だったのが、Builder
オブジェクトに変わったのが原因のようです。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, createBuilder(key)]);
// { name: {builder}, age: {builder}} のようなオブジェクト
return Object.fromEntries(entries);
}
失敗しているテストをひとまず無視して、プロパティ名を取れるようにcreateBuidler
関数を修正します。
まずはテストケースの追加。
it('propNameというプロパティに引数で渡した値が格納される', () => {
// Act
const builder = createBuilder('name');
// Assert
expect(builder).toHaveProperty('propName', 'name');
});
実装は簡単です。
function createBuilder (key) {
return {
asc: () => { },
desc: () => { },
propName: key
};
}
続けてsort
関数本体を修正します。
function sort (array, ...fns) {
//...
if (fns.length > 0) {
const model = createModel(array[0]);
// fn(model)の返却値はBuilder
fns.map(fn => fn(model))
.map(builder => builder.propName)
.forEach(key => keys.push(key));
orders.push('asc');
}
return _.orderBy(array, keys, orders);
}
失敗するテストケースが減りましたが、まだ2件失敗しています。内容を見てみましょう。
● sort: › createModel関数 › 渡したオブジェクトと同じプロパティを持ち、その値はプロパティ名である
expect(received).toHaveProperty(path, value)
Expected path: "name"
Expected value: "name"
Received value: {"asc": [Function asc], "desc": [Function desc], "propName": "name"}
なるほど、Model
オブジェクトの形状も変わったのでした。
createModel
関数のテストスイートを修正します。
describe('createModel関数', () => {
const createModel = require('../sort').createModel;
it('渡したオブジェクトと同じプロパティを持つ', () => {
// Assert
const obj = {name: 'any', age: 0}
// Act
const model = createModel(obj);
// Assert
expect(model).not.toBe(obj);
expect(model).toHaveProperty('name');
expect(model).toHaveProperty('age');
});
it('Modelのプロパティ値はBuilderである', () => {
// Assert
const obj = {name: 'any', age: 0}
// Act
const model = createModel(obj);
// Assert
const value = model.name;
expect(value).toHaveProperty('asc');
expect(value).toHaveProperty('desc');
expect(value).toHaveProperty('propName', 'name');
});
});
あと失敗するテストケースは一つだけです。
● sort: › 単一のプロパティを昇順指定でソートできる
TypeError: Cannot read property 'propName' of undefined
13 | const model = createModel(array[0]);
14 | // fn(model)の返却値はBuilder
> 15 | fns.map(fn => fn(model)).map(builder => builder.propName).forEach(key => keys.push(key));
おっと、これはまさに今実装しようとしている振舞いでした。テストケースを.skip
としてもいいですが、このまま進みましょう。
このエラーメッセージが次に何をすべきか示唆してくれます。
createBuilder
関数の今の実装ではasc
関数の呼び出し結果がないため、propName
を取れていないのですね。
function createBuilder (key) {
return {
asc: () => { },
desc: () => { },
propName: key
};
}
createBuilder
関数のテストケースを追加します。
describe('createBuilder関数', () => {
//...
it('asc()を呼び出した結果にはpropNameプロパティが正しく設定されている', () => {
// Arrange
const builder = createBuilder('name');
// Act
const result = builder.asc();
// Assert
expect(result).toHaveProperty('propName', 'name');
});
});
実装を修正します。
function createBuilder (key) {
return {
asc: () => ({propName: key}),
desc: () => { },
propName: key
};
}
この修正により、すべてのテストケースが成功するようになりました。
desc()
にも対応させます。
describe('createBuilder関数', () => {
//...
it('desc()を呼び出した結果にはpropNameプロパティが正しく設定されている', () => {
// Arrange
const builder = createBuilder('name');
// Act
const result = builder.desc();
// Assert
expect(result).toHaveProperty('propName', 'name');
});
});
function createBuilder (key) {
return {
asc: () => ({propName: key}),
desc: () => ({propName: key}),
propName: key
};
}
sort
関数本体のテストケースに戻って、降順ソートが動くか確認してみます。
it('単一のプロパティを降順指定でソートできる', () => {
// Act
const sorted = sort(array, user => user.age.desc());
// Assert
expect(sorted).toEqual([
{name: 'Bob', age: 37},
{name: 'Bob', age: 25},
{name: 'Alice', age: 25},
{name: 'John', age: 20},
]);
});
残念なことに、テストは失敗しました。
sort
関数の実装を見ると、ソート順が'asc'
とべた書きのままになってました。
function sort (array, ...fns) {
//...
if (fns.length > 0) {
const model = createModel(array[0]);
// fn(model)の返却値はBuilder
fns.map(fn => fn(model))
.map(builder => builder.propName)
.forEach(key => keys.push(key));
orders.push('asc');
}
return _.orderBy(array, keys, orders);
}
ソート順についてもBuilder
から取得しなければなりません。
ということでcreateBuilder
関数のテストに戻り、実装していきます。propName
のテストケースの下に新しくテストケースを追加しました。
describe('createBuilder関数', () => {
//...
it('propNameというプロパティに引数で渡した値が格納される', () => {
// Act
const builder = createBuilder('name');
// Assert
expect(builder).toHaveProperty('propName', 'name');
});
it('orderプロパティの値はデフォルトでascとなっている', () => {
// Act
const builder = createBuilder('name');
// Assert
expect(builder).toHaveProperty('order', 'asc');
});
//...
実装は簡単。order
プロパティを追加します。
function createBuilder (key) {
return {
asc: () => ({propName: key}),
desc: () => ({propName: key}),
propName: key,
order: 'asc'
};
}
asc
関数、desc
関数を呼び出した場合のテストコードと実装はまとめてやってしまいましょう。
it('asc()を呼び出した結果にはorderプロパティが正しく設定されている', () => {
// Arrange
const builder = createBuilder('name');
// Act
const result = builder.asc();
// Assert
expect(result).toHaveProperty('order', 'asc');
});
it('desc()を呼び出した結果にはorderプロパティが正しく設定されている', () => {
// Arrange
const builder = createBuilder('name');
// Act
const result = builder.desc();
// Assert
expect(result).toHaveProperty('order', 'desc');
});
実装コードは、asc
desc
のアロー関数の戻り値のオブジェクトにプロパティを追加するだけです。
function createBuilder (key) {
return {
asc: () => ({propName: key, order: 'asc'}),
desc: () => ({propName: key, order: 'desc'}),
propName: key,
order: 'asc'
};
}
次に進む前に、createBuilder
関数のテストコードが散らかってきたので、describe
でグルーピングして整理します。
describe('createBuilder関数', () => {
const createBuilder = require('../sort').createBuilder;
describe('デフォルトのBuilder', () => {
const builder = createBuilder('name');
it('ascとdescという関数をプロパティに持つ', () => {
// Assert
expect(builder).toHaveProperty('asc');
expect(typeof builder.asc).toEqual('function');
expect(builder).toHaveProperty('desc');
expect(typeof builder.desc).toEqual('function');
});
it('propNameというプロパティに引数で渡した値が格納される', () => {
// Assert
expect(builder).toHaveProperty('propName', 'name');
});
it('orderプロパティの値はデフォルトでascとなっている', () => {
// Assert
expect(builder).toHaveProperty('order', 'asc');
});
});
describe('asc関数の結果', () => {
const builder = createBuilder('name');
it('asc()を呼び出した結果にはpropNameプロパティが正しく設定されている', () => {
// Act
const result = builder.asc();
// Assert
expect(result).toHaveProperty('propName', 'name');
});
it('asc()を呼び出した結果にはorderプロパティが正しく設定されている', () => {
// Act
const result = builder.asc();
// Assert
expect(result).toHaveProperty('order', 'asc');
});
});
describe('desc関数の結果', () => {
const builder = createBuilder('name');
it('desc()を呼び出した結果にはpropNameプロパティが正しく設定されている', () => {
// Act
const result = builder.desc();
// Assert
expect(result).toHaveProperty('propName', 'name');
});
it('desc()を呼び出した結果にはorderプロパティが正しく設定されている', () => {
// Act
const result = builder.desc();
// Assert
expect(result).toHaveProperty('order', 'desc');
});
});
});
さて、Builder
にorder
プロパティを追加したので、sort
関数本体でそれを使うようにしましょう。
function sort (array, ...fns) {
//...
if (fns.length > 0) {
const model = createModel(array[0]);
// fn(model)の返却値はBuilder
fns.map(fn => fn(model))
.forEach(builder => {
keys.push(builder.propName);
orders.push(builder.order);
});
}
return _.orderBy(array, keys, orders);
}
全てのテストケースが成功しました!
複数のプロパティを指定しても期待どおり動作するか、テストケースを追加して確認します。
it('複数のプロパティと昇順降順指定を組み合わせてソートできる', () => {
// Act
const sorted = sort(array,
user => user.age.desc(),
user => user.name.asc());
// Assert
expect(sorted).toEqual([
{name: 'Bob', age: 37},
{name: 'Alice', age: 25},
{name: 'Bob', age: 25},
{name: 'John', age: 20},
]);
});
問題なく動作することが確認できました。
sort
関数のテストケースもこのタイミングで整理しておきます。
describe('sort:', () => {
const array = [
{name: 'Bob', age: 25},
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
]
describe('特殊ケース', () => {
it('ソート順指定の関数をに引数を指定しない場合、元の配列と等しい配列が得られる', () => {
// Act
const sorted = sort(array);
// Assert
expect(sorted).not.toBe(array);
expect(sorted).toEqual([
{name: 'Bob', age: 25},
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
])
});
it('配列でないオブジェクトを指定した場合、それがそのまま返される', () => {
// Assert
const user = {name: 'any', age: 0};
// Act
const sorted = sort(user, user => user.name);
// Assert
expect(sorted).toBe(user);
});
it('空の配列を指定した場合、空配列が返却される', () => {
// Assert
const empty = [];
// Act
const sorted = sort(empty, user => user.name);
// Assert
expect(sorted).not.toBe(empty);
expect(sorted).toEqual([]);
});
});
describe('プロパティによるソート', () => {
it('ひとつのプロパティ(name)を指定してソートできる', () => {
// Act
const sorted = sort(array, user => user.name);
// Assert
expect(sorted).toEqual([
{name: 'Alice', age: 25},
{name: 'Bob', age: 25},
{name: 'Bob', age: 37},
{name: 'John', age: 20},
]);
});
it('ひとつのプロパティ(age)を指定してソートできる', () => {
// Act
const sorted = sort(array, user => user.age);
// Assert
expect(sorted).toEqual([
{name: 'John', age: 20},
{name: 'Bob', age: 25},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
]);
});
it('複数のプロパティ(age, name)を指定してソートできる', () => {
// Act
const sorted = sort(array, user => user.age, user => user.name);
// Assert
expect(sorted).toEqual([
{name: 'John', age: 20},
{name: 'Alice', age: 25},
{name: 'Bob', age: 25},
{name: 'Bob', age: 37},
]);
});
});
describe('昇順・降順を指定したソート', () => {
it('単一のプロパティを昇順指定でソートできる', () => {
// Act
const sorted = sort(array, user => user.age.asc());
// Assert
expect(sorted).toEqual([
{name: 'John', age: 20},
{name: 'Bob', age: 25},
{name: 'Alice', age: 25},
{name: 'Bob', age: 37},
]);
});
it('単一のプロパティを降順指定でソートできる', () => {
// Act
const sorted = sort(array, user => user.age.desc());
// Assert
expect(sorted).toEqual([
{name: 'Bob', age: 37},
{name: 'Bob', age: 25},
{name: 'Alice', age: 25},
{name: 'John', age: 20},
]);
});
it('複数のプロパティと昇順降順指定を組み合わせてソートできる', () => {
// Act
const sorted = sort(array,
user => user.age.desc(),
user => user.name.asc());
// Assert
expect(sorted).toEqual([
{name: 'Bob', age: 37},
{name: 'Alice', age: 25},
{name: 'Bob', age: 25},
{name: 'John', age: 20},
]);
});
});
//...
ネストされたオブジェクトに対応する
次は、ネストされたオブジェクトにも対応可能なように実装していきます。
まずはsort
関数に対するテストケースを作成しますが、通るようになるまでの道のりは長そうなので失敗を確認した後.skip
としておきます。
describe('ネストされたオブジェクトのプロパティを指定したソート', () => {
const users = [
{id: 1, values: {name: 'Bob', age: 25}},
{id: 2, values: {name: 'John', age: 20}},
{id: 3, values: {name: 'Alice', age: 25}},
{id: 4, values: {name: 'Bob', age: 37}},
];
it.skip('ネストされたプロパティ名を指定してソートできる', () => {
// Act
const sorted = sort(users, user => user.values.age);
// Assert
expect(sorted).toEqual([
{id: 2, values: {name: 'John', age: 20}},
{id: 1, values: {name: 'Bob', age: 25}},
{id: 3, values: {name: 'Alice', age: 25}},
{id: 4, values: {name: 'Bob', age: 37}},
]);
});
});
createModel
関数に対するテストコードを書きましょう。これも失敗を確認後.skip
しておきます。
ネストしているため、propsName
はname
ではなくvalues.name
となるべき点に注意します。
describe('createModel関数', () => {
//...
it.skip('ネストされたオブジェクトに対応できる', () => {
// Arrange
const obj = {id: 0, values: {name: 'any', age: 0}};
// Act
const model = createModel(obj);
// Assert
const value = model.values.name;
expect(value).toHaveProperty('asc');
expect(value).toHaveProperty('desc');
expect(value).toHaveProperty('propName', 'values.name');
});
});
ここで実装コードの方を眺めてみます。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, createBuilder(key)]);
// { name: {builder}, age: {builder}} のようなオブジェクト
return Object.fromEntries(entries);
}
function createBuilder (key) {
return {
asc: () => ({propName: key, order: 'asc'}),
desc: () => ({propName: key, order: 'desc'}),
propName: key,
order: 'asc'
};
}
先ほどcreateModel
関数の引数に渡したテストデータから得られるModel
オブジェクトは以下のような形状をしています(asc
desc
関数は省略)。
{
id: {
propName: 'id',
order: 'asc'
},
values: {
propName: 'values',
order: 'asc'
}
}
user.values.age
にアクセスできるようにするには、values
にage
プロパティが必要ということになります。
なんとなく、createBuilder
関数に再帰的な処理が必要なことが見えてきました。プロパティがオブジェクトである場合、再帰処理が必要なので、プロパティのキーでなく値自体も引数に渡すようにします。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, createBuilder(key, obj[key])]);
// { name: {builder}, age: {builder}} のようなオブジェクト
return Object.fromEntries(entries);
}
function createBuilder (key, value) {
return {
asc: () => ({propName: key, order: 'asc'}),
desc: () => ({propName: key, order: 'desc'}),
propName: key,
order: 'asc'
};
}
引数を追加しただけなので、既存のテストは通ります。createBuilder
関数のテストケースを追加しましょう。
describe('createBuilder関数', () => {
//...
describe('オブジェクトの場合', () => {
const builder = createBuilder('foo', {bar: 'baz'});
it('ネストされたプロパティにアクセスできる', () => {
// Act
expect(builder).toHaveProperty('bar');
});
});
});
createBuilder
関数の実装を修正します。
function createBuilder (key, value) {
const builder = {
asc: () => ({propName: key, order: 'asc'}),
desc: () => ({propName: key, order: 'desc'}),
propName: key,
order: 'asc'
};
if (typeof value === "object") {
Object.keys(value).forEach(k => {
const b = createBuilder(k, value[k]);
builder[k] = b;
});
}
return builder;
}
テストケースを追加します。いずれもそのまま成功します。
describe('オブジェクトの場合', () => {
//...
it('ネストされたプロパティのプロパティが正しい', () => {
// Assert
const childBuilder = builder.bar;
// Act
expect(childBuilder).toHaveProperty('asc');
expect(childBuilder).toHaveProperty('desc');
expect(childBuilder).toHaveProperty('propName', 'bar');
expect(childBuilder).toHaveProperty('order', 'asc');
});
it('asc()の呼び出し結果が正しい', () => {
// Assert
const result = builder.bar.asc();
// Act
expect(result).toHaveProperty('propName', 'bar');
expect(result).toHaveProperty('order', 'asc');
});
it('desc()の呼び出し結果が正しい', () => {
// Assert
const result = builder.bar.desc();
// Act
expect(result).toHaveProperty('propName', 'bar');
expect(result).toHaveProperty('order', 'desc');
});
});
.skip
していたcreateModel
関数のテストケースを元に戻します。
it('ネストされたオブジェクトに対応できる', () => {
// Arrange
const obj = {id: 0, values: {name: 'any', age: 0}};
// Act
const model = createModel(obj);
// Assert
const value = model.values.name;
expect(value).toHaveProperty('asc');
expect(value).toHaveProperty('desc');
expect(value).toHaveProperty('propName', 'values.name');
});
テストが失敗しました。
● sort: › createModel関数 › ネストされたオブジェクトに対応できる
expect(received).toHaveProperty(path, value)
Expected path: "propName"
Expected value: "values.name"
Received value: "name"
ネストされたオブジェクトのプロパティをドット連結した形式で表現するためには、再帰呼び出しの際に親のプロパティ名を渡す必要があります。
createBuilder
関数のシグネチャを変更し、引数を追加します。
function createModel (obj) {
const entries = Object.keys(obj).map(key => [key, createBuilder(key, obj[key], null)]);
// { name: {builder}, age: {builder}} のようなオブジェクト
return Object.fromEntries(entries);
}
function createBuilder (key, value, parentKey) {
//...
createBuilder
関数のテストケースを修正し、propName
の期待値をfoo.bar
に変更します。
describe('オブジェクトの場合', () => {
const builder = createBuilder('foo', {bar: 'baz'}, null);
it('ネストされたプロパティにアクセスできる', () => {
// Act
expect(builder).toHaveProperty('bar');
});
it('ネストされたプロパティのプロパティが正しい', () => {
// Assert
const childBuilder = builder.bar;
// Act
expect(childBuilder).toHaveProperty('asc');
expect(childBuilder).toHaveProperty('desc');
expect(childBuilder).toHaveProperty('propName', 'foo.bar');
expect(childBuilder).toHaveProperty('order', 'asc');
});
it('asc()の呼び出し結果が正しい', () => {
// Assert
const result = builder.bar.asc();
// Act
expect(result).toHaveProperty('propName', 'foo.bar');
expect(result).toHaveProperty('order', 'asc');
});
it('desc()の呼び出し結果が正しい', () => {
// Assert
const result = builder.bar.desc();
// Act
expect(result).toHaveProperty('propName', 'foo.bar');
expect(result).toHaveProperty('order', 'desc');
});
});
実装を修正します。
function createBuilder (key, value, parentKey) {
const currentKey = parentKey ? `${parentKey}.${key}` : key;
const builder = {
asc: () => ({propName: currentKey, order: 'asc'}),
desc: () => ({propName: currentKey, order: 'desc'}),
propName: currentKey,
order: 'asc'
};
if (typeof value === "object") {
Object.keys(value).forEach(k => {
const b = createBuilder(k, value[k], currentKey);
builder[k] = b;
});
}
return builder;
}
sort
関数の.skip
していたテストケースも戻します。
it('ネストされたプロパティ名を指定してソートできる', () => {
// Act
const sorted = sort(users, user => user.values.age);
// Assert
expect(sorted).toEqual([
{id: 2, values: {name: 'John', age: 20}},
{id: 1, values: {name: 'Bob', age: 25}},
{id: 3, values: {name: 'Alice', age: 25}},
{id: 4, values: {name: 'Bob', age: 37}},
]);
});
これも成功することが確認できました。
念の為、ネストがもう一段深い場合のテストケースを追加して確認します。
it('ネストされたプロパティ名を指定してソートできる', () => {
// Act
const sorted = sort(users, user => user.values.age);
// Assert
expect(sorted).toEqual([
{id: 2, values: {name: 'John', age: 20}},
{id: 1, values: {name: 'Bob', age: 25}},
{id: 3, values: {name: 'Alice', age: 25}},
{id: 4, values: {name: 'Bob', age: 37}},
]);
});
問題ないようです!
まとめ:TDDの価値
筆者はTDDを、オンデマンドの設計を支援するツールと捉えています。
ソフトウェアに期待される振舞い(仕様)を実現するために、以下を検討することがソフトウェアにおける設計です。
- どのような構成要素に分割するか(構造)
- 構成要素同士がどのように協調するか(相互作用)
今回の例では、再帰的な処理構造が必要そうだ、という目星はつくものの、事前に正確な設計に落とし込むことは簡単ではありません。やったとしても、過剰な設計に陥ってしまうリスクもあります。
TDDでは、最もシンプルな仕様からスタートし、それを満たすもっともシンプルな実装を行います。インクリメンタルに仕様の追加・実装を繰り返し、適宜リファクタリングを入れながら、シンプルで、かつ必要なだけの柔軟性をもった設計を実現することができます。
TDDは、リズムです。
今風にいえば、呼吸です。
TDDの呼吸を会得することで、設計の力・実装の力が格段に向上することでしょう。
Discussion
tidyjsとdot-path-valueでマルチカラムソートを実現してみました。
demo code.
定義側
使用側
簡単ですが、以上です。