不仅仅是单元测试

更多面向后端开发

一、 为什么需要单元测试

我们平常写代码,想要验证写的方法或函数是否正常,一般做法是写个 main 方法或者 Test 方法,然后直接运行看输出是否正常,不正常就修改直到正常为止。如果需要一些特殊数据,只能 debug 模式手动修改运行中的值,又如果需要调用其他模块的服务(如 http 调用),那是否只能等对方提供好才能进行下去等等。如果代码重构了,以上步骤又是否需要重新来?

以上可能是很大一部分人的做法,最后由 QA 保证质量。

以上做法会有什么问题呢?

  1. 会有很大的重复工作,因为验证方法正常没有固化,修改了代码就要重新运行再人工验证
  2. 如果一些服务调用,需要对方提供好服务才能正常进行,这样工期肯定会缩短
  3. 即使 QA 做最后保障,但 QA 不能完全覆盖你的用例,测试用例一些数据特别是中间数据QA无法伪造,另外 QA 并不清楚你的方法的边界(只有开发最清楚)
  4. 代码把控不好的情况下,解决了 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 工具,主要有三类:

  • 动态代理:MockitoEasyMockMockRunner
  • 自定义类加载器:PowerMock
  • 运行时字节码修改:JMockitTestableMock
工具 原理 最小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

压力测试我们关注的主要有两个级别

  1. 方法级别,特别是算法层面,或者对代码优化上
  2. 接口级别 – 更好定义限流阈值
  3. 线上流量复制 – 了解

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 线上流量复制回放

高并发场景比较需要,准生产环境

可以完全模拟线上的流量,对复杂的业务场景进行真实的服务测试,又不会对生产服务产生任何副作用

  1. tcp - TCPCopy, TcpReplay
  2. http - GOReplay

六、 自动化测试

干扰因素会很大

  1. 推荐 puppeteer + nodejsSelenium 官方已经不维护
  2. 目前 Chrome Canary 支持脚本录制回放功能,可以处理一些自动化场景

总结

  • 单元测试不仅仅是单一职责的重要体现(强烈推荐:代码整洁之道 这本书)
  • 服务端开发建议加上一些单元测试,特别是一些核心的逻辑,也是对自己写的代码负责
  • 写单元测试会相当耗时间,特别是刚开始写可能会更耗时间,甚至比开发时间还长
  • 不用盲目追求单元测试覆盖率,像 java 很多 setter\getter 生成的方法,会被覆盖率的框架计算进去
  • 如果代码做很大的重构,对单元测试维护也是挺麻烦的,需要同步修改

写单元测试有以下几点好处:

  1. 很多bug可以尽早被发现
  2. 帮助我们更好重构代码,让我们代码更健壮
  3. 减少修改代码而引入其他bug,持续集成与交付的可靠保障,如java mvn compile/install/package等 不显示指定-Dmaven.test.skip=true都会有mvn test前置,如果会引入其他bug,很多情况下mvn test会失败
  4. 好的单元测试是示例代码的一部分
  5. 减少了bug,让大家都开心,对编程更热情

题外话: 现在面向云原生开发, 是一件非常幸福的事了。在不需要了解很多底层或者架构相关知识都可以做到高可用。这样我们只需要关注业务开发,这样反向过来体现单元测试的重要性,让自己的代码更健壮。