单元测试Unit Test Python

指南与踩坑:Python 单元测试

2017-11-06  本文已影响134人  苏尚君

本文试图总结编写单元测试的流程,以及自己在写单元测试时踩到的一些坑。如有遗漏,纯属必然,欢迎补充。

目录概览:

编写思想

尽可能地按「单元」测试,重点是:保证待测单元内部的所有流程能按设想正确运行、返回预期结果。

假设我们有一个待测程序如下

# to_test.py

def func_01(param0101, param0102):
    # 处理参数 param0101 和 param0102 的代码,如
    ret = param0101 * param0102
    # 假设结果保存在名为 ret 的变量中
    return ret

def func_02(param0201, param0202):
    # 处理参数 param0201 和 param0202 的代码,如
    ret = param0201 / param0202
    # 假设结果保存在名为 ret 的变量中
    return ret
    

def call_other_funcs(param0101, param0102, param0201, param0202):
    # 对传入参数 param0x0y 等进行一些处理后,存到了 param0x0y_changed 中。例如
    param0x0y = param0x0y * 10
    
    ret01 = func_01(param0101_changed, param0102_changed)
    ret02 = func_02(param0201_changed, param0202_changed)
    
    # 对得到的 ret01, ret02 进行一些处理,得到最终返回值 ret。例如
    if ret01 and ret02:
       ret = (ret01, ret02)
    elif ret01 and (not ret02):
       ret = (ret01, 0)
    elif (not ret01) and ret02
       ret = (0, ret02)
    else:
       ret = (0, 0)
    return ret

要关注的点有2:

1. 每个测试仅保证 1 个单元的内部流程正确,即待测单元;这种正确性是不依赖于外部流程的正确性的

所以要假定该单元内调用的外部单元(如引入的模块、函数等)能返回预期结果——这通过所谓的 Mock 来实现,可理解为就是伪造出预期结果;至于那个外部单元能不能真的按给定输入返回预期结果,那是那个外部单元对应的单元测试应该负责的事。

以上述示例程序 to_test.py 为例,本原则所关注的点体现在:

def call_other_funcs_new(param0101, param0102, param0201, param0202):
    # 对传入参数 param0x0y 等进行一些处理后,存到了 param0x0y_changed 中。例如
    param0x0y = param0x0y * 10
    
    ret01 = param0101_changed * param0102_changed  # 我们还测了 func_01 的内部处理逻辑
    ret02 = param0201_changed / param0202_changed  # 我们还测了 func_02 的内部处理逻辑
    
    # 对得到的 ret01, ret02 进行一些处理,得到最终返回值 ret。例如
    if ret01 and ret02:
       ret = (ret01, ret02)
    elif ret01 and (not ret02):
       ret = (ret01, 0)
    elif (not ret01) and ret02
       ret = (0, ret02)
    else:
       ret = (0, 0)
    return ret

2. 测试要尽可能覆盖到所有语句

仍然以上述函数为例,这里并不只是说测试需要覆盖所有的 if-else 分支,而更着重于强调对上一条原则的配合,即:尽管我们要伪造一些函数的值,但我们也要保证对应的函数调用了指定的参数。

这是因为函数调用的参数可能依赖于调用函数前的代码,因此确保函数调用了指定参数,这种行为则确保了函数调用前那些(涉及到参数的)代码能够正确执行。例如上述代码中, 如果我们仅仅令 funcs_01 的返回值为某值,而没有去检查 funcs_01 到底调用的参数是不是我们预期的参数,那么实际上我们就并没有测试到像 param0101 = param0101 * 10 这样的代码。

上述 2 点是基本原则。在此之上,根据我踩的坑,还有 1 点想补充:

1. 测试要写得「傻」一点

感谢首席测试小姐姐指出:测试不仅仅是为了保证功能正确,也是一份「代码阅读指南」——即当待测单元的行为不容易理解时,用户可以通过阅读这份代码对应的单元测试来理解程序行为。

仍以上述对 to_test.py 的测试为例。(下面的 @mock.patch.object 与 mock_funcs_0x.return_value 配合,实现「伪造函数值」)

坏样例:

from unittest import mock
import to_test

class ToTestTestCase(unittest.TestCase):

    # ...其他测试函数...
   
    @mock.patch.object(to_test, 'funcs01')
    @mock.patch.object(to_test, 'funcs02')
    def test_call_other_funcs(self, mock_funcs_01, mock_func_02):
        funcs_ret_values = [
            {"funcs_01": 1, "funcs_02": 1},
            {"funcs_01": 1, "funcs_02": 0},
            {"funcs_01": 0, "funcs_02": 1},
            {"funcs_01": 0, "funcs_02": 0}
        ]
        for funcs_ret in funcs_ret_values:
            mock_funcs_01.return_value = funcs_ret["funcs_01"]
            mock_funcs_02.return_value = funcs_ret["funcs_02"]
            # 剩下的测试语句……

