单测的理解

最近开始写Java单测,我认为某种情况下单测很难写,而且写的也不好,从单测中获得的收益也觉得没那么高,因此我研究了一下Java单测到底是什么东西。

单测的意义

每家公司都会让员工,或者至少希望员工去写单测,每个领导也会让自己的下属在开发项目时去写单测,但是没有一家公司,也没有一个领导会因为你单测写得好而给你正向的奖励。他们也不会特意去看你的单测实现的怎么样,但你如果问他们单测重不重要,他们肯定会说相当重要。他们希望的是有这个东西,但不会关心这个东西好不好,质量高不高,只要有,他们就心安了。

所以对于一个程序员来说,单测到底意味着什么?

功利性来说,单测除了拖延你项目的进度,对于你的升职加薪一点用都没有。不用听他们说的好听,什么单测很重要,只要项目排期赶,单测一定是第一个让放弃开发的功能,no body cares。

稳定性来说,我不否认单测充分的项目,可靠性更强一点,但是互联网企业那么多没有单测的屎山在稳定运行,说明一点,对稳定性来说,最重要的不是单测,是不开发。

个人成长来说,单测更偏向的是一种工程经验,只有在你实际做项目时,才可能会体现出不一样的价值。但是做项目(排除开源项目)得首先进公司,进公司所需要的能力,是面试的能力,不是写单测的能力。

首先因为no body care,其次我真的很质疑面试的时候,你会看一个面试者的单测写的怎么样吗?

因此,总体说来,单测这么一个三无产品,除了说出去好听(例如一个实际上没有认认真真写过单测的人会吹嘘覆盖率100%,这只是一个没有用的指标),对于我们来说究竟意味着什么?

在我这几天写单测时,这个问题一直困扰着我,我得出的结论是,如果你没有相应的精神追求,那么单测对你来说拖累是大于收益的,但是对于领导来说单测就像绩效和末位淘汰一样,没有这些,领导是没有安全感的。因此你不想写也得写。

那对于精神追求来说,单测可以带来什么?

  1. 后期开发更小的精神负担,在你后续进行更改代码后,你可以放心没有break之前的代码,当然也可能需要重写一下单测。

  2. 如果你想写单测,说明你是希望你写的代码有一定的质量,有质量的代码不一定有良好的可测试性,但是代码没有良好的可测试性,一定是没有质量的代码。因此通过写单测可以重新审视你的代码结构,在寻求一种具有更好的可测试代码时,你也就找到了更好的代码结构(代码具有良好的可测试性,说明耦合度较低)

  3. 有时间写单测,说明你的公司还没有丧心病狂,good job

  4. 众所周知,函数式编程语言是目前抽象程度最高的语言,单测更多的像是希望在命令式/面向对象编程语言上获得如同函数式编程一样的特性。

后续不会教你用什么junit,也不会说mock的奇技淫巧,只是介绍单测,我们究竟测的是什么?

2. 单测和集成测试的区别(首要区别,不要把单测写成集成测试)

首先我们要明白的是,这两种测试面向的对象是不一样的,集成测试是面向整个系统,单测是面向一个函数。举个例子,你系统开发完,你人工调几个功能,发个请求,看看结果怎么样,这都算集成测试,区别一个是人工一个是自动。但是单测是只对一个函数负责 ,测得只是一个函数。编写单测的时候,如果把单测写成集成测试,那么可以说等待你的就是一场灾难。

举个我的真实例子,数据库实现了transaction_manager,你想写一个单测,很有可能你的单测就实现为:

