单元测试 | 唯一不变的是变化本身

589 阅读4分钟

对于我来说,代码的最终目标有两个,第一个是实现需求, 第二个是提高代码质量和可维护性。而单元测试是我们开发保证迭代过程中代码质量的手段。

1. 什么是单元测试

单元测试又称为 模块测试,是业务中最小的一个执行单元, 他可以是一个函数,一个过程。 从代码层面上来说, 如果能保证足够全的 case 都通过单元测试,基本可以保证你所写的代码基本上没有太大问题,但是这不能证明你的业务模块集成没有 BUG业务模块集成测试 不属于单元测试的范畴,同样模块之间的集成问题仅通过单元测试是无法发现的。

2. 单元测试的作用

单元测试的目标是隔离程序部件并证明这些单个部件是正确的。一个单元测试提供了代码片断需要满足的严密的书面规约。 因此,单元测试带来了一些益处。

2.1 适应变更

单元测试允许程序员在未来重构代码,并且确保模块依然工作正确(复合测试)

2.2 减少你后期的维护成本

单元测试越完善的项目,后期维护成本和难度就越小 image.png

持续交付持续部署 中单元测试显得尤为重要, 单元测试可以保证我们持续交付高质量的代码, 即使集成出错,也可以给我们一个正向反馈,让我们持续改进代码,直至成功。

tdd.png

3. 如何编写单元测试

相信大多数小伙多多少少都会了解到单元测试在我们项目变更中带来的好处,但是却很少人会主动去编写单元测试。 总结一下大概是这么几个情况

  • 代码设计缺陷

这种情况多数是本身在对模块或者业务划分不明确,导致大量模块耦合,可替换性几乎为 "零"

例如一个推送粉丝采集 程序,需求如下

  • 能从地区库里采集 (根据地区获取粉丝用户信息)
  • 能从标签数据里采集(能根据用户标签获取数据)
  • 能从上传的用户标识数据中读取(根据上传文件中包含的用户数据读取)

如何写出可以测试的代码? 首先我们要保证逻辑之间有封闭性,这里主要是让 三种采集方案 各自实现又方便集成到应用中。

首先需要一个契约 FansProvider 接口, 规定好采集方式需要实现的接口.


namespace App\Services\Fans;

interface FansProvider
{
    // 规定获取粉丝的方法
    public function getFans();
}

第二需要一个管理器FansManager初始化粉丝提供者

namepace App\Services\Fans;

class Fansmanager
{
   /**
    * @var FansProvider
   */
   protected $fansProvider;
   
   public function __construct(FansProvider $fansProvider)
   {
       $this->fansProvider = $fansProvider;
   }
   
   public function setProvider(FansProvider $fansProvider)
   {
      $this->fansProvider = $fansProvider;
      return $this;
   }
   
   public function handle()
   {
       return $this->fansProvider->getFans();
   }
}

因为此处我们的所有采集方法都实现了 FansProvider 接口中的 getFans 方法, 所以我们只需要保证

  • AreaProvider 的 getFans 方法通过测试
  • TagsProvider 的 getFans 方法通过测试
  • FileProvider 的 getFans 方法通过测试

我们既可以保证 推送粉丝采集 是被验证的。

后续如果我们需要新增逻辑,我们只需要针对新增的方法完成 单元测试,即可集成到 FansManager 这个类

  • 没有把握单元测试的技巧

这种情况则是代码里存在过多的第三方请求和数据库操作,内存操作,受制于当前环境影响,导致编写单元测试需要花大量的时间和精力,因此放弃。

还是以上面 代码设计缺陷 的例子延伸, 如果我们某一个Provider里需要我们读取数据库去获取粉丝的数据。

这时候如果我们过多考虑数据库集成测试这一块的问题,就会影响我们写单元测试的决心,所以,为了避免数据库影响我们的决心且影响到测试类的边界 (测试类本身不应关注除了类功能本身的其他逻辑), 我们决定做一个 测试替身

假设在 Laravel 框架中, 获取标签的代码长这样

namespace App\Services\Fans;

class TagsProvider implements FansProvider
{   
    protected $dao;
    
    protocted $tagIds;
    
    public function __construct($tagIds)
    {   // 标签模型
        $this->tagIds = $tagIds;
        $this->dao = new Tags();
    }
    
    public function setDao($dao)
    {
       $this->dao = $dao;
       return $this;
    }
    
    public function getFans()
    {
         $tags = $this->dao->whereIn("id", $this->tagIds)->get();
         
         $fans = [];
         foreach($tags as $tag) {
             array_push($fans, $tag);
         }
         
         return $fans;
    }
}

这时候我们想测试这段代码, 我们应该要 Mock 上面代码中的 dao


class TagsProviderTest extends TestCase
{
    public function test_can_get_fans()
    {   // 这是Mocker出来的Dao
        $daoMocker = Mockery::mock(Tags::class);
        $daoMocker->shouldReceive("whereIn")->andReturn($daoMocker);
        $daoMocker->shouldReceive("get")->andReturn([
                ...
                ...
        ]);
        
        $this->assertIsArray(
          (new TagsProvider())->setDao($daoMocker)->getFans()
        );
    }
}

这段代码在测试时使用了数据库 替身 实际测试中不会连接数据库,只要我们的 getFans 函数处理正确,同样可以返回结果。

4. 局限性

测试不可能发现所有的程序错误,单元测试也不例外。按定义,单元测试只测试程序单元自身的功能。因此,它不能发现集成错误、性能问题、或者其他系统级别的问题。单元测试结合其他软件测试活动更为有效。与其它形式的类似,单元测试只能表明测到的问题,不能表明不存在未测试到的错误。

5. 总结

关于单元测试,我觉得是成为一个优秀 Coder 必须具备的素养

下面几条圣经,我们一起共勉把

  • Think about what you want to do. (清楚你的代码要做些什么)
  • Think about how to test it. (清楚怎么去测试你的代码)
  • Write just enough code to fail the test. (写足够多的用例让你的代码出错)
  • Run and watch the test fail. (不断的编写和观察出错的代码,并修复它)

6.感谢

示例代码使用的是世界上最好的语言 PHP, 写此篇文章时,阅读了以下文章,借鉴了一些表达技巧
TestDriverDeveloper
单元测试问答
Laravel-testing-章节
TestUnit Wiki