好样例:

from unittest import mock
import to_test

class ToTestTestCase(unittest.TestCase):

    # ...其他测试函数...
   
    @mock.patch.object(to_test, 'funcs01')
    @mock.patch.object(to_test, 'funcs02')
    def test_call_other_funcs_if(self, mock_funcs_01, mock_func_02):
        mock_funcs_01.return_value = 1
        mock_funcs_02.return_value = 1
        # 剩下的测试语句……
        
    @mock.patch.object(to_test, 'funcs01')
    @mock.patch.object(to_test, 'funcs02')
    def test_call_other_funcs_elif_1(self, mock_funcs_01, mock_func_02):
        mock_funcs_01.return_value = 1
        mock_funcs_02.return_value = 0
        # 剩下的测试语句……
        

    @mock.patch.object(to_test, 'funcs01')
    @mock.patch.object(to_test, 'funcs02')
    def test_call_other_funcs_elif_2(self, mock_funcs_01, mock_func_02):
        mock_funcs_01.return_value = 0
        mock_funcs_02.return_value = 1
        # 剩下的测试语句……
        

    @mock.patch.object(to_test, 'funcs01')
    @mock.patch.object(to_test, 'funcs02')
    def test_call_other_funcs_else(self, mock_funcs_01, mock_func_02):
        mock_funcs_01.return_value = 0
        mock_funcs_02.return_value = 0
        # 剩下的测试语句……

第一种写法看起来更简洁,更「模块化」,对于单个函数的测试被封装到了同一个函数中;但首先要面临的问题就是:

每次你在阅读测试函数是如何测试目标函数时,就要到上述的 list(如这里的 funcs_ret_values)中去查对应的函数到底被伪造成了什么值。

乍一看,这在需要伪造的函数值较少时看起来还不是大问题;但当需要伪造的函数数量多起来时,上面的 list of dict 就会变得冗长无比,非常不容易阅读。

更严重的是第二个问题:

设想一种情景:你需要测试函数内部的 2 条不同逻辑 A 和 B,而这些不同逻辑会返回同样的值 a。那么,由于测试是在for循环中进行的,当你发现希望返回 a 的时候没有返回 a,你就不知道到底是在测逻辑 A 时出了错,还是在测逻辑 B 时出了错。于是你可能不得不非常仔细地去检查样例,手动再模拟一遍测试的过程,而且还要手动模拟 2 次:既要考虑模拟逻辑 A,也要考虑模拟逻辑 B。这加大了debug测试程序的难度。

第二种写法虽然看上去更琐碎,但由于测试粒度比较小,上述这两个问题就都不复存在了。

编写方法

基本单元测试框架

基本测试框架如下。先阅读,再解释:

假设我们有一个类似刚才的 to_test.py 的待测函数 your_mod_name.py

# 引入单元测试模块(unittest)和伪造模块(mock)
import unittest
from unittest import mock

import your_mod_name

class YourModNameTestCase(unittest.TestCase):


    def setUp(self):
        """
        若每个单元测试前都要用到同一组数据,则在这里编写,如
        
        self.var00 = val00
        self.var01 = val01
        
        不一定要有
        """
        pass
       
         
    def test_funcs_01(self):
        """
            测试 funcs_01
            
            所有要进行通过自动化测试框架的运行都以 test_ 开头
        """
        actual_output = your_mode_name.funcs_01(5, 2)
        self.assertEqual(
            10,
            actual_output
        )


    def test_funcs_02(self):
        """
            测试 funcs_02
        """
        actual_output = your_mode_name.funcs_02(9, 3)
        self.assertEqual(
            3,
            actual_output
        )


    @mock.patch.object(your_mod_name, 'funcs_02')
    @mock.patch.object(your_mod_name, 'funcs_01')
    def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
        mock_funcs_01.return_value = (5 * 10) * (2 * 10)
        mock_funcs_02.return_value = (9 * 10) / (3 * 10)
        actual_output = your_mode_name.funcs_01(5, 2, 9, 3)
        mock_funcs_01.assert_called_with(5 * 10, 2 * 10)
        mock_funcs_02.assert_called_with(9 * 10, 3 * 10)
        self.assertEqual(
            ((5 * 10) * (2 * 10), (9 * 10) / (3 * 10)),
            actual_output
        )

    # 对 call_other_funcs 其他分支的测试
    #
    # ……
    #
    # 对其他函数的测试

