Python语法速查: 14. 测试与调优,


返回目录

 

本篇索引

(1)测试的基本概念

(2)doctest模块

(3)unittest模块

(4)调试器和pdb模块

(5)程序探查

(6)调优与优化

 

  (1)测试的基本概念

对程序的各个部分建立测试,这个称为:单元测试(unit test),更进一步的是测试驱动编程(test-driven programming)。 简单来说,就是“先写测试、再写程序”。是否一定要为所有的函数和模块编写测试代码, 这个目前仍有争论,因为构建完备的测试集本身就是一件工作量很大的事情。而且常常会发生这样的事情: 测试还没编好,需求已经变更了。但不管如何,测试驱动的理念是一种进步,掌握自动化测试工具, 比以前没有自动化测试工具的时代,效率要提高很多倍。

 

● 测试时驱动开发的4步:

(1)指出需要的新特性,可以记录下来,然后为其编写一个测试。

(2)编写特性的概要代码,程序代码没有任何语法错误,但测试结果会失败。 看到测试失败是很重要的,这样就能确定测试可以失败。 这里再强调一遍:在试图让测试成功之前,先要看到它失败!

(3)为特性的概要编写虚设代码(dummy code),不用准确实现功能,只要保证测试可以通过即可。

(4)现在重写(Refactor)代码,真正实现需要的特性功能,之后要保证测试一直成功。

 

 

 

  (2)doctest模块

函数、类或模块的第一行如果是一个字符串,这个字符串就是“文档字符串”。 使用doctest模块可以在文档字符串中查找“交互式会话”的例子, 并使用一系列测试的形式测试这些例子给出的数据和结果。

# my_math.py
def add2(x,y):
    """
    函数名:add(x,y)
    功能:返回2个输入参数之和
    >>> add2(1, 2)
    3
    >>> add2(1.3, 2.4)
    3.7
    """
    return x+y

上例中,在自定义的add2()函数的文档字符串中,举了2个调用和结果的例子, doctest模块可以从my_math.py文件中提取出add2()的示例部分, 加以测试,若测试结果和示例结果不同,则会输出错误报告。

可以编写单独的测试文件,也可以在库模块的末尾包含测试代码来测试自身,以下分别示例:

● 编写单独的测试文件:

# my_math.py
def add2(x,y):
    ...(内容同上例)
    
# testmymath.py (单独测试文件)
import my_math
import doctest
nfails, ntests = doctest.testmod(my_math)   # nfails和ntest分别表示失败的数量和执行的总测试数

如果所有测试都顺利通过,则不产生输出。否则将会在屏幕上输出错误报告, 如果想在屏幕上看到测试的详细输出,可使用verbose参数:

doctest.testmod(my_math, verbose=True)

 

● 在库模块的末尾包含测试代码:

# my_math.py
def add2(x,y):
    ...(内容同上例)
    
if __name__=='__main__':
    import doctest, my_math
    doctest.testmod(my_math)

如果my_math.py文件作为主程序在解释器中运行,就会运行文档测试。否则, 如果文件是由import加载的,测试将被忽略。 如果所有测试都顺利通过,则不产生输出。否则将会在屏幕上输出错误报告, 如果想在屏幕上看到测试的详细输出,可使用以下-v参数:

$ python my_math.py -v

 

 

 

  (3)unittest模块

对于更全面的程序测试,可以使用unittest模块。如果进行单元测试, 开发人员会为程序的每个组成元素(如各个函数、方法、类和模块)编写独立的测试案例。 然后运行这些测试来验证组成更大程序的基本组件的行为是否正确。

unittest的基本使用方法为:定义一个继承自unittest.TestCase的类, 在这个类中,各种测试由以名称 test 开头的方法定义,在每隔测试内,可用各种断言来检查不同条件。

 

TestCase实例支持以下方法和属性:

