单元测试利器,Mockery

引言

我们在写单元测试的时候经常会遇到第三方 API 无法调用成功的情况。一方面是网络的原因,从本质上来讲,单元测试的运行是不需要网络的支持;另一个方面是有些第三方的 API 的调用是需要收费的,再者如果测试支付流程,总不能每次跑一次测试就真实的去支付吧,这个肯定是行不通的。

当然了,除了第三方 API 的调用场景,还有一些你不需要真实去执行的逻辑,你可以通过 Mock 的方式去跳过。TDD 开发也是极大的需要 Mock 的支持,至于具体的 TDD 开发流程,就不在这里过多的阐述了,反正概括一下就是:先写测试代码,后写开发代码~

Mockery 框架的介绍

Mockery 是一个简单而灵活的 PHP 模拟对象框架,配合 PHPUnit、PHPSpec 或任何其他测试框架进行单元测试。能够使用可读的领域特定语言 (DSL) 清楚地定义所有可能的对象操作和交互。

Mockery 框架的安装

$ composer require --dev mockery/mockery

⚠️:–dev 表示此包只在本地开发的时候使用,而不会在生产环境进行安装

Mockery 框架的基本使用

示例就拿获取短信验证码(需要联网、需要收费、需要配置)的服务来做一个演示。

基础的示例

  • Sms
class Sms
{
    private $service;

    public function __construct(SmsInterface $service)
    {
        $this->service = $service;
    }

    public function send(string $phone, string $content)
    {
         return $this->service->send($phone, $content);
    }
}

class AliyunSms implements SmsInterface
{
    public function send(string $phone, string $content)
    {
         ...
         $response = Http::post(xxxxx);

         ...
         return $response;
    }

    public function generateCode()
    {
        return 123456;
    }
}
  • PHPUnit
public function test_send_sms
{
    $phone = '+18618888888888';
    $content = 'Hello Mockery!';

    $service = Mockery::mock(AliyunSmsService::class);
    $service->shouldReceive('send')
        ->with($phone, $content)
        ->once()
        ->andReturn(666);

    $sms = new Sms($service);

    $this->assertEquals(666, $sms->send($phone, $content));
}

下面来详细讲解一下每个步骤以及一些拓展:

1. $service = Mockery::mock(AliyunSmsService::class);

通过 Mockery::mock 将需要 Mock 的类作为参数传入,返回的结果是 Mock 之后的对象实例。

  • 创建一个实现特定接口的 Mock 对象

    $mock = mock('MyClass, MyInterface')
    
    $mock = \Mockery::mock('MyClass', 'MyInterface, OtherInterface');
  • 创建模拟对象,除了上面的 Mock 对象之外,还提供了一种 Spy 的方式。Spy 方式与 Mock 方式最大的区别就是不在乎返回值,也就是说你只需要断言代码逻辑执行过这个方法,而不在乎返回了什么。下面来看一个示例:

    // Mock
    $mock = \Mockery::mock('MyClass');
    $mock->shouldReceive('foo')->andReturn(42);
    $mockResult = $mock->foo();
    
    var_dump($mockResult); // int(42)
    
    // Spy
    $spy = \Mockery::spy('MyClass');
    $spyResult = $spy->foo();
    $spy->shouldHaveReceived()->foo();
    
    var_dump($spyResult); // null
  • 通过上面两种方式创建的 Mock 对象,从一定意义上来说抛开了原有类的一些方法和属性,只对 foo 这一个方法有意义。如果你还需要调用 MyClass 类中的其他的方法,而不需要去断言什么,就可以利用 makePartial 方法。也就是说只模拟其中的一个方法,其他的走原有的逻辑。来看一下示例:

    public function test_send_sms
    {
        $phone = '+18618888888888';
        $content = 'Hello Mockery!';
    
        $service = Mockery::mock(AliyunSmsService::class)->makePartial();
    
        $service->shouldReceive('send')
            ->with($phone, $content)
            ->once()
            ->andReturn(666);
    
        $sms = new Sms($service);
    
        $this->assertEquals(666, $sms->send($phone, $content));
        $this->assertEquals(123456, $sms->generateCode());
    }
  • 还有一种 mock 方式与 makePartial 的作用类似,这种方式与 makePartial 不同的是,如果一旦这样申明了,就只能对 send 方法进行 Mock,对 AliyunSmsService 类的其他方法进行 Mock 没有任何作用,会直接当作普通实例去执行

    public function test_send_sms
    {
        $phone = '+18618888888888';
        $content = 'Hello Mockery!';
    
        $service = Mockery::mock(AliyunSmsService::class . '[send]');
    
            ...
        $this->assertEquals(123456, $sms->generateCode());
    
        $service->shouldReceive('generateCode')
            ->once()
            ->andReturn(888);
    
        // 返回值依旧是 123456    
        $this->assertEquals(123456, $sms->generateCode()); 
    }
