いかにLaravelで外部依存しているサービスを作ったか

今回は弊社で開発しているWorkinGoodでいかに外部依存しているシステムを開発したか、簡単なサンプルのコードも交えて話をします。

WorkinGoodは派遣スタッフの方が自分で勤怠を入力したり、給与明細を確認することができるサービスです。

技術的には以下のもので構成されています。

Laravel AWS(色々) TypeScript React + flux 見た感じモダンな雰囲気がありますが、このシステムには一つ解決しなければならない問題があります。

レガシーシステムとの連携

それは弊社で開発している別のシステムをバックエンドにした構成にしなくてはなりませんでした。

f:id:cosuke2000:20190405162043j:plain

このMGというのがレガシーシステムなのですが、もともとAPIを用意していたわけではなく、WorkinGoodの開発と同時に新たにAPIをつくる必要があります。しかもこのレガシーシステムWindows上でしか動かないので以前の記事で紹介したDockerで環境を用意することもできません。

matchingood-engineer.hatenablog.com

このような状況では以下のような問題が発生します。

開発時に依存しているシステムに繋げないといけない CIでのユニットテストの時に依存しているシステムのバージョンがあわない 開発時に依存しているシステムに繋げる時は弊社が所有しているサーバーに開発用のバックエンドを立ち上げて開発を行うことで解決しました。しかし、CI時にバックエンドにつなげようとするとバージョンが合わなかったり、繰り返し使うことによってデータがユニットテストに合わなくなってきてしまいます。

どう解決したか

まず、バックエンドに依存する部分をサービス層として独立させました。全てのバックエンドへのリクエストはこのサービス層を通します。

f:id:cosuke2000:20190405161850j:plain

こうすることでDIを利用してサービス層をモックすることができます。実際にこのアイディアの簡単なコードの例を見て見ましょう。

class AssignmentService { protected $requestBuilder;

public function __construct($requestBuilder)
{
    $this->requestBuilder = $requestBuilder;
}

public function get($since, $until)
{
    $url = "http://backend.com/assignments?since={$since}&until={$until}";

    // リクエストを投げる
    $response = $this->requestBuilder
        ->setUrl($url)
        ->sendRequest();
    $result = $response->getResult();

    // あとはリクエスト結果をモデルに変換する
    .
    .
    .

     return $assignments } }

これはAssignmentというリソースをバックエンドからとってくるサービス層の架空の例です。コード中に出てくる$requestBuilderというオブジェクトが実際にHTTPリクエストを行なっています。この$requestBuilderをモックオブジェクトに差し替えることで実際の通信自体をモックすることができます。

ユニットテスト

このままだとコードを書いても実装が正しいのかどうかわかりません。そこでユニットテストを並行して書きます。

class AssignmentServiceTest extends TestCase { protected $mock; protected $result = // 想定されるリクエストの返り値

public function setUp()
{
    parent::setUp();
    $mock = $this->getMockBuilder('RequestBuilder')
        ->setMethods([
            'setUrl',
            'sendRequest'
        ])
        ->getMock();
    $mock->method('setUrl')
        ->will($this->returnSelf());
    $this->mock = $mock;
}

public function testGet()
{
    $this->mock->method('sendRequest')
        ->willReturn($this->result);
    $service = new AssignmentService($this->mock);
    $assignments = $service->get('2016-09-01', '2016-09-30');
    // あとはこの$assignmentsが想定通りかどうかチェックする
}

}

こうすることで依存しているサービスの開発状況によらずに開発を進めることができます。

フロントエンドAPIの開発

WorkinGoodではUIをReactで構築しているのでLaravel側にクライアントサイド向けのAPIを用意する必要があります。これをここではバックエンド側のAPIと区別するためにフロントエンドAPIと呼びます。サービス層を実際に使っている部分でもあります。

サービス層を呼んでいるのでこのAPIを開発するときも結果を確認するためにユニットテストを並行して書くことで開発を進めます。

class AssignmentsController extends Controller { function get(Request $request) { $validator = Validator::make($request->all(), [ 'since' => 'required|date_format:"Y-m-d"', 'until' => 'required|date_format:"Y-m-d"', ]); if ($validator->fails()) { return response()->json(['status' => 'failed', 'errors' => $validator->errors()], 422); } $since = $request->since; $until = $request->until; $service = new AssignmentService(new RequestBuilder); $assignments = $service->get($since, $until); // あとはレスポンスの形式にして返す。 } }

フロントエンドAPIを簡単にこう書いた場合、コントローラのユニットテストはこのように書くことができます。なお、簡単にするためにログイン処理などは省きます。

class AssignmentsControllerTest extends TestCase { protected $mock; protected $result;

public function setUp()
{
    parent::setUp();
    $this->mock = Mockery::mock('overload:App\Service\AssignmentService');
}


public function testGet()
{
    $this->result = // サービス層が返す期待するモデル
    $this->mock
        ->shouldReceive('get')
        ->andReturn($this->result);
    $this->visit('/assignments?since=2016-09-01&until=2016-09-30')
        ->seeJson([
            // 期待するAPIの返り値
        ]);
}

}

このMockeryというのはPHPライブラリでユニットテスト時にモックすることを可能にしてくれます。overloadというものを使うと呼び出す関数が内部的に呼び出しているクラス自体をモックできるという優れものです。

この実装とユニットテストを並行して書き進めることでバックエンドの進捗によらずスムーズに開発することができています。

現状の問題点

このサービスでは積極的にMockeryを使っていますが、ドキュメントを見ると

Prefixing the valid name of a class (which is NOT currently loaded) with “overload:” will generate an alias mock (as with “alias:”) except that created new instances of that class will import any expectations set on the origin mock ($mock).

とあります。"which is NOT currently loaded"という部分が実は厄介で、他の部分ですでにモック対象のクラスが読み込まれているとモックすることができません。これはこのユニットテストのファイル一つだけをテストする場合は問題ないのですが、CIなどで全てのユニットテストを行う時に引っかかってしまいます。同じプロセスですでに読み込まれている場合はモックできないので

/* * @runInSeparateProcess * @preserveGlobalState disabled / public function testGet()

のようにアノテーションをつける必要があります。こうするとユニットテストの実行時間が結構伸びてしまうので現在改善案を考えています。

まとめ

Laravelに寄せた話でしたが、アイディア自体はどのような環境でも適応できる話だったと思います。外部依存するサービスがあると同じPC上に環境を構築できなかったりするのでこのようなスタイルで開発することがとても有効な場合があると思います。

これらは全てアルバイトエンジニアが設計して実装してきました。これはまだまだ一例にすぎず、やる気があればもっと高度な技術的なチャレンジをやりきることもできます。プログラミング初心者の方も実際のコードを通じて得られるものがたくさんあると思います。