🧗‍♂️

ややこしい処理ほどTDDで少しずつ書くのに向くよ、という話

2021/02/07に公開1

はじめに

再帰処理のような、ややこしい処理を一発でちゃんと動くように実装するのは難しいです。そのような場合こそ、シンプルなケースから始めて、レッド・グリーン・リファクターのサイクルで少しずつコードを進化させていくテスト駆動開発(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)。

sort.js
const _ = require('lodash');

function sort (array, ...fns) {
    return null;
}

module.exports.sort = sort

最初のテストケースを作成します。lodashの_.orderByに配列以外の引数を指定しなかった場合、元の配列と等しい配列が返されるので、そのように期待値を書きます。このテストは失敗するはずです。

sort.spec.js
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)により元の配列の参照ではないことを確認しています。

実装コードを修正してテストが通ることを確認します。

sort.js
function sort (array, ...fns) {
    return _.orderBy(array);
}

続けて、基本的なテストケースとその実装を書いていきましょう。

配列でないものを第1引数に渡した場合、エラーとしてもよいですが、今回はそのままそれを返却する仕様とします。

sort.spec.js
    it('配列でないオブジェクトを指定した場合、それがそのまま返される', () => {
        // Assert
        const user = {name: 'any', age: 0};
        // Act
        const sorted = sort(user, user => user.name);
        // Assert
        expect(sorted).toBe(user);
    });

テストは失敗します。渡されたものが配列であることを確認するガード条件を入れて、テストが通るようにしましょう。

sort.js
function sort (array, ...fns) {
    if (!Array.isArray(array)) {
        return array;
    }
    return _.orderBy(array);
}

空の配列を渡した場合は、返却値も(別の)空配列とします。

sort.spec.js
    it('空の配列を指定した場合、空配列が返却される', () => {
        // Assert
        const empty = [];
        // Act
        const sorted = sort(empty, user => user.name);
        // Assert
        expect(sorted).not.toBe(empty);
        expect(sorted).toEqual([]);
    });

テストコードを書いたら実装コードを書きます。

sort.js
function sort (array, ...fns) {
    if (!Array.isArray(array)) {
        return array;
    }
    if (array.length === 0) {
        return [];
    }
    return _.orderBy(array);
}

最も単純なパターンの実装

さて、ここからが本番です。まずはひとつのプロパティでのソートができるように実装していきます。

sort.spec.js
    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の格言に従って、以下のように書いてみます。(え、そんなベタな実装でいいの? と思うかもしれませんが、いいんです!)

sort.js
function sort (array, ...fns) {
    //...
    return _.orderBy(array, ['name'], ['asc']);
}

今書いたテストは通ったものの、別のテストが失敗してしまいました。以下はそのエラーメッセージです。

  ● sort: › ソート順指定の関数をに引数を指定しない場合、元の配列と等しい配列が得られる

    expect(received).toEqual(expected) // deep equality

このように、実装済みの振舞いを壊してしまったら、すぐにフィードバックが得られるのがTDDの醍醐味です。
実装を修正しましょう。第2引数以降が指定された場合(残余引数でfnsに格納された配列の長さが0より大きい場合)、キー・順序を指定するように修正します。

sort.js
function sort (array, ...fns) {
    //...
    const keys = [];
    const orders = [];
    if (fns.length > 0) {
        keys.push('name');
        orders.push('asc');
    }
    return _.orderBy(array, keys, orders);
}

すべてのテストケースが通るようになりました。
nameという値がべた書きされているのがやはり気になりますね。では次に別のプロパティでソートできるか検証するテストケースを書きましょう。

sort.spec.js
    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と呼ぶことにし、それを生成する関数を作成します。

sort.js
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関数は内部でのみ使用する関数ですが、テストコードを書くために一時的にエクスポートします。

sort.js
module.exports.createModel = createModel;

createModel関数に対するテストは完成後消す予定なので、describeをネストしてまとめておきましょう。インポートもブロック内にて行います。