2. $service->shouldReceive(‘send’)

申明 $service 对象会调用一个 send 方法(如果没有申明就去调用是会提示方法不存在的)

  • 断言在 Mock 对象上上不调用此方法(如果调用了则会抛出异常):

    $mock->shouldNotReceive('foo');
3. …->with($phone, $content)

表示调用 send 方法将会传入两个参数 $phone、$content,如果不传或者少传会提示参数异常

  • withArg:与 with 一样的效果

  • withArgs:同上

  • withAnyArg:无论传递什么参数都可以

    $mock = \Mockery::mock('MyClass');
    $mock->shouldReceive('name_of_method')
        ->withAnyArgs();
  • withNoArgs:没有参数

    $mock = \Mockery::mock('MyClass');
    $mock->shouldReceive('name_of_method')
        ->withNoArgs();
4. …->once()

表示调用 send 方法最少一次,如果没有发生调用,也会抛出一个异常。

  • zeroOrMoreTimes:被调用零次
  • times($n) 被调用 n 次
5. …->andReturn(666)

定义 send 方法的返回值为 666。

  • 期望在 Mock 对象上调用一个方法并为每个连续调用返回不同的值:

    $mock->shouldNotReceive('foo')->andReturn(1, 2, 3)
    
    $mock->foo(); // 1
    $mock->foo(); // 2
    $mock->foo(); // 3
  • andReturnNull:设定值返回值为空

  • andThrow:设定返回值抛出异常

6. $sms->send($phone, $content)

执行调用~顺利返回 666

项目中的实战

Route::post('security/verify-code', 'SecurityController@sendVerifyCode')->name('security.verify.code');
  • 逻辑代码(为了更加贴近我们的项目,示例代码的结构与项目中的代码结构基本一致)
class SecurityController extends Controller
{
        ...
    public function sendVerifyCode(Request $request): Response
    {
        $this->validate($request, [
            'phone' => ['required', new Phone()],
                        ...
        ]);

        $response = (new SmsService())->send($request->input('phone'));

        return \response()->json($response);
    }
     ...
}


class SmsService
{
        ...

        public function __construct(public $easySms)
        {
            $this->easySms = new EasySms(config('sms'));
        }

    public static function send(string $phone, int $codeLength = 6, int $timeliness = 5): array
    {
               // 生成验证码
        $code = self::generateCode($codeLength);

        ...

        // 验证码放进缓存中
        \Cache::put(self::getCacheKeyName($phone->getUniversalNumber()), $code, $timeliness * 60);

                ...
                // 利用第三方服务
        $this->easySms->send(
            $phone,
            [
                'content' => $content,
                'template' => $templateId,
                'data' => [
                    'code' => $code,
                    'timeliness' => $timeliness,
                ],
            ]
        );

        ...
    }
     ...
}
  • 开始来写测试

当我们需要测试这个 API 的时候,可能会直接就这样来写:

class AuthTest extends TestCase
{
    public function test_sms_send()
    {
        ...
        $this->postJson(route('security.verify.code'), ['phone' => '+8618888888888'])
            ->assertOk()
            ->assertJsonFragment([
                'xxxx'
            ]);
        ...
    }
}

但是往往这个测试是跑不通的,一方面配置文件的原因,另一方面网络的原因,就算是网络通了,配置也正确的,但是费钱~

我刚看到这个代码的时候,我其实有点不知道从哪里下手的,没有办法去 mock 控制器中通过 new 的方式创建的对象实例,也就是上面的 SmsService。在为了不改动代码逻辑的前提下,我是这么写的:

