Laravel5.4とVue.jsでSPAを作ってみる。① -環境構築-

はじめに


このエントリーについて

この記事は「Laravelでウェブアプリケーションをつくるときのベストプラクティスを探る」シリーズの一編です。
他の記事は目次からアクセスしてください。

※ 社内勉強会向けの資料として作成したので、スライドモードになっていますが、終了後通常モードに戻します。


Kent Beck による Test-Driven Development by Example の新訳版が出たことを記念して、本書に載っているサンプルを題材に、Laravel でテストを書く際のベストプラクティスを探ってみようと思います(と言いつつ、私は旧訳版しか持ってないです、すみません)。

51hsd-b1RTL._SX350_BO1,204,203,200_.jpg

※ 異なる通貨単位を持つ金額を足し合わせる機能のコード例を若干編集して使用しています。


環境

PHP: 7.1.4
Laravel: 5.5.18
PHPUnit: 6.4.3
Laravel Dusk : 2.0.7

為替レートAPI: Open Exchange Rates (Free プランなので、USD 基準のレートしか取れません)


Laravel におけるテストの特徴

  • PHPUnit をバンドルしている
  • Mockery をバンドルしている
  • Faker をバンドルしている
  • Dusk パッケージを追加導入できる
  • テスト実行ごとにデータベースをリセットできる (DatabaseTransactions, DatabaseMigrations, RefreshDatabase)
  • ログインが簡単にできる (actingAs, loginAs)

テストの種類について

 
Laravel では以下の3つに分類しています。

  • ユニットテスト
  • フィーチャーテスト
  • ブラウザテスト

ユニットテスト (単体テスト)

対象となるクラスのメソッドの動作を検証します。

例) 異なる通貨への変換が、期待通りの数値および通貨単位になるかどうか


フィーチャーテスト (機能テスト)

複数のクラスが連携して行う一連の動作を検証します。ウェブアプリケーションであれば、リクエストデータを入力し、処理された結果の出力が検査対象です。

例) ふたつの異なる通貨を持つ金額入力すると、足す側は変換レートを元に足される側の通貨単位を持つ金額に変換されたのち合計され、足される側の通貨単位を持つ合計金額が表示されるかどうか


ブラウザテスト (受け入れテスト)

ブラウザを自動で操作し、単一または複数の動作を検証します。こちらも、リクエストデータを入力し、処理された結果の出力が検査対象ですが、直接ブラウザに表示された要素を操作・検証するので、 JavaScript による動的な制御が含まれる場合に有用です。

例) ふたつの異なる通貨を持つ金額入力すると、足す側は変換レートを元に足される側の通貨単位を持つ金額に変換されたのち合計され、足される側の通貨単位を持つ合計金額が表示されるかどうか


テストクラス生成コマンド

ユニットテスト

$ php artisan make:test --unit ExampleTest

フィーチャーテスト

$ php artisan make:test ExampleTest

ブラウザテスト

$ php artisan dusk:make ExampleTest

※ laravel/dusk パッケージをインストールする必要があります(インストール方法はググってください)


シナリオ

  • 異なる通貨の金額を合計する。
  • 合計された通貨は足される側のものになるとする。
  • 変換レートは外部の API を利用してその都度取得する。
  • 通貨変換時、1 未満の端数は四捨五入する。

例)

為替レート: 1 USD = 0.84 EUR

計算式: 5 USD + 10 EUR = 17 USD


テスト計画のポイント

  • ユニットテスト、フィーチャーテスト、ブラウザテスト、それぞれでチェックすべきことを分離しましょう(同じことを複数の方式でテストしても意味ないです)
  • すべてのテストを書くことは現実的には無理なので、優先順位を定め、重要なところからテストを書きましょう
  • テストファーストにこだわる必要はありません、必要だと思った時点でテストを書きましょう

ユニットテストのポイント

  • データベースや外部 API などに依存しないように設計しましょう
  • どうしても依存してしまう場合はモックやスタブで代替しましょう
  • 入力と出力のパターンを把握し、必要十分なテストケースを作成しましょう

フィーチャーテストのポイント

  • 外部APIなどはモックやスタブで代替しましょう
  • データベースは DatabaseTransactions または DatabaseMigrations トレイトを使って、毎回リセットしましょう

