关于单元测试的几点看法

88 阅读8分钟

以下都是个人看法,带有个人偏见。

大部分测试代码示例由实际项目中的单元测试改编而来

可测试 (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)的情况下,这些几乎是不需要测试的。

但可以说,所有将被放在生产环境里跑的项目,几乎都存在需要单元测试的用例。