以下解释上述代码

1. 最简单的测试

对于像 funcs_01 和 funcs_02 这样的函数写测试是非常简单的:我们只要

  1. 引入待测模块
  2. 把参数传给 待测模块.待测函数,取得返回值 actual_output 即为实际输出
  3. 使用 assertEuqal 之类以 assert 开头的函数来断言:实际行为与预期行为一致。通常至少用 assertEqual 来断言:实际输出(actual_output)与预期输出一致;在安排「实际输出」与「预期输出」在 assertEqual 中的参数顺序时,我的做法是:第一个参数是「预期输出」,第二个参数是「实际输出」;理由是「预期输出」的形状和长度是固定的,「实际输出」的形状和长度通常会有各种变化(当测试出错或函数没有执行预期行为时),把「实际输出」安排在后面,在调试测试函数时,我们的视线关注点是固定的。
  4. 对于比较复杂的函数如 call_other_funcs 的测试,可能还需要使用 assert_called_with 等方法断言函数调用的参数,见 https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_with 。当需要断言多次调用时(例如同一个函数 funcs_01 被调用了 3 次,每次传入了不同参数),可以考虑使用 assert_has_calls,见 https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_has_calls
  5. 其他的断言方法可见 https://docs.python.org/3/library/unittest.html#assert-methods 对于 mock 对象的断言方法可见 https://docs.python.org/3/library/unittest.mock.html#the-mock-class

2. 如何伪造一个对象

2.1 通用的 mock 框架

一般来说,我们把「伪造」称为 mock,因为这就是 Python 中的伪造类的名字。

可以通过装饰器 @mock.patch.object 的写法「制造」 mock 对象,见 https://docs.python.org/3/library/unittest.mock.html#patch-object

以上述写法为例:

@mock.patch.object(your_mod_name, 'funcs_02')
@mock.patch.object(your_mod_name, 'funcs_01')
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):

上面这三句的意思是:

把函数 your_mod_name.funcs_01 伪造成 mock_funcs_01,把 your_mod_name.funcs_02 伪造成 mock_funcs_02。这里的 mock_funcs_01 和 mock_funcs_02 的变量名没有特别的规定,这和一般的变量命名没有两样,也可以命名为 mock_weird_name_007, mock_I_dont_know_why_101 等奇奇怪怪的名字。现在这样命名只是为了方便理解。

在「制造」出 mock 对象后,我们要给 mock 对象赋值,因为伪造传值才是我们的实际目标。以上述代码为例,该目标通过这两句来完成:

mock_funcs_01.return_value = (5 * 10) * (2 * 10)
mock_funcs_02.return_value = (9 * 10) / (3 * 10)

注意:上面这两句,即所有伪造值的语句,都要在调用函数的语句前执行,即下面这句之前执行上述两句:

actual_output = your_mode_name.funcs_01(5, 2, 9, 3)

而 return_value 也可以在装饰器中就指定,例如:

@mock.patch.object(your_mod_name, 'funcs_02', return_value=(9 * 10) / (3 * 10))
@mock.patch.object(your_mod_name, 'funcs_01', return_value=(5 * 10) * (2 * 10))
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
    # 此后就不用再写 mock_funcs_01.return_value = (5 * 10) * (2 * 10) 这样的句子了

除了通过装饰器来伪造,还可以通过上下文管理器(context manager)的方法来伪造 。同样是类似上述代码,可以写为:

@mock.patch.object(your_mod_name, 'funcs_02')
@mock.patch.object(your_mod_name, 'funcs_01')
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
    with mock.patch.object(your_mod_name, 'funcs_01', \
          return_value=(5 * 10) * (2 * 10)) as mock_funcs_01,
         mock.patch.object(your_mod_name, 'funcs_02', \
          return_value=(9 * 10) / (3 * 10)) as mock_funcs_02:

        actual_output = your_mode_name.funcs_01(5, 2, 9, 3)
        mock_funcs_01.assert_called_with(5 * 10, 2 * 10)
        mock_funcs_02.assert_called_with(9 * 10, 3 * 10)
        self.assertEqual(
            ((5 * 10) * (2 * 10), (9 * 10) / (3 * 10)),
            actual_output
        )

2.2 特殊语句顺序

所有的伪造值语句,必须要在调用 actual_output 的赋值语句(即实际调用目标函数)之前执行。

