資産価値の高いテストを書くためにFabricateを使い始めました

Product Dev Divisionの川島(@nazonohito51)です。

BASEでは創業当時よりCakePHPによるWebアプリケーション開発を行っており、同時にそのテストも充実させてきました。ですがその過程で気づくのは、CakePHP標準の仕組みだけではテストを増やせば増やすほどテストデータの管理が難しくなり、テストをメンテナンスするのが困難になる問題でした。きちんと長期的にサービスを良くしてくれる資産価値の高いテストが書けるように、今回はその問題と向き合い、解決するために@sizuhikoさんの開発されたFabricateというライブラリを導入したお話を書かせていただこうかと思います。

f:id:nazonohito51:20190725112014j:plain

BASEの直面した課題

CakePHP2のFixtureという仕組み

詳細は公式ページを参照していただきたいのですが、CakePHP2にはテストデータを作るためのFixtureという仕組みがあります。おおよそのWebフレームワークにもあるとは思いますが、事前にデータベースに対してテストに必要なテストデータを入れておくための仕組みです。

このFixture、テストの数が少ないうちは良いのですが、テストの数が増えてくると扱いが難しくなる面が出てきます。BASE内部で発生した問題を挙げますと、まずテストメソッド毎に個別のテストデータを用意することがFixtureだと大変なので、複数のテストメソッドで同じFixtureを共有していましたが、後の改修案件の際にテストデータを更新するとたくさんテストが失敗していました。どこがいじって良くて、どこがいじっちゃだめなのか、テストからそれを読み解こうと思っても各テストメソッドにとって本当に必要なカラムがどれなのかを理解することが困難でもはや手を出せません。ちょっとした改修でも本来なら全然関係ない箇所のテストの修正まで派生し、テストのメンテナンスに大きなコストがかかっていました。

もちろんFixtureという仕組みの問題というより、BASEの運用の問題もあったりしますが、本質的にFixtureはテストのスケールに耐えられる仕組みではないと捉えています。正確性には欠けますが、社内にFixtureの問題を理解していただくために簡易的に用意した図が以下となります。

CakePHP初期 f:id:nazonohito51:20190725124713p:plain

CakePHP中期 f:id:nazonohito51:20190725124730p:plain

CakePHP後期 f:id:nazonohito51:20190725124744p:plain

xUTPから見る現状の問題整理

これらは具体的にどんな問題なのか、そして解決するにはどの方向へ向かうべきなのかを理解するにあたり、xUnit Test Patternsの掲げるテスト原則を参考にしますと、 Keep Tests Independent(テストを独立させよ)Communicate Intent(意図を明確にせよ) に違反している状況と捉えています。

  • Keep Tests Independent
    • 原則の意味
      • テストが相互依存的あるいは順序依存的である場合に、テストが我々に提供するフィードバックが信用できなくなってしまうため独立させるべき、という原則
      • 若干の読み替えはありますが、テストデータを含めてテストメソッド間の依存を取り払うべきとも解釈できます
    • BASEにおける原則違反
      • 複数のテストメソッドで同じFixtureを共有しているため、テストデータを介してテストメソッド同士が相互依存的になってしまっている
      • 後にテストデータを更新すると多くのテストが壊れてしまい、変更しづらい・メンテナンスしづらいテストになってしまっている
      • ※テストデータを共有すること自体が悪いことではないのだが、BASEではメンテナンスに支障をきたすほどの共有が行われている
  • Communicate Intent
    • 原則の意味
      • 意図が明確なテストは理解することとメンテナンスすることが容易である、という目的で掲げられている原則
    • BASEにおける原則違反
      • そのテストメソッドが本当に必要としているテストデータをFixtureから読み解きづらいため、何故テストが成功/失敗しているのかその因果関係を読み解くことが出来ない
      • 理解することが出来ないため、後から改修することも難しい

以上の原則から照らし合わせて、現状のBASEはテストを作ったは良いものの、後から理解し修正することが容易ではない、資産価値の低いテストコードを量産してしまっている状況であると言えます。(資産価値の低いテストコードとは、テストを通して開発コストを下げたかったはずが、逆にメンテナンスするコストが大きいようなテストコードを指しています)これらの問題を解決するには、各テストが独立しやすく、かつ可読性が高くできる新しい仕組みが必要であると考えました。そしてそれは後述のFabricateというライブラリが最適であると考えました。