$request = new Request();
$request->replace(['phone' => '8618888888888']);

$sms = \Mockery::mock(SecurityController::class)
    ->shouldReceive('sendVerifyCode')
    ->with($request)
    ->andReturn([
        'xxxx'
    ]);

$response = $sms->sendVerifyCode($request);
...

这简直没办法看啊,把内部逻辑全部跳过了,没有达到一丁点的测试效果。思来想去还是对代码下手了。

因为短信服务是在控制器内部进行调用的,而且还走了两次服务转发,一次是转发到自己的 SmsService,另一次是转发到拓展包的 EasySms 中。所以说我们想通过对象模拟注入的方式来进行测试的编写是行不通的。

从代码中可以很直观的看到,EasySms 的实例化只是单纯的加载了配置而已,所以利用 Laravel 服务容器的理念,可以将 EasySms 注册到服务容器中。

class EasySmsServiceProvider extends ServiceProvider
{
    ....
    public function register()
    {
        $this->app->singleton(EasySms::class, function ($app) {
            return new EasySms(config('easysms'));
        });

        $this->app->alias(EasySms::class, 'easysms');
    }
}

为什么要加入到服务容器中呢,因为 Laravel 针对这些绑定在容器中的服务,给我们提供了一种 mock 方式:

// 方式一
$this->instance(
    Service::class,
    Mockery::mock(Service::class, function (MockInterface $mock) {
        $mock->shouldReceive('process')->once();
    })
);

// 方式二
$mock = $this->mock(Service::class, function (MockInterface $mock) {
    $mock->shouldReceive('process')->once();
});

也就是说按照上面的方式进行 Mock 之后,你再去调用 Service 中的 process 方法的时候,无论你在服务代码写得多深,最终的返回结果都是如 mock 的设定一样。将上面的方式带入到我们的短信服务中来:

// 首先得修改一下 SmsService 中 EasySms 的实例化方式
class SmsService
{
        ...

        public function __construct(public $easySms)
        {
            $this->easySms = app('easysms');
        }
...

修改测试:

$this->instance(
    EasySms::class,
    \Mockery::mock(EasySms::class, function (MockInterface $mock) {
        $mock->shouldReceive('send')->withAnyArgs()->once()->andReturnNull();
    })
);

$this->postJson(route('security.verify.code'), ['phone' => '+8618888888888'])
    ->assertOk()
    ->assertJsonFragment([
        'code' => 0,
        'success' => true,
    ]);

// 保证验证码有正常写入到缓存中
$this->assertNotEmpty(Cache::get('xxxxx'));

最终就达到了我们想要的测试效果啦,仅仅只跳过了最终请求第三方 API 的地方,其余的逻辑都可以测试到,保证逻辑正常。

结束语

Mockery 的强大远不止于此,很多其他的功能没有一一展示,剩下的就交给作为使用者的你了,如果在使用的过程中基础的 Mock 解决不了了(比如静态方法的模拟、保护方法的模拟、常量的模拟等等),不妨翻翻文档,你就可以发现很多的解决办法。

上面的一些示例已经很大程度上涵盖了我们日常写测试的所需。作为一个辅助测试的工具,如果能够很流程的把它用起来,在编写测试和代码质量上面会有很大的提升。

本作品采用《CC 协议》,转载必须注明作者和本文链接
finecho # Lhao
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 2

推荐一篇关于单测的 github.com/sarven/unit-testing-tip...

静态方法的模拟,Mockery的比较鸡肋,万能一点的可以用aop去搞定。

9个月前 评论

感謝分享!

靜態方法或是沒有使用依賴注入的話可以使用這種方式

Mockery::mock('overload:' . AliyunSmsService::class);

但是跑下一個測試如果有再次使用 AliyunSmsService 的方法,就會噴出錯誤 class is already exist

解決方法是在使用測試的地方加上 PHPDoc

/**
 * @runTestsInSeparateProcesses
 * @preserveGlobalState disabled
 */
public function test_send_sms() {
    // ...
}

我之前找資料都不是很推薦用這種方式,這也是為什麼大家都建議儘量使用依賴注入的方式寫程式

7个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!