GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

PHP8化に向けてAspectMockを利用したユニットテストを卒業しました #Mockery

はじめに

サービス開発部の神崎です。

現在、GameWithではPHPのバージョンを7.3から8.1へとアップデートする大規模なプロジェクトを推進しています。このアップデートにより、PHP 8.1に対応したコードが既に本番環境に適用され、現在はPHP 8へ完全に切り替える準備が整っています。

このプロジェクトは、始動からすでに約1年の歳月を費やしており、その中でも特に多くの時間と労力を要したのが、ユニットテストの大幅な修正作業でした。本記事では、そんなユニットテストの修正プロセスに焦点を当て、我々が直面した課題とその解決策について詳しく紹介します。

関連記事

tech.gamewith.co.jp

ユニットテストの対応方針決定まで

GameWithにおけるPHP8へのアップデートが予定よりも遅れた主因は、ユニットテストのアップデート作業のコストが高すぎるという点でした。
特に、AspectMockに対する依存度が高かったため、このライブラリのアップデートを待っていた結果、期限を超過してしまいました。
1年以上の時間が経過しライブラリの更新状況を鑑みた結果、AspectMockを引き続き利用することは適切ではないとの結論に至りました。
このような背景から、AspectMockの利用を段階的に終了させる方針が固まりました。

フレームワークについてはMockeryを選定しました。 以下2点をAspectMockの力でカバーしており、低コストで置き換えやすそうだったのがMockeryでした。

  • 静的メソッドの多用
  • class内での依存注入なしのインスタンス化の多用

Mockeryでも念の為に用意しているよ。というような温度感に感じます。 https://docs.mockery.io/en/latest/reference/public_static_properties.html https://docs.mockery.io/en/latest/reference/creating_test_doubles.html#creating-test-doubles-aliasing

Mockeryへの置き換え

基本的には静的メソッドに依存している場合はaliasに、 インスタンス化したクラスのメソッドに依存している場合はoverloadに書き換えるという作業をしていきます。 いくつか躓くポイントがあったため紹介したいと思います。

また前提として、今回はテストコードの修正のみで対応する方針で進めています。

alias や overload を多用する場合はプロセスを分けて実行が必要

以下のテストは通りません。

<?php
namespace MockeryTest\lib;

class TimeUtils {

    public static function isPastStatic($timestamp) {
        $now = new \DateTime();
        $targetTime = new \DateTime();
        $targetTime->setTimestamp($timestamp);
        return $targetTime < $now;
    }

    public function isPast($timestamp) {
        $now = new \DateTime();
        $targetTime = new \DateTime();
        $targetTime->setTimestamp($timestamp);
        return $targetTime < $now;
    }
}
<?php
namespace MockeryTest\lib;

class EventScheduler {
    public function scheduleEventUseStatic($timestamp) {
        if (\MockeryTest\lib\TimeUtils::isPastStatic($timestamp)) {
            return 'Event cannot be scheduled in the past.';
        } else {
            return 'Event scheduled successfully.';
        }
    }

    public function scheduleEvent($timestamp) {
        $class = new \MockeryTest\lib\TimeUtils();
        if ($class->isPast($timestamp)) {
            return 'Event cannot be scheduled in the past.';
        } else {
            return 'Event scheduled successfully.';
        }
    }
}
<?php
namespace MockeryTest\test;

use Mockery as m;
use PHPUnit\Framework\TestCase;

class EventScheduler_Test extends TestCase {
    protected function tearDown(): void {
        m::close();
    }

    public function testScheduleEventInThePast() {
        m::mock('alias:\MockeryTest\lib\TimeUtils')
            ->shouldReceive('isPastStatic')
            ->andReturn(true);

        $scheduler = new \MockeryTest\lib\EventScheduler();
        $result = $scheduler->scheduleEventUseStatic(time() + 3600); // 1時間後
        $this->assertEquals('Event cannot be scheduled in the past.', $result);
    }

    public function testScheduleEventInTheFuture() {
        m::mock('overload:\MockeryTest\lib\TimeUtils')
            ->shouldReceive('isPast')
            ->andReturn(false);

        $scheduler = new \MockeryTest\lib\EventScheduler();
        $result = $scheduler->scheduleEvent(time() - 3600); // 1時間前
        $this->assertEquals('Event scheduled successfully.', $result);
    }
}
.E                                                                  2 / 2 (100%)

Time: 00:00.303, Memory: 8.00 MB

There was 1 error:

