分类
php

单元测试如何落地

单元测试在大部分的项目中都不会被重视,或者知道很重要,但是不知道如何应用。这一篇文章描述了如何在项目中进行单元测试,如何对数据进行 Mock。文章以 Laravel 框架举例,实际上以来的是 PHPUnit FrameWork, 与其他具体的框架是无关的。

对本地方法的 Mock

除了对微服务方法的数据 Mock 外,还需要对部分本地的方法的数据进行 Mock。可以调用 Laravel 测试框架中提供的 TestCase::mock 方法:

<?php
$res = $this->mock(PictureOnlineOrderRepository::class, function (MockInterface $mock) {
	$mock->shouldReceive('getByOrderNos')->andReturn(collect([
		[	'id'                      => 1],		// 数据部分省略了几行代码
	]));
});

上面就是我从 Paperless 项目的测试代码中提取的一段实例:Mock 了PrictureOnlineOrderRepository 的对象的 getByOrderNos 方法,当我们在测试用例中调用这个方法后会返回`指定的数组 ['id'=>1]

Laravel 中采用了第一个第三方库 Mockery, 这个 mock 方法就来源于这个库。

在 Laravel 中提供了 TestCase::mock 方法,对 Mockery 库中的 mock 方法进行了一层封装,封装的目的就使用 Mock 对象去替换容器中的真实对象,以此来达到 Mock 的目的:

<?php
class HelloControllerTest extends TestCase
{
    public function testSay()
    {
        $this->mock(HelloService::class, function (MockInterface $mock) {
            $mock->shouldReceive('getChar')->andReturn('c');
        });			// mock
        $result = (new HelloController())->say();		// 调用被测试方法
        $this->assertEquals('ac', $result);		// 校验结果
    }
}

需要注意的是,被 Mock 的对象也必须在容器当中,才能够被替换,比较下面两种写法:

<?php
$char = app(HelloService::class)->getChar();	// 使用容器中的对象,可以被替换
$char = (new HelloService)->getChar();	// 自己实例化的对象,不可以被替换

对数据库的测试

虽然可以连接数据库进行测试,但是这样的测试过于耗时。所以,我们可以采用 Mock 的方式进行数据库相关的方法的测试。

如下示例,创建了一个 HelloService 的类,在这个类中有一个 getUser 的方法,获取数据库中的用户:

<?php
class HelloService{
    public function getUser(){
        $model = app(User::class);
        $user = $model->where('id', '=', 1)->find(['id', 'name']);
        if (is_null($user)) throw new Exception('User not found!');
        return $user;
    }
}

代码还是非常简单的,使用 app 容器方法获取了 User 模型的实例,然后查询 id 为 1 的用户。前面我们已经提到过了,使用 app 方法,将模型加入到容器中去,是为了之后可以去 Mock 这个模型类。

主要我们还是看看单元测试怎么写。在代码中存在一个分支,如果用户不存在则抛出异常。我们先来测试用户不存在的情况:

<?php
class TestHelloService {
    public function testGetUser1()
    {
        $this->throwException(new Exception);	// 我们断言下面的测试会抛出异常
        $this->mock(User::class, function (MockInterface $mockery) {
          	// Mock 数据库模型的我们用到的两个方法,其中 where 是链式调用,所以返回自身
            $mockery->shouldReceive('where')->andReturn(new User);
          	// find 方法我们返回空数组,模拟没有查到用户的情况
            $mockery->shouldReceive('find')->andReturn(collect([]));
        });
        (new HelloService())->getUser();		// 然后执行这个方法,这个测试用例是通过的
    }
}

然后我们再来写一个用户存在的测试用例:

<?php
public function testGetUser2()
{
		$this->mock(User::class, function (MockInterface $mockery) {
		$mockery->shouldReceive('where')->andReturn(new User);
				// 模拟存在的用户的数据
				$mockery->shouldReceive('find')->andReturn(collect([
					'id' => 1, 'name' => 'tom', 'create_at' => '2021-03-15 00:00:00',
          'update_at' => '2021-03-15 01:02:24'
				]));
		});
		$user = (new HelloService())->getUser();
		$this->assertIsArray($user);		// 断言返回值是数组
		// 然后测试用户数组中是否存在指定的键
		$this->assertEquals(true, 
		array_diff(array_keys($user), ['id', 'name', 'create_at', 'update_at']));
}

然后我们就完成了 HelloService 中的 getUser 方法的测试,并且覆盖到了分支中的每一个可能。

测试覆盖率

我们来写一个简单的 Bean 类,实现将数组转化为 Bean 类,代码如下:

<?php

class Bean {
    public function __construct (?array $data) {
        if (!is_null($data)) {
            $this->arrayToBean($data);
        }
    }
    /**
     * 转换为数组
     * @return array
     */
    public function toArray (): array {
        $data = [];
        foreach ($this as $key => $value) {
            $data[$key] = $value;
        }
        return $data;
    }
    /**
     * 数组转为 Bean
     * @param array $data
     */
    private function arrayToBean (array $data) {
        foreach ($data as $key => $value) {
            $this->$key = $value;
        }
    }
}

接着我们来为它写测试用例如下:

<?php
namespace Tests\Unit;

use App\Beans\Bean;
use Tests\TestCase;

/**
 * Class BeanTest
 * @package Tests\Unit
 */
class BeanTest extends TestCase {

    protected Bean $bean;

    protected function setUp () {
        parent::setUp();
        // 创建匿名类
        $this->bean = new class(['name' => 'bob', 'age' => 15]) extends Bean {
            protected string $name;
            protected int $age;

            public function getName () { return $this->name; }
        };
    }

    /**
     * 测试构造函数
     */
    public function testConstruct () {
        $this->assertEquals('bob', $this->bean->getName());
    }

    /**
     * 测试输出数组
     */
    public function testToArray () {
        $arr = $this->bean->toArray();
        $this->assertIsArray($arr);
        $this->assertArrayHasKey('name', $arr);
        $this->assertArrayHasKey('age', $arr);
        $this->assertEquals('bob', $arr['name']);
        $this->assertEquals(15, $arr['age']);
    }
}

要给出测试覆盖率需要先安装 PHP 的 XDbug 扩展,然后在 php.ini 配置文件中写入如下配置:

xdebug.mode=coverage

然后使用 PHPStorm 运行测试用例,会给出如下测试覆盖率的界面显示:

我们可以看到测试的覆盖率是 Bean.php 文件的测试覆盖率是 100%。仔细想想,测试覆盖率是不是越高越好呢?覆盖率很高是不是就万事大吉了呢?测试覆盖率的原理是什么呢?

什么样的项目需要应用单元测试

在公司实际的项目开发中,我发现并不是所有的项目都需要应用单元测试的。

比如,公司中一些项目并没有复杂的逻辑,没有数据库,只是对其他微服务的数据的打包整理。这样的项目并没有必要应用单元测试,需要花费大量的时间去 Mock 第三方的数据,而真正应该测试的逻辑却不多。

再比如,如果项目写完了之后就根本不需要迭代,那么这样的项目也肯定是没有必要投入单元测试的。另外,如果项目的逻辑本身并不复杂,在可以预期的时间内,并不需要大量的迭代重构,也不需要单元测试的。

那么什么样的项目是需要投入单元测试的呢?

项目中一些核心的服务,比如说订单、支付这些和钱相关的服务必须对每一个方法、方法中的每一个分支编写测试用例。

另外,几乎所有的与业务代码无关的第三方类库也是需要单元测试的。那么不怎么迭代。

所有的需要重构的代码,都应该在重构之前添加单元测试,没有单元测试不重构,只有单元测试最大程度减少重构代码所带来的隐患。

单元测试必须结合自动化来做,没有条件做自动化的团队也应该考虑先自动化,再投入单元测试。

发表评论

邮箱地址不会被公开。 必填项已用*标注