所有的断言语句,包括 assertEqual 或 assert_called_with,必须要在调用 actual_output 的赋值语句(即实际调用目标函数)之后执行。这是因为需要断言的值、对象都要在函数执行后才会产生(需要断言的参数调用也是一种值,也要在函数执行后,才会在内存中留下「痕迹」,在执行之前,程序无法知道待测函数内部调用的其他函数到底调用了什么参数)。

要注意:调用 assert_called_with 的一定是某个 mock 对象而非 self,这是与 assertEqual 最大的区别

2.3 使用场景

更具体地说,分为 2 种情况:(1)在诸多测试用例中,每个对象的返回值之间各自独立;(2)这些返回值之间符合某种函数关系

根据这 2 种情况,对应的有 4 种伪造对象的写法。其中 2.3.1 对应第 (1) 种情况,2.3.2~2.3.4 对应第 (2) 种情况:

2.3.1 一般外部模块、网络连接、数据库连接

常见于伪造一般外部模块(自己或团队其他成员写的模块、开源库模块等)、数据库连接、网络连接的返回结果。都是类似上面对 funcs_01 的 mock 方法。给出 2 个在数据库连接和网络连接方面的 mock 示例代码:

假设使用数据库连接的原始代码为:

# your_mod_name.py

import db_conn

# some other function code ...

def funcs_with_db_connection(params):
    # some code ...
    answers = db_conn.query(sql)  # sql 是指定的 SQL 语句字符串
    # some code to process answers
    # 假定 ret 是返回变量
    return ret

则对应的 mock 代码为:

# 数据库连接
import your_mod_name

@mock.patch.object(your_mod_name.db_conn, 'query', return_value=['000001', '000002'])
def test_funcs_with_db_connection(self, mock_db_conn):
    # params 是参数
    # 在有了上面
    actual_output = your_mod_name.funcs_with_db_connection(params)

网络连接则以 requests.get 为例:

# your_mod_name.py

import requests

# some other function code ...

def funcs_with_requests(params):
    # some code ...
    answers = requests.get('your_url_to_site')  # 从 your_url_to_site 获取信息
    # some code to process answers
    # 假定 ret 是返回变量
    return ret

这时候返回的对象可能有多个属性如 status_coe 和 text,而且都要用上。那么此时对应的 mock 代码可写成:

@mock.patch.object(your_mod_name.requests, 'get')
def test_funcs_with_requests(self, mock_requests_get)
    mock_response = mock.Mock()
    mock_response.status_code = status
    mock_response.text = {'key01': 'val01', 'key02': 'val02'}
    mock_requests.return_value = mock_response

2.3.2 伪造成一个指定函数:转发输入

相当于把原始函数的输入值「转发」到指定函数上。例如原始代码为:

# your_mod_name.py

def format_answers(raw_string):
    # 处理 raw_string 非常复杂的处理逻辑
    # 假设处理完后保存到 good_string 中
    return good_string
    
def funcs_with_format_answers(params):
    # 某些代码生成了原始答案字符串 raw_string
    answers = format_answers(raw_string)
    # some code ... 返回 ret
    return ret

比如在测试函数中,我多次调用了该函数,但我不想对每次 mock 都指定一个值,那么可以这么做:

import your_mod_name

def mock_format_answers(input_string):
    return input_string
    
@mock.patch.object(your_mod_name, 'format_answers', mock_format_answers):
def test_funcs_with_format_answers(self):
    # some code for testing

注意到当我们指定了转发目标后,实际上指定了「制造」的 mock 对象为我们设计好的函数,这样就不需要在函数头中再写 mock_format_answers 了(如果写,反而会报错)。

2.3.3 从一个伪造类生成一个伪造对象

设想一个情况:你在原始函数中调用了某个类 ClassA 生成了实例 instance_A,并在原始函数中使用了该类的多个方法。那么一次次 mock 这个函数的一个个方法,可能看着或写着繁琐。在这种情况下,我们就可以通过将对应的类转发到我们设计好的伪造类上,并在伪造类下定义需要 mock 的方法,从而 mock 一个类就相当于 mock 了和该类相关的所有方法。

一个简单的例子是:某个函数内部调用了 time 这个类的 time() 和 sleep() 方法,例如:

# your_mod_name.py

import time

def funcs_with_time(params):
    t_start = time.time()
    # some code ...
    t_cost = time.time() - t_start
    ret = []
    while t_cost <= 10:
       # do something
       time.sleep(0.5)
       t_cost = time.time() - t_start
       if t_cost > 5:
           ret.append('good')
           
     return ret