实例方法说明
t.setUp() 在运行任何测试方法之前,调用它来执行设置步骤。
t.tearDown() 在运行测试之后,调用它来执行清除操作。
t.assert_(expr [,msg]) 如果expr的计算结果为False,表明测试失败。 msg是一条消息字符串,提供对失败的解释。
t.failUnless(expr [,msg])
t.assertEqual(x, y, [,msg]) 如果x和y不相等,则表明测试失败。msg含义同上。
t.failUnlessEqual(x, y, [,msg])
t.assertNotEqual(x, y, [,msg]) 如果x和y相等,则表明测试失败。msg含义同上。
t.failIfEqual(x, y, [,msg])
t.assertAlmostEqual(x, y, [,places [,msg]]) 如果数字x和y未包含在对方的places小数位中,则表明测试失败。 检查方法是计算x和y的差,并将结果舍入到给定位数。如果结果为0,则x和y的值相近。 msg含义同上。
t.failUnlessAlmostEqual(x, y, [,places [,msg]])
t.assertNotAlmostEqual(x, y, [,places [,msg]]) 如果x和y在places小数位内无法区分大小,则表明测试失败。 msg含义同上。
t.failIfAlmostEqual(x, y, [,places [,msg]])
t.assertRaises(exc, callable, ...) 如果可调用对象callable未引发一场exc,则表明测试失败。 剩余参数将以参数形式传递给callable。可以使用异常元组exc检查多个异常。 msg含义同上。
t.failUnlessRaises(exc, callable, ...)
t.failIf(expr [,msg]) 如果expr计算结果为True,则表明测试失败。msg含义同上。
t.fail([msg]) 表明测试失败,msg含义同上。
t.failureException 该属性设置为在测试中捕获到的最后一个异常值。用于不仅要检查是否出现异常, 还想要检查异常是否抛出了恰当的值。

 

 

● 单元测试的例子

如果需要编写单元测试来测试前面add2()函数的各个方面,可以创建一个独立模块 testmymath.py,如下例所示。

import my_math
import unittest

# 单元测试
class TestMyMathFunction(unittest.TestCase):
    def setUp(self):
        # 执行设置操作(如果有的话)
        pass
        
    def tearDown(self):
        # 执行清除操作(如果有的话)
        pass
        
    def testintint(self):
        r = my_math.add2(2,3)
        self.assertEqual(r, 5)
        
    def testfloatfloat(self):
        r = my_math.add2(1.2, 3.4)
        self.assertEqual(r, 4.6)
        
    def testintfloat(self):
        r = my_math.add2(2, 3.5)
        self.assertEqual(r, 5.5)
        
        
# 运行unittest
if __name__=='__main__':
    unittest.main()

要运行单元测试,只需在文件 testmymath.py 上运行Python:

$ python testmymath.py

 

 

 

  (4)调试器和pdb模块

Python提供了一个基于命令的简单调试器,pdb模块支持:事后检查、检查栈帧、设置断点、单步调试以及计算代码。

 

● pdb模块的功能函数

函数说明
run(statement [,globals [,locals]]) 在调试器控制下执行statement,globals 和 locals 分别定义运行代码的全局和局部命名空间。
runeval(expression [,globals [,locals]]) 在调试器控制下计算expression字符串表达式。成功运行之后, 将返回表达式的值。globals 和 locals 含义同上。
runcall(function [,argument, ...]) 在调试器内调用一个函数。function是一个可调用对象, 其他参数可在其后的argument部分提供。函数运行后将返回它的返回值。
set_trace() 在调用该函数的位置启动调试器,这可用于将调试器断点硬编码到程序代码中。
post_mortem(traceback) 对回溯对象traceback启动事后检查。通常使用sys.exc_info()等函数获得。
pm() 使用最后一个异常的回溯进入事检查调试。

 

● 从Python交互环境启动调试器

启动调试器后,会显示一个(Pdb)提示符:

以下为待调试文件

#testfile.py
def testadd(a, b):
    c = a + b;
    return c

以下为交互环境命令行:

>>> import pdb
>>> import testfile   # 要调试的文件名
>>> pdb.run('testfile.testadd(1,2)')
> <string>(1)()
(Pdb)

 

● 调试器命令

可以在调试器提示符(Pdb)下使用命令,一些命令具有长短2种形式,例如, h(elp)表示hhelp都是可接受的。 可用调试命令见下表:

命令说明
[!]statement 在当前栈帧上下文中执行一行statement语句。感叹号可以忽略, 但如果语句的第一个词于调试器命令类似,则必须使用它来避免歧义。如要设置全局变, 可在同一行上添加global命令作为前缀,如:global a; a = 1
a(rgs) 打印当前函数的参数列表。
alias [name [command]] 创建名为name的别名来运行command。在command字符串中, 键入别名时子字符串'%1', '%2'等被替换为相应的参数,'%*'被替换为所有参数。 ,如果没有给定任何命令,则显示当前别名列表。command可以很长, 甚至可以使用for循环等(但需写在一行内)。
b(reak) [loc [,condition]] 在位置loc处设置断点。loc指定一个特定文件名和行号, 或者指定一个模块中的一个函数名称,可使用以下语法:
    n(当前文件中的行号);
    filename:n(另一个文件中的行号);
    function(当前模块中的函数名称);
    module.function(某个模块中的函数名称);