DatabaseTransactions: トランザクションを開始してからテストを実施し、終了後にロールバックします
DatabaseMigrations: artisan migrate:fresh を実行し、テストを実施した後、artisan migrate:rollback を実行します


ブラウザテストのポイント

  • データベースは RefreshDatabase または DatabaseMigrations トレイトを使って、毎回リセットしましょう
  • テストシナリオごとにテストメソッドを書きましょう

RefreshDatabase: artisan migrate:refresh を実行後、トランザクションを開始してからテストを実施し、終了後にロールバックします


ユニットテストの例


異なる通貨への変換が、期待通りの数値および通貨単位になるかどうか

<?php
declare(strict_types=1);

namespace TestsUnitModels;

use AppModelsCurrency;
use AppModelsCurrencyRates;
use AppModelsMoney;
use TestsTestCase;

class MoneyTest extends TestCase
{
    /**
     * @dataProvider dataReduce
     */
    public function testReduce(Money $money, Currency $toCurrency, float $rate, Money $exchangedMoney)
    {
        $rates = new CurrencyRates();
        $rates->add($toCurrency, $money->currency(), $rate);

        $reduced = $money->reduce($rates, $toCurrency);

        $this->assertEquals($exchangedMoney, $reduced);
    }

    public function dataReduce()
    {
        // ここに入力パターン分、テストケースを追加する
        return [
            'EUR -> USD' => [
                new Money(10, new Currency('EUR')),
                new Currency('USD'),
                0.84,
                new Money(12, new Currency('USD')),
            ],
        ];
    }
}

テスト対象のクラスは、

<?php
declare(strict_types=1);

namespace AppModels;

class Money
{
    private $amount;
    private $currency;

    public function __construct(int $amount, Currency $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function __toString(): string
    {
        return sprintf('%d %s', $this->amount, $this->currency);
    }

    public function toArray(): array
    {
        return ['amount' => $this->amount(), 'currency' => (string)$this->currency()];
    }

    public function amount(): int
    {
        return $this->amount;
    }

    public function currency(): Currency
    {
        return $this->currency;
    }

    public function plus(Money $added): Sum
    {
        return new Sum($this, $added);
    }

    public function reduce(CurrencyRates $rates, Currency $to): Money
    {
        $rate = $rates->get($this->currency, $to);

        return new Money((int)round($this->amount / $rate), $to);
    }
}

依存関係は、メソッドの引数のみになるようになっています。


フィーチャーテストの例

ふたつの異なる通貨を持つ金額入力すると、足す側は変換レートを元に足される側の通貨単位を持つ金額に変換されたのち合計され、足される側の通貨単位を持つ合計金額が表示されるかどうか

<?php
declare(strict_types=1);

namespace TestsFeature;

use AppModelsBank;
use AppServicesRateExchanger;
use AppUser;
use IlluminateFoundationTestingDatabaseTransactions;
use TestsTestCase;

class MoneyTest extends TestCase
{
    use DatabaseTransactions;

    /**
     * @dataProvider dataReduce
     * @param $input
     * @param $expected
     */
    public function testReduce($input, $expected)
    {
        // 外部 API のスタブ
        $mock = Mockery::mock(RateExchanger::class)->makePartial();
        $mock->shouldReceive('rate')->andReturn(2.0);
        app()->bind(RateExchanger::class, function () use ($mock) {
            return $mock;
        });

        $bank = factory(Bank::class)->create();
        $user = factory(User::class)->create();
        $this->actingAs($user);

        $input += ['bank' => $bank->id];
        $response = $this->post('/money', $input);

        $response->assertStatus(200);
        $this->assertContains($expected, $response->getContent());
    }

