Lzh on GitHub

在类中模拟类

想象一种情况,你需要在同一个方法中创建一个类的实例并使用它:

想象一种情况,你需要在同一个方法中创建一个类的实例并使用它:

// Point.php
<?php
namespace App;

class Point {
    public function setPoint($x, $y) {
        echo "Point (" . $x . ", " . $y . ")" . PHP_EOL;
    }
}

// Rectangle.php
<?php
namespace App;
use App\Point;

class Rectangle {
    public function create($x1, $y1, $x2, $y2) {
        $a = new Point();
        $a->setPoint($x1, $y1);

        $b = new Point();
        $b->setPoint($x2, $y1);

        $c = new Point();
        $c->setPoint($x2, $y2);

        $d = new Point();
        $d->setPoint($x1, $y2);

        $this->draw([$a, $b, $c, $d]);
    }

    public function draw($points) {
        echo "Do something with the points";
    }
}

并且你想测试 Rectangle->create() 中的逻辑是否正确调用了每个需要的东西 —— 在这种情况下调用了 Point->setPoint(),但 Rectangle->draw() 会做一些图形相关的事,而你不想让它真正执行。

你为 App\PointApp\Rectangle 设置了 mocks:

<?php
class MyTest extends PHPUnit\Framework\TestCase {
    public function testCreate() {
        $point = Mockery::mock("App\Point");
        // 检查我们的 mock 是否被调用
        $point->shouldReceive("setPoint")->andThrow(Exception::class);

        $rect = Mockery::mock("App\Rectangle")->makePartial();
        $rect->shouldReceive("draw");

        $rect->create(0, 0, 100, 100);  // 不会抛出异常
        Mockery::close();
    }
}

但是测试并没有生效。原因是:mocking 依赖于类尚未被加载,而类一旦被自动加载,单独为 App\Point 设置的 mock 就没用了,你可以看到 echo 被真正执行了。

不过对于加载顺序中的第一个类(例如 App\Rectangle),mock 是有效的,因为它会去加载 App\Point 类。在更复杂的例子中,会有一个单独的入口类触发整个加载(use Class),例如:

A        // 主加载发起者
|- B     // 另一个加载发起者
|  |-E
|  +-G
|
|- C     // 另一个加载发起者
|  +-F
|
+- D

这基本上意味着加载阻止了 mocking,而对于每个这样的加载发起者,都需要实现一个变通方案。重载是一种方法,但它会污染全局状态。在这里我们尝试完全避免全局状态污染,通过在特定加载发起者处自定义 new Class() 行为,这样在几个关键点就能很容易地 mock。

话虽如此,虽然我们不能阻止类加载,但我们可以返回 mocks。来看 Rectangle->create() 方法:

class Rectangle {
    public function newPoint() {
        return new Point();
    }

    public function create($x1, $y1, $x2, $y2) {
        $a = $this->newPoint();
        $a->setPoint($x1, $y1);
        ...
    }
    ...
}

我们创建一个自定义函数来封装 new 关键字,否则它会直接使用自动加载的 App\Point 类,在测试中我们 mock 这个函数,使其返回我们的 mock

<?php
class MyTest extends PHPUnit\Framework\TestCase {
    public function testCreate() {
        $point = Mockery::mock("App\Point");
        // 检查我们的 mock 是否被调用
        $point->shouldReceive("setPoint")->andThrow(Exception::class);

        $rect = Mockery::mock("App\Rectangle")->makePartial();
        $rect->shouldReceive("draw");

        // 将 App\Point mock 传入 App\Rectangle
        // 作为替代直接在代码中使用 new App\Point()
        $rect->shouldReceive("newPoint")->andReturn($point);

        $this->expectException(Exception::class);
        $rect->create(0, 0, 100, 100);
        Mockery::close();
    }
}

如果现在运行这个测试,它应该会通过。对于更复杂的情况,我们会在程序流中找到下一个加载点,并继续通过包装和传递带有预定义行为的 mock 实例到已存在的类中。