单元测试在测试过程中是比较重要的一环,但是也是很多团队缺失的一环,单元测试的意义是什么?单元测试的实施过程中会有怎样的坑?为什么一些团队没有单元测试呢?是由测试来做单元测试还是开发来做单元测试呢?
单元测试的定义及意义
首先是最经典的测试金字塔,其实针对测试金字塔有很多种搭建方式,例如:
- 从常用的测试技术类型来看: 单元测试->接口测试->UI测试,这可能是比较常见的测试金字塔( unit->api->ui )
- 从系统分层测试(或测试阶段)来看: 单元测试->组件测试->集成测试->系统测试
这只是从测试金字塔角度去谈测试的方法,也可以说是测试的分类,当然如果是严格意义上的测试分类又有很多(例如以是否测试代码:黑盒,白盒,灰盒;是否运行:静态测试,动态测试等等)
那单元测试的定义是什么?
单元测试是对软件中的最小单元进行测试和验证,通俗来讲就是代码中的一个函数或一个类,单元测试一定是白盒测试。
为什么提到测试金字塔,因为单元测试不仅是测试阶段的第一环,也是测试金字塔的基础,那代表着什么?
- 从重要程度来说,单元测试作为地基,承担着保证稳定性的作用,最终决定整个软件质量的不是功能完整,功能实现没有问题,而是实现功能的代码逻辑是否正确,程序是否健壮
- 从开发测试成本来说,我们知道在开发测试整个环节,越晚发现问题,解决问题的成本越高;越晚发现问题,代表着测试开发流程要不断重复,且重复的成本越高,也就是说如果能将大部分的问题或者明显的代码逻辑问题解决在单元测试阶段,将极大的减少开发测试成本,提高开发测试效率
- 从测试覆盖来说,测试金字塔越往上执行的测试,可覆盖case会逐渐变小,例如UI测试只能保证页面正常,接口异常不会测试覆盖完整;而接口覆盖完整了,又不能保证代码中所有逻辑都覆盖,某些函数某些类的功能无法覆盖,而通常发现一些复杂的bug,不太好复现的bug基本都是用功能测试用例覆盖不全的
- 从自动化测试角度来说,都知道UI自动化测试的性价比是最低的,目前接口自动化测试慢慢成为主流,而一些公司也开始注重单元测试或者关注白盒测试,招聘的测试都需要懂开发,需要可以review代码,可以看到测试正在慢慢的下沉渗透
单元测试通常由开发工程师完成,一般会伴随开发代码一起递交至代码库。单元测试属于最严格的软件测试手段,是最接近代码底层实现的验证手段,可以在软件开发的早期以最小的成本保证局部代码的质量。另外,单元测试都是以自动化的方式执行,所以在大量回归测试的场景下更能带来高收益。同时,你还会发现,单元测试的实施过程还可以帮助开发工程师改善代码的设计与实现,并能在单元测试代码里提供函数的使用示例,因为单元测试的具体表现形式就是对函数以各种不同输入参数组合进行调用,这些调用方法构成了函数的使用说明。
如何做单元测试
要做好单元测试,首先要知道测试的对象是代码,代码的基本特征和逻辑,这样才能应用的相关的单元测试技术来进行单元测试case设计和进行测试。
要测试什么
单元测试是代码级别的测试,那么到底怎么测试代码。开发语言是多种多样的,客户端的Java,OC,swift,js等,服务端的Java,PHP,Python,Go等。先不提单元测试,代码级别的测试还有代码扫描,代码覆盖率的测试,可以找到一些定义好规则的代码漏洞或者代码规范方面的问题,那单元测试肯定做的就是除了这些之外的一些工作了。单元测试是对代码中的一个函数一个类的测试,那测试的是什么?
一个函数或者一个类包含什么,函数名(类名)、参数(属性/变量)、函数体(类中的各种方法)、返回结果,在函数的实现的中有各种循环、分支判断、函数调用,我们如果不管代码处理的是什么样的业务逻辑,仅看代码它就是在进行各种数据的处理,这也是为什么有的程序员会厌烦写业务,因为底层就是各种数据的增删改查,当然这其中根据业务还会有各种复杂的判断处理,并且也并不是所有的代码都是在做增删改查。
代码中的循环、每个分支判断、每个函数的输入输出都有可能产生缺陷,而单元测试的话就是测试这些函数(类)的功能输入输出、内部条件的判断。我们来看个例子(以开源项目httprunner为例,作者写了大量的单元测试)
在httprunner中loader类实现的功能是将yaml格式文件或者json格式文件亦或者存有两种格式文件的文件夹的接口测试case加载实现为程序中的case model,拿类中其中一个功能函数为例;
def test_load_json_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testcase_hardcode.json') testcases = loader.load_file(testcase_file_path) self.assertEqual(len(testcases), 3) test = testcases[0]["test"] self.assertIn('name', test) self.assertIn('request', test) self.assertIn('url', test['request']) self.assertIn('method', test['request'])
我们看到loader类实现将json格式的文件转换为testcase,然后进行断言是否加载成功,case中的每个字段是否能正确提取出来。
单元测试用例设计
无论是做功能测试,UI自动化测试,接口测试还是单元测试,都有一个很重要的东西那就是用例设计,用例设计体现了对代码对功能的理解程度,也一定程度上决定了测试功能覆盖,也会很明显的体现出软件质量。我从没觉得一个好的功能测试能比自动化测试能比接口测试差多少,自动化接口亦或者测试开发更多的是对于测试质量的点缀,无论是提高了效率还是丰富了测试技术来提高测试质量,这些工作都必须基于有很好的功能测试基础。
功能测试的用例设计是业务功能逻辑的输入输出,单元测试中就是函数的输入输出,那么单元测试中的输入输出有哪些呢?
输入:
- 被测试函数的输入参数
- 被测试函数需要的全局变量
- 被测试函数的内部私有变量
- 函数内部调用子函数的数据
- 函数内部调用其他模块的数据
- 函数内部调用外部服务的数据
- ......
输出:
- 被测函数的返回值
- 被测试函数的输出参数
- 被测试函数修改的全局变量
- 被测试函数修改的内部变量
- 被测试函数增删改的数据库数据等
- 被测试函数进行的文件更新
- 被测试函数进行的消息队列的更新
- .......
了解了测试的输入输出,进行测试case设计就跟功能测试的用例设计差不多了,首先需要对上述可能产生的情况进行分类,也就是常用的case设计方法:等价类划分,然后针对不同分类的case再进行边界参数case设计,也就是边界值法。另外针对代码实现的逻辑应当根据产品业务逻辑进行预期的输入输出设计,而不能根据代码进行相关的设计,那就没什么用了。
再以httprunner为例,loader类加载csv文件,在加载时可能是一个参数,也可能是多个,因此需要进行两个或多个case设计
def test_load_csv_file_one_parameter(self): csv_file_path = os.path.join( os.getcwd(), 'tests/data/user_agent.csv') csv_content = loader.load_file(csv_file_path) self.assertEqual( csv_content, [ {'user_agent': 'iOS/10.1'}, {'user_agent': 'iOS/10.2'}, {'user_agent': 'iOS/10.3'} ] ) def test_load_csv_file_multiple_parameters(self): csv_file_path = os.path.join( os.getcwd(), 'tests/data/account.csv') csv_content = loader.load_file(csv_file_path) self.assertEqual( csv_content, [ {'username': 'test1', 'password': '111111'}, {'username': 'test2', 'password': '222222'}, {'username': 'test3', 'password': '333333'} ] )
以上就是单元测试case设计。
桩代码(stub)和mock
单元测试是测试软件的最小单元,它应该是与软件其他部分相分隔,例如与真实的数据库、网络环境等分隔开的,从而只测试我们关心的逻辑部分。那么对于有外部依赖的单元如何进行测试呢?这里提到两个概念:桩代码和mock
桩代码:用来代替真实代码的临时代码,对于依赖的其它部分直接使用固定代码或固定数据返回,属于完全模拟外部依赖
mock:这个就很常见了,它的作用也是替代真实的代码或者数据,与桩代码不同的是,mock还是可以进行相关的规则制定,还需要关心mock函数的调用和返回数据,例如mock的多次调用是否异常等等。mock用来模拟一些交互进行一些断言判断测试是否通过。
但是两者都是为了对被测试函数进行隔离和补齐。
在项目中如何进行单元测试
以上是单元测试的一些理论基础知识,那么如何在项目中应用单元测试。我认为单元测试的应用与自动化测试应用于项目应该是相同的考量。
- 项目适合不适合进行单元测试
- 项目中哪些模块适合单元测试
- 选用什么样的单元测试框架
- 如何执行单元测试
- 如何将单元测试融入ci进行持续集成
基于上面的考虑,如何在项目中开展单元测试。
- 并不是所有的项目都适合进行单元测试,即使进行单元测试,也应该是一些基础底层模块或者核心模块进行单元测试
- 选择合适的单元测试框架,Java中的TestNG、JUnit,Python中的Unittest、Pytest,PHP中的PHPUnit
- 将单元测试集成到CI流程当中
通常单元测试的框架选型以及配套的代码覆盖率工具的引入由开发架构师和测试架构师共同决定,并针对单元测试的一些细节进行相关的规范规定。
代码规范
单元测试的运用也需要一些规范支持,例如代码规范,注释规范,正是有这些规范的支持才能更好的进行单元测试,或者说没有这些规范很难进行单元测试。单元测试除了进行代码测试也为测试人员提供了很好的功能测试用例设计的逻辑参考,也为其它开发者熟悉代码提供了极大的便利。因此如果想让单元测试能做到这些功能就必须要能让别人看懂写的单元测试,或者写的代码,那这就要求需要有代码规范。而实际的工作中正因为缺少这样的规范或者开发没有时间去做到这些规范才导致了单元测试无法推动。那可以想想做哪些方面的规范:
1. 代码注释规范
2. 代码命名规范
3. 单元测试注释规范
4. 单元测试覆盖规范
5. 单元测试执行规范
规范这个问题也可以引申出一个问题,是由开发做单元测试,还是测试做单元测试,如果测试来做单元测试的话,需要掌握开发语言及框架,无论是前端的单元测试还是后端的单元测试,都需要熟悉相应的开发语言及相应的框架(开发框架,单元测试框架),只有熟悉这些才能进行合理的单元测试case设计和测试。
以httprunner为例,来看下它的代码规范
我们看下httprunner中runner类的注释:除了注释类的名称作用,还提供了example告诉如何使用这个类。
class Runner(object): """ Running testcases. Examples: >>> tests_mapping = { "project_mapping": { "functions": {} }, "testcases": [ { "config": { "name": "XXXX", "base_url": "http://127.0.0.1", "verify": False }, "teststeps": [ { "name": "test description", "variables": [], # optional "request": { "url": "http://127.0.0.1:5000/api/users/1000", "method": "GET" } } ] } ] } >>> testcases = parser.parse_tests(tests_mapping) >>> parsed_testcase = testcases[0] >>> test_runner = runner.Runner(parsed_testcase["config"]) >>> test_runner.run_test(parsed_testcase["teststeps"][0]) """ def __init__(self, config, http_client_session=None): """ run testcase or testsuite. Args: config (dict): testcase/testsuite config dict { "name": "ABC", "variables": {}, "setup_hooks", [], "teardown_hooks", [] } http_client_session (instance): requests.Session(), or locust.client.Session() instance. """ self.verify = config.get("verify", True) self.export = config.get("export") or config.get("output", []) self.validation_results = [] config_variables = config.get("variables", {})
再看一个函数的注释,handle_skip_feature,在执行case中跳过执行某些case
注释包含函数名解释,函数参数说明,函数体内分支判断说明,异常捕获说明
def _handle_skip_feature(self, test_dict): """ handle skip feature for test - skip: skip current test unconditionally - skipIf: skip current test if condition is true - skipUnless: skip current test unless condition is true Args: test_dict (dict): test info Raises: SkipTest: skip test """ # TODO: move skip to initialize skip_reason = None if "skip" in test_dict: skip_reason = test_dict["skip"] elif "skipIf" in test_dict: skip_if_condition = test_dict["skipIf"] if self.session_context.eval_content(skip_if_condition): skip_reason = "{} evaluate to True".format(skip_if_condition)
如果代码规范能做到上述那样,无论是开发者自己梳理单元测试用例case还是其他人来做单元测试,都可以比较容易的着手。相反,试想一下完全没有代码规范,或者规范做的不到位,那么在这样的基础上做单元测试应该要付出多少的成本。
总结
各个语言都有自己的单元测试框架,各个框架也都根据语言本身的特点或者语言应用的特点提供了许多实用的功能来让开发者或者测试人员来进行测试,例如iOS的单元测试框架xctest,提供了丰富的API进行单元测试和UI自动化测试。如果我们要提高代码质量,提高软件质量,就应该从底层做起,从规范做起,打不好底子在上层搞再多的工作也不会起到应有的效果。真正了解代码的只有代码的开发者,真正能把代码测试好的也是最了解代码的人。测试要做好,还有很长的路要走。