実例で学ぶ、JavaScriptのテスト駆動開発 ファーストステップガイド

2017/05/29

James Wright

101

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

「テスト駆動開発」という言葉は聞いたことがあっても、いきなりプロジェクトに持ち込むのは難しいもの。小さなJavaScriptのプロジェクトを例に、テスト駆動開発に取り組む方法を紹介します。

自動テストとそのメリットについてはよく知っていると思います。アプリケーションに対して一連のテストを用意しておけば、万が一なにかを壊してしまってもテストで発見できるので安心してコードを変更できます。さらに一歩進んで、コードを書く前にテストをすることもできます。テスト駆動開発(Test-driven development: TDD)として知られる手法です。

この記事ではTDDについて、またTDDが開発者にもたらす恩恵について紹介します。そのあと、TDDを使ってフォームバリデーター(ユーザーによって入力された値が所定のルールセットに一致しているかどうかを確認する機能)を実装します。

TDDについて

テスト駆動開発はプログラム開発手法の1つで、コードの各ユニットの設計、実装、テストを目的としています。また、プログラムに求められる機能の設計、実装、テストに対してもある程度活用できます。

TDDは、開発者が機能やユニットを実装する前にテストを書くエクストリームプログラミングのテストファースト手法を、コードのリファクタリングを取り入れて補完しています。この手法は一般に赤/緑/リファクタリングサイクルと呼ばれています。

The red-green-refactor cycle associated with test-driven development

  • 失敗するテストを書く(Write a failing test):ロジックを考えさせるようなテストを書き、正しい動作が生成されるようにする
    • ユニットテストでは、関数の戻り値を確認するか、モックアップされた依存オブジェクトが期待通りに呼び出されるか確認する
    • 機能テストでは、各操作におけるユーザーインターフェイスやAPIの動作が期待通りか確認する
  • テストをパスする(Make the test pass):テストをパスできる最低限の量のコードを書き、ほかのすべてのテストも続けてパスできるか確認する
  • 実装したコードをリファクタリングする(Refactor the implementation):パブリックコントラクトを壊さないようにしながらコードを更新したり書き直して、新たなテストと実施済みテストの条件を満たしつつ品質を向上させる

私はプログラミングを始めたころにTDDを教えられ、それなりに使ってきました。しかし、より複雑な要件のアプリケーションやシステムを開発するようになるにつれて、TDDが時間短縮に役立ち、品質や堅牢性の確保に役立つことが分かってきました。

次に進む前に、さまざまな種類の自動テストについて少し触れておきます。Eric Ellioがテストの種類をうまくまとめています

  • ユニットテスト(Unit tests):関数やクラスといった、アプリの個別ユニットが期待通りに動くことを確認する。アサーションでは与えられた入力に対し、ユニットが期待通りのアウトプットを返すことをテストする
  • 結合テスト(Integration tests):ユニットを組み合わせたときに、期待通りに動くことを確認する。アサーションではAPI、ユーザーインターフェイス、副作用(データベース入出力やログなど)を発生させる可能性のあるインタラクションをテストする
  • エンドツーエンドテスト(End-to-end tests):ユーザーの視点からソフトウェアが期待通りに動くこと、システムを総合的に捉え1つ1つのユニットが正しく動作することを確認する。アサーションでは主にユーザーインターフェイスをテストする

テスト駆動開発のメリット

テストカバレッジがただちに保障される

機能を実装する前にテストケースを書くことで、コードカバレッジがただちに保障され、さらにプロジェクトの開発ライフサイクルの早い段階でバグを発見できます。一方で、エラー処理を含むすべての動作を網羅するテストが必要になります。当然のことですが、TDDを実践する際には常にこのマインドセットを持って臨むべきです。

安心してリファクタリングできる

図の赤/緑/リファクタリングサイクルが示すように、実装に変更を加えても、変更後に実施済みのテストを引き続きパスできるかを確かめることで変更を検証できます。このフィードバックループは、テストをできるだけ素早く実施できるように書くことで短くできます。起こり得るすべてのシナリオを網羅することは重要で、実行時間もコンピューターによってわずかに異なりますが、無駄のない要領を得たテストを作成すれば長期的には時間を節約できます。