1) MockeryTest\test\EventScheduler_Test::testScheduleEventInTheFuture
Mockery\Exception\BadMethodCallException: Method MockeryTest\lib\TimeUtils::isPast() does not exist on this mock object

/app/src/lib/EventScheduler.php:15
/app/src/test/EventScheduler_Test.php:28

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

mockeryのドキュメントに記載がありますが、ユニットテストのプロセスを分ける必要があります。

Note Using alias/instance mocks across more than one test will generate a fatal error since we can’t have two classes of the same name. To avoid this, run each test of this kind in a separate PHP process (which is supported out of the box by both PHPUnit and PHPT).

以下のアノテーションをつけてあげるとテストをパスできます。

<?php

/**
 * @runTestsInSeparateProcesses
 * @preserveGlobalState disabled
 */

https://docs.mockery.io/en/latest/cookbook/mocking_hard_dependencies.html?highlight=preserveGlobalState#

デメリットとしてテストの所要時間が長くなります。 GameWithでは全テストの処理時間が2倍になりました。 現状は+数分程度なので許容していますが、基本的にはこれを使わないほうが良いと思います。

定数を持つクラスに依存しているテストは事前にクラスを作って置き換える

これに関してはドキュメントに記載されている通りです。 https://docs.mockery.io/en/latest/cookbook/class_constants.html

クラス定数を持つクラスを定義しておくことでモック可能です。

<?php
namespace MockeryTest\lib;

class TimeUtils {

    const TIME_ZONE_TOKYO='Asia/Tokyo';
    const TIME_ZONE_UTC='UTC';

    public static function isPastStatic($timestamp) {
        $now = new \DateTime();
        $targetTime = new \DateTime();
        $targetTime->setTimestamp($timestamp);
        return $targetTime < $now;
    }

    public function isPast($timestamp) {
        $now = new \DateTime();
        $targetTime = new \DateTime();
        $targetTime->setTimestamp($timestamp);
        return $targetTime < $now;
    }

    public function isPastByTimeZone($timestamp, $timezone = null) {
        if (is_null($timezone)) {
            $timezone = static::TIME_ZONE_TOKYO;
        }
        $now = new \DateTime();
        $targetTime = new \DateTime();
        $targetTime->setTimestamp($timestamp);
        $now->setTimezone(new \DateTimeZone($timezone));
        $targetTime->setTimezone(new \DateTimeZone($timezone));
        return $targetTime < $now;
    }
}
<?php
namespace MockeryTest\lib;

class EventScheduler {
    public function scheduleEventUseStatic($timestamp) {
        if (\MockeryTest\lib\TimeUtils::isPastStatic($timestamp)) {
            return 'Event cannot be scheduled in the past.';
        } else {
            return 'Event scheduled successfully.';
        }
    }

    public function scheduleEvent($timestamp) {
        $class = new \MockeryTest\lib\TimeUtils();
        if ($class->isPast($timestamp)) {
            return 'Event cannot be scheduled in the past.';
        } else {
            return 'Event scheduled successfully.';
        }
    }

    public function scheduleEventWithTimezone($timestamp) {
        $class = new \MockeryTest\lib\TimeUtils();
        if ($class->isPastByTimeZone($timestamp, \MockeryTest\lib\TimeUtils::TIME_ZONE_TOKYO)) {
            return 'Event cannot be scheduled in the past.';
        } else {
            return 'Event scheduled successfully.';
        }
    }
}
<?php
namespace MockeryTest\test;

use Mockery as m;
use PHPUnit\Framework\TestCase;

/**
 * @runTestsInSeparateProcesses
 * @preserveGlobalState disabled
 */
class EventScheduler_Test extends TestCase {
    protected function tearDown(): void {
        m::close();
    }

    public function testScheduleEventInThePast() {
        m::mock('alias:\MockeryTest\lib\TimeUtils')
            ->shouldReceive('isPastStatic')
            ->andReturn(true);

        $scheduler = new \MockeryTest\lib\EventScheduler();
        $result = $scheduler->scheduleEventUseStatic(time() + 3600); // 1時間後
        $this->assertEquals('Event cannot be scheduled in the past.', $result);
    }

    public function testScheduleEventInTheFuture() {
        m::mock('overload:\MockeryTest\lib\TimeUtils')
            ->shouldReceive('isPast')
            ->andReturn(false);

        $scheduler = new \MockeryTest\lib\EventScheduler();
        $result = $scheduler->scheduleEvent(time() - 3600); // 1時間前
        $this->assertEquals('Event scheduled successfully.', $result);
    }

