Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

mockery/mockery の仕組み

サーバーサイドエンジニアの田実です!

今回はPHPのモックライブラリである mockery/mockery がどのようにして動いているのかを紹介します。

本記事は PHP Advent Calendar 2023 の5日目の投稿になります!

コードリーディング

今回紹介するmockery のバージョン1.6.6 です。 Mockery::mock() メソッドを使った以下のパターンでコードを読んでいきます。

<?php

Mockery::mock(Service::class, function (MockInterface $mock) {
  $mock->shouldReceive('hoge')->once();
});

Mockery::mock()Mockery\Container::mock() を呼び出します。 https://github.com/mockery/mockery/blob/1.6.6/library/Mockery.php#L105-L108

<?php

    public static function mock(...$args)
    {
        return call_user_func_array(array(self::getContainer(), 'mock'), $args);
    }

Mockery\Container::mock() で実際のモック用のクラス生成を行っています。 https://github.com/mockery/mockery/blob/1.6.6/library/Mockery/Container.php#L83-L223

引数のパターンによって処理が変わってくるのですが、明示的にクラス名を指定したパターンだと大まかな処理は以下のようになっています。

<?php

$builder = new MockConfigurationBuilder();
$builder->addTarget($type);
$builder->addBlackListedMethods($blocks);
$builder->setMockOriginalDestructor(true);

$config = $builder->getMockConfiguration();
$def = $this->getGenerator()->generate($config);
$this->getLoader()->load($def);
$mock = $this->_getInstance($def->getClassName(), $constructorArgs);
return $mock;

MockConfigurationBuilder に値を設定して、 getMockConfiguration()メソッドでconfigを作成、それを使ってクラスのコードを動的に生成、ロードしています。最後にこのモッククラスをインスタンス化して返しています。

$this->getGenerator()->generate($config) は最終的に StringManipulationGenerator::generate() を呼び出してコードを生成しています。 https://github.com/mockery/mockery/blob/1.6.6/library/Mockery/Generator/StringManipulationGenerator.php#L63-L75

<?php
    public function generate(MockConfiguration $config)
    {
        $code = file_get_contents(__DIR__ . '/../Mock.php');
        $className = $config->getName() ?: $config->generateName();

        $namedConfig = $config->rename($className);

        foreach ($this->passes as $pass) {
            $code = $pass->apply($code, $namedConfig);
        }

        return new MockDefinition($namedConfig, $code);
    }

Mock.php ファイルのコードを取得して、コードを書き換え、そのコード文字列を MockDefinition クラスに入れて返しています。

コードの書き換え処理は library/Mockery/Generator/StringManipulation/Pass 内の各クラスで置き換えられています。 例えばクラス名を変更するのは ClassNamePass クラスです。ClassNamePass クラスは以下のような実装になっていて、 str_replace() で namespace を書き換えています。

<?php

class ClassNamePass implements Pass
{
    public function apply($code, MockConfiguration $config)
    {
        $namespace = $config->getNamespaceName();

        $namespace = ltrim($namespace, "\\");

        $className = $config->getShortName();

        $code = str_replace(
            'namespace Mockery;',
            $namespace ? 'namespace ' . $namespace . ';' : '',
            $code
        );

        $code = str_replace(
            'class Mock',
            'class ' . $className,
            $code
        );

        return $code;
    }
}

継承の書き換えは ClassPass クラスによって行われます。

<?php

class ClassPass implements Pass
{
    public function apply($code, MockConfiguration $config)
    {
        $target = $config->getTargetClass();

        if (!$target) {
            return $code;
        }

        if ($target->isFinal()) {
            return $code;
        }

        $className = ltrim($target->getName(), "\\");

        if (!class_exists($className)) {
            \Mockery::declareClass($className);
        }

        $code = str_replace(
            "implements MockInterface",
            "extends \\" . $className . " implements MockInterface",
            $code
        );

        return $code;
    }
}

これによってmock元のクラスを継承したモック用のクラスのコードが生成しています。

この動的に生成されたコードはただの文字列なので $this->getLoader()->load($def) によってクラスを動的に定義しています。 具体的には EvalLoader::load() が eval() 関数を呼び出して実現しています。 https://github.com/mockery/mockery/blob/1.6.6/library/Mockery/Loader/EvalLoader.php#L18-L25

<?php

class EvalLoader implements Loader
{
    public function load(MockDefinition $definition)
    {
        if (class_exists($definition->getClassName(), false)) {
            return;
        }

        eval("?>" . $definition->getCode());
    }
}

最終的に作成されるクラスはこんな感じになります。

<?php

class Mockery_0_Service extends Service implements MockInterface
{
// ...

public function hoge(string $hoge): string{
$argc = func_num_args();
$argv = func_get_args();
$ret = $this->_mockery_handleMethodCall(__FUNCTION__, $argv);
return $ret;
}
}

mock元に定義されていたpublicメソッドがMockクラスでも定義されていることがわかります。 このpublicメソッド内で _mockery_handleMethodCall を引数付きで呼び出すことでメソッド呼び出しの検証・モックを行っています。

makePartial() で部分的にモックする場合は $this->_mockery_deferMissing が true になるので、shouldReceive() などでメソッドをモックをしない場合は親クラス(Mock元のクラス)のメソッドをそのまま呼び出します。

<?php
...
        } elseif ($this->_mockery_deferMissing && is_callable("parent::$method")
            && (!$this->hasMethodOverloadingInParentClass() || (get_parent_class($this) && method_exists(get_parent_class($this), $method)))) {
            return call_user_func_array("parent::$method", $args);

call_user_func_array("parent::$method", $args) がなかなか強烈ですね…w

まとめ

PHPのモックライブラリの仕組みについて紹介しました。PHPはevalで動的にクラスを定義できるため、型の恩恵を受けつつカジュアルにモッククラスを作れるのが面白いですね。

モックライブラリは言語仕様によって実現方法が変わってきます。PHP以外のモックライブラリのコードを読んでみると、意外と知らなかった言語仕様を発見したりして面白いかもしれません(かくいう私もPHPで eval() 使ってクラスを動的に定義できるの知らなかったです…w)