契約による設計

テスト駆動開発では、開発者は実装について心配せずにAPIがどのように利用されるか、またAPIがどれだけ使いやすいかを検討できます。ユニットにテストケースを適用することは本質的に実際のコールサイトを模倣することと同じなので、実装段階に入る前に外部設計を修正できます。

不要なコードを避けられる

テストに関連する実装の変更に応じて頻繁に、または自動的にテストをするかぎり、実施済みテストをパスできる状態にしておくことが不要なコードを追加してしまう可能性を減らすことになります。その結果、保守しやすい簡単に理解できるコードベースを作成できます。従って、TDDはKISS(Keep it simple, stupid!)の原則を守るのに役立ちます。

結合に依存しない

入力要件に基づいたユニットテストを書いていれば、コードベースに結合されたあともユニットは期待通りに動作します。ただし、新たなコードのコールサイトを正しく起動できることを確認するためにも結合テストは必要です。

たとえば、ユーザーが管理者かどうか判定する次の関数を考えます。

'use strict'

function isUserAdmin(id, users) {
    const user = users.find(u => u.id === id);
    return user.isAdmin;
}

ユーザーデータはハードコードではなく、パラメーターとして受け取るようにします。こうしておくことで、テストではあらかじめデータを入れておいた配列を渡せるようになります。

const testUsers = [
    {
        id: 1,
        isAdmin: true
    },

    {
        id: 2,
        isAdmin: false
    }
];

const isAdmin = isUserAdmin(1, testUsers);
// TODO: assert isAdmin is true

この手法により、ユニットをシステムのほかの部分から切り離して実装、テストできます。データベースにユーザーが登録されたら、ユニットを結合し結合テストを書いてパラメーターをユニットに正しく渡せるか検証できます。

JavaScriptでのテスト駆動開発

JavaScriptで書かれたフルスタックソフトウェアが登場したことで、クライアントサイドとサーバーサイドの両方をテスト可能なテストライブラリーが大量に生まれました。そうしたライブラリーの1つが今回使用するMochaです。

個人的には、TDDの使用例にはフォームバリデーションが適していると思います。フォームバリデーションが一般的に次の手順を踏む、ある程度複雑な作業だからです。

  1. バリデーションの対象となる値を<input>から読み込む
  2. 値に対してルール(例:アルファベット、数字)を適用する
  3. 無効であれば、理由が分かるようにしてユーザーにエラーを通知する
  4. 次のバリデーション入力に対して同じことを繰り返す

記事のためにCodePenを用意しました。典型的なテストコードと空のvalidateForm関数が含まれています。最初にこのCodePenをフォークしてください

このフォームバリデーションAPIはHTMLFormElement<form>)インスタンスを取得し、data-validation属性を持っている各入力を検証します。属性が取り得る値は次のとおりです。

  • alphabetical:大文字と小文字を区別しない、英語のアルファベット26文字の任意の組み合わせ
  • numeric:0から9の数字の任意の組み合わせ

エンドツーエンドテストを書き、実際のDOMノードに対するvalidateFormの機能や、最初にサポートする2つのバリデーションタイプを検証します。最初の実装が動作したら、TDDの手順に沿いながらより小さなユニットを書くことで徐々にリファクタリングしていきます。

以下がテストで使うフォームです。

<form class="test-form">
    <input name="first-name" type="text" data-validation="alphabetical" />
    <input name="age" type="text" data-validation="numeric" />
</form>

それぞれのテストの間にフォームの新たなクローンを作成することで、副作用が起きる可能性を取り除きます。trueパラメーターをcloneNodeに渡すとフォームの子ノードも同様にクローンされます。

let form = document.querySelector('.test-form');

beforeEach(function () {
    form = form.cloneNode(true);
});

最初のテストケースを書く