如果省略了loc,将打印当前的所有断点。condition是一个表达式, 在打印断点之前,该表达式的值必须计算为True。所有断点都会被分配一个数字, 该数字将在完成此命令时作为输出打印出来。这些数字可以在一些其他调试命令中使用。
cl(ear) [bpnumber [bpnumber ...]] 清除断点编号列表。如果没指明断点编号,所有断点都会被清除。
commands [bpnumber] 设置在遇到bpnumber时,将自动执行一系列调试命令。列出要执行的命令时, 只需在后续行中键入它们并使用end来标记命令序列的结束即可。 如果包含continue命令,在遇到断点时,程序将自动继续执行。如果省略bpnumber, 将使用最后一个断点集。
condition bpnumber [condition] 在断点上放置一个条件。condition是一个表达式,在识别该断点之前, 该表达式的值必须计算为True。省略该条件会清除任何以前的条件。
c(ont(inue)) 继续执行,直到遇到下一个断点。
disable [bpnumber [bpnumber ...]] 禁用指定断点集,可以在以后用enable命令重新启用这些断点。
d(own) 将当前帧在栈跟踪中下移一层。
enable [bpnumber [bpnumber ...]] 启用指定的断点集。
h(elp) [command] 显示可用命令的列表。指定一个命令将返回该命令的帮助信息。
ignore bpnumber [count] 忽略一个断点count次。
j(ump) lineno 设置要执行的下一行。只能用于同一执行帧中的不同语句之间移动。而且, 无法跳到某些语句中(如循环中的语句)
l(ist) [first [,last]] 列出源代码。如果没有参数,该命令将列出当前行前后的共11行。如果使用一个参数, 它将列出该行前后的11行。如果使用2个参数,它将列出指定范围内的行。
n(ext) 执行到当前函数中的下一行,与step命令的区别在于, next 会将调用整体函数视为一行执行,而step将进入那个函数执行一行。
p expression 在当前上下文中计算表达式expression的值并打印其值。
q(uit) 退出调试器。
r(eturn) 持续运行,直到当前函数返回值。
run [args] 重新启动程序,并使用args中的命令行参数作为 sys.args 的新设置。 所有断点和其他调试器设置都会被保留。
s(tep) 执行一行源代码并停在被调用的函数内。
tbreak [loc [,condition]] 设置一个临时断点,该断点将在第一次到达时删除。 loccondition用法同前。
u(p) 将当前帧在跟踪中上移一层。
unalias name 删除指定别名
until 恢复执行,直至不再控制当前执行帧,或者直至到达一个比当前行号大的行号。 例如在循环中键入 until 命令,将执行循环中的所有语句,直至循环结束。
w(here) 打印栈跟踪。

 

● 从命令行进行调试

可以在命令行上调用调试调试某文件,在这种情况下,启动程序时调试器将自动启动。

$ python -m pdb testfile.py

如果用户的主目录或当前目录中包含.pdbrc文件,那么每次调试器启动时将执行该文件, 可以使用这种方法来指定要在调试器每次启动时执行的调试命令。

 

 

 

  (5)程序探查

profilecProfile模块用于收集探查信息,两个模块的工作方式相同, 但cProfile速度更快且更先进。程序探查可以通过以下命令行方式调用:

$ python -m cProfile testfile.py

运行该命令后,会在屏幕上打印出性能统计信息。

 

也可以在交互命令行中或程序代码中调用探查器(profiler),具体方法是使用run()函数, 其语法如下:

import cProfile
cProfile.run(command [,filename])

探查器会使用exec语句执行command的内容,filename是输出报告要保存到的文件, 如果忽略该参数,报告将输出到标准输出。

 

生成报告的部分说明如下:

抬头说明
primitive calls 非递归性函数调用的数量
ncalls 调用总数(包括自递归),当表示为 n/m 时,n表示实际调用数量,m表示原始调用的数量。
tottime 该函数消耗的时间(不含子函数)
percall tototime / ncalls
cumtime 函数消耗的总时间
percall cumtime / (primitive calls)
filename:lineno(function) 每个函数的位置和名称

 

通常对于普通的探查分析,cProfile模块足够了。如果希望保存数据和进一步分析, 可使用pstats模块,它可以进一步分析cProfile模块输出的报告信息。

>>> import pstats
>>> p = pstats.Stats('result.profile')

 

 

 

  (6)调优与优化

● 程序运行时间测量

(1)方法一:使用Linux的time命令

可用于简单对长时间运行的Python程序进行计时:

$ time python testfile.py

 

(2)方法二:在程序中直接放入统计时间代码

time模块的perf_counter()函数可得到本进程开始起到现在的总秒数, process_time()函数可得到本进程开始起到现在的进程运行秒数。

