Python 单元测试中的 Mock 进程
背景:
我现在正在用Python写一个进程监控工具,支持Windows和Linux,并且实现单元测试覆盖率。这个进程监控工具会连接到Windows的API函数EnumProcesses,在Windows上监控进程,而在Linux上则监控/proc目录来查找当前的进程。然后,进程的名称和进程ID会被写入一个日志,单元测试可以访问这个日志。
问题:
在我进行监控行为的单元测试时,我需要有一个进程可以启动和终止。如果能有一种(跨平台的?)方法来启动和终止一个我可以独特命名的假系统进程,那就太好了(这样我可以在单元测试中跟踪它的创建)。
初步想法:
- 我可以使用subprocess.Popen()来打开任何系统进程,但这会遇到一些问题。如果我用来测试的进程也是系统运行的,那么单元测试可能会错误地通过。此外,单元测试是从命令行运行的,而我想到的任何Linux进程都会挂起终端(比如nano等)。
- 我可以启动一个进程并通过它的进程ID来跟踪它,但我不太确定如何做到这一点而不挂起终端。
这些只是我在初步测试中的一些想法和观察,如果有人能在这两点上证明我错了,我会很高兴。
我使用的是Python 2.6.6。
编辑:
获取所有Linux进程ID:
try:
processDirectories = os.listdir(self.PROCESS_DIRECTORY)
except IOError:
return []
return [pid for pid in processDirectories if pid.isdigit()]
获取所有Windows进程ID:
import ctypes, ctypes.wintypes
Psapi = ctypes.WinDLL('Psapi.dll')
EnumProcesses = self.Psapi.EnumProcesses
EnumProcesses.restype = ctypes.wintypes.BOOL
count = 50
while True:
# Build arguments to EnumProcesses
processIds = (ctypes.wintypes.DWORD*count)()
size = ctypes.sizeof(processIds)
bytes_returned = ctypes.wintypes.DWORD()
# Call enum processes to find all processes
if self.EnumProcesses(ctypes.byref(processIds), size, ctypes.byref(bytes_returned)):
if bytes_returned.value < size:
return processIds
else:
# We weren't able to get all the processes so double our size and try again
count *= 2
else:
print "EnumProcesses failed"
sys.exit()
2 个回答
你最开始想用子进程的想法很好。只需要创建一个可执行文件,并给它起个名字,让人一看就知道这是个测试用的东西。你可以让它做点简单的事情,比如让程序暂停一段时间。
另外,你也可以使用多进程模块。我在Windows上用Python的经验不多,但你应该能从你创建的Process对象中获取到进程的识别数据:
p = multiprocessing.Process(target=time.sleep, args=(30,))
p.start()
pid = p.getpid()
编辑:这个回答有点长 :),但我原来的回答中有些内容仍然适用,所以我保留它 :)
你的代码和我最初的回答其实差不多。我的一些想法仍然适用。
在写单元测试的时候,你只想测试你自己的逻辑。当你使用与操作系统交互的代码时,通常希望把那部分“模拟”掉。原因是你对那些库的输出没有太多控制权,正如你发现的那样。所以模拟这些调用会更简单。
在这个例子中,有两个库与系统交互:os.listdir
和 EnumProcesses
。因为你不是自己写的这些库,所以我们可以轻松地假装它们返回我们需要的东西。在这个情况下,就是一个列表。
但是等等,你在评论中提到:
“我遇到的问题是,它并没有真正测试我的代码是否能看到系统中的新进程,而是测试代码是否能正确监控列表中的新项。”
问题是,我们并不需要测试实际上监控系统进程的代码,因为那是第三方的代码。我们需要测试的是你的代码逻辑如何处理返回的进程。因为那是你写的代码。我们之所以在列表上进行测试,是因为那正是你的逻辑在做的事情。os.listdir
和 EnumProcesses
返回的是进程ID的列表(分别是数字字符串和整数),而你的代码就是基于这个列表进行操作的。
我假设你的代码是在一个类里面(你在代码中使用了self
)。我也假设它们是隔离在各自的方法中的(你使用了return
)。所以这将是我最初建议的那种方式,只不过是用实际的代码 :) 我不知道它们是在同一个类里还是不同的类,但这并不重要。
Linux 方法
现在,测试你的Linux进程函数并不难。你可以把os.listdir
“修补”成返回一个进程ID的列表。
def getLinuxProcess(self):
try:
processDirectories = os.listdir(self.PROCESS_DIRECTORY)
except IOError:
return []
return [pid for pid in processDirectories if pid.isdigit()]
现在进行测试。
import unittest
from fudge import patched_context
import os
import LinuxProcessClass # class that contains getLinuxProcess method
def test_LinuxProcess(self):
"""Test the logic of our getLinuxProcess.
We patch os.listdir and return our own list, because os.listdir
returns a list. We do this so that we can control the output
(we test *our* logic, not a built-in library's functionality).
"""
# Test we can parse our pdis
fakeProcessIds = ['1', '2', '3']
with patched_context(os, 'listdir', lamba x: fakeProcessIds):
myClass = LinuxProcessClass()
....
result = myClass.getLinuxProcess()
expected = [1, 2, 3]
self.assertEqual(result, expected)
# Test we can handle IOERROR
with patched_context(os, 'listdir', lamba x: raise IOError):
myClass = LinuxProcessClass()
....
result = myClass.getLinuxProcess()
expected = []
self.assertEqual(result, expected)
# Test we only get pids
fakeProcessIds = ['1', '2', '3', 'do', 'not', 'parse']
.....
Windows 方法
测试你的Windows方法就有点棘手了。我会这样做:
def prepareWindowsObjects(self):
"""Create and set up objects needed to get the windows process"
...
Psapi = ctypes.WinDLL('Psapi.dll')
EnumProcesses = self.Psapi.EnumProcesses
EnumProcesses.restype = ctypes.wintypes.BOOL
self.EnumProcessses = EnumProcess
...
def getWindowsProcess(self):
count = 50
while True:
.... # Build arguments to EnumProcesses and call enun process
if self.EnumProcesses(ctypes.byref(processIds),...
..
else:
return []
我把代码分成两个方法,以便更容易阅读(我相信你已经在这样做了)。这里有个棘手的地方,EnumProcesses
使用了指针,这玩意儿不太好处理。还有,我不知道在Python中如何处理指针,所以我不能告诉你一个简单的模拟方法 =P
我可以告诉你的是,干脆别测试它。你那里的逻辑非常简单。除了增加count
的大小,那个函数里的其他部分都是在创建EnumProcesses
指针将要使用的空间。也许你可以给计数大小加个限制,但除此之外,这个方法简短明了。它只返回Windows进程,没别的。这正是我在原评论中所要求的 :)
所以就让那个方法保持原样。别测试它。不过要确保,任何使用getWindowsProcess
和getLinuxProcess
的地方都按照我最初的建议进行模拟。
希望这样说更清楚了 :) 如果还是不明白,告诉我,也许我们可以聊聊或者视频通话什么的。
原回答
我不太确定如何做你所要求的,但每当我需要测试依赖某些外部因素(外部库、popen或者在这种情况下的进程)的代码时,我都会把这些部分模拟掉。
现在,我不知道你的代码结构如何,但也许你可以这样做:
def getWindowsProcesses(self, ...):
'''Call Windows API function EnumProcesses and
return the list of processes
'''
# ... call EnumProcesses ...
return listOfProcesses
def getLinuxProcesses(self, ...):
'''Look in /proc dir and return list of processes'''
# ... look in /proc ...
return listOfProcessses
这两个方法只做一件事,就是获取进程列表。对于Windows来说,可能只是调用那个API,而对于Linux来说,就是读取/proc目录。就这些,没别的。处理进程的逻辑会放在其他地方。这使得这些方法非常容易模拟,因为它们的实现只是返回列表的API调用。
然后你的代码可以轻松调用它们:
def getProcesses(...):
'''Get the processes running.'''
isLinux = # ... logic for determining OS ...
if isLinux:
processes = getLinuxProcesses(...)
else:
processes = getWindowsProcesses(...)
# ... do something with processes, write to log file, etc ...
在你的测试中,你可以使用一个模拟库,比如Fudge。你模拟这两个方法,让它们返回你期望的结果。
这样你就能测试你的逻辑,因为你可以控制结果。
from fudge import patched_context
...
def test_getProcesses(self, ...):
monitor = MonitorTool(..)
# Patch the method that gets the processes. Whenever it gets called, return
# our predetermined list.
originalProcesses = [....pids...]
with patched_context(monitor, "getLinuxProcesses", lamba x: originalProcesses):
monitor.getProcesses()
# ... assert logic is right ...
# Let's "add" some new processes and test that our logic realizes new
# processes were added.
newProcesses = [...]
updatedProcesses = originalProcessses + (newProcesses)
with patched_context(monitor, "getLinuxProcesses", lamba x: updatedProcesses):
monitor.getProcesses()
# ... assert logic caught new processes ...
# Let's "kill" our new processes and test that our logic can handle it
with patched_context(monitor, "getLinuxProcesses", lamba x: originalProcesses):
monitor.getProcesses()
# ... assert logic caught processes were 'killed' ...
请记住,如果你以这种方式测试代码,你不会得到100%的代码覆盖率(因为你的模拟方法不会被执行),但这没关系。你在测试的是自己的代码,而不是第三方的,这才是最重要的。
希望这能帮到你。我知道这没有直接回答你的问题,但也许你可以用这个来找出测试代码的最佳方法。