describe('the validateForm function', function () {})スイートを使ってAPIをテストします。内部の関数に最初のテストケースを書き、アルファベットのルール、数字のルールに合致する値は両方とも有効になることを確認します。

it('should validate a form with all of the possible validation types', function () {
    const name = form.querySelector('input[name="first-name"]');
    const age = form.querySelector('input[name="age"]');

    name.value = 'Bob';
    age.value = '42';

    const result = validateForm(form);
    expect(result.isValid).to.be.true;
    expect(result.errors.length).to.equal(0);
});

フォークしたコードを変更して保存したら、テストの失敗が表示されるはずです。

A failing Mocha test case

では、このテストを緑に変えます! テストを満たす最小限の、妥当な(return true;としない!)量のコードを書くことを忘れないでください。つまり、エラー報告についてはいまのところ考える必要はありません。

以下が最初の実装です。フォームのinput要素に対する繰り返し処理の中で正規表現を使って、それぞれの値を検証します。

function validateForm(form) {
    const result = {
        errors: []
    };

    const inputs = Array.from(form.querySelectorAll('input'));
    let isValid = true;

    for (let input of inputs) {
        if (input.dataset.validation === 'alphabetical') {     
            isValid = isValid && /^[a-z]+$/i.test(input.value);
        } else if (input.dataset.validation === 'numeric') {
            isValid = isValid && /^[0-9]+$/.test(input.value);
        }
    }

    result.isValid = isValid;
    return result;
}

テストにパスしたはずです。

A passing Mocha test case

エラー処理

最初のテストの下に次のテストを書きます。このテストでは、アルファベットフィールドが無効な場合に、戻り値であるresultオブジェクトのerror配列に期待通りのメッセージが格納されたErrorインスタンスが含まれているかを検証します。

it('should return an error when a name is invalid', function () {
    const name = form.querySelector('input[name="first-name"]');
    const age = form.querySelector('input[name="age"]');

    name.value = '!!!';
    age.value = '42';

    const result = validateForm(form);

    expect(result.isValid).to.be.false;
    expect(result.errors[0]).to.be.instanceof(Error);
    expect(result.errors[0].message).to.equal('!!! is not a valid first-name value');
});

CodePenフォークを保存したら、アウトプットに新たなテストケースの失敗が表示されるはずです。実装を更新して両方のテストケースを満たすようにします。

function validateForm(form) {
    const result = {
        get isValid() {
            return this.errors.length === 0;
        },

        errors: []
    };

    const inputs = Array.from(form.querySelectorAll('input'));

    for (let input of inputs) {
        if (input.dataset.validation === 'alphabetical') {     
            let isValid = /^[a-z]+$/i.test(input.value);

            if (!isValid) {
                result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`));
            }
        } else if (input.dataset.validation === 'numeric') {
            // TODO: we'll consume this in the next test
            let isValid = /^[0-9]+$/.test(input.value);
        }
    }

    return result;
}

次に、数字のバリデーションエラーが正しく処理されていることを確認するテストを追加します。

it('should return an error when an age is invalid', function () {
    const name = form.querySelector('input[name="first-name"]');
    const age = form.querySelector('input[name="age"]');

    name.value = 'Greg';
    age.value = 'a';

    const result = validateForm(form);

    expect(result.isValid).to.be.false;
    expect(result.errors[0]).to.be.instanceof(Error);
    expect(result.errors[0].message).to.equal('a is not a valid age value');
});

テストに失敗することを確認したら、以下のようにvalidateForm関数を更新します。

} else if (input.dataset.validation === 'numeric') {
    let isValid = /^[0-9]+$/.test(input.value);

    if (!isValid) {
        result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`));
    }
}

最後に、複数のエラーが処理されることを確認するテストを追加します。

it('should return multiple errors if more than one field is invalid', function () {
    const name = form.querySelector('input[name="first-name"]');
    const age = form.querySelector('input[name="age"]');

    name.value = '!!!';
    age.value = 'a';

    const result = validateForm(form);

    expect(result.isValid).to.be.false;
    expect(result.errors[0]).to.be.instanceof(Error);
    expect(result.errors[0].message).to.equal('!!! is not a valid first-name value');
    expect(result.errors[1]).to.be.instanceof(Error);
    expect(result.errors[1].message).to.equal('a is not a valid age value');
});