    public function dataReduce()
    {
        // ここに入力パターン分、テストケースを追加する
        return [
            '正常系' => [
                [
                    'augend' => '5',
                    'augend_currency' => 'USD',
                    'added' => '10',
                    'added_currency' => 'EUR',
                ],
                '5 USD + 10 EUR = 10 USD'
            ]
        ];
    }
}

テスト対象のアクションメソッドです(一部省略しています)。

<?php
declare(strict_types=1);

namespace AppHttpControllers;

use AppHttpRequestsReduceRequest;
use AppModelsBank;
use AppModelsCurrency;

class MoneyController extends Controller
{
    public function reduce(ReduceRequest $request)
    {
        $bankId = $request->input('bank');
        $augend = $request->augend();
        $added = $request->added();

        /** @var Bank $bank */
        $bank = Bank::findOrFail($bankId);
        $bank->addRate($augend->currency(), $added->currency());

        $sum = $augend->plus($added);

        $result = $bank->reduce($sum);

        return view('money.result')->with(compact('augend', 'added', 'result'));
    }
}

ReduceRequest.php のコードも念のため載せておきます(一部省略しています)。

<?php
declare(strict_types=1);

namespace AppHttpRequests;

use AppModelsCurrency;
use AppModelsMoney;
use IlluminateFoundationHttpFormRequest;

class ReduceRequest extends FormRequest
{
    public function augend(): Money
    {
        $augendCurrency = new Currency($this->input('augend_currency'));
        $augendAmount = (int)$this->input('augend');

        return new Money($augendAmount, $augendCurrency);
    }