import time
start_proc = time.process_time()    # 进程时间
start_real = time.time()            # UTC时间
......
end_proc = time.process_time()
end_real = time.time()
print('%f Real Seconds" %(end_real - start_real))
print('%f Process Seconds" %(end_proc - start_proc))

 

(3)方法三:使用timeit()函数

如果想对一个特定语句进行基准测试,可以使用timeit模块中的timeit()函数,语法如下:

timeit(code [,setup])

其中,code参数时希望对其基准测试的代码,setup参数是一条语句, 用来设置执行环境。timeit()函数会运行这条语句100万次并报告执行时间。 可以向timeit()函数提供number=count关键字参数来更改重复次数。

>>> from timeit import timeit
>>> timeit('math.sqrt(3.0)', 'import math')
0.10910729999886826

>>> timeit('sqrt(3.0)', 'from math import sqrt')
0.0762049000004481

timeit模块还有一个repeat()函数,功能与timeit()相同, 但它重复测量5次并返回一个结果列表:

>>> from timeit import repeat
>>> repeat('math.sqrt(3.0)', 'import math')
[0.07255980000081763, 0.07150849999925413, 0.07361959999980172, 0.0723534999997355, 0.08293649999905028]

 

 

● 内存测量

sys模块有一个getsizeof()函数,可用于分析Python对象的内存占用(以字节为单位):

>>> import sys
>>> sys.getsizeof(2)
28
>>> sys.getsizeof('a')
50
>>> sys.getsizeof([1,])
72
>>> sys.getsizeof([1,2,3])
88
>>> sum(sys.getsizeof(x) for x in [1,2,3])
84

对于列表、元组和字典等容器,报告的大小只是容器对象本身的大小,不是容器中包含的所有对象的累计大小。 可以像上面显示的那样使用sum()函数来计算列表内容的总大小。

测量实际内存占用的一种辅助技术是,从操作系统的进程查看器或任务管理器检查正在运行的程序。

 

 

● 返汇编

dis模块可用于将Python函数、方法、类反汇编为低级的解释器指令, 该模块中的dis()函数使用示例如下:

>>> from dis import dis
>>> import testfile
>>> dis(testfile.testadd)

在多线程程序中,反汇编中的每行操作都采用是原子执行方式,一行不会被中间打断, 可以利用此信息来跟踪复杂的竞争条件。

 

 

● 调优策略

下面列出了一些比较典型的优化策略:

(1)尽量使用内置类型

Python内置的元组、列表、集合、字典完全是用C语言实现的,是解释器中优化程度最高的数据结构。 尽量避免构建自定义的数据结构(如:二叉搜索树、链表等)来模仿它们的功能。 标准库中的类型也是很好的选择,例如collection.deque双端队列,用它在队列头插入项, 比在普通列表头部插入项要高效得多。

 

(2)能使用字典结构就不要定义新class

用户定义得类和实例时用字典构建的,因此,查找、设置、删除实例数据的速度几乎总是比直接 在字典上执行这些操作更慢。如果只是构建一个简单的数据结构来存储数据,元组和字典通常就够用了。

 

(3)使用__slots__

如果程序创建了自定义类的大量实例,可以考虑在类定义中使用__slots__属性。 __slots__有时被看作一种安全功能,因为它会限制属性名称的设置, 但它更主要的用途是性能优化。使用__slots__的类不使用字典存储实例数据 (而是用一种更高效的内部数据结构),所以其实例使用的内存也更少、访问速度也更快。

不过,需要注意的是,将__slots__功能添加到类中可能会无故破坏其他代码。 因为__slots__会占用__dict__属性,故依赖__dict__的代码会失败。

 

(4)避免多次重复使用(.)运算符

使用.在对象上查找属性时,总会涉及名称查找。对于大量使用方法或模块查找的计算, 最好首先将要执行的操作方道一个局部变量中,从而避免属性查找。例如:使用 from math import sqrt 和 sqrt(x) 要比 math.sqrt(x) 要快 1.4 倍左右。

 

(5)使用异常来处理不常见的情况,但是避免对常见情况使用异常

对于try中语句块正常运行的情况,其运行速度要比前面加一个额外的if判断要快10%左右, 但是如果经常会让程序陷入异常except的语句块,程序就会变得非常慢。 因此,在这种情况下,用if判断语句比较好。

另外,检查字典中是否有某个键名,用in操作符也比用d.get(key)要快2倍。

 

(6)鼓励使用函数式编程和迭代

使用:列表推导、生成器表达式、生成器、协程、闭包,这些方式会使程序执行效率大大提高, 尤其是对于大量数据处理来说,更是如此。射你用生成器编写的代码,不仅运行速度快,而且内存使用效率也高。

 

(7)使用装饰器和元类

装饰器和元类用于修改函数和类,可以通过多种方式使用它们来改进性能, 特别是程序拥有很多可以启动或禁用的可选功能时。

 

 

 

 

 

返回目录

 

相关内容

    暂无相关文章

评论关闭