リーダブルテストコード


はじめに

よく言われるように、ソースコードというものは書かれることよりも読まれることの方が多く、それゆえ読みやすいコードを書くということが非常に重要です。それはテストコードにおいても同様であり、プロダクトコードと同等に資産として扱う必要があります。

テストコードは具体的な値を用いて記述し、また複数の変数の値の組み合わせでテストケースを起こすため、プロダクトコードと比べて冗長になりがちです。

書籍『リーダブルコード』の14章でもテストコードの読みやすさについて触れられていますが、本稿では読みづらいテストコードをリファクタリングして読みやすくするためのテクニックを紹介したいと思います。

なおサンプルコードはJavaScriptで記述されており、そのテストコードはJest1を用いて書いています。
ソースコードはGitHubにあります。

リファクタリング(その壱)

以下の、決して読みやすいとはいえないテストコードをリファクタリングしていきます。
Scrumによるアジャイル開発プロセスを支援するソフトウェアの一部だと思ってください。

HardToReadTest.spec.js
describe('Sprint', () => {
    it('ストーリーが正しい', () => {
        const sprint = new Sprint(1);
        expect(sprint.id).toBe(1);
        expect(sprint.stories).toHaveLength(0);

        const story = new Story('環境構築', '開発環境をセットアップする');
        sprint.addStory(story);
        expect(sprint.stories).toHaveLength(1);
    });

    it('アサイン状況を正しく取得できる', () => {
        const sprint = new Sprint(1);

        const story1 = new Story('環境構築', '開発環境をセットアップする', 3, '井上');
        sprint.addStory(story1);
        const story2 = new Story('サンプル開発', 'サンプルコードを書く', 2, '山田');
        sprint.addStory(story2);
        const story3 = new Story('ユニットテスト', 'テストコードを書く', 1, '町田');
        sprint.addStory(story3);
        const story4 = new Story('E2E', 'E2Eテストを作成する', 2, '山田');
        sprint.addStory(story4);
        // 人別に、ポイント合計の降順でソートされる
        expect(sprint.assignment).toEqual(
            ['山田', 4],
            ['井上', 3],
            ['町田', 1],
        ]);
    });
});

テスト対象を明確化する

テストにおける検証対象コンポーネントをSUT(System Under Test)と呼びます。一方、SUTが依存するコンポーネントをDOC(Depended-on Component)と呼びます。
テストケースにおける主人公であるSUTをその他の登場人物と明確に区別するため、SUTを格納する変数をsutと命名するのはよいプラクティスです。

describe('Sprint', () => {
    it('ストーリーが正しい', () => {
        const sut = new Sprint(1);
        expect(sut.id).toBe(1);
        expect(sut.stories).toHaveLength(0);

        const story = new Story('環境構築', '開発環境をセットアップする');
        sut.addStory(story);
        expect(sut.stories).toHaveLength(1);
    });
    ...
})

テストケースではただ一つのことを検証する

一つのテストケースにいろいろなことを盛り込まないようにしましょう。先ほどのテストケースは2つに分割します。

describe('Sprint', () => {
    it('初期状態が正しい', () => {
        const sut = new Sprint(1);
        expect(sut.id).toBe(1);
        expect(sut.stories).toHaveLength(0);
    });

    it('ストーリーを追加できる', () => {
        const sut = new Sprint(1);
        const story = new Story('環境構築', '開発環境をセットアップする');
        sut.addStory(story);
        expect(sut.stories).toHaveLength(1);
    });
});

共通するテストフィクスチャをセットアップメソッドで作成する

テストフィクスチャとはテストの事前条件を指します。具体的にはSUTの状態、DOCの状態、環境の状態などのことです。
コードの重複を取り除くため、テストフィクスチャを作成するコードはセットアップメソッドへ移動させましょう。Jestの場合、beforeEachメソッドとなります。

describe('Sprint', () => {
    let sut;

    beforeEach(() => {
        sut = new Sprint(1);
    });

    it('初期状態が正しい', () => {
        expect(sut.id).toBe(1);
        expect(sut.stories).toHaveLength(0);
    });

    it('ストーリーを追加できる', () => {
        const story = new Story('環境構築', '開発環境をセットアップする');
        sut.addStory(story);
        expect(sut.stories).toHaveLength(1);
    });
    ...
});

AAA(またはGive-When-Then)を意識する

テストコードは以下の4つのフェーズから成り立ちます。
1. 準備
2. 実行
3. 検証
4. 後片付け

4つ目の後片付けが必要となるのはDB・ストレージ・ネットワーク等の共有リソースを使用するテストケースが中心ですので、一般的なテストケースでは準備・実行・検証が基本的な構成要素となります。
AAA(トリプルエー)とは、この3つをそれぞれArrange/Act/Assertと呼んでその頭文字を取ったものです。BDD2流ならばGiven-When-Thenと呼ぶでしょう。
テストコードを書く際はこの3つを意識し、コメントを入れて論理分割すると見通しがよくなります。

