从单元测试出发聊聊开发中一些测试方法
不仅仅是单元测试
更多面向后端开发
一、 为什么需要单元测试
我们平常写代码,想要验证写的方法或函数是否正常,一般做法是写个 main
方法或者 Test
方法,然后直接运行看输出是否正常,不正常就修改直到正常为止。如果需要一些特殊数据,只能 debug
模式手动修改运行中的值,又如果需要调用其他模块的服务(如 http
调用),那是否只能等对方提供好才能进行下去等等。如果代码重构了,以上步骤又是否需要重新来?
以上可能是很大一部分人的做法,最后由 QA
保证质量。
以上做法会有什么问题呢?
- 会有很大的重复工作,因为验证方法正常没有固化,修改了代码就要重新运行再人工验证
- 如果一些服务调用,需要对方提供好服务才能正常进行,这样工期肯定会缩短
- 即使
QA
做最后保障,但QA
不能完全覆盖你的用例,测试用例一些数据特别是中间数据QA无法伪造,另外QA
并不清楚你的方法的边界(只有开发最清楚) - 代码把控不好的情况下,解决了
bug
很容易引入其他bug
要规避以上一些问题,可以通过单元测试来保障,很大程度提高我们代码的质量,又能提高我们的编码水平。
单元测试有多很做法,如 TDD
(测试驱动开发,比较难,先写测试代码后写逻辑代码),BDD
(业务或者行为驱动开发,先写逻辑代码后写测试代码)等等,我们使用 BDD
方式即可。
二、 普通测试
没有任何的依赖,纯粹一些代码逻辑,如工具类等
这类代码最容易测试,没写过单元测试可以先从这类代码上手,把 print
换成 assert
即可,这样就固化了测试用例
题外话: 很多源码库里面都会assert断言来判断参数,非预期直接抛异常(用assert代替if-throw)
示例
go有Example示例测试(用print与注释结果达到assert效果),其他语言直接断言就好
//golang比较独特的写法,做示例比assert更直观
func ExampleInetAtoN() {
n, _ := InetAtoN("123.123.123.123")
fmt.Println(n)
//Output:
//2071690107
}
func TestInetAtoNNE(t *testing.T) {
n := InetAtoNNE("123.123.123.123")
assert.Equal(t, int64(2071690107), n)
}
func ExampleInetNtoA() {
a := InetNtoA(2071690107)
fmt.Println(a)
//Output:
//123.123.123.123
}
三、mock测试
非常非常重要的测试
可以伪造各种边界数据
我们的业务代码需要资源层(数据层:mysql,mongodb
等),中间件(redis,kakfa
等),其他服务调用(http,https,rpc,gprc
,回调等),对于这些的调用,我们测试不用实际请求,直接mock他们测试。
如上第二点,对于需要调用其他服务,我们可以使用 mock
来伪造数据,可以并行开发,这样工期不至于被缩短。
2.1 mock框架
工欲善其事,必先利其器
java
java主流的 Mock
工具,主要有三类:
- 动态代理:
Mockito
、EasyMock
、MockRunner
- 自定义类加载器:
PowerMock
- 运行时字节码修改:
JMockit
、TestableMock
工具 | 原理 | 最小Mock单元 | 对被Mock方法的限制 | 上手难度 | IDE支持 |
---|---|---|---|---|---|
Mockito | 动态代理 | 类 | 不能Mock私有/静态和构造方法 | 较容易 | 很好 |
PowerMock | 自定义类加载器 | 类 | 任何方法皆可 | 较复杂 | 较好 |
JMockit | 运行时字节码修改 | 类 | 不能Mock构造方法(new操作符) | 较复杂 | 一般 |
TestableMock | 运行时字节码修改 | 方法 | 任何方法皆可 | 很容易 | 一般 |
注意 PowerMock
不兼容 Junit5.x
。 比较推荐:Mockito + TestableMock + Junit5.x
, 一般场景几乎能满足,TestableMock
是阿里出品,体验是真的还不错,足够简单,迭代也挺快的,但问题也是有点多。
golang
go 虽然官方支持很全,但有些还是需要一些第三方库来扩展
- testify - 断言库
- gomonkey - 使用nm运行时动态替换符号表
- goconvey - BDD方式,测试用例更直观
- …
javascript && nodejs
只推荐 Jest
,BDD测试方式 – Facebook出品
其他
大家网上直接搜索
2.2 示例
java
java Mockito框架非常好用也直观
@Nested //使用junit5.x Nested注解+内部类,聚合测试用例(一个方法多个测试用例)
class DeleteByUser {
@Test
@DisplayName("删除数据库,删除缓存")
public void normal() {
long userId = 110001L;
doNothing().when(filemetaDao).deleteByUserId(anyLong());
doAnswer(invocation -> {
assertEquals("file_meta_110001", invocation.getArguments()[0]); //验证接收参数
return true;
}).when(redisTemplate).delete(anyString());
boolean ret = filemetaService.deleteByUserId(userId);
assertTrue(ret);
verify(filemetaDao, times(1)).deleteByUserId(anyLong()); //验证方法被调用的次数
verify(redisTemplate, times(1)).delete(anyString());
}
@Test
@DisplayName("重试删除")
public void normal_retry() {
long userId = 110001L;
doThrow(new RuntimeException("need retry")).doNothing().when(filemetaDao).deleteByUserId(anyLong()); //语法糖,写起来相当舒畅,第一次,第二次调用返回不同的结果
doAnswer(invocation -> {
assertEquals("file_meta_110001", invocation.getArguments()[0]);
return true;
}).when(redisTemplate).delete(anyString());
boolean ret = filemetaService.deleteByUserId(userId);
assertTrue(ret);
verify(filemetaDao, times(2)).deleteByUserId(anyLong());
verify(redisTemplate, times(1)).delete(anyString());
}
@Test
@DisplayName("删除失败")
public void normal_fail() {
long userId = 110001L;
doThrow(new RuntimeException("need retry")).when(filemetaDao).deleteByUserId(anyLong());
doAnswer(invocation -> {
assertEquals("file_meta_110001", invocation.getArguments()[0]);
return true;
}).when(redisTemplate).delete(anyString());
boolean ret = filemetaService.deleteByUserId(userId);
assertFalse(ret);
verify(filemetaDao, times(2)).deleteByUserId(anyLong());
verify(redisTemplate, times(1)).delete(anyString());
}
}
TestableMock
//声明静态类
public static class Mock {
//只mock一个方法,不用mock整个类
@MockMethod(targetClass = OSSFactory.class)
private OSS get() {
return new OSSClientBuilder().build("endpoint", "ak", "sk");
}
}
golang
//和java TestableMock写法很像
patches.ApplyMethod(reflect.TypeOf(_service), "Get", func(_ *ContainerService, _ int64) (*container.Container, error) {
fmt.Println("mock service get")
return dest, nil
})
patches.ApplyMethod(reflect.TypeOf(dao), "Update", func(_ *container2.ContainerDao, message proto.Message) (int64, error) {
fmt.Println("mock dao update")
fmt.Println(util.ToJSON(message))
return dest.Id, nil
})
//目前比较不方便就是不能验证被调用的次数,得我们自己计数来处理,或者非预期调用直接退出返回失败
mockRedisClient.CustomMatch(func(expected, actual []interface{}) error {
t.Fatal("unexpected invoke")
return nil
}).ExpectHSet(table, field).SetVal(1)
javascript
let mockRedisClient = {
on: jest.fn(),
quit: jest.fn()
};
let mockRedis = {
createClient: jest.fn(() => mockRedisClient),
};
jest.mock('redis', () => mockRedis);
let redis = require('../redis');
describe('redis string', () => {
test('normal set', () => {
let set = jest.fn((k, v, ex, timeout, callback) => {
expect(k).toMatch('key');
expect(v).toMatch('value');
expect(ex).toMatch('EX');
expect(timeout).toBe(24 * 30 * 3600);
callback(null, 'OK')
});
mockRedisClient.set = set;
return redis.set('key', 'value').then(v => {
expect(mockRedis.createClient.mock.calls.length).toBe(1);
expect(set.mock.calls.length).toBe(1);
expect(mockRedisClient.quit.mock.calls.length).toBe(1);
expect(v).toMatch('OK');
});
});
});
2.3 真实资源层测试
数据是非常重要的,可以做实际的测试(验证表定义与实际存储),在dao层做是最方便的,可以模拟各种数据做真实操作
需要谨慎,测试环境跑即可,集成时可以把这个测试忽略,防误操作
意义不是很大,可以忽略
也是通过 mock
方式,在测试时动态把数据库替换成其他的测试库,这样操作数据不会影响到业务库
func TestRealDao(t *testing.T) {
//t.SkipNow() 忽略此测试
db := mock.MockDatabase() //这里mock实例化出测试db
defer db.Close()
//在业务代码调用dao.Database()会被规则成mock出来有数据库
patches := ApplyFunc(dao.Database, func() *sqlx.DB {
return db
})
defer patches.Reset()
//.....具体crud 及 assert
}
四、 接口测试
这也是QA比较好介入测试点,但场景有限(很多数据是中间数据,模拟不了),一般只能验证接口是否正常
直接调用接口,验证返回值,一般是验证状态码200
发布前跑一遍接口测试脚本,集成脚本输出是否打印看需要,非预期结果直接中断测试,这样马上能知道那个api的问题
五、 压力测试
有些场景需要关注服务指标表现(cpu,内存,io等)
有些需要关注语言层面上
gc
情况,高并发场景出现gc
抖动会引起大量5xx
压力测试我们关注的主要有两个级别
- 方法级别,特别是算法层面,或者对代码优化上
- 接口级别 – 更好定义限流阈值
- 线上流量复制 – 了解
4.1 方法级别
这个 go 在这方面有天然优势, 官方提供基准测试,而且粒度相当细
像 java 等得使用第三方,如 jmeter
等来测试
以下示例计算相似度,不同方法效率相差69倍,通过压力测试很容易对比出来
func BenchmarkCanvasFingerprintService_similarityWithBit(b *testing.B) {
var _service = NewCanvasFingerprintService("t_biz")
v1 := `0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100001100110100100101111001001001011101101010101000100100001000`
v2 := `0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011010110001100000010011100101010000000000000000000000000`
_v1, _ := strconv.ParseInt(v1, 2, 64)
_v2, _ := strconv.ParseInt(v2, 2, 64)
for i := 0; i < b.N; i++ {
_service.similarityWithBit(_v1, _v2, 64)
}
}
func BenchmarkCanvasFingerprintService_similarityWithString(b *testing.B) {
var _service = NewCanvasFingerprintService("t_biz")
v1 := `0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100001100110100100101111001001001011101101010101000100100001000`
v2 := `0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011010110001100000010011100101010000000000000000000000000`
for i := 0; i < b.N; i++ {
_service.similarityWithString(v1, v2, 64)
}
}
//Output: 默认1s内执行结果(次数 一个操作的耗时),执行时间,cpu等都可指定。
//从以下输入就很容易对比下性能差异
//BenchmarkCanvasFingerprintService_similarityWithBit-4 1000000000 0.6035 ns/op
//BenchmarkCanvasFingerprintService_similarityWithString-4 27301350 41.70 ns/op
4.2 接口级别
直接使用工具来压测,如ab
, wrk
, ddosify
等。ddosify
可以模拟 cc
攻击(高级的 ddos
攻击),目前还没支持多节点。
也可自己写压测代码测试
4.3 线上流量复制回放
高并发场景比较需要,准生产环境
可以完全模拟线上的流量,对复杂的业务场景进行真实的服务测试,又不会对生产服务产生任何副作用
- tcp - TCPCopy, TcpReplay
- http - GOReplay
六、 自动化测试
干扰因素会很大
- 推荐
puppeteer + nodejs
,Selenium
官方已经不维护 - 目前
Chrome Canary
支持脚本录制回放功能,可以处理一些自动化场景
总结
- 单元测试不仅仅是单一职责的重要体现(强烈推荐:代码整洁之道 这本书)
- 服务端开发建议加上一些单元测试,特别是一些核心的逻辑,也是对自己写的代码负责
- 写单元测试会相当耗时间,特别是刚开始写可能会更耗时间,甚至比开发时间还长
- 不用盲目追求单元测试覆盖率,像 java 很多
setter\getter
生成的方法,会被覆盖率的框架计算进去 - 如果代码做很大的重构,对单元测试维护也是挺麻烦的,需要同步修改
写单元测试有以下几点好处:
- 很多bug可以尽早被发现
- 帮助我们更好重构代码,让我们代码更健壮
- 减少修改代码而引入其他bug,持续集成与交付的可靠保障,如
java mvn compile/install/package等
不显示指定-Dmaven.test.skip=true
都会有mvn test
前置,如果会引入其他bug,很多情况下mvn test
会失败 - 好的单元测试是示例代码的一部分
- 减少了bug,让大家都开心,对编程更热情
题外话: 现在面向云原生开发, 是一件非常幸福的事了。在不需要了解很多底层或者架构相关知识都可以做到高可用。这样我们只需要关注业务开发,这样反向过来体现单元测试的重要性,让自己的代码更健壮。