Open6

【学び】Kahlan TIPS

koukou

PHPテストフレームワークである Kahlan(GitHub) についての個人的な学びまとめ。

目次

参考になるもの

※Kahlanは参考になる記事が本当に少ないので基本的には公式リファレンスを読む
※Kahlanは大元をPHPUnitをラップして作成している(?)そうなので、類似メソッドについてはPHPUnitの方に説明を求めるとより良い

その他書きたいことメモ

  • expect($cbFunc())->toBeNull()expect($cbFunc)->toThrow()の挙動の違いについて(前者は明示的に関数実行(())をしないと(object) Closureというただの未実行コールバック関数として判定されてしまうが、後者は特に関数実行を明示しなくても$cbFuncを実行処理してくれる)
  • モックの allow()->toReceive()->with()->andReturn('hoge')allow()->toReceive()->andRun(fn () => 'hoge') の違いについて(モック化したと思ったのにDB疎通されてしまう現象の原因臭い。登録・更新系処理を前者でモックしてしまうと正しくモック出来ていない。andRun()を使う後者でモック化すると上手くいく)
koukou

テストの実行結果を見易くする --reporter=verbose オプション

概要

composer.jsonに記載されているデフォルトのテスト実行設定では、簡潔な結果のみの表示しかされないが、--reporter=verboseを付けることで、より詳細なテストの実行結果を表示させることができる(参考:公式リファレンス)。

方法

composer.jsonの以下の部分に--reporter=verboseの記述を追加する。

"scripts": {
    ...,
    "spec": [
        // --reporter=verbose を追加
        "./vendor/bin/kahlan --reporter=verbose"
    ],
}

追加したあとは、普通にcomposer specでテストを実行。

参考画像

koukou