describe('Sprint', () => {
    ...
    it('ストーリーを追加できる', () => {
        // Arrange
        const story = new Story('環境構築', '開発環境をセットアップする');
        // Act
        sut.addStory(story);
        // Assert
        expect(sut.stories).toHaveLength(1);
    });
})

振舞いに影響を与える値とそうでない値を区別する

以下のテストケースを見ましょう。

describe('Sprint', () => {
    ...
    it('アサイン状況を正しく取得できる', () => {
        const sprint = new Sprint(1);

        const story1 = new Story('環境構築', '開発環境をセットアップする', 3, '井上');
        sprint.addStory(story1);
        const story2 = new Story('サンプル開発', 'サンプルコードを書く', 2, '山田');
        sprint.addStory(story2);
        const story3 = new Story('ユニットテスト', 'テストコードを書く', 1, '町田');
        sprint.addStory(story3);
        const story4 = new Story('E2E', 'E2Eテストを作成する', 2, '山田');
        sprint.addStory(story4);
        // 人別に、ポイント合計の降順でソートされる
        expect(sprint.assignment).toEqual([
            ['山田', 4],
            ['井上', 3],
            ['町田', 1],
        ]);
    });
});

Storyは複数の属性を持ちます(タイトル、記述、ポイント、担当者)が、この中でテスト対象の振舞いであるsprint.assignment(アサイン状況)に影響を与えるのはポイントと担当者です。それ以外の属性の値はこのテストケースにおいてはノイズとなります。
このような属性値には'any'といった値を設定することで、本当に関心のある属性値を際立たせることが可能です。

describe('Sprint', () => {
    ...
    it('アサイン状況を正しく取得できる', () => {
        // Arrange
        const story1 = new Story('any', 'any', 3, '井上');
        sut.addStory(story1);
        const story2 = new Story('any', 'any', 2, '山田');
        sut.addStory(story2);
        const story3 = new Story('any', 'any', 1, '町田');
        sut.addStory(story3);
        const story4 = new Story('any', 'any', 2, '山田');
        sut.addStory(story4);
        // Act
        const assignment = sut.assignment;
        // Assert
        // 人別に、ポイント合計の降順でソートされる
        expect(assignment).toEqual([
            ['山田', 4],
            ['井上', 3],
            ['町田', 1],
        ]);
    });
});

生成メソッドを活用する

しかしながら、'any'という値自体も少し邪魔ですね。new演算子で直接オブジェクトを生成するのではなく、オブジェクトを生成するヘルパーメソッドを用意しましょう。

Storyオブジェクトを生成する関数を定義します。

const defaults = (val, defaultVal) => val === undefined ? defaultVal : val;

const aStory = ({title, description, point, asignee} = {}) =>
    new Story(
        defaults(title, 'any'),
        defaults(description, 'any'),
        defaults(point, 0),
        defaults(asignee, 'any')
    );

引数に指定したオブジェクトにStoryコンストラクタの属性がある場合はそれを、そうでなければデフォルト値を使ってStoryオブジェクトを生成するように実装しています。
このヘルパーメソッドを利用すると、前述のテストケースは以下のように書き直すことができます。

describe('Sprint', () => {
    ...
    it('アサイン状況を正しく取得できる', () => {
        // Arrange
        const story1 = aStory({point: 3, asignee: '井上'});
        const story2 = aStory({point: 2, asignee: '山田'});
        const story3 = aStory({point: 1, asignee: '町田'});
        const story4 = aStory({point: 2, asignee: '山田'});
        [story1, story2, story3, story4].forEach(s => sut.addStory(s));
        // Act
        const assignment = sut.assignment;
        // Assert
        // 人別に、ポイント合計の降順でソートされる
        expect(assignment).toEqual([
            ['山田', 4],
            ['井上', 3],
            ['町田', 1],
        ]);
    });
});

ノイズが減り、少し読みやすくなったのではないでしょうか。

計算結果ではなく計算式を記述する

先ほどのコードのAssertの部分を抜き出します。

        expect(assignment).toEqual([
            ['山田', 4],
            ['井上', 3],
            ['町田', 1],
        ]);

山田さんの4(ポイント)は、Arrangeでセットした2つのストーリーのポイントの合計値です。

        const story1 = aStory({point: 3, asignee: '井上'});
        const story2 = aStory({point: 2, asignee: '山田'}); // ←これ
        const story3 = aStory({point: 1, asignee: '町田'});
        const story4 = aStory({point: 2, asignee: '山田'}); // ←これ