sort.spec.js
describe('sort:', () => {
    //...

    describe('createModel関数', () => {
        const createModel = require('../sort').createModel;
    });
});

例によって簡単なテストケースから始めます。

sort.spec.js
        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関数を実装します。

sort.js
function createModel (obj) {
    const entries = Object.keys(obj).map(key => [key, '']);
    return Object.fromEntries(entries);
}

キーと値のペア(長さ2の配列)の配列を作成し、Object.fromEntriesを呼び出すことで新しいオブジェクトを作成するようにしました。
プロパティの値には暫定的に空文字列をセットしています。

次のテストですが、先ほどのテストケースを修正して、プロパティの値も確認するようにします。

sort.spec.js
        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関数の実装の修正も簡単です。

sort.js
function createModel (obj) {
    const entries = Object.keys(obj).map(key => [key, key]);
    return Object.fromEntries(entries);
}

この時点で、元のテストケースも通るようになっているはずなので、.skipしていたのを外して全てのテストケースが通ることを確認します。

sort関数本体のテストに戻りましょう。複数のプロパティを指定したソートも難しくなさそうなのでさくっと実装しましょう。

sort.spec.js
    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の順だと偶然通ってしまうため逆の順序にしています)。
実装コードを修正します。

sort.js
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が指し示すものは単なる文字列でしかないからです。

sort.js
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と呼び、それを生成する関数を用意します。

sort.js
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関数に対する最初のテストケースを作成します。

sort.spec.js
    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');
        });
    });

シンプルな実装を書きます。

sort.js
function createBuilder (key) {
    return {
        asc: () => { },
        desc: () => { }
    };
}

テストは通りますが、既存のテストケースがいくつか失敗するようになってしまいました。
今までModelオブジェクトのプロパティ値はプロパティ名を表す文字列だったのが、Builderオブジェクトに変わったのが原因のようです。

sort.js
function createModel (obj) {
    const entries = Object.keys(obj).map(key => [key, createBuilder(key)]);
    // { name: {builder}, age: {builder}} のようなオブジェクト
    return Object.fromEntries(entries);
}

失敗しているテストをひとまず無視して、プロパティ名を取れるようにcreateBuidler関数を修正します。
まずはテストケースの追加。

sort.spec.js
        it('propNameというプロパティに引数で渡した値が格納される', () => {
            // Act
            const builder = createBuilder('name');
            // Assert
            expect(builder).toHaveProperty('propName', 'name');
        });

実装は簡単です。

sort.js
function createBuilder (key) {
    return {
        asc: () => { },
        desc: () => { },
        propName: key
    };
}

続けてsort関数本体を修正します。

sort.js
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関数のテストスイートを修正します。

sort.spec.js
    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');
        });
    });

実装を修正します。

sort.js
function createBuilder (key) {
    return {
        asc: () => ({propName: key}),
        desc: () => { },
        propName: key
    };
}

この修正により、すべてのテストケースが成功するようになりました。
desc()にも対応させます。

sort.spec.js
    describe('createBuilder関数', () => {
        //...
        it('desc()を呼び出した結果にはpropNameプロパティが正しく設定されている', () => {
            // Arrange
            const builder = createBuilder('name');
            // Act
            const result = builder.desc();
            // Assert
            expect(result).toHaveProperty('propName', 'name');
        });
    });
sort.js
function createBuilder (key) {
    return {
        asc: () => ({propName: key}),
        desc: () => ({propName: key}),
        propName: key
    };
}

sort関数本体のテストケースに戻って、降順ソートが動くか確認してみます。

sort.spec.js
    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'とべた書きのままになってました。

sort.js
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のテストケースの下に新しくテストケースを追加しました。

sort.spec.js
    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プロパティを追加します。

sort.js
function createBuilder (key) {
    return {
        asc: () => ({propName: key}),
        desc: () => ({propName: key}),
        propName: key,
        order: 'asc'
    };
}

asc関数、desc関数を呼び出した場合のテストコードと実装はまとめてやってしまいましょう。