c++ 复制代码
// NOLINTNEXTLINE
TEST_F(TransactionTest, SimpleInsertRollbackTest) {
  // txn1: INSERT INTO empty_table2 VALUES (200, 20), (201, 21), (202, 22)
  // txn1: abort
  // txn2: SELECT * FROM empty_table2;
  auto txn1 = GetTxnManager()->Begin();
  auto exec_ctx1 = std::make_unique<ExecutorContext>(txn1, GetCatalog(), GetBPM(), GetTxnManager(), GetLockManager());
  // Create Values to insert
  std::vector<Value> val1{ValueFactory::GetIntegerValue(200), ValueFactory::GetIntegerValue(20)};
  std::vector<Value> val2{ValueFactory::GetIntegerValue(201), ValueFactory::GetIntegerValue(21)};
  std::vector<Value> val3{ValueFactory::GetIntegerValue(202), ValueFactory::GetIntegerValue(22)};
  std::vector<std::vector<Value>> raw_vals{val1, val2, val3};
  // Create insert plan node
  auto table_info = exec_ctx1->GetCatalog()->GetTable("empty_table2");
  InsertPlanNode insert_plan{std::move(raw_vals), table_info->oid_};

  GetExecutionEngine()->Execute(&insert_plan, nullptr, txn1, exec_ctx1.get());
  GetTxnManager()->Abort(txn1);
  delete txn1;

  // Iterate through table make sure that values were not inserted.
  auto txn2 = GetTxnManager()->Begin();
  auto exec_ctx2 = std::make_unique<ExecutorContext>(txn2, GetCatalog(), GetBPM(), GetTxnManager(), GetLockManager());
  auto &schema = table_info->schema_;
  auto colA = MakeColumnValueExpression(schema, 0, "colA");
  auto colB = MakeColumnValueExpression(schema, 0, "colB");
  auto out_schema = MakeOutputSchema({{"colA", colA}, {"colB", colB}});
  SeqScanPlanNode scan_plan{out_schema, nullptr, table_info->oid_};

  std::vector<Tuple> result_set;
  GetExecutionEngine()->Execute(&scan_plan, &result_set, txn2, exec_ctx2.get());

  // Size
  ASSERT_EQ(result_set.size(), 0);
  std::vector<RID> rids;

  GetTxnManager()->Commit(txn2);
  delete txn2;
}

这是一个典型的例子,测的的确就是transaction_manager.cpp的内容,但是这不是单测,这是集成测试!这已经是在测试这个功能实现的正确与否了,单测并不关心功能。

3. 单测cook book

简单的单测

Java 复制代码
// 假设你实现了一个计算面积的函数
public class CalculateArea {

    SquareService squareService;
    RectangleService rectangleService;
    CircleService circleService;

    CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService){
        this.squareService = squareService;
        this.rectangleService = rectangeService;
        this.circleService = circleService;
    }

    public Double calculateArea(Type type, Double... r )
    {
        switch (type)
        {
            case RECTANGLE:
                if(r.length >=2)
                return rectangleService.area(r[0],r[1]);
                else
                    throw new RuntimeException("Missing required params");
            case SQUARE:
                if(r.length >=1)
                    return squareService.area(r[0]);
                else
                    throw new RuntimeException("Missing required param");

            case CIRCLE:
                if(r.length >=1)
                    return circleService.area(r[0]);
                else
                    throw new RuntimeException("Missing required param");
            default:
                throw new RuntimeException("Operation not supported");
        }
    }
}

对于这样的函数,我相信大多数人的测试方法是

bad

Java 复制代码
assertEquals(calculateArea(RECTANGLE, 3,4), 12) 
assertEquals(calculateArea(RECTANGLE, 4,5), 20)
//balabala

但实际这样的测试方法是不对的。 unit test -> 这个unit是什么 -> calculateArea函数 -> 根据type的不同选择不同的面积Service。 assertEquals(calculateArea(RECTANGLE, 3,4), 12) 测的是什么 -> 根据type的不同选择不同的面积Service,且该面积Service的计算结果正确。

测试已经超出我们的负责范围了,service实现的正确与否会影响到分发函数的正确与否,这显然是不对的。

good case

Java 复制代码
    @Test
    public void calculateRectangleAreaTest() {
        Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
        Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d);
        Assert.assertEquals(new Double(20d),calculatedArea);
    }

看上去似乎大同小异,但是实际上这个展示的完全是不同的思想,我只测分发功能,输入的Type为RECTANGLE,调用的就应该是rectangleService,至于rectangleService是怎么实现的,无所谓。

事实上,我认为这个例子很好的讲述了单测的思想,如果按照这个想法去编写单测,无疑会大大简化编写单测的负担。

2. private函数需要写单测吗?

简单来说不需要为private函数写单测,因为用户(往往你的第一个用户就是你的单测)只会接触到public的函数,但是可能某些对象很难只通过public接口进行测试,那么恭喜你,你写的这个代码也很难用,可以重新思考设计是否合理(这也是单测的意义之一)。那么private的函数是不用测了吗?不,private的测试作为public函数测试的side effect来呈现。

这是你可能会问,那和你刚刚上面讲的测试原理不是矛盾了吗?注意,private和public都是一个object的,上面的例子是,一个object依赖另一个object。实际并不矛盾。

不要为了测试覆盖率,硬去测试private函数。如果你想要靠测试覆盖率说明你的代码质量,这证明了你的代码质量本身就是一坨屎。

3. 有些真的很难测试

对于这个话题,我的意思是想写很多例子,但是我一个人写的毕竟有限不可能包含所有的可能性,欢迎大家补充。

3.1 我的类是依赖注入的

Java 复制代码
@Component
public class CalculateArea {