    public function testScheduleEventInThePastWithTimezone() {
        m::mock('overload:\MockeryTest\lib\TimeUtils','\MockeryTest\test\tmp_TimeUtils')
            ->shouldReceive('isPastByTimeZone')
            ->andReturn(false);

        $scheduler = new \MockeryTest\lib\EventScheduler();
        $result = $scheduler->scheduleEventWithTimezone(time() - 3600); // 1時間前
        $this->assertEquals('Event scheduled successfully.', $result);
    }
}

class tmp_TimeUtils
{
    const TIME_ZONE_TOKYO='Asia/Tokyo';
    const TIME_ZONE_UTC='UTC';
}

根本的なテストの作り方の改善

データベースやキャッシュなどのデータストレージのアクセスを含むテスト

GameWithではfuelphpのorm/modelを利用してDB操作をしています。 AspectMockが何でも出来てしまうため以下のようなテストが多く存在していました。

<?php
namespace model;

class User extends \Orm\Model
{
    protected static $_properties = array(
        'id',
        'username',
        'password',
        'email',
        'full_name',
        'created_at',
        'updated_at',
    );

    protected static $_observers = array(
        'Orm\Observer_CreatedAt' => array(
            'events' => array('before_insert'),
            'mysql_timestamp' => false,
        ),
        'Orm\Observer_UpdatedAt' => array(
            'events' => array('before_save'),
            'mysql_timestamp' => false,
        ),
    );

    protected static $_table_name = 'users';
}
<?php
namespace service;
class User
{
    public function get(int $id)
    {
        return \model\User::find($id);
    }
}
<?php
namespace tests;

use PHPUnit\Framework\TestCase;

class Test_User_Aspectmock extends TestCase
{
    public function test_get_success()
    {
        $now = date("Y/m/d H:i:s");
        $model[1] = new \model\User([
            'username' => 'testuser1',
            'password' => 'password1',
            'email' => 'testuser1@example.com',
            'full_name' => 'Test User One',
            'created_at' => $now,
            'update_at' => $now,
        ]);
        $model[1]->id = 1;
        $model[2] = new \model\User([
            'username' => 'testuser2',
            'password' => 'password2',
            'email' => 'testuser2@example.com',
            'full_name' => 'Test User Two',
            'created_at' => $now,
            'update_at' => $now,
        ]);
        $model[2]->id = 2;
        \AspectMock\Test::double('\model\User', [
            'find' => function ($id) use ($model) {
                return $model[$id] ?? null;
            }
        ]);

        $user = new \service\User();
        $res = $user->get(1);
        $this->assertEquals(1, $res->get_id());
        $this->assertEquals('Test User One', $res->get_full_name());
        $res = $user->get(2);
        $this->assertEquals(2, $res->get_id());
        $this->assertEquals('Test User Two', $res->get_full_name());
    }
}

このようにAspectMockではクラスの一部だけMock化ができますが、Mockeryでは出来ません。
※その他モッキングフレームワークも出来ないのでこれが一般的なのかなと思います。

そもそもこのようにストレージデータのテストデータを用意したい場合は、 モックを使うよりもテストデータを準備してモックせずにテストするほうが正しくテスト出来ます。

上記の例だとGameWithのPHPUnitを4から8にバージョンアップした話でも紹介しているdbunitを利用してテストデータをいれる形に修正しています。

最後に

適切な依存性注入ができる設計にしよう!
この作業を進める中で、本当にその重要性を感じました。
ただし、GameWithのメインリポジトリでは、全てのクラスを書き換えることがコストがかかりすぎると判断し、その計画は断念しました。
しかし、最近のAIの進化によりここでも今よりも低コストでの実施が可能になるかもしれません。
今回の置き換え作業に関しては、サポート期限が切れて時間が経過していたため、このタイミングでの実施となりました。
もしかすると、もう少し待っていれば、AIの力を借りてこの置き換え作業をもっと簡単に行えたかもしれません(笑)。

余談ですが、私自身文章を書くのが苦手な方なのですがChatGPTに校正してもらっていつもより迷わず書けています。

採用情報

こんなGameWithではエンジニアを絶賛募集中です!
ChatGPTやGitHub CopilotなどAIまわりのツール利用も部として積極的に推進しています。
興味がある方は是非GitHubの採用情報まとめをご覧ください!
カジュアル面談もお待ちしております!

github.com