sort.spec.js

        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 のアロー関数の戻り値のオブジェクトにプロパティを追加するだけです。

sort.js
function createBuilder (key) {
    return {
        asc: () => ({propName: key, order: 'asc'}),
        desc: () => ({propName: key, order: 'desc'}),
        propName: key,
        order: 'asc'
    };
}

次に進む前に、createBuilder関数のテストコードが散らかってきたので、describeでグルーピングして整理します。

sort.spec.js
    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');
            });
        });
    });

さて、Builderorderプロパティを追加したので、sort関数本体でそれを使うようにしましょう。

sort.js
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);
}

全てのテストケースが成功しました!
複数のプロパティを指定しても期待どおり動作するか、テストケースを追加して確認します。

sort.spec.js
    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関数のテストケースもこのタイミングで整理しておきます。

sort.spec.js
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としておきます。

sort.spec.js
    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しておきます。
ネストしているため、propsNamenameではなくvalues.nameとなるべき点に注意します。

sort.spec.js
   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');
        });
    });

ここで実装コードの方を眺めてみます。

sort.js
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 にアクセスできるようにするには、valuesageプロパティが必要ということになります。
なんとなく、createBuilder関数に再帰的な処理が必要なことが見えてきました。プロパティがオブジェクトである場合、再帰処理が必要なので、プロパティのキーでなく値自体も引数に渡すようにします。

sort.js
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関数のテストケースを追加しましょう。

sort.spec.js
    describe('createBuilder関数', () => {
        //...
        describe('オブジェクトの場合', () => {
            const builder = createBuilder('foo', {bar: 'baz'});

            it('ネストされたプロパティにアクセスできる', () => {
                // Act
                expect(builder).toHaveProperty('bar');
            });
        });
    });

createBuilder関数の実装を修正します。

sort.js
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;
}

テストケースを追加します。いずれもそのまま成功します。

sort.spec.js
        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関数のテストケースを元に戻します。

sort.spec.js
        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');
            });
        });

実装を修正します。

sort.js
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していたテストケースも戻します。

sort.spec.js
        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

nap5nap5

tidyjsdot-path-valueでマルチカラムソートを実現してみました。

demo code.
https://codesandbox.io/p/sandbox/peaceful-matan-cgpm5d?file=/src/index.test.ts:1,39

定義側

import { arrange, asc, desc, tidy } from '@tidyjs/tidy'
import { Path, getByPath } from 'dot-path-value'

type Param<T extends Record<string, unknown>> = {
  key: Path<T>
  sort: "asc" | "desc"
}

export const sortData = <T extends Record<string, unknown>>(data: T[]) => {
  return (params: Param<T>[]) => {
    const sorters = params.map(p => {
      if (p.sort === "desc") {
        return desc((d: T) => getByPath(d, p.key))
      }
      return asc((d: T) => getByPath(d, p.key))
    })
    return tidy(data, arrange(sorters))
  }
}

使用側

import { test, expect } from "vitest";
import { sortData } from ".";
import { users } from "./data";

test("", () => {
  const outputData = sortData(users)([
    { key: "privilege.id", sort: "desc" },
    { key: "age", sort: "asc" },
  ]);
  expect(outputData).toStrictEqual([
    {
      id: "U003",
      name: "Yosuke Suzuki",
      age: 35,
      privilege: { id: "P003", name: "Guest" },
    },
    {
      id: "U004",
      name: "Yumi Sato",
      age: 23,
      privilege: { id: "P002", name: "User" },
    },
    {
      id: "U002",
      name: "Hanako Tanaka",
      age: 31,
      privilege: { id: "P002", name: "User" },
    },
    {
      id: "U005",
      name: "Kenji Kato",
      age: 25,
      privilege: { id: "P001", name: "Admin" },
    },
    {
      id: "U001",
      name: "Taro Yamada",
      age: 28,
      privilege: { id: "P001", name: "Admin" },
    },
  ]);
});

簡単ですが、以上です。