我们写的类、方法常常存在依赖,为这些类编写单元测试的时候,我们要想尽可能地覆盖更多的测试用例(Test Case),难以避免地就要对依赖类进行Mock。

想象一个场景,我们现在编写一个吃货类(Foodaholic),这个吃货只有一个功能,就是吃,但是他自己不会煮,所以要找个厨师给他做饭吃,而且他还很挑剔,只吃蛋糕(cake)、牛排(steak)和披萨(pizza),代码如下:

class Foodaholic {
    private static $eatable = ["cake","steak","pizza"];
    public function eat() {
        $food = Cook::provideMeal();
        if (!in_array($food,self::$eatable)) {
            echo "Not eatable";
        } else {
            echo "Eating ".$food;
        }
    }
}

好了,我们现在打算对吃货类的eat方法编写单元测试,测试他是不是真的只吃蛋糕、牛排和披萨,理所当然地,我们开始编写厨师类(Cook)的Mock类。

但是此时我们发现,厨师类提供了一个静态方法,而吃货类是调用该静态方法来对厨师类进行依赖,那么我们如何mock呢?

最简单的方法就是在AutoLoader找到Cook类之前就手动引入我们编写好的Mock厨师类,参考以下代码:

//mockStaticCakeCook.php
class Cook {
    public static function provideMeal() {
        return "cake";
    }
}
require_once "mockStaticCakeCook.php";

use PHPUnit\Framework\TestCase;

class FoodaholicTest extends TestCase {
    public function testEat() {
        $this->expectOutputString('Eating cake');
        $f = new Foodaholic();
        $f->eat();
    }
}

这方法肯定是可行的,我们mock了一个做蛋糕的厨师,并且测试了吃蛋糕的用例,但这个方法实在称不上优雅。

那么PHPUNIT有没有提供mock功能呢,答案是肯定的,看下面代码:

// 修改自phpunit文档的示例
use PHPUnit\Framework\TestCase;

class SubjectTest extends TestCase
{
    public function test()
    {
        // Create a mock object
        $mockObject = $this->getMockBuilder("Cook")->getMock();

        // Configure
        $mockObject ->method("provideMeal")->willReturn("cake");
        
        $this->assertEquals("cake", $mockObject->provideMeal());
    }
}

上面的代码是参考官方范例,结合本文场景改写的,我们mock了一个厨师类对象,并且mock了它的provideMeal方法返回cake。

眼尖的小伙伴估计一下就发现问题了:“可这mock的不像是静态方法呀?”

没错,上面确实mock的不是静态方法,因为phpunit并不支持mock静态方法,在官方文档也明确提到这一点。

那么估计这里就有小伙伴要骂我了:“搞半天,原来PHPUNIT不支持啊!你这不是标题党吗?!”

好吧,事已至此,我也只能承认本文确实有点标题党…… 不过各位不好奇为什么PHPUNIT不支持mock静态方法吗?

接下来,我就从本人理解来解读下为什么PHPUNIT不去支持mock静态方法。


首先,我们沿着PHPUNIT的思路走,如果我们要用PHPUNIT的mock方式来对我们的代码进行单元测试,那么我们要怎么改写吃货类呢?我先来举个例子,看以下代码:

单元测试

use PHPUnit\Framework\TestCase;
class FoodaholicTest extends TestCase {
    public function testEat() {
        // Create a mock object
        $mockCook = $this->getMockBuilder("Cook")->getMock();

        // Configure
        $mockCook->method("provideMeal")->willReturn("cake");

        //test
        $foodaholic = new Foodaholic($mockCook);
        $this->expectOutputString('Eating cake');
        $foodaholic->eat();
    }
}

吃货类