    public function added(): Money
    {
        $addedCurrency = new Currency($this->input('added_currency'));
        $addedAmount = (int)$this->input('added');

        return new Money($addedAmount, $addedCurrency);
    }
}

テンプレート

index.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
  <div class="row">
    <div class="col-md-8 col-md-offset-2">
      {!! Form::open(['method' => 'POST', 'class' => 'form-inline']) !!}
        <div class="panel panel-default">
          <div class="panel-heading">Reduce Test</div>
          <div class="panel-body">
            {!! Form::select('bank', $banks, null, ['class' => 'form-control']) !!}
            {!! Form::text('augend', 0, ['class' => 'form-control text-right']) !!}
            {!! Form::select('augend_currency', $currencies, null, ['class' => 'form-control']) !!}
            <span class="small"> + </span>
            {!! Form::text('added', 0, ['class' => 'form-control text-right']) !!}
            {!! Form::select('added_currency', $currencies, null, ['class' => 'form-control']) !!}
          </div>
          <div class="panel-footer">
            {!! Form::submit('Reduce', ['class' => 'btn btn-primary']) !!}
          </div>
        </div>
      {!! Form::close() !!}
    </div>
  </div>
</div>
@endsection

ブラウザテストの例

ふたつの異なる通貨を持つ金額入力すると、足す側は変換レートを元に足される側の通貨単位を持つ金額に変換されたのち合計され、足される側の通貨単位を持つ合計金額が表示されるかどうか

MoneyTest.php
<?php
declare(strict_types=1);

namespace TestsBrowser;

use AppUser;
use LaravelDuskBrowser;
use TestsDuskTestCase;

class MoneyTest extends DuskTestCase
{
    // ブラウザテストでは、シナリオごとにテストメソッドを書く
    public function testReduceSuccess()
    {
        $user = factory(User::class)->create();
        $this->browse(function (Browser $browser) use ($user) {
            $browser->loginAs($user);
            $browser->visit('/money/async')
                ->assertSee('Reduce Test')
            ;

            $options = $browser->elements('select[name="bank"] option');
            $this->assertGreaterThan(1, count($options));

            $browser->select('select[name="bank"]', '1')
                ->type('input[name="augend"]', 5)
                ->select('select[name="augend_currency"]', 'USD')
                ->type('input[name="added"]', 10)
                ->select('select[name="added_currency"]', 'EUR')
                ->click('#submit')
                ->waitFor('.alert')
                ->assertSee('5 USD + 10 EUR =')
            ;
        });
    }
}

ブラウザテスト用に、Vue.js で書き換えたバージョンです。

ReduceResult.vue
<template>
  <div class="alert alert-info" v-if="resultStr != 0">
     +  = 
  </div>
</template>

<script>
  export default {
      props: ['augend', 'added', 'result'],
      computed: {
          augendStr: function () {
              return this.augend.amount + ' ' + this.augend.currency
          },
          addedStr: function () {
              return this.added.amount + ' ' + this.added.currency
          },
          resultStr: function () {
              return this.result.amount + ' ' + this.result.currency
          }
      },
  }
</script>
app.js
Vue.component('reduce-result', require('./components/ReduceResult.vue'));

const app = new Vue({
    el: '#app',
    data: {
        augend: {
            amount: 0,
            currency: 'USD'
        },
        added: {
            amount: 0,
            currency: 'USD'
        },
        result: {
            amount: 0,
            currency: ''
        }
    },
    methods: {
        onSubmit: function () {
            window.axios.post('/api/money/reduce', {augend: this.augend, added: this.added})
                .then((res) => {
                    this.result = {amount: res.data.amount, currency: res.data.currency}
                })
        }
    }
});

テンプレート

async_index.blade.php
@extends('layouts.app')

@section('content')
  <div class="container" id="app">
    <div class="row">
      <div class="col-md-8 col-md-offset-2">
        <reduce-result v-bind:augend="augend" v-bind:added="added" v-bind:result="result"></reduce-result>
        <div class="panel panel-default form-inline">
          <div class="panel-heading">Reduce Test (Async)</div>
          <div class="panel-body">
            {!! Form::select('bank', $banks, null, ['id' => 'bank', 'class' => 'form-control']) !!}
            {!! Form::text('augend', 0, ['id' => 'augend', 'v-model' => 'augend.amount', 'class' => 'form-control text-right']) !!}
            {!! Form::select('augend_currency', $currencies, null, ['id' => 'augend_currency', 'v-model' => 'augend.currency', 'class' => 'form-control']) !!}
            <span> + </span>
            {!! Form::text('added', 0, ['id' => 'added', 'v-model' => 'added.amount', 'class' => 'form-control text-right']) !!}
            {!! Form::select('added_currency', $currencies, null, ['id' => 'added_currency', 'v-model' => 'added.currency', 'class' => 'form-control']) !!}
          </div>
          <div class="panel-footer">
            {!! Form::button('Reduce', ['id' => 'submit', 'class' => 'btn btn-primary', 'v-on:click' => 'onSubmit']) !!}
          </div>
        </div>
      </div>
    </div>
  </div>
@endsection

@push('script')
  <script src=""></script>
@endpush

為替レート API は本番のものを使用しているので、合計金額自体は検証していません(実行タイミングによって変わってしまうので)。この辺はモック API 用のルートを用意して、テスト時にはそちらに差し替えるのも手かもしれません。
が、金額自体のチェックは、別途 /api/money/reduce API 向けのフィーチャーテストで行っているので(本記事には載せてません)、フォームの動きと結果が表示されるところのみ検証しています。


まとめ

「Laravelでウェブアプリケーションをつくるときのベストプラクティスを探る」シリーズ最後の記事ですが、前回より間がすごく空いてしまったのは、やはりテストは難しく、なかなか「これ」という方針が打ち出せずにいたり、これに先立って Codeception を試していた中で Dusk より Codeception の方がいいかもなぁ、なんて思っていたりしたためです。

それでも、まぁ、数をこなしていくうちにある程度方向性というか、やり方が見えてきたので、雑ではありますが、公開することにしました。

ポイントとしては、

  • ユニットテスト、フィーチャーテスト、ブラウザテスト、それぞれでチェックすべきことを分離しましょう
  • データベースや外部 API などに依存しないように設計しましょう
  • どうしても依存してしまう場合はモックやスタブで代替しましょう
  • データベースはトレイトを使って、毎回リセットしましょう

といったところでしょうか。

規模が大きくなってきたり、動作が複雑になってくると、なかなか上のようにはいかず、ユニットテストが書けないとか、依存関係を整理できない、とかのシチュエーションが出てくるので、あくまでも原則というか、目安というか、こだわりすぎて時間と労力を無駄にするのもアレなので、試行錯誤しながら、アプリケーションに必要なところを重点的にテストを書いていけるようになれるといいなぁ、と思っております。

他にもこんなベストプラクティスがあるよ、などコメントや編集リクエストいただけると助かります :bow:

[紹介元] PHPタグが付けられた新着投稿 – Qiita Laravel5.4とVue.jsでSPAを作ってみる。① -環境構築-

関連記事