ですが、その情報はテストコードからは失われています。以下のように、計算した結果ではなく計算式として記述することで意図がより明確となります。

        expect(assignment).toEqual([
            ['山田', (2 + 2)], // 計算式で記述する
            ['井上', 3],
            ['町田', 1],
        ]);

さらに、以下のように定数を使用することでもっと見通しがよくなるでしょう。

    it('アサイン状況を正しく取得できる', () => {
        // Arrange
        const [pt1, pt2, pt3, pt4] = [3, 2, 1, 2]; // 定数化
        const story1 = aStory({point: pt1, asignee: '井上'});
        const story2 = aStory({point: pt2, asignee: '山田'});
        const story3 = aStory({point: pt3, asignee: '町田'});
        const story4 = aStory({point: pt4, asignee: '山田'});
        [story1, story2, story3, story4].forEach(s => sut.addStory(s));
        // Act
        const assignment = sut.assignment;
        // Assert
        // 人別に、ポイント合計の降順でソートされる
        expect(assignment).toEqual([
            ['山田', (pt2 + pt4)],
            ['井上', pt1],
            ['町田', pt3],
        ]);
    });

テストケース名を雄弁にする

先ほどのテストケースの、以下のコメントについて。

        // 人別に、ポイント合計の降順でソートされる
        expect(assignment).toEqual([
            ['山田', (pt2 + pt4)],
            ['井上', pt1],
            ['町田', pt3],
        ]);

これは、テストケース名でちゃんと表現してあげましょう。

    it('アサイン状況は人別にポイントが表示され、ポイント合計の降順でソートされる', () => {
        // Arrange
        const [pt1, pt2, pt3, pt4] = [3, 2, 1, 2];
        const story1 = aStory({point: pt1, asignee: '井上'});
        const story2 = aStory({point: pt2, asignee: '山田'});
        const story3 = aStory({point: pt3, asignee: '町田'});
        const story4 = aStory({point: pt4, asignee: '山田'});
        [story1, story2, story3, story4].forEach(s => sut.addStory(s));
        // Act
        const assignment = sut.assignment;
        // Assert
        expect(assignment).toEqual([
            ['山田', (pt2 + pt4)], //4ポイント
            ['井上', pt1], // 3ポイント
            ['町田', pt3], // 1ポイント
        ]);
    });

リファクタリング(その弐)

Storyに対する以下のテストコードをリファクタリングします。

describe('Story', () => {
    it('未アサインは開始不可', () => {
        const story = new Story('環境構築', '開発環境をセットアップする', 3, null);
        expect(story.canBeStarted).toBe(false);
    });
    it('ポイントを振ってない場合は開始不可', () => {
        const story = new Story('環境構築', '開発環境をセットアップする', 0, '井上');
        expect(story.canBeStarted).toBe(false);
    });
    it('ポイントもアサインも入ってない場合は開始不可', () => {
        const story = new Story('環境構築', '開発環境をセットアップする', 0, null);
        expect(story.canBeStarted).toBe(false);
    });
    it('ポイントもアサインも入っている場合は開始可', () => {
        const story = new Story('環境構築', '開発環境をセットアップする', 3, '井上');
        expect(story.canBeStarted).toBe(true);
    });
});

いったん、これまでに述べたリファクタリングテクニックを適用します。

describe('Story', () => {
    it('未アサインは開始不可', () => {
        // Arrange
        const sut = aStory({point: 3, asignee: null});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(false);
    });
    it('ポイントを振ってない場合は開始不可', () => {
        // Arrange
        const sut = aStory({point: 0, asignee: '山田'});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(false);
    });
    it('ポイントもアサインも入ってない場合は開始不可', () => {
        // Arrange
        const sut = aStory({point: 0, asignee: null});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(false);
    });
    it('ポイントもアサインも入っている場合は開始可', () => {
        // Arrange
        const sut = aStory({point: 3, asignee: '山田'});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(true);
    });
});

パラメータ化テストを適用する

前述の4つのテストケースはとても似通っていて冗長な印象があります。パラメータ化テスト(Parameterized Test)というテスティングパターンを使ってすっきりまとめてみましょう。

describe('Story', () => {
    test.each`
        point | asignee  | expected | desc
         ${3} | ${null}  | ${false} | ${'未アサインは開始不可'}
         ${0} | ${'山田'} | ${false} | ${'ポイントを振ってない場合は開始不可'}
         ${0} | ${null}  | ${false} | ${'ポイントもアサインも入ってない場合は開始不可'}
         ${3} | ${'山田'} | ${true}  | ${'ポイントもアサインも入っている場合は開始可'}
    `("開始可能か: $desc", ({point, asignee, expected}) => {
        // Arrange
        const sut = aStory({point, asignee});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(expected);
    });
})