class Foodaholic {
    private static $eatable = ["cake","steak","pizza"];
    private $cook;
    // 因为我们的吃货没有厨师做饭就活不下去了
    // 所以在构造的时候就要传入一个厨师
    public function __construct(Cook $cook) {
        $this->cook = $cook;
    }
    public function eat() {
        $food = $this->cook->provideMeal();
        if (!in_array($food,self::$eatable)) {
            echo "Not eatable";
        } else {
            echo "Eating ".$food;
        }
    }
}

我们将厨师作为吃货类构造方法的参数传入,这样就可以顺利使用PHPUNIT的mock功能去对我们的吃货类进行测试了。

那么改写后的代码跟原本的调用静态方法的吃货类最根本的区别是什么呢?

没错,是依赖的引入方式。

使用静态方法调用时,实际上是将吃货类跟厨师类强耦合在一起了,也就是说,假如哪天我们想给吃货换个厨师,那么就必须要改吃货类的代码。

而如果采用构造方法传参的方式,那么依赖就是从外部传入的,我们想要更换厨师,只要给吃货传入另外一个厨师就行了,不需要修改吃货的代码,这时候吃货类跟厨师类就是松耦合了。

说到这里,可能又有小伙伴要问了,在构造方法要求传入Cook类,那不还是耦合了Cook类吗?没错,确实是这样,所以让我们再稍微改一下这个构造方法:

interface ICook {
    public function provideMeal();
}
class Foodaholic {
    private $cook;
    public function __construct(ICook $cook) {
        $this->cook = $cook;
    }
    ...
}

这样一来,只要是会做饭的人(实现了provideMeal方法的类),我们都认为他可以是一个厨师(也就是ICook接口的实现类),这时候我们的吃货对厨师的依赖关系就变得非常合理了,我们可以随时给吃货换厨师,这个厨师可以是保姆,妈妈,爸爸等等,而且在更换厨师的过程中,不需要修改吃货的代码,吃货只要专注吃这件事就可以了,可谓是非常地高内聚了。

同时,我们的单元测试也变得非常简单,以下代码测试吃货是不是只吃蛋糕、牛排和披萨:

class FoodaholicTest extends TestCase {
    /**
    * @dataProvider dp
    */
    public function testEat($food,$expectOutput) {
        // Create a mock object
        $mockCook = $this->getMockBuilder("ICook")->getMock();
        // Configure
        $mockCook->method("provideMeal")->willReturn($food);
        //test
        $foodaholic = new Foodaholic($mockCook);
        $this->expectOutputString($expectOutput);
        $foodaholic->eat();
    }

    public function dp() {
        return [
            ["cake","Eating cake"],
            ["steak","Eating steak"],
            ["pizza","Eating pizza"],
            ["rice","Not eatable"]
        ];
    }
}

结合PHPUNIT提供的 dataProvider 注解,我们轻松优雅地完成了吃货类的测试,真是可喜可贺~


回到问题,为什么PHPUNIT不支持mock静态方法?

我想到的答案是,PHPUNIT希望我们在引入依赖的时候,都能遵循“依赖倒置原则”(Dependence Inversion Principle, DIP)。DIP的原文表达得比较含蓄,我站在PHP这门语言(跟JAVA应该也一样)的角度来解读如何遵循DIP:

  1. 类之间的依赖应该通过抽象来进行,实现类之间不应该直接发生依赖,而是通过接口或抽象类来产生依赖
  2. 接口或抽象类不能依赖实现类
  3. 实现类依赖接口或抽象类

看完上面3句话,再想想前面讲了半天的吃货和厨师的例子,应该明白DIP是个怎么回事了吧!

实际上如果用过最优雅的PHP框架“Laravel”的开发者,都会发现官方给出的大量示例代码都遵循了依赖倒置原则,这也是实现服务容器(Service Container)的基础,当然这是另外一个故事了。

所以,PHPUNIT不去支持静态类的mock,我个人认为是很合理的,反而是我们在实际编写代码的过程中,如果过分贪图快速,而不分场景地去编写静态类,依赖静态类,最后我们的代码大概只会越来越难测试和维护了吧。