2つ目と3つ目のテスト用にエラー処理を実装しているので、この新たなテストケースはその場でパスできるはずです。ここまでの作業を正しくできているかどうかは実装したコードを私のコードと比べることで確認できます。

バリデーターのリファクタリング

テストを網羅する関数を作りましたが、まだまだコードの臭いがします。

  • 複数責任
    • 同じ関数内で、インプット内部のDOMノードを取得し、ルールセットを指定し、結果を処理している。これはSOLIDの原則における単一責任の原則に反する
    • 加えて、コードが出力する結果が抽象化されていないため、ほかの開発者が理解するのが難しい
  • 密結合
    • 現状の実装では上で述べたように各責任が絡み合っているため、それぞれの責任に関わる更新でシステムに欠陥を作り込む可能性が高い。つまり、メソッドの規模が大きいため、問題に対処するために細かい変更を1つ加えただけでもデバッグが困難なものとなる
    • さらに、ifステートメントを更新しなければ、バリデーションルールを追加したり変更したりできない。これはSOLIDの原則におけるオープン・クローズドの原則に反する
  • ロジックの重複:フォーマットやエラーメッセージを更新したいときや、配列に別のオブジェクトを追加したいとき、2箇所を更新しなければならない

幸い、バリデーター関数の機能テストを書いてあるので、コードを壊すことはないという安心感の元でコードを改善できます。

TDDを使って以下の役割を持つ関数を別々に書きます。

  1. 入力をバリデーションクエリにマッピング
  2. 適切なデータ構造からバリデーションルールを読み込む

createValidationQueries関数

HTMLInputElementNodeListを、フォームフィールドの名前を格納するオブジェクト、バリデートに使うタイプを格納するオブジェクト、フィールドの値を格納するオブジェクトにそれぞれマッピングします。validateForm関数をDOMから分離できるだけでなく、ハードコードされた正規表現を置き換えるときにバリデーションルールを調べるのが簡単になります。

たとえば、first-nameフィールドのバリデーションクエリオブジェクトは次のようになります。

{
    name: 'first-name',
    type: 'alphabetical',
    value: 'Bob'
}

validateForm関数の上に、createValidationQueriesという名前の空の関数を作成します。続いて、validateFormをテストするdescribe suiteの外に、「the createValidationQueries function」という名前の別のdescribe suiteを作成します。

以下のようなテストケースを1つ書いてください。

describe('the createValidationQueries function', function () {
    it(
        'should map input elements with a data-validation attribute to an array of validation objects',

        function () {
            const name = form.querySelector('input[name="first-name"]');
            const age = form.querySelector('input[name="age"]');

            name.value = 'Bob';
            age.value = '42';

            const validations = createValidationQueries([name, age]);

            expect(validations.length).to.equal(2);

            expect(validations[0].name).to.equal('first-name');
            expect(validations[0].type).to.equal('alphabetical');
            expect(validations[0].value).to.equal('Bob');

            expect(validations[1].name).to.equal('age');
            expect(validations[1].type).to.equal('numeric');
            expect(validations[1].value).to.equal('42');
        }
    );
});

テストに失敗することを確認したら、以下のコードを実装します。

function createValidationQueries(inputs) {
    return Array.from(inputs).map(input => ({
        name: input.name,
        type: input.dataset.validation,
        value: input.value
    }));
}

テストをパスしたらvalidateFormforループを更新して、新たな関数を呼び出しクエリオブジェクトを使ってフォームの有効性を判定するようにします。

for (let validation of createValidationQueries(form.querySelectorAll('input'))) {
    if (validation.type === 'alphabetical') {     
        let isValid = /^[a-z]+$/i.test(validation.value);

        if (!isValid) {
            result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`));
        }
    } else if (validation.type === 'numeric') {
        let isValid = /^[0-9]+$/.test(validation.value);

        if (!isValid) {
            result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`));
        }
    }
}

