以下都是个人看法,带有个人偏见。
大部分测试代码示例由实际项目中的单元测试改编而来
可测试 (Testable)的程序可能意味着更好的程序结构
举例:
假设有一个程序,需要定时执行某个任务。任务存在数据库里,程序需要查询任务,根据当前时间判断是否需要执行此任务。
func ShouldExecute(task model.TaskInfo, current time.Time) bool {
var flag bool
// ... 省略的业务逻辑:读取task.TaskTime,和current比较是否足够接近
return flag
}
本程序的可测试性要点:
- 当前时间是个变量,而不是直接调用
time.Now()。这意味着测试程序可以指定任意时间。 task是由外部传入的,而不是直接根据id查询,这意味着测试程序无需考虑数据库里是否真实存在,测试时只需直接传入一个model.TaskInfo。
测试代码举例:
func TestShouldExecute(t *testing.T) {
// 无需查询数据库,手动构建用例
task := model.TaskInfo{
TaskTime: "10:33:00",
}
// 指定时间,而不是time.Now(),这意味这段测试逻辑无论在什么时间执行,永远是正确的
current, _ := time.Parse("2006-01-02 15:04:05", "2021-08-17 10:34:00")
shouldExecute := ShouldExecute(task, current)
if !shouldExecute {
t.Error("任务应该执行")
}
}
为什么这意味着更好的结构?
为了让这个函数更容易测试,我们在编程的时候故意把它写成“无状态”的。它既不受数据库的状态影响,也不受时间(时间也是一种状态)的影响。
无状态的代码是最容易排查错误的,而受到状态影响的代码,排查链路往往较为复杂。
一个项目里无状态代码的占比越高,复杂度就越低。维护成本、debug难度就越低。
不同语言的单元测试本质是一致的
- 写一些代码,执行某些操作
- assert 某种条件是不是成立
不同的编程语言、不同的测试框架,区别是某些测试框架提供了更方便、更全面的assert函数、测试工具函数、不同的错误提醒效果,但本质上都是一样的。
任何逻辑性的程序(相对于前端的展示层),都可以写单元测试。Go可以写,Python可以写,JavaScript也可以写。
前端开发中,也不乏较复杂的逻辑,当逻辑成长到足够复杂的时候,要想想是不是某些东西是可以拆解成可以被独立测试的模块。
拆解完通过JavaScript(或TypeScript)的测试框架来保证其正确性。
比如说,有一个购物车模块,当用户下达加购/减购的指令时,是否能保证总价刷新是正确的。
class ShoppingCart {
totalPrice = ref('0.00')
// ...
addItem(item) {}
removeItem(item) {}
}
// 以 Vitest 框架举例
test('添加商品后,价格应该是2.00元', () => {
const cart = new ShoppingCart()
const item = new Item()
item.price = '1.00'
item.num = 2
cart.addItem(item)
assert(cart.totalPrice.value === '2.00', '商品总价应该是2元') // 本质上也是在判断某个逻辑是否成立
})
这里提一点关于某些测试框架的帮助函数,Vitest有一个utils很有意思,vi.fn
官方文档里称之为 "create a spy on a function",即针对某个函数建立一个“探子”(spy就是间谍,刺探情报)
它可以完整的记录一个函数的调用周期,官方给出基本示例如下:
const getApples = vi.fn(() => 0)
getApples()
// 是否被调用
expect(getApples).toHaveBeenCalled()
// 是否返回了某个值
expect(getApples).toHaveReturnedWith(0)
// 在不修改代码的情况下,改变下次调用的返回值
getApples.mockReturnValueOnce(5)
const res = getApples()
// 本次返回值变成了5
expect(res).toBe(5)
// 第2次返回了5
expect(getApples).toHaveNthReturnedWith(2, 5)
这个可以用来测什么呢?比如事件触发器 mitt
describe('mitt', () => {
it('should emit and handle events', () => {
// 创建一个mitt实例
const emitter = mitt();
// fn spy
const handler = vi.fn();
// 注册监听器
emitter.on('foo', handler);
// 触发事件 'foo'
emitter.emit('foo', { a: 'b' });
// 事件监听器应该被调用了一次
expect(handler).toHaveBeenCalledTimes(1);
// 事件监听器应该收到了这样的参数
expect(handler).toHaveBeenCalledWith({ a: 'b' });
});
it('should handle multiple handlers for the same event', () => {
const emitter = eventBus;
const handler1 = vi.fn();
const handler2 = vi.fn();
emitter.on('foo', handler1);
emitter.on('foo', handler2);
emitter.emit('foo', null);
// 两个监听器应该分别被调用了一次
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
这个测试很巧妙,如果没有 vi.fn ,此类测试写起来会比较麻烦,也很难保证易读。
在 js / ts开发中,总躲不开回调函数,所以这种utils很有用,它被纳入了 Vitest 库。
同理,不同领域的开发都有它独特的模式,也就会有相对应的测试utils。
单元测试 vs 手动调试
在没接触单元测试之前,平时开发的过程中,如果想要验证某些函数是否符合预期,都是通过手写一些调试代码(cli、调试接口),观察log的方式。
实际上单元测试也只不过是一种代码,很多情况下可以替代这些调试程序。
这些调试程序往往是用后即丢弃了,但单元测试却可以保留下来,后续如果对某些函数有优化,可以重新再跑一遍测试。
而且调试程序如果写的比较清晰,也展示了程序的 use case,可以当做文档来看。
举一个真实场景,在一个报警系统中,有一个需求如下:
- 报警逻辑可能会触发一级、二级、三级报警,三级最不严重,一级最严重
- 当触发了某个级别的告警时,某个时间段内就不要再触发某个等级及其以下等级的告警了。比如:触发了二级告警,那么三级和二级在短时间内都不要触发了,但一级还是需要触发
我们根据这个需求,做了一个分级别flag 叫 LevelDisableFlag
public class LevelDisableFlag {
public LevelDisableFlag(long minInterval) {
// minInterval 是最小触发时间间隔, ms
}
// disable a level
public void disableLevel(String level) {
// ...
}
// 是否在某个级别被禁用
public boolean isDisabledAtLevel(String level) {
// ...
}
// 是否全等级禁用
public boolean isDisabledAtAllLevel() {
// ...
}
}
这个逻辑不算太复杂,但写完之后如果不通过程序来调试,也很难保证它的正确性。
当然我们可以通过写一个main函数来测试这个逻辑,但通过单元测试起来也很方便。(单元测试也不过是代码而已)
public class DisableFlagTest {
@Test
public void testBasic() throws Exception {
LevelDisableFlag flag = new LevelDisableFlag(0);
flag.disableLevel(""); // 禁用空级别
assertTrue(flag.isDisabledAtLevel("")); // 应该禁用
assertFalse(flag.isDisabledAtLevel("二级")); // 空级别属于较低的级别,所以“二级”应该没被禁用
assertFalse(flag.isDisabledAtAllLevel()); // 全等级当然也没有禁用
flag.disableLevel("一级"); // 一级是最高级别,所以应该都禁用了
assertTrue(flag.isDisabledAtLevel(""));
assertTrue(flag.isDisabledAtLevel("二级"));
assertTrue(flag.isDisabledAtAllLevel());
// 我们可以继续写其它逻辑,来测试 minInterval 是否符合预期...
}
}
(并不是所有调试程序都可以用单元测试替代,比如测试调用某些第三方API)
http api 的测试
很多项目里包含数据库的增删改查api,针对这些api的单元测试是没有意义的。
Controller 会出错吗?数据库会出错吗?可能会,但单元测试对这些并基本没有帮助意义。
有些api相对复杂一些,比如:
可能涉及到一些与业务相关的校验,如果这个校验非常复杂且重要,把这个校验逻辑做成“无状态”的,然后把各种Case在单元测试里覆盖掉。那么这个校验逻辑就不容易出错。
可能涉及到复杂的计算,把这个计算写成“无状态”的,然后用单元测试去测这个计算。
要点:对一个复杂的、包含状态的服务,把其中无状态的服务拆出来(如果对于这些计算逻辑你没有把握的话),对这些进行测试
并不是所有的测试必须是无状态的
如果我们写一个针对 Cache 的测试,那它肯定是有状态的。
不过它的状态往往应该是受限的,比如测试时对某个缓存的读写,只作用于本次测试的作用域。
无论执行多少次,测试用例都应该能顺利通过,不受状态的影响。
单元测试的设计与具体的项目需求有关
什么是需要单元测试的,什么是不需要的,并没有唯一的标准,要看具体的项目是什么。
也许大部分项目的大部分逻辑都是扁平、分散、相对简单的,业务本身不小,但分业务模块看起来基本上是简单的增删改查,这种情况下如果开发者对语言足够熟悉,而且开发团队没有要求测试覆盖率(coverage)的情况下,这些几乎是不需要测试的。
但可以说,所有将被放在生产环境里跑的项目,几乎都存在需要单元测试的用例。