Fabricateによる解決

Fabricateとはなにかを身も蓋もなく言ってしまえば「RailsのFactoryBotみたいなやつ」と言ってしまったほうが多くの人に伝わりやすいかもしれません。概要を書くなら「テストメソッドごとの差分をオーバーライドしながら実テストデータを生成する」ライブラリです。Laravelにも同様の仕組みがあります。

Usage

まずはFabricateの1テーブル辺りの定義を書きます。これは実テストデータを作るためのテンプレートのようなものであり、コレ自体は実テストデータではありません。あらかじめ属性のセットないしはその生成方法を定義しておくと、これをベースにFabricateはテストデータを作成してくれます。この作業は必須ではありませんが、なるべくリアルなテストデータを使いたいという理由からBASEでは全テーブル分の定義を書いています。

<?php
class FabricateUserFixture extends CakeTestFixture
{
    public $import = 'User';

    public function init()
    {
        parent::init();

        Fabricate::define(['User', 'class' => 'User'], function ($data, $world) {
            $now = new DateTimeImmutable();

            return [
                'id' => $world->sequence('user_id'),
                'login_id' => $world->sequence('login_id', function ($i) {
                    return 'loginid' . $i;
                }),
                'last_name' => $world->faker()->lastName,
                'first_name' => $world->faker()->firstName,
                'state' => 0,
                'created' => $now->format('Y-m-d H:i:s'),
                'modified' => $now->format('Y-m-d H:i:s')
            ];
        });
    }
}

そしてテストメソッド内では以下のようにしてテストデータを作成します。

<?php
class SomeClassTest extends CakeTestCase
{
    public $fixtures = [
        'app.Fabricate/fabricate_user',
    ];

    public function testMethod1()
    {
        Fabricate::create('User');

        $this->assertSame(1, ClassRegistry::init('User')->find('count'););
    }
}

上記のうち Fabricate::create('user'); がFabricateによるテストデータ生成を行っている箇所となります。このステートメントでFabricateが事前に定義したテンプレートを元にテストデータを作成してデータベースに保存してくれています。これはサンプルなので特に意味のないテストではありますが、その後のアサーションでデータが1件保存されていることを確認しています。ちなみに実際のデータベースを覗くと以下のようなデータが保存されており、テンプレートの定義に沿ったデータが保存されていることが分かります。

mysql> select * from users;
+----+---------------+-----------+------------+-------+---------------------+---------------------+
| id | login_id      | last_name | first_name | state | created             | modified            |
+----+---------------+-----------+------------+-------+---------------------+---------------------+
|  1 | loginid1      | 鈴木      | 裕太       |     0 | 2019-07-23 11:43:55 | 2019-07-23 11:43:55 |
+----+---------------+-----------+------------+-------+---------------------+---------------------+
1 row in set (0.00 sec)

Fabricateで独立したデータを作成する

さて前述の課題のうち、テストが密結合してしまっている課題をテストメソッド個別のテストデータを作ることで解消してみます。もちろんFixtureでもやろうと思えば出来なくもないのですが、Fabricateだととてもかんたんに実現できるという例になります。

<?php
class SomeClassTest extends CakeTestCase
{
    public $fixtures = [
        'app.Fabricate/fabricate_user',
    ];

    public function testMethod1()
    {
        Fabricate::create('User', ['state' => '0']);

        $this->assertSame('0', ClassRegistry::init('User')->find('first')['User']['state']);
    }

    public function testMethod2()
    {
        Fabricate::create('User', ['state' => '1']);

        $this->assertSame('1', ClassRegistry::init('User')->find('first')['User']['state']);
    }
}

上記の例の場合、2つのテストメソッドで Fabricate::create() が呼ばれていますが、その第2引数に違いがあります。Fabricateは生成したいテストデータのうち、特定のカラムの値をオーバーライドすることでテストメソッド上から明示的に指定することが出来ます。上記の例の場合はtestMethod1では 'state' カラムの値を0として生成しており、testMethod2では1として生成しています。指定していないカラムの値はテンプレートの定義に従って作成されます。

