天天看点

erlang - 单元测试总结(一)对rds proxy的非正确理解单元测试elang 中的单元测试框架 euniterlang中mock框架 meck

加入rds proxy不到一个月,有太多的东西需要重新学习,这篇博客作为对上一个月的总结(我觉得一个月总结一次蛮不错的,N年后可以看看这些年的成长路径)。

9月初加入到rds团队,开始从C++切换到erlang,一边学习新语言言,一边泛泛地看了一遍主流程的代码。中间还蹭了rds团队的outing。

rds proxy的进程模型很像hotwheel。共同点是:他们都是自己不做数据计算,都是把数据转发。

只不过rds proxy是一个商业项目,业务上要复杂的多; 而hotwheel是一个toy的开源项目。

两者都是对每一个client的连接,创建两个进程,一个做为client的代理人,一个做为server的代理人。client对真正server的数据交互,在rds上就是这两个进程的交互,因为他们代表了各自的实体。

对rds proxy的非正确理解

代码复杂度

rds proxy有固有的复杂度。

在加入rds proxy团队之前,粗略的看了ranch,cowboy,hotwheel的代码。这几个开源项目在rds proxy面前太简单了。

rds proxy是如何复杂的呢:它里面需要解析mysql变态的协议,如何握手,协商加解密,处理mysql所有的command,所有的sql语句,如何流控,如何做sql的安全,还有主备的切换,还有分库分表。。。

还有大量我暂时不知道的功能。

每一个交互都是一个状态机,每个环节都会出错。业务上的复杂导致rds proxy代码相当的复杂。

代码的模块化

在如此高的复杂下,rds proxy做了模块化设计,必须这么做。每个功能点都是一个独立的模块比如网络模块,协议处理模块,api模块,分库分表等,监控模块。

在OTP的提供的便利上,把业务做成多个supervisor监控的进程树。

这也是顺其自然的事,没有出色的地方。

代码级别一些非正确的理解

从高层上看代码的模块化已经很棒了,为了追求完美,有一些地方可以做的更好:
1) 框架代码和业务代码还存在一定的耦合度。做为一个proxy server,连接client和连接server应该使通用的,协议无关的。
2) 有的函数体积有点大,尽量使用函数特化代替cond,if,使得函数小而美等。
3) 能否把mysql的协议细节切分到多个模块中呢,看着数千行的协议代码都恐惧???而且改动起来真的是手抖阿。
4) 制定出一套编码约定:比如所有的数据全部用binary类型(Username之前都是字符串类型后来改成了binary); 变量命名方式是使用下划线,还是连在一块微软的命名方式。。。
5) 测试:目前还没有完善的单测,集成测试,系统测试等。这也是正在做的事情。
6) 类型系统:被exported出来的函数,声明类型。完善rds proxy的类型系统,借助analyzer帮助发现问题。
7) 从连接被accept到真正的建立进程路径太长,影响并发。      

由于erlang和业务不熟悉,上述观点仅仅是当前的理解,目的是做为后续优化的灵感来源。

另外,最近3周在冲刺rds proxy中的mysql协议的单元测试。和mysql协议处理相关的模块逻辑很复杂,但是这些模块都是没有状态的,加上elrang的函数式特点,非常容易做单元测试。

做为一篇测试技术的总结,还是要罗唆的说一些测试相关的理论。

单元测试

对测试的理解

测试的分类:系统测试,集成测试,性能测试,单元测试,heartbeat等。前面几种测试都有测试同学的参与,同时开发同学也有相应的参与; 单元测试主要是开发同学完成。

测试同学是从产品质量的角度出发,对一个系统进行黑盒测试,保证产品的可用性,稳定性。

单元测试在函数式语言中的作用

单元测试是由开发同学深入代码,基于自己对代码的了解,对模块中的每一行代码进行测试。

设计单元测试的时候要做到:

1) 单元测试的能够保证一个模块提供的功能是正确无误的。即覆盖率要高,业界建议是80%以上,100%确实很难做到。
2) 单元测试的用例能够起到回归测试的作用。代码的重构其实比最初设计压力还要大,改的越多越心虚。因为这个时候的代码已经在线上为用户服务了,重构要达到重构目标的同时,最基本的还要保证重构后的代码逻辑和之前是一致的。
  这个时候如果能够由足够多的测试用例做回归,在重构过程中进行回归测试,会放心的多。
3) TDD,测试驱动开发。TDD在函数式语言的开发中是合适的,因为函数式语言的开发过程就是对一个个小函数拼装的过程。当模块通过了设计的单元测试用例,也就开发完成了。而且这些测试用例为以后的重构提供了保障,何乐不为呢?      

elang 中的单元测试框架 eunit

eunit是从其他语言的单元测试框架(JUnit,SUnit, CPPUnit)中获取灵感。都提供了一些断言的宏,然后对测试的结果进行收集和统计。

功能是一样的,关键的是如何做到好用。

http://www.erlang.org/doc/apps/eunit/chapter.html      

eunit的使用

方式一 单测代码放在模块后面(如cowbow)

准备工作(头文件)

使用eunit最简单的方式就是把测试代码放在被测试代码模块文件的可后面(如cowboy),需要包含eunit的头文件      
-include_lib("eunit/include/eunit.hrl").      
这个文件3个作用:
    1) 创建一个被exported的函数text(),这个函数会调用所有测试用例。
    2) 使所有函数名为..._test() 或者 ..._test_()函数被exported。
    3) 定义eunit中所有的预处理宏。
