LaravelでモデルのUnitテストを書く時の注意


はじめに

以下の記事にあるように、インスタンス化したモデルに自分で値をセットすることで、Unitテストを行うことができます。
ですが、この時プロパティをdateへキャストする設定を行うとエラーが発生します。その時の解決法です。

TL;DR

  • モデルのプロパティをDatetimeへキャストするときは
<?php
...
class User extends Authenticatable
{
...
    protected $casts = [
        'active_from' => 'datetime' // active_fromカラムの値をDatetimeへキャストする
    ];

    public function isActive(): bool
    {
        return $this->active_from < Carbon::now();
    }
}

  • setDateFormat()を呼ぶ
<?php

namespace Tests\Unit;

use App\Models\User;
use Carbon\Carbon;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function test_isAdmin()
    {
        $user = new User();
        $user->setDateFormat('Y-m-d H:i:s'); // これ
        $user->active_from = Carbon::yesterday();

        self::assertTrue($user->isActive());
    }
}

キャストの設定

次のようなモデルを想定します。$castsを使って、active_fromDatetimeへキャストする設定を書いています。

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'password',
        'active_from'
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'active_from' => 'datetime' // active_fromカラムの値をDatetimeへキャストする
    ];

    public function isActive(): bool
    {
        return $this->active_from < Carbon::now();
    }
}

Unitテストを書いてみる

Unitテストは以下のようになります。一見動きそうですが、実行するとエラーになります。

<?php

namespace Tests\Unit;

use App\Models\User;
use Carbon\Carbon;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function test_isActive()
    {
        $user = new User();
        $user->active_from = Carbon::yesterday();

        self::assertTrue($user->isActive());
    }
}

PHPUnit 9.5.5 by Sebastian Bergmann and contributors.


Error : Call to a member function connection() on null
 /xxxxx/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:1569
 /xxxxx/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:1535
 /xxxxx/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php:1139
 /xxxxx/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php:1087
 /xxxxx/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php:740
 /xxxxx/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:1904
 /xxxxx/tests/Unit/UserTest.php:16

Time: 00:00.027, Memory: 12.00 MB

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

何故なのか

スタックトレースを追ってみます。

まず、HasAttributes.php:740を見ます。(とんでもない行数だ...)
このsetAttribute()関数はUserモデルのactive_fromに値をセットする時に呼ばれる関数のようです。

    public function setAttribute($key, $value)
    {
        // First we will check for the presence of a mutator for the set operation
        // which simply lets the developers tweak the attribute as it is set on
        // this model, such as "json_encoding" a listing of data for storage.
        if ($this->hasSetMutator($key)) {
            return $this->setMutatedAttributeValue($key, $value);
        }

        // If an attribute is listed as a "date", we'll convert it from a DateTime
        // instance into a form proper for storage on the database tables using
        // the connection grammar's date format. We will auto set the values.
        elseif ($value && $this->isDateAttribute($key)) {
            $value = $this->fromDateTime($value); // ここ!
        }

        if ($this->isClassCastable($key)) {
            $this->setClassCastableAttribute($key, $value);

            return $this;
        }

        if (! is_null($value) && $this->isJsonCastable($key)) {
            $value = $this->castAttributeAsJson($key, $value);
        }

        // If this attribute contains a JSON ->, we'll set the proper value in the
        // attribute's underlying array. This takes care of properly nesting an
        // attribute in the array's value in the case of deeply nested items.
        if (Str::contains($key, '->')) {
            return $this->fillJsonAttribute($key, $value);
        }

        if (! is_null($value) && $this->isEncryptedCastable($key)) {
            $value = $this->castAttributeAsEncryptedString($key, $value);
        }

        $this->attributes[$key] = $value;

        return $this;
    }

続いてHasAttributes.php:740から呼ばれるHasAttributes.php:1087を見ます。fromDateTime()関数はモデルにセットされた値をDB用へ変換する関数のようです。続いてfromDateTime()から呼ばれるgetDateFormat()を見ます。

    /**
     * Convert a DateTime to a storable string.
     *
     * @param  mixed  $value
     * @return string|null
     */
    public function fromDateTime($value)
    {
        return empty($value) ? $value : $this->asDateTime($value)->format(
            $this->getDateFormat()
        );
    }

DBにDatetime型の値を保存するときの変換フォーマットを取得するメソッドです。ここで、$this->dateFormatが取得できなかったときに$this->getConnection()としてDBからフォーマットを取得しようとします。上の記事にあるように、UnitテストではDBアクセスを行うことができないので、エラーが発生してしまいます。

    /**
     * Get the format for database stored dates.
     *
     * @return string
     */
    public function getDateFormat()
    {
        return $this->dateFormat ?: $this->getConnection()->getQueryGrammar()->getDateFormat();
    }

どうするのか

答えは単純で、$this->dateFormatにフォーマットを自分でセットしてしまえばOKです。そのためのsetDateFormat()というメソッドがありますのでこちらを使います。

<?php

namespace Tests\Unit;

use App\Models\User;
use Carbon\Carbon;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function test_isAdmin()
    {
        $user = new User();
        $user->setDateFormat('Y-m-d H:i:s'); // これ
        $user->active_from = Carbon::yesterday();

        self::assertTrue($user->isActive());
    }
}

テストが通る!

PHPUnit 9.5.5 by Sebastian Bergmann and contributors.



Time: 00:00.054, Memory: 10.00 MB

OK (1 test, 1 assertion)