この記事は個人ブログの転載になります。
toyo.hatenablog.jp
とある方から、「なんで静的メソッドはモックできないんですか?」ときかれたときに、「そういえば、Mockがどういう原理で動いているかいまいち知らないなー」と思ったので、モックがどのように作られているのかを調べてみました。
僕は普段はMockeryを使用しているので、Mockeryでのモックの生成のされ方をまとめます。
docs.mockery.io
Mockeryは色々な種類のモックが作れるので、今回は一番ベーシックな「スタブやモック」の生成過程を追ってみました。
コードでいうと、以下のような感じのやつ。
<?php
Mockery::mock(Post::class);
以下ではこのコードを例にとって説明していきます。
大まかな流れ
雑にまとめると、以下です。
- Postクラスを継承したモッククラスの定義を作成する
- 定義したモッククラスをロードする
- ロードしたモッククラスのインスタンスを生成する
です。もっと Refrection Class が関わってくるのかなと身構えていたのですが、とてもシンプルでした。
モッククラスの定義
Mockeryではモッククラスごとにクラスの定義を行っています。
つまり、
<?php
class MockedPost {
}
みたいなコードを、都度生成しているということです。
モッククラスのテンプレート
ただし、モッククラスには、 shouldRecieve
や andReturn
などのメソッドが共通して必要です。
そのため、モッククラスの定義のテンプレートのようなものが存在しています。
このテンプレートが、 Mockery\Mock
です。
github.com
このクラスの中で shouldRecieve
などの定義も書かれています。
つまり、 Mockery::mock(Post::class)
で生成したインスタンスの振る舞いは、このクラスの内容を読めばわかるということです。
テンプレートの書き換え
Mockeryではモッククラスごとにクラスの定義を行っているため、クラス名の衝突については考えないといけません。
また、PHPには型が存在しているため、それぞれのモッククラスは、引数で渡されたクラスの型である必要があります。
今回でいうと、生成されたモッククラスは Post
型である必要があります。
なので、テンプレートを書き換える必要があります。
テンプレートのファイルを file_get_contents
で読み込み、文字列として変数に入れます。
その文字列を str_replace
などを用いて書き換えていきます。
元のテンプレートは以下のような感じです。
<?php
class Mock implements MockInterface
{
}
クラス名の書き換え
クラス名が衝突するとエラーになるため、ユニークなクラス名が必要です。
そのため、テンプレートのクラス名を書き換える必要があります。
クラス名は Mockery_0_Post
という命名になります。
0
の部分は連番です。
たとえばモックを2個生成した場合は、以下のようになります。
<?php
$post1 = Mockery::mock(Post::class);
$post2 = Mockery::mock(Commentable::class);
よって、クラス定義は以下のような状態になります。
<?php
class Mockery_0_Post implements MockInterface
{
}
型情報の追加
生成されたモッククラスはPost型である必要があります。
ただ、この段階でモッククラスの型は Mockery_0_Post
, MockInterface
だけです。
そのため、Postクラスを継承することで解決します。
つまり、ドキュメントにも書いてありますが、 final
キーワードのついているクラスのモックは(継承できないので)作成できません。1
クラス定義は以下のような状態になります。
<?php
class Mockery_0_Post extends Post implements MockInterface
{
}
メソッド呼び出しの抑制
継承をすることで型を付与することはできますが、問題がひとつ残ります。
それは、Postクラスのメソッドが継承されてしまうということです。
たとえば、PostクラスにgetTitle()
というメソッドが存在していたとして、Postをモックしたインスタンスを呼ぶと、そのままPost::getTitle()
が呼ばれてしまいます。
別の挙動に差し替えるためにモックを使いたいので、これではモックの意味がありません。
モッククラスの定義に、Postで定義されているすべてのメソッドを再定義(オーバーライド)することで、この問題に対応します。
Postで定義されているすべてのメソッドの取得は、Postクラスのリフレクションクラスのインスタンスを作成し、そのインスタンスの getMethods
メソッドを使用することで実現できます。
https://www.php.net/manual/ja/reflectionclass.getmethods.php
ちなみに、メソッドの内容はほぼ固定で、以下が入ります。
<?php
$argc = func_num_args();
$argv = func_get_args();
$ret = $this->_mockery_handleMethodCall(__FUNCTION__, $argv);
return $ret;
最終的に、クラス定義は以下のようになります。
<?php
class Mockery_0_Post extends Post implements MockInterface
{
public function getTitle(): string {
$argc = func_num_args();
$argv = func_get_args();
$ret = $this->_mockery_handleMethodCall(__FUNCTION__, $argv);
return $ret;
}
}
クラス定義のロード
クラス定義はできましたが、これは現状ただの文字列です。
これをどうにか読み込まないと、クラスとして使えません。
「どうやって読み込むんだろう・・・?」と思っていましたが、
<?php
eval("?>" . $definition->getCode());
github.com
のように、evalを使用して読み込んでいる感じでした。
PHPのevalのドキュメントには
PHP 開始タグを含めてはいけません。
とありますが、クラス定義の先頭はPHP開始タグで始まっているので、 ?>
を先頭につけて相殺しているようです。
さて、モッククラスの定義をし、定義の読み込みもできたので、あとはインスタンスを生成するだけです。
ただし、Postクラスにコンストラクタが宣言されていた場合を考えないといけません。
<?php
class Post
{
private string $title;
public function __construct(string $title)
{
$this->title = $title;
}
public function getTitle(): string
{
}
}
このような場合、モッククラスはPostクラスを継承しているので、インスタンスを生成するためには $title
が必要になります。
モックとしては、コンストラクタの処理は実行させたくない場合が多いと思います。
また、その際はそもそも引数を渡す意味もなく、引数の生成のためのコード2も書きたくないですよね。
なので、コンストラクタを呼ばずにインスタンスを生成します。
そのため、モッククラスのリフレクションクラスのインスタンスを作成し、 newInstanceWithoutConstructor
メソッドを使用して、コンストラクタ無しにモックインスタンスを作成します。
https://www.php.net/manual/ja/reflectionclass.newinstancewithoutconstructor.php
これで無事にモックのインスタンスを生成できました。
まとめ
僕はまだモックを知らなかった頃は
<?php
$mock = new class extends Post
{
public function getTitle(): string
{
return 'dummy';
}
};
のように無名クラスを使用して書いていました。
今回は有名なモックライブラリの中を調べてみたわけですが、基本的な発想は同じなんだなと思いました。
簡単な発想にどこまで向き合えるか、が有名ライブラリへの道なのかもしれません。
おまけ1: インターフェースのモック
さて、Postはクラスでしたが、インターフェースのモックの場合がどのようになるかも追ってみました。
<?php
interface Commentable
{
function writeComment(string $content): void;
}
Mockery::mock(Commentable::class);
基本的にはクラスの場合と同じです。
ただし、クラス定義の部分で extends
していた部分は implements
になります。
- class Mockery_0_Post extends Post implements MockInterface
+ class Mockery_0_Commentable implements MockInterface, Commentable
その他は大きな違いはありませんでした。
おまけ2: なぜ静的メソッドはモックできないのか
モックの生成過程が見えてくれば、静的メソッドのモックができない理由もみえてきます。
<?php
function getAllPosts(): array
{
return Post::all();
}
たとえばこのようなコードがあったとします。
Postのモックインスタンスを作成することは可能です。
Postのモッククラスの静的メソッドの内容を差し替えることも可能でしょう。
ただし、モッククラスは Mockery_0_Post
クラスであり、 Post
クラスではないのです。
つまり、 Mockery_0_Post::all()
は自由に定義できても、 Post::all()
の内容はそのままです。
そのため、 Post::all()
をそのままモックすることはできません。
これを解決するために、Mockeryでは「エイリアスモック」という機能があります。
<?php
Mockery::mock('alias:' . Post::class);
このように書くと、モッククラスの定義が以下のように変化します。
- class Mockery_0_Post extends Post implements MockInterface
+ class Post extends \stdClass implements MockInterface
引数で渡したクラス名がそのままモッククラスのクラス名になりました。
当然、すでにPostクラスが読み込まれている場合には、クラス名が衝突してエラーになります。
なので、エイリアスモックは、Postクラスが読み込まれる前に読み込む必要があります。3
つまり、「本物のコードが読み込まれる前に、モックのコードを読ませて、本物のコードが読み込まれないようにしちゃえ」という作戦なわけですね。
「エイリアスモックを使えば静的メソッドもモック可能」なわけですが、実コードではなかなか上手くいかないことが多いです。
というのは、たいていのプロダクトではLaravelなどのフレームワークを使用していると思います。
そして、テストコードを書く際も、フレームワークの機能を使用して書く場合が多いでしょう。
そのため、テストの実行時には、フレームワークの初期化などを行われますが、その際に、モックしたいクラスが読み込まれてしまうことが多いのです。
たとえばLaravelの場合だと、サービスプロバイダによってユーザが定義したクラスが読み込まれます。
その場合、個々のテストケースに処理が渡ってきたときにはすでにモックしたいクラスが読み込まれてしまっているため、
エイリアスモックを使えないことが多いのです。
エイリアスモックという手段は存在しているものの、「基本的には静的メソッドはモックできない」と考えたほうが良いかもしれません。