このようにFabricateではテストメソッド個別に必要なテストデータを柔軟に用意することが出来ます。Fixtureで用意するテストデータは(多くの場合)固定値であるため柔軟に用意することは難しかったのですが、実際のところ各テストメソッドがそのテスト要件として本当に必要なデータはそれぞれ 微妙に 違うことがほとんどです。テストメソッドの数を増やせば増やすほどテスト全体として必要とするテストデータのバリエーションは膨大な数になりますが、Fabricateはそのようなバリエーションの違いをテストメソッド上のオーバーライドによって実現し、柔軟なテストデータの供給を実現しています。

また「必要なカラムだけオーバーライドする」というこの作成方法は後述の可読性にも寄与していると捉えています。

Fabricateで可読性の高いテストメソッドを書く

<?php
class SomeClassTest extends CakeTestCase
{
    public $fixtures = [
        'app.Fabricate/fabricate_user',
    ];

    public function testMethod3()
    {
        Fabricate::create('User', [
            'login_id' => 'testuser',
            'state' => '1'
        ]);

        $user = ClassRegistry::init('User')->find('first')['User'];
        $this->assertSame('testuser', $user['login_id']);
        $this->assertSame('1', $user['state']);
    }
}

上記の例では 'login_id''state' の2つのカラムをオーバーライドしてテストデータを作成しています。そしてこのステートメントは「このテストメソッドではlogin_idとstateが必要であり、その他のカラムの値に関心はない」と読むことが出来ます。仮にこのテストが他のカラムも実は必要としている場合でも(テンプレートの作り方にもよりますが)Fakerによるランダムな値生成により安定したテストとはならないでしょう。

このようにテストデータをオーバーライドで柔軟に用意する仕組みは、同時にそのテストに本当に必要なカラムをすべて明示的に指定することをある程度強制する力があると思っています。(色々な要素が絡むのでこの辺りをハッキリ断言することはとてもとても気後れがあります・・・・・・・・・こういう使い方もある程度に捉えていただけましたら幸いです)そしてそれはテストの可読性を上げ、メンテナンスしやすいテストにする効果があると考えています。

FactoryBotをご存知のかたは「traitで属性の集合に名前つければいちいちテストメソッドで全部指定しなくて良い」といった指摘もきっとあるかと思いますが、もちろんそういったこともBASEでは行っています。ですが今回はFabricateの詳細な機能まで触れる記事ではないので割愛させていただきます。

弊社内の工夫

Fabricate v1はCakePHP2での利用を想定されたライブラリでした。しかしFabricate v2ではフレームワーク非依存を目指し、アダプタを書けばどのフレームワークでも動くことを目指されています。(ただしアダプタはCakePHP3用のもののみが用意されているので、それ以外で動かす場合はアダプタを自作する必要があります)弊社ではこのうちあえてv2を使用しています。

というのも以下の標語が頭をかすめたためです。

テストコードのライフサイクルはフレームワークのライフサイクルよりも長い

要するにフレームワークのバージョンアップ時にアプリケーションの振る舞いが変わっていないことを確認できるようにテストを書いているのに、バージョンアップと同時に動かなくなるようなテストでは本末転倒だという話です。なのでテストコードはフレームワークとは関係なく動かなくてはなりません。そのためにもわざわざCakePHP2用のアダプタを自作して、フレームワーク非依存のFabricate v2を選択しました。

まとめ

テスト原則と照らし合わせながら現状の課題を整理し、目指す方向を見据えて弊社ではFabricateを導入しました。Fabricateはまだまだ機能があり、実テストを書く上ではそれらも使いこなさないと実際の運用に耐えるテストは書けないのですが、今回はBASEにおいて導入して感じたメリットを端的にまとめる形でこのぐらいで記事は締めさせていただこうかと思います。

BASEではサービスを成長させながら同時にテストを充実させてきました。そして今度はテストをスケールさせる過程で発生する問題をどう解決するかを考えるフェーズに既にあります。今回はテストデータを供給する部分の問題をFabricateというライブラリの導入によって乗りこえようと試みました。まだ導入したてで効果の程は測りかねますが概ねうまく行っている感触です。