Jestでテスト駆動開発(TDD)を実践してみよう

こんにちは!SPEEDA開発の岩見です。普段は業務でKotlinやClojureなどのJVM言語によく触れています。

今回TypeScriptを使ってテスト駆動開発(以下TDD)を実践する機会があり、良い勉強になったなと感じたので記事を書くことにしました。この記事では、TypeScriptとJestというテストフレームワークを用いてTDDをやってみる、というところまでを簡単にまとめています。

これからTypeScriptやJestに触れる方、TypeScriptは使ったことがあるが自動テストやTDDにはあまり馴染みがない方などに読んでいただけると嬉しいなと思っています。

Jestの特徴を知る

jestjs.io

JestはFacebookがメンテナンスを行っているJavaScript用のテストフレームワークです。特徴としては以下のようなものが挙げられます。

並列実行可能なこと

アプリケーションの規模が肥大化するにつれて、テストのケース数、実行時間が増加するのはよくあることかと思います。Jestはデフォルトでテストケースを並列実行するため、逐次実行させるよりも高速にテストを回すことが出来ます。 *1

Matcherが豊富なこと

後ほど触れますが、Jestは色々な種類のMatcherをサポートしています。 値の同一性を確認する toBe, toEqual に始まり*2、Objectがあるプロパティを保持しているか確認する toHaveProperty 、Errorを投げたか確認する toThrowError 等、確認したいことはおおよそ実現できるくらい豊富な種類があります。

Mockのサポートが豊富なこと

いわゆるService層のクラスの単体テストを書く場合にRepository層等の依存するクラスをMock化したい場合が出てくると思います。こうしたケースでもJestでは簡単にFunctionやObjectをMockすることができるので、実装に依存せずふるまいに着目した上で開発を進めることが出来ると思います。


その他にもテストカバレッジを出力できたりasync/awaitに対応していたりと色々と細かな機能を多く備えていますが、この記事では全ては触れません。より詳しくはJestのドキュメントを眺めてみて下さい。

それでは以下でJestの基本的な記法をおさえていきましょう!

Jestの基本的な記法を知る

基本

以下のようなシンプルなfunctionが存在すると仮定します。

export function add(a: number, b: number): number {
    return a + b;
}

これに対して、Jestでは describe 関数で関連するテストのまとまりを、, it (もしくは test )関数でひとつひとつのテストを表現します。

import { add } from "."

describe(("add"), () => {
    it("2つの数の和を返す", () => {
        expect(add(1,2)).toBe(3);
    });
});

describe 関数はJestにおいて同じObjectやClassをテストする際に一つのまとまり、ブロックとして表現する際に利用します。 it関数は test関数のaliasとなっているのでどちらを使っても大丈夫です。

before / after

JestにはJUnitの@Before/@Afterのように、before.../after... のような関数を使って各テストケースやテストスイート全体の前後で実行する処理を書くことが出来ます。 beforeAll関数は全てのテストの実行前に一度だけ実行され、beforeEach関数はtest関数で表現される各テストの実行直前に実行されます。afterAll,afterEachはそれぞれテストの実行後に対応するタイミングで実行されます。

mock を知る

以下のようなUserを取得する処理を持った簡単なユースケースとそれに対応するドメインオブジェクト、リポジトリを考えてみます。

export class UserUsecase {
    private repository: UserRepository;
    constructor(repository: UserRepository) {
        this.repository = repository;
    }
    public get(id: String): User {
        return this.repository.get(id);
    }
}

export class User {
    id: string;
    name: string;
}

export interface UserRepository {
    get(id: String): User;
}

この際、 UserUsecaseUserRepositoryに依存することになり、単体テストにおいてはモックオブジェクトに差し替えたくなります。

Jestではいくつかの方法でモックを作ることができます。例として、以下のようなやり方でUserRepositoryのモックを作成し、 UserUsercaseの単体テストを書いてみます。

import { User, UserRepository, UserUsecase } from "."

describe("UserUsecase", () => {
    it("idでユーザーを取得する", () => {
        const repository = {} as UserRepository;
        const get = jest.fn();
        const expected = {} as User;
        repository.get = get;
        get.mockReturnValue(expected);

        expect(new UserUsecase(repository).get("id")).toEqual(expected);
        expect(get).toBeCalledWith("id");
    });
});