このCodePenのように新たなテストと実施済みのテストを両方パスできるようになったら、次はより大きな変更を実施します。バリデーションルールの分離です。

validateItem関数

ハードコードされたルールを取り除くため、ルールをMapとして受け取り入力の有効性を確認する関数を書きます。

createValidationQueriesと同じように、実装する前に新たなテストスイートを書きます。validateFormの実装の上に、validateItemという名前の空の関数を書いてください。次にメインのdescribe suite内に別のdescribe suiteを書いて以下を新たに追加します。

describe('the validateItem function', function () {
    const validationRules = new Map([
        ['alphabetical', /^[a-z]+$/i]
    ]);

    it(
        'should return true when the passed item is deemed valid against the supplied validation rules',

        function () {
            const validation = {
                type: 'alphabetical',
                value: 'Bob'
            };

            const isValid = validateItem(validation, validationRules);
            expect(isValid).to.be.true;
        }
    );
});

動作確認をメイン関数から独立して実施するために、ルールが格納されたMapをテストから実装に明示的に渡しています。従って、このテストはユニットテストです。以下がvalidateItem()の最初の実装です。

function validateItem(validation, validationRules) {
    return validationRules.get(validation.type).test(validation.value);
}

テストにパスしたら2つ目のテストケースを書き、バリデーションクエリが無効な場合に関数がfalseを返すことを確認します。現状の実装のままでテストにパスするはずです。

it(
    'should return false when the passed item is deemed invalid',

    function () {
        const validation = {
            type: 'alphabetical',
            value: '42'
        };

        const isValid = validateItem(validation, validationRules);
        expect(isValid).to.be.false;
    }
);

最後に、バリデーションタイプが見つからなかった場合にvalidateItemfalseを返すことを確認するテストケースを書きます。

it(
    'should return false when the specified validation type is not found',

    function () {
        const validation = {
            type: 'foo',
            value: '42'
        };

        const isValid = validateItem(validation, validationRules);
        expect(isValid).to.be.false;
    }
);

値を対応する正規表現でテストする前に、指定されたバリデーションタイプがvalidationRulesマップに存在するか確認するようにします。

function validateItem(validation, validationRules) {
    if (!validationRules.has(validation.type)) {
        return false;
    }

    return validationRules.get(validation.type).test(validation.value);
}

テストをパスしたら、createValidationQueriesの上に新たなMapを作ります。ここにAPIが使用する実際のバリデーションルールを格納します。

const validationRules = new Map([
    ['alphabetical', /^[a-z]+$/i],
    ['numeric', /^[0-9]+$/]
]);

最後に、validateForm関数をリファクタリングして新たな関数とルールを使うようにします。

function validateForm(form) {
    const result = {
        get isValid() {
            return this.errors.length === 0;
        },

        errors: []
    };

    for (let validation of createValidationQueries(form.querySelectorAll('input'))) {
        let isValid = validateItem(validation, validationRules);

        if (!isValid) {
            result.errors.push(
                new Error(`${validation.value} is not a valid ${validation.name} value`)
            );
        }
    }

    return result;
}

すべてのテストにパスできるはずです。テスト駆動開発を使ってコードをリファクタリングし品質を向上できました! 最終的な実装は、このCodePenのようになっているはずです。

最後に

TDDの手順に沿って開発することで、フォームバリデーションの最初のコードを実装し、さらにコードを独立した、分かりやすい複数のパーツに分解できました。この記事を楽しんでくれたなら幸いです。今回学んだことを普段の作業の中でさらに発展させてください。

※本記事はVildan Softicが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Learning JavaScript Test-Driven Development by Example

[翻訳:薮田佳佑/編集:Livit

Copyright © 2017, James Wright All Rights Reserved.

James Wright

James Wright

高いスキルを持つように努力をして、ソフトウェア開発者でWebテクノロジーに情熱を傾けています。現在はNode.js、C#、Goに取り組んでいます。SkyやNET-A-PORTERに勤務した経験があります。

Loading...