注意eunit.hrl要在erlang的搜索路径里面。      

开始使用eunit

eunit会自动的调用所有..._test()的函数,返回值会自动丢掉。      
最简单的测试用例 - 验证是否crash
reverse_test() -> lists:reverse([1,2,3]).      
这个测试了lists:reverse在输入参数是[1,2,3]时,是否能够正常返回,并没有对返回值做任何的期望。仅仅期望这个函数不要crash。      
reverse_nil_test() -> [] = lists:reverse([]).
    reverse_one_test() -> [1] = lists:reverse([1]).
    reverse_two_test() -> [2,1] = lists:reverse([1,2]).      
这个3个测试,对lists:reverse的返回结果做了期望,如果不符合就会match失败,抛出异常。      
使用断言宏
eunit和其他语言一样提供了各种断言宏      
length_test() -> ?assert(length([1,2,3]) =:= 3).      
这是一个对boolean值的断言,如果表达式返回的不是true,则抛出一个异常。      

运行单元测试

通过include的方式,eunit.hrl已经把test()和所有..._test()函数exported出来了,可以直接运行。
 加入你的模块名字是m,那么有2种方式运行m中的单测:
     1) m:test().
     2) eunit:test(m).      

方式二 把单测代码单独放置在另一个文件里

对模块m做单元测试,eunit会自动寻找m_tests()模块,并运行其中的测试代码。      

eunit对标准输出的捕获

eunit对测试代码中的标准输出进行了捕获,只有当测试用例失败的时候才会把你的输出一起打印出来。
推荐使用eunit提供的宏打印调试中的信息。Debugging macros      

eunit test as data

对一些简单的方法编写单测,eunit提供了方便的生成器,方法...test_()的返回fun列表会逐一被执行。      

eunit的关闭

eunit可以关闭,只要定义NOTEST宏。如,

erlc -DNOTEST my_module.erl      

或者在include Eunit头文件之前显示的定义:

-define(NOTEST, 1).      

开启eunit:

erlc -DTEST my_module.erl      

避免对测试代码的编译

当你把你的项目开源出去的时候,不希望测试代码被使用者编译,可以使用宏把所有的测试代码包起来。

-ifdef(TEST).
   -include_lib("eunit/include/eunit.hrl").
   -endif.      

erlang中mock框架 meck

单元测试过程中有时候需要外部模块的配合,而外部模块的行为又不要控制,这个时候就需要自己动手写‘桩模块’。

erlang中的meck就是这样一个框架。

https://github.com/eproxus/meck/blob/master/src/meck.erl      

meck的使用

入门级使用

Eshell V5.8.4  (abort with ^G)
    1> meck:new(dog).
    ok
    2> meck:expect(dog, bark, fun() -> "Woof!" end).
    ok
    3> dog:bark().
    "Woof!"
    4> meck:validate(dog).
    true
    5> meck:unload(dog).
    ok
    6> dog:bark().
    exception error: undefined function dog:bark/0      
可以看到meck的使用很简单:
1) meck:new(dog) 先装载一个模块dog。
2) meck:expect(dog, bark, fun() -> "Woof!" end) 声明dog中的bark函数被调用的时候,执行fun() -> "Woof!" end。
3) meck:unload(dog)。使用完之后卸载dog模块。
这样在调用dog:bark()的时候,就调用了meck的函数。      

meck模拟异常

meck可以模拟一个函数抛出异常,以此达到调用模块对异常的处理能力。      
5> meck:expect(dog, meow, fun() -> meck:exception(error, not_a_cat) end).
    ok
    6> catch dog:meow().
    {'EXIT',{not_a_cat,[{meck,exception,2},
    {meck,exec,4},
    {dog,meow,[]},
    {erl_eval,do_apply,5},
    {erl_eval,expr,5},
    {shell,exprs,6},
    {shell,eval_exprs,6},
    {shell,eval_loop,3}]}}
    7> meck:validate(dog).
    true      

mock部分函数

在meck:new(dog)的时候,会隐藏掉dog模块中所有的函数。
通过传入passthrough可以使meck仅隐藏被expect的函数。      
Eshell V5.8.4  (abort with ^G)
    1> meck:new(string, [unstick, passthrough]).
    ok
    2> string:strip("  test  ").
    "test"      
也可以在expect中调用passthrough/1
比如,      
Eshell V5.8.4  (abort with ^G)
    1> meck:new(string, [unstick]).
    ok
    2> meck:expect(string, strip, fun(String) -> meck:passthrough([String]) end).
    ok
    3> string:strip("  test  ").
    "test"
    4> meck:unload(string).
    ok
    5> string:strip("  test  ").
    "test"      
更好的选项可以直接察看meck.erl。      

meck 一个函数的多个返回结果

meck:sequence可以指定一个列表,使得对同一个函数的多次调用,返回不同的结果。      
Ret = ["woof1", "Woof2", "Woof3"],
    meck:new(dog, [passthrough]),
    meck:sequence(dog, bark, 0, Ret),
    dog:bark(), % "Woof1"
    dog:bark(), % "Woof2"
    dog:bark(), % "Woof3"      

更多的使用技巧直接看meck的代码吧。