モックを作成するやり方は他にもいくつかあり、詳しい説明は公式ドキュメントに譲ります。

Mock Functions · Jest ES6 Class Mocks · Jest

実際にやってみる

ここからは実際にJestや必要なものをインストールして、テスト用のプロジェクトを作ってJestの魅力を体感してみましょう。

今回は有名なFizzBuzz問題をテスト駆動で作ってみることにします。

下準備

必要なものをインストールする

今回はまっさらなプロジェクトに以下のようなものをインストールしていきます。

$ mkdir sandbox
$ cd sandbox
$ yarn init
$ yarn add --dev typescript jest @types/jest ts-jest

ts-jestはTypeScriptのプリプロセッサで、TypeScriptで書いたテストをJestで実行するために必要になります。*3

次に、以下のコマンドでts-jestで必要とする設定ファイルを作成します。

$ yarn ts-jest config:init

最後にpackage.jsonに以下のようにjestの実行スクリプトを追加して、 yarn run testを実行すると単体テストが全て実行されるようにしましょう。

{
  (中略)
  "scripts": {
    "test": "jest"
  },
  (中略)
}

TDDを実践する

最初のテストを書く

いよいよTDDの実践に入ります。改めて今回実装するFizzBuzz問題の仕様を確認しておきましょう。

  • 1から100までの数字に対して、以下のルールに沿って文字列を出力する
    • 3の倍数であればFizz
    • 5の倍数であればBuzz
    • 15の倍数であればFizzBuzz
    • 上記のいずれでもない場合はその数字

まずは最初のテストを書いてみましょう。一旦1から100実行することは忘れて、「3の倍数を受け取るとFizzを返す」というシンプルな関数を持ったFizzbuzzクラスを作ることにします。

プロジェクト内に fizzbuzz.test.ts という新しいファイルを作って、以下のようなテストを書いてみます。

describe("FizzBuzzは", () => {
    test("3の倍数を受け取るとFizzを返す", () => {
        expect(new FizzBuzz().execute(3)).toEqual("Fizz");
    });
});

この状態で一度 yarn run testを実行してみましょう。

$ yarn run test
yarn run v1.22.10
$ jest
 FAIL  ./fizzbuzz.test.ts
  ● Test suite failed to run

    fizzbuzz.test.ts:3:20 - error TS2304: Cannot find name 'FizzBuzz'.

    3         expect(new Fizzbuzz().execute(3)).toEqual("Fizz");
                         ~~~~~~~~

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.298 s, estimated 2 s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

テストをパスさせる実装を書く

当然ではありますがFizzbuzzなんてクラスはまだ存在していないのでテストが失敗しました。一旦仮実装で以下のようなFizzbuzzクラスをfizzbuzz.tsというファイルに作ります。

export class FizzBuzz {
    public execute(i: number): String {
        return "Fizz";
    }
}

もう一度yarn run testを実行します。

$ yarn run test
yarn run v1.22.10
$ jest
 PASS  ./fizzbuzz.test.ts
  FizzBuzzは
    ✓ 3の倍数を受け取るとFizzを返す (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.341 s
Ran all test suites.
Done in 1.72s.

どんどん仕様を追加していく

無事テストがパスしました。次は「5の倍数を受け取るとBuzzを返す」仕様を追加したいので、テストに以下のように書き加えます。

import { FizzBuzz } from "./fizzbuzz";

describe("FizzBuzzは", () => {
    test("3の倍数を受け取るとFizzを返す", () => {
        expect(new FizzBuzz().execute(3)).toEqual("Fizz");
    });
    test("5の倍数を受け取るとBuzzを返す", () => {
        expect(new FizzBuzz().execute(5)).toEqual("Buzz");
    });
});

もう一度テストを実行すると以下のように新しく追加したテストが失敗します。

$ yarn run test
yarn run v1.22.10
$ jest
 FAIL  ./fizzbuzz.test.ts
  FizzBuzzは
    ✓ 3の倍数を受け取るとFizzを返す (1 ms)5の倍数を受け取るとBuzzを返す (2 ms)

  ● FizzBuzzは › 5の倍数を受け取るとBuzzを返す

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

    Expected: "Buzz"
    Received: "Fizz"

       6 |     });
       7 |     test("5の倍数を受け取るとBuzzを返す", () => {
    >  8 |         expect(new FizzBuzz().execute(5)).toEqual("Buzz");
         |                                           ^
       9 |     });
      10 | });

      at Object.<anonymous> (fizzbuzz.test.ts:8:43)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.351 s, estimated 2 s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