    @Autowired
    SquareService squareService;
    @Autowired
    RectangleService rectangleService;
    @Autowired
    CircleService circleService;

    public Double calculateArea(Type type, Double... r )
    {
        // (same implementation as before)
    }
}

对于这种我的建议是,可以增加一个构造函数,将你依赖的Object通过构造函数传进来:

Java 复制代码
@Component
public class CalculateArea {

    SquareService squareService;
 
    RectangleService rectangleService;
   
    CircleService circleService;
  
  	public CalculateArea(SquareService ss, RectangleService rs, CircleService cs) {
    }

    public Double calculateArea(Type type, Double... r )
    {
        // (same implementation as before)
    }
}

但是这样子做有一个缺点就是,如果后续增加字段,可能需要增加构造函数(十分繁琐),也可能造成构造函数的参数过多(不知道哪个是哪个)

这种问题我们可以通过以下方法解决:

Java 复制代码
// set
CalculateArea ca = new CalculateArea();
ca.setxxxx()
  
// builder
CalculateAreaBuilder ca = new CalculateAreaBuiler();
ca.setxxxx().setyyyyy().create()
  
// static method(String.valueOf())
CalculateArea ca = CalculateArea.fromXXX();

如果这样还是不行,那我真的建议你好好思考一下你的设计,单测的主要目的实际是,作为一个你的第一个client来使用你编写的代码,你单测很难写,说明真的很难用。

3.2 我编写的代码和外界有交互

对于这种情况有两种方式解决:

  1. 直接mock,模拟和外界的交互
  2. 第一种有时也无法解决问题,比如:
Go 复制代码
func addOne() error{
  return DB.sql("xxx yyyy").execute();
}

这个函数我们需要测试sql语句对不对,如果直接mock的话,那等于自己骗自己,即不管你sql对不对,你测出来肯定对。

这时候我们只能借助内存数据库了,类似的还有本地HttpServer等....

3.3 写单测很痛苦

单测不是一个压轴题,当你觉得很难写的时候,你千万不能把它作为一个压轴题给他硬写出来(当然我丝毫不怀疑大多数人会直接放弃写单测,如同你高考因为某些原因放弃了压轴题一样),压测是一个提示,提示你的实现太烂了,当你觉得痛苦的时候,说明实现的有大问题,这时候就不该写了,想一想怎么重新更改代码结构才是首要的,。任何一份优秀的代码,在第一版出来以后会在短期内几乎完全重构。不要上班上傻了,觉得重写是一件很差劲的事情,在大学期间做有分量的lab实验,如实现数据库kernel,OS kernel,tcp协议时,在过不了测试的时候,除了抄袭代码,唯一能让你过测试的办法就是重新思考,重新实现,而且往往重新实现的代码更加精简,实现的时间也不会有你想的那么长。因此如果觉得单测痛苦,就重构吧。当然如果排期紧,该堆屎山还得堆。

3.4 后面遇到,再补充

..........

4. Mock真的是万能灵药吗?

Mock作为现在Java写单测的state-of-the-art方法,的确是一个很吸引人的技术。这个话题我认为Martin Fowler的 mocks Aren't Stubs这篇博客已经介绍的很好了,所以推荐大家可以看一下,可能对于单测有更深的认识,例如mock到底是出于什么动机被提出,难道仅仅只是为了解决外部依赖吗? mock和stub的单测思想到底有什么区别? mock的缺点是什么? 优点又是什么?。

相关推荐
程序猿000001号14 小时前
探索Python的pytest库:简化单元测试的艺术
python·单元测试·pytest
星蓝_starblue2 天前
单元测试(C++)——gmock通用测试模版(个人总结)
c++·单元测试·log4j
whynogome2 天前
单元测试使用记录
单元测试
字节程序员2 天前
使用JUnit进行集成测试
jmeter·junit·单元测试·集成测试·压力测试
love静思冥想3 天前
Java 单元测试中 JSON 相关的测试案例
java·单元测试·json
乐闻x4 天前
如何使用 TypeScript 和 Jest 编写高质量单元测试
javascript·typescript·单元测试·jest
Cachel wood4 天前
Vue.js前端框架教程4:Vue响应式变量和指令(Directives)
前端·vue.js·windows·python·单元测试·django·前端框架
@TangXin5 天前
单元测试-Unittest框架实践
单元测试
十年一梦实验室5 天前
【C++】sophus : test_macros.hpp 用于单元测试的宏和辅助函数 (四)
开发语言·c++·单元测试
编码浪子5 天前
Springboot3.x配置类(Configuration)和单元测试
单元测试