test.each の部分はタグ付きテンプレートリテラル3を使って記述しており、Jestが提供するDSL4です。テンプレートリテラル中の2行目以降の各行がテストケースに展開されます。
例えば2行目は以下のテストケースと同等ということになります。

    // point = 3, asignee = null, expected = false
    it('開始可能か: 未アサインは開始不可', () => {
        // Arrange
        const sut = aStory({point: 3, asignee: null});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(false);
    });

最終的なコード

リファクタリング後のテストコードは以下となります。

ReadableTest.spec.js
const {Sprint, Story} = require('../entities');

const defaults = (val, defaultVal) => val === undefined ? defaultVal : val;

const aSprint = ({id, description} = {}) =>
    new Sprint(
        defaults(id, 1),
        defaults(description, 'any')
    );

const aStory = ({title, description, point, asignee} = {}) =>
    new Story(
        defaults(title, 'any'),
        defaults(description, 'any'),
        defaults(point, 0),
        defaults(asignee, 'any')
    );

describe('Sprint', () => {
    let sut;

    beforeEach(() => {
        sut = aSprint();
    });

    it('初期状態が正しい', () => {
        // Assert
        expect(sut.id).toBe(1);
        expect(sut.stories).toHaveLength(0);
    });

    it('ストーリーを追加できる', () => {
        // Arrange
        const story = new Story('環境構築', '開発環境をセットアップする');
        // Act
        sut.addStory(story);
        // Assert
        expect(sut.stories).toHaveLength(1);
    });

    it('アサイン状況は人別にポイントが表示され、ポイント合計の降順でソートされる', () => {
        // Arrange
        const [pt1, pt2, pt3, pt4] = [3, 2, 1, 2];
        const story1 = aStory({point: pt1, asignee: '井上'});
        const story2 = aStory({point: pt2, asignee: '山田'});
        const story3 = aStory({point: pt3, asignee: '町田'});
        const story4 = aStory({point: pt4, asignee: '山田'});
        [story1, story2, story3, story4].forEach(s => sut.addStory(s));
        // Act
        const assignment = sut.assignment;
        // Assert
        expect(assignment).toEqual([
            ['山田', (pt2 + pt4)], //4ポイント
            ['井上', pt1], // 3ポイント
            ['町田', pt3], // 1ポイント
        ]);
    });
});

describe('Story', () => {
    test.each`
        point | asignee  | expected | desc
         ${3} | ${null}  | ${false} | ${'未アサインは開始不可'}
         ${0} | ${'山田'} | ${false} | ${'ポイントを振ってない場合は開始不可'}
         ${0} | ${null}  | ${false} | ${'ポイントもアサインも入ってない場合は開始不可'}
         ${3} | ${'山田'} | ${true}  | ${'ポイントもアサインも入っている場合は開始可'}
    `("開始可能か: $desc", ({point, asignee, expected}) => {
        // Arrange
        const sut = aStory({point, asignee});
        // Act
        const canBeStarted = sut.canBeStarted;
        // Assert
        expect(canBeStarted).toBe(expected);
    });
});

参考までに、プロダクトコードは以下となります。

entities.js
class Sprint {
    constructor(id, description) {
        this.id = id;
        this.description = description;
        this.stories = [];
    }

    addStory (story) {
        this.stories.push(story);
    }

    get assignment () {
        const perAsignee = this.stories.reduce((map, story) => {
            const key = story.asignee;
            if (map.has(key)) {
                map.set(key, map.get(key) + story.point);
            } else {
                map.set(key, story.point);
            }
            return map;
        }, new Map());
        return Array.from(perAsignee).sort((s1, s2) => s2[1] - s1[1]);
    }
}

class Story {
    constructor(title, description, point = 0, asignee) {
        this.title = title;
        this.description = description;
        this.point = point;
        this.asignee = asignee;
    }

    get canBeStarted () {
        return Boolean(this.asignee) && this.point > 0;
    }
}

module.exports.Sprint = Sprint;
module.exports.Story = Story;

まとめ

テストコードが散らかっていき可読性が低下すると、テストコードの信頼性が失われ、ゆくゆくはメンテナンスされなくなってしまうリスクがあります。テストコードの負債化は、プロダクトコードの品質やメンテナンス性に大きな影響を与える由々しき問題です。
そのような状況の発生を防止するため、テストコードの可読性の大切さを知り、本稿で紹介したようなテクニックを用いて日々リファクタリングをしていくことを心がけましょう。

参考文献

  • Dustin Boswell, Trevor Foucher 『The Art of Readable Code』 (O'REILLY, 2011)
  • Gerard Meszaros 『xUnit Test Patterns』 (Addison-Wesley, 2007)

  1. Facebook社主導で開発されているオープンソースのテスティングフレームワーク 

  2. 振舞い駆動開発(Behavior Driven Development) 

  3. ECMAScript 6(ES6)で導入された機能 

  4. ドメイン固有言語(Domain Specific Language)