在我们编写测试的时候,如果不将 time 这个类 mock 掉,那么程序的行为就不可预测:程序运行时是一个随机行为,我们如果不能「控制时间」,就不能保证测试函数在测试时能走到目标函数中的指定分支。

我们只要在测试函数中这么写即可:

import your_mod_name

class MockTime(object):
    """                                                                                                                                                                                       
        用于 time 的 Mock 类
    """
       
    def __init__(self):
        self.time_count = -0.5  # 配合 time() 方法使 ts_start = 0
       
    def time(self):
        """
            每次对象被调用时会运行这里的代码。
        """
        if self.time_count == -0.5:
            self.time_count = 0.0
        return self.time_count
       
    def sleep(self, gap):
        """
            0.5 的增量保证能在 cost 超过 10 之前触发:销毁 Token,返回 URL
        """
        self.time_count += 0.5
        return
        
mock_time_helper = MockTime()


class YourModNameTestCase(unittest.TestCase):

    # some code for testing other functions ...

    @mock.patch.object(your_mod_name, 'time', mock_time_helper)
    def test_funcs_with_time(self):
        # 这样就可以将不可控的「程序运行时」变为可控的「计数器」
        # some code for testing ...

2.3.4 如何伪造内置函数(built-in functions)的返回结果,如open(path_to_file).readlines()

这其实是 2.3.3 这个情况的一个特例,但也是一个容易让人抓狂的点。比如有时候我们需要测试的函数内有一个 open 函数,在打开文件后还调用了 readlines() 方法。那么我们如何 mock 掉 open?或者 open 返回的对象类名是啥,我能不能去 mock 那个类对应的 readlines() 方法?其实这个问题的关键在于:内置函数的类是什么?

答案是:builtins https://docs.python.org/3/library/builtins.html

那么之后就能够像 2.3.3 一样去处理了。

例如原始代码是

# your_mod_name.py

def funcs_with_open(params):
    # some code ...
    tokens = [line.strip('\n') for line in open('path_to_file').readlines()]
    return tokens

对应的测试函数中可以这么写

import unittest
import builtins

import your_mod_name


class MockOpen(object):
    """  
        内置函数 open 的 mock 类
    """
    def __init__(self, data):
        assert isinstance(data, list), '请输入一个列表: {}'.format((data))
        self.data = data 
 
    def __call__(self, blabla):
        return self 
 
    def readlines(self):
        return self.data

class YourModNameTestCase(unittest.TestCase):

    # some code to test other functions
    
    def test_funcs_with_open(self):
        with mock.patch('builtins.open', MockOpen(['test00\n', 'test01\n']))
            # some code to get params
            actual_output = your_mod_name.funcs_with_open(params)
            self.assertEqual(
                ['test00', 'test01'],
                actual_output
            )

对基于网站框架搭建的网络应用进行单元测试

和编写网络应用一样:编写网络应用(即涉及到网络通信的程序)的单元测试,与编写一般程序的单元测试基本一致,最大的差别就在于:网络应用和网络应用的单元测试一般需要额外关注:

这里就不做太多展开,在 2 个框架下各给出 1 个例子并做简要解释,更多情况请参阅对应的文档,或等我日后填坑(然后可能就不知不觉弃坑了?)

[Flask]

更多有关 Flask 的测试方法,见

https://pythonhosted.org/Flask-Testing/
http://flask.pocoo.org/docs/0.12/testing/

原程序

@app.route('/userapi/get_phone_number', methods=['POST'])
def get_phone_number():
    # some code to get phone number

对应测试程序中如何调用该程序:

# request_data 是已经处理过的要 POST 的 JSON
rv = self.client.post('/userapi/get_phone_number',  data=request_data)

注意到这里的 '/userapi/get_phone_number' 就是调用路由

[Tornado]

更多有关 Tornado 的测试方法,见 http://www.tornadoweb.org/en/stable/testing.html

原项目中由这个程序指定了程序路由:

# server.py

class Application(tornado.web.Application):  

    def __init__(self):
        handlers = [
            # some other handlers ...
            (r"/qaapi/qa", RESTfulAPIHandler)  # 已有 RESTfulAPIHandler.py 是对应应用
        ]

        tornado.web.Application.__init__(self, handlers)
        
        # some other codes ...

要测试时:

uri = '/qaapi/qa?userid={}&token={}'.format(userid, token)
data = get_data()
response = self.fetch(uri, method="POST", body=data)

self.assertEqual(400, response.code)
self.assertEqual('expected_output', response.buffer.getvalue())
上一篇 下一篇

猜你喜欢

热点阅读