最近开始写Java单测,我认为某种情况下单测很难写,而且写的也不好,从单测中获得的收益也觉得没那么高,因此我研究了一下Java单测到底是什么东西。
单测的意义
每家公司都会让员工,或者至少希望员工去写单测,每个领导也会让自己的下属在开发项目时去写单测,但是没有一家公司,也没有一个领导会因为你单测写得好而给你正向的奖励。他们也不会特意去看你的单测实现的怎么样,但你如果问他们单测重不重要,他们肯定会说相当重要。他们希望的是有这个东西,但不会关心这个东西好不好,质量高不高,只要有,他们就心安了。
所以对于一个程序员来说,单测到底意味着什么?
功利性来说,单测除了拖延你项目的进度,对于你的升职加薪一点用都没有。不用听他们说的好听,什么单测很重要,只要项目排期赶,单测一定是第一个让放弃开发的功能,no body cares。
稳定性来说,我不否认单测充分的项目,可靠性更强一点,但是互联网企业那么多没有单测的屎山在稳定运行,说明一点,对稳定性来说,最重要的不是单测,是不开发。
个人成长来说,单测更偏向的是一种工程经验,只有在你实际做项目时,才可能会体现出不一样的价值。但是做项目(排除开源项目)得首先进公司,进公司所需要的能力,是面试的能力,不是写单测的能力。
首先因为no body care,其次我真的很质疑面试的时候,你会看一个面试者的单测写的怎么样吗?
因此,总体说来,单测这么一个三无产品,除了说出去好听(例如一个实际上没有认认真真写过单测的人会吹嘘覆盖率100%,这只是一个没有用的指标),对于我们来说究竟意味着什么?
在我这几天写单测时,这个问题一直困扰着我,我得出的结论是,如果你没有相应的精神追求,那么单测对你来说拖累是大于收益的,但是对于领导来说单测就像绩效和末位淘汰一样,没有这些,领导是没有安全感的。因此你不想写也得写。
那对于精神追求来说,单测可以带来什么?
-
后期开发更小的精神负担,在你后续进行更改代码后,你可以放心没有break之前的代码,当然也可能需要重写一下单测。
-
如果你想写单测,说明你是希望你写的代码有一定的质量,有质量的代码不一定有良好的可测试性,但是代码没有良好的可测试性,一定是没有质量的代码。因此通过写单测可以重新审视你的代码结构,在寻求一种具有更好的可测试代码时,你也就找到了更好的代码结构(代码具有良好的可测试性,说明耦合度较低)
-
有时间写单测,说明你的公司还没有丧心病狂,good job
-
众所周知,函数式编程语言是目前抽象程度最高的语言,单测更多的像是希望在命令式/面向对象编程语言上获得如同函数式编程一样的特性。
后续不会教你用什么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 我编写的代码和外界有交互
对于这种情况有两种方式解决:
- 直接mock,模拟和外界的交互
- 第一种有时也无法解决问题,比如:
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的缺点是什么? 优点又是什么?。