基本的なテストの書き方(前提条件結果

概要

テストの基本的な書き方は、前提条件結果の順に書くと良い。
Kahlanにおいては、以下のようにコードを対応させて書くと綺麗にテストコードを設計することができる(はず)。

  • 前提 → describe
  • 条件 → context
  • 結果 → it

describeは、HTMLでいうdivタグのように「区切り」を明示的に作ることができる。必ずしも「前提」を書くためだけに使うというわけではない。

// イメージ
describe('テストする対象のユースケース(?)', function () {
    describe('前提: 〇〇', function () {
        context('条件: 〇〇', function() {
            it('結果: 〇〇', function () {
                expect('hoge')->toBe('hoge'); // テスト実行箇所
            });
        });
    });
});

参考:公式リファレンス(Describe Your Specs)

実際のサンプル

実際にテストを実行している箇所(toBe, toEqualなどの"マッチャー"と呼ばれるもの)については、公式リファレンス参照(Matchers)。

describe('Todo一覧を取得する[fetchTodos]', function () {
    // モック作成(TodoQueryというクエリサービスクラスをモックしている)
    $mock = Double::instance(['extends' => TodoQuery::class]);

    describe('前提: Todoが2件存在する', function () use ($mock) {
        // モックがTodoを2件固定で返すように指定
        allow($mock)->toReceive('fetchAll')->andReturn(new Collection([
            ['id' => 1, 'body' => 'TodoA'],
            ['id' => 2, 'body' => 'TodoB'],
        ]));

        context('条件: Todo一覧を取得した場合', function () use ($mock) {
            $todoService = new TodoService($mock);
            $todos = $todoService->fetchTodos();

            it('結果: Todoが2件取得できる', function () use ($todos) {
                expect($todos->count())->toBe(2);
                expect($todos[0]['body'])->toEqual('TodoA');
                expect($todos[1]['body'])->toEqual('TodoB');
            });
        });
    });

    describe('前提: Todoが1件も存在しない', function () use ($mock) {
        // モックがTodoを0件固定で返すように指定
        allow($mock)->toReceive('fetchAll')->andReturn(new Collection([]));

        context('条件: Todo一覧を取得した場合', function () use ($mock) {
            $todoService = new TodoService($mock);
            $todos = $todoService->fetchTodos();

            it('結果: Todoが0件取得できる', function () use ($todos) {
                expect($todos->count())->toBe(0);
            });
        });
    });
});
koukou

共通で使う値を定義できる given メソッド

概要

givenメソッドは、共通で使いたい値を予め定義しておき、その値を使いたい場所で$this->hoge(hogeの部分はgivenメソッドの第一引数で決めた名前)とすることで呼び出すことができる便利なもの。

これが欲しくなる場面の例として、上の「基本的なテストの書き方(前提条件結果)」の中の、「実際のサンプル」に書いてあるテストコードの上から3行目部分で、テストケースごとに共通で使うモックを予め定義している部分があるが($mock = Double::instance()部分)、ここで定義した値を使う際はPHPの仕様上、各describe, context, itなどを跨ぐ際に都度useを用いる必要があるため少し煩わしい。

この煩わしさを解消するのが given メソッド。
その便利さは実際のコードを見た方が早いのでそちらで解説。

使い方

// 使い方
given('呼び出すときの名前', コールバック関数);

// サンプル(ex. 共通で使いたい名前を定義するケース)
given('name', function () {
  return '山田 太郎'
});

// PHPは1行で処理できる内容の関数であればアロー関数を用いることが可能なので以下のように書くこともできる
given('name', fn () => '山田 太郎');

// givenで定義した値を呼び出すとき
it('名前が山田太郎と表示される', function () {
    // $this->name(givenの第一引数で定義した名前)で定義した値を呼び出すことができる
    expect($this->name)->toBe('山田 太郎');
});

参考:公式リファレンス(Memoized Helper using given())

実際のサンプル

最初にgivenを用いて、共通で使用するモックを定義している。

describe('Todo一覧を取得する[fetchTodos]', function () {
    // 各テストケースで使用するモックを予めgivenメソッドを用いて定義(使用する際は $this->todoQuery のようにして呼び出す)
    given('todoQuery', fn () => Double::instance(['extends' => TodoQuery::class]));
    given('todoCommand', fn () => Double::instance(['extends' => TodoCommand::class]));

    // この部分で、use ($todoQuery, $todoCommand) とする必要がなくなる!(givenの恩恵)
    describe('前提: Todoが2件存在する', function () {
        allow($this->todoQuery)->toReceive('fetchAll')->andReturn(new Collection([
            ['id' => 1, 'body' => 'TodoA'],
            ['id' => 2, 'body' => 'TodoB'],
        ]));

        // また、この部分でも use ($todoQuery, $todoCommand) とする必要がなくなる!(givenの恩恵)
        context('条件: Todo一覧を取得した場合', function () {
            $todoService = new TodoService($this->todoQuery, $this->todoCommand);
            $todos = $todoService->fetchTodos();

            it('結果: Todoが2件取得できる', function () use ($todos) {
                expect($todos->count())->toBe(2);
                expect($todos[0]['body'])->toEqual('TodoA');
                expect($todos[1]['body'])->toEqual('TodoB');
            });
        });
    });

    // この部分で、use ($todoQuery, $todoCommand) とする必要がなくなる!(givenの恩恵)
    describe('前提: Todoが1件も存在しない', function () {
        allow($this->todoQuery)->toReceive('fetchAll')->andReturn(new Collection([]));

        // また、この部分でも use ($todoQuery, $todoCommand) とする必要がなくなる!(givenの恩恵)
        context('条件: Todo一覧を取得した場合', function () {
            $todoService = new TodoService($this->todoQuery, $this->todoCommand);
            $todos = $todoService->fetchTodos();

            it('結果: 取得件数が0件である', function () use ($todos) {
                expect($todos->count())->toBe(0);
            });
        });
    });
});

補足:givenで定義した値のスコープについて

givenで定義した値のスコープは describe内部のみ になる。

describe('Todo一覧を取得する', function () {
    given('hoge', fn () => 'hoge'); // ここで定義した値は、describe('Todo一覧を取得する')の中のみで使用可能

    describe('前提: 〇〇', function () {
        context('条件: 〇〇', function() {
            it('結果: 〇〇', function () {
                expect($this->hoge)->toBe('hoge'); // $this->hogeは「Todo一覧を取得する」で定義されたものなので使うことができる
            });
        });
    });
});

describe('Todoを登録する', function () {
    given('fuga', fn () => 'fuga'); // ここで定義した値は、describe('Todoを登録する')の中のみで使用可能

    describe('前提: 〇〇', function () {
        context('条件: 〇〇', function() {
            it('結果: 〇〇', function () {
                expect($this->hoge)->toBe('hoge'); // $this->hogeは「Todo一覧を取得する」の方で定義されているものなのでエラーとなる
                expect($this->fuga)->toBe('fuga'); // $this->fugaは「Todoを登録する」で定義されたものなので使用可能
            });
        });
    });
});
koukou

[WIP] andReturnandRunの挙動の違い

MEMO

/**
 * ユーザー登録
 *
 * @param string $name
 * @return void
 * @throws UserNameDuplicateException
 */
public function createUser(string $name): void
{
    // 重複チェック(本当はcheckDuplicateUserByNameメソッド内で重複時は例外を投げる方が綺麗。この実装は返り値bool)
    $isDuplicate = $this->userQuery->checkDuplicateUserByName($name);
    if ($isDuplicate) {
        throw new CompanyNameDuplicateException();
    }

    // トランザクションを貼る必要はないが名残でそのまま残しておく
    DB::transaction(function () use ($name) {
        $userId = $this->userCommand->add(new User(['name' => $name]));
    });
}

/**
 * ユーザー登録ユースケーステスト
 */
describe('ユーザーを登録する[createUser]', function () {
    context('条件: name:"太郎"で登録した場合', function () {
        $fn = function () {
            allow($this->userQuery)->toReceive('checkDuplicateUserByName')->with('太郎')->andReturn(false);
            // NOTE: andReturnだと上手くいかなかった(DBと疎通してしまう)(↓)
            // allow($this->userCommand)->toReceive('add')->with(new User(['name' => '企業1']))->andReturn(1);
            // NOTE: andRunだとDB疎通せずにちゃんとモック出来る(↓)
            allow($this->userCommand)->toReceive('add')->andRun(fn (User $user) => 1);
            $this->userService->createUser('太郎');
        };

        it('結果: 正しくパラメータが渡されていること', function () use ($fn) {
            expect($fn())->toBeNull();
            expect($this->userCommand)->toReceive('add')->with(Arg::toEqual(new User(['name' => '太郎'])));
        });
    });
});
koukou

モック

[次書きたいことタスク]
外部への依存部分をモック化してテストをしやすくする。主には小テストの文脈で用いられる認識。DBへの保存やメールの送信など。