今度は今失敗しているテストをパスさせるような実装を書きましょう。一番簡単に実装すると以下のようになります。

export class FizzBuzz {
    public execute(i: number): String {
        return i % 5 == 0 ? "Buzz": "Fizz";
    }
}

今度はテストがパスするはずです。

テストが全て通ったことを確認したら、コード内に重複や無駄な処理が無いか確認し、それらを取り除いたり整理したりできる部分が無いか確かめてみましょう。

このように「失敗するテストを書く(Red)」「テストをパスさせるコードを書く(Green)」「テストが通る状態を保ちながらコードを整理する(Refactoring)」というサイクルを短いスパンで繰り返していくのがテスト駆動開発の基本サイクルとなります。

モックを使ってみる

後は「15の倍数の時はFizzBuzzを返す」「それ以外の場合は与えられた数値を文字列として返す」仕様を実装すれば、ひとまずFizzBuzzクラスは実装が完了です。

今回は1から100までFizzBuzzを繰り返すようなプログラムを書きたいので、「与えられた範囲の数字でFizzBuzzを繰り返す」役割を持ったFizzBuzzExecutorという新しいクラスを作ることにします。FizzBuzzExecutorの単体テストにおいては、FizzBuzzの実装に依存したくないので、FizzBuzzをモック化してテストを書いてみます。

describe("FizzBuzzExecutorは", () => {
    test("1から100までFizzBuzzする", () => {
        const fizzBuzz = {} as FizzBuzz;
        const execute = jest.fn();
        fizzBuzz.execute = execute;
        new FizzBuzzExecutor(fizzBuzz).execute(1,100)

        expect(execute).toHaveBeenCalledTimes(100)
    })
})

toHaveBeenCalledTimesはモック化された対象の関数が何回呼び出しされたかを確認することが出来るMatcherです。今回は100回きちんと呼び出しがされているか確認するために利用してみました。 このように必要に応じてmockや時にspy等を利用することで、プログラムが依存する関数やオブジェクトの詳細を気にしすぎることなく開発を進めることが出来ます。

なお今回はテスト駆動で進めるイメージを持ってもらいやすくするためにややコードやプロセスを冗長に記載しましたが、普段の開発では必要に応じてよりシンプルな設計を採用したり、明白な実装をスキップして進めることが多いです。

まとめ

Jestの簡単な記法の確認から、実際にテストファーストで開発するプロセスをやってみるところまで駆け抜けてみました。 最初はテストファーストで開発を進めることに違和感を覚えることもあると思います。ですが開発を重ねていくうちに、テストが安全帯のような役割を担い、自分たちの設計をより良いものにしてくれていることを実感できるようになるのではないかと思います。

仲間募集中!

私達は普段テスト駆動開発とペアプログラミングを駆使してプロダクトの開発を行っています。使用する言語も今回紹介したTypeScriptのみならず、Kotlin、Clojure、Rust等多様な言語に挑戦する機会があります。 ご興味を持たれた方がいらっしゃれば、プロダクトやユーザベースという会社、そして開発チームの事をカジュアルにお話する機会を設けさせていただきたいので、以下からご応募いただければと思います!

apply.workable.com

*1:なお--runInBandオプションを付与することで全てのテストを逐次実行させるようにすることも出来ます。

*2:厳密に言うとtoBeとtoEqualの動きは異なります。 toBeはObject.is(value1,value2)を用いて同値性を判断しますが、toEqualはオブジェクトや配列内の全てのフィールドの値を再帰的に確認します。

*3:TypeScriptで書かれたテストをJestで実行するにはBabelを利用する方法もあります。詳しくはこちらをご参照下さい。 Getting Started · Jest

© Uzabase, Inc. All rights reserved.