如何在Python单元测试中模拟文件系统?

68

是否有标准的方法(不安装第三方库)在Python中进行跨平台文件系统模拟?如果必须使用第三方库,哪个库是标准的?


2
“文件系统”这个概念太宽泛了,它可以是任何东西。你到底想要什么? - Leonardo.Z
@Leonardo.Z:与文件系统的任何交互。我最关心的是创建打开删除文件和目录。在其他语言中,整个文件系统都可以被模拟。 - DudeOnRock
4
tempfile模块是否会有帮助? - SethMMorton
5个回答

53

pyfakefs主页)提供所需的功能-一个“假”文件系统;它是第三方库,但这个第三方是谷歌。有关用法的讨论,请参见如何替换要测试的模块中的文件访问引用

对于mockingunittest.mock是Python 3.3+标准库(PEP 0417);对于早期版本,请参见PyPI: mock(适用于Python 2.5+) (主页)。

测试和mocking中的术语不一致;使用Gerard Meszaros的Test Double术语,你需要一个“fake”:像文件系统一样运作(可以创建、打开和删除文件),但并不是真正的文件系统(在这种情况下,它存在于内存中),因此您不需要测试文件或临时目录。

在经典mocking中,您将替换掉系统调用(在Python中,替换掉os模块中的函数,例如os.rmos.listdir),但这要麻烦得多。


1
关于pyfakefs的兼容性的重要提示:“pyfakefs无法与使用C库访问文件系统的Python库一起使用。这是因为pyfakefs无法修补底层C库的文件访问函数- C库始终会访问真实的文件系统。例如,pyfakefs无法与lxml一起使用。在这种情况下,必须使用纯Python替代方案,例如xml.etree.ElementTree。” - Chris Collett
有人曾经成功地将pyfakefs与behave一起使用吗?对我来说,只要我使用behave运行脚本,os中的所有内容都不会被修补。 - Joerg S

26

pytest越来越受欢迎了,它可以使用tmpdirmonkeypatching(模拟)来实现所有这些功能。

您可以使用tmpdir函数参数,该参数提供了一个临时目录,该目录对于测试调用是唯一的,创建在基本临时目录中(默认情况下,这些目录会作为系统临时目录的子目录创建)。

import os
def test_create_file(tmpdir):
    p = tmpdir.mkdir("sub").join("hello.txt")
    p.write("content")
    assert p.read() == "content"
    assert len(tmpdir.listdir()) == 1

monkeypatch 函数的参数可以帮助你安全地设置/删除属性、字典条目或环境变量,或者修改 sys.path 以进行导入操作。

import os
def test_some_interaction(monkeypatch):
    monkeypatch.setattr(os, "getcwd", lambda: "/")

你也可以传递一个函数,而不是使用 lambda。

import os.path
def getssh(): # pseudo application code
    return os.path.join(os.path.expanduser("~admin"), '.ssh')

def test_mytest(monkeypatch):
    def mockreturn(path):
        return '/abc'
    monkeypatch.setattr(os.path, 'expanduser', mockreturn)
    x = getssh()
    assert x == '/abc/.ssh'

# You can still use lambda when passing arguments, e.g.
# monkeypatch.setattr(os.path, 'expanduser', lambda x: '/abc')

如果你的应用程序需要与文件系统进行大量交互,那么使用像pyfakefs这样的工具可能会更容易,因为模拟会变得繁琐和重复。


15

Python 3.3+标准的模拟框架是unittest.mock,你可以用它来模拟文件系统或其他任何东西。

你也可以通过monkey patching手动编写模拟:

以下是一个简单的示例:

import os.path
os.path.isfile = lambda path: path == '/path/to/testfile'

稍微完整些(未经过测试):

import classtobetested                                                                                                                                                                                      
import unittest                                                                                                                                                                                             

import contextlib                                                                                                                                                                                           

@contextlib.contextmanager                                                                                                                                                                                  
def monkey_patch(module, fn_name, patch):                                                                                                                                                                   
    unpatch = getattr(module, fn_name)                                                                                                                                                                      
    setattr(module, fn_name)                                                                                                                                                                                
    try:                                                                                                                                                                                                    
        yield                                                                                                                                                                                               
    finally:                                                                                                                                                                                                
        setattr(module, fn_name, unpatch)                                                                                                                                                                   


class TestTheClassToBeTested(unittest.TestCase):                                                                                                                                                              
    def test_with_fs_mocks(self):                                                                                                                                                                           
        with monkey_patch(classtobetested.os.path,                                                                                                                                                          
                          'isfile',                                                                                                                                                                         
                          lambda path: path == '/path/to/file'):                                                                                                                                            
            self.assertTrue(classtobetested.testable())                 

在这个例子中,实际的模拟(mock)很简单,但你可以用具有状态的东西来支持它们,以便能够代表文件系统操作,例如保存和删除。是的,这有点丑陋,因为它涉及在代码中复制/模拟基本的文件系统。

请注意,您不能猴子补丁(Patch)Python内置函数。话虽如此...

对于早期版本,尽可能使用第三方库,我会选择Michael Foord的强大的Mock,自从3.3+以来已经是标准库中的unittest.mock,感谢PEP 0417,并且您可以在PyPI上获取它,适用于Python 2.5+。而且,它可以模拟内置函数!


10

伪造还是模拟?

个人认为,在文件系统中存在许多边缘情况(例如使用正确的权限打开文件、字符串与二进制之间的转换、读取/写入模式等),使用准确的虚拟文件系统可以发现许多通过模拟无法发现的错误。在这种情况下,我建议查看pyfilesystem 中的memoryfs 模块(它包含了实现相同接口的各种具体实现,因此您可以在代码中进行交换)。

模拟(而且不需要猴子补丁!):

话虽如此,如果你真的想模拟,你可以轻松使用 Python 的unittest.mock 库:

import unittest.mock 

# production code file; note the default parameter
def make_hello_world(path, open_func=open):
    with open_func(path, 'w+') as f:
        f.write('hello, world!')

# test code file
def test_make_hello_world():
    file_mock = unittest.mock.Mock(write=unittest.mock.Mock())
    open_mock = unittest.mock.Mock(return_value=file_mock)

    # When `make_hello_world()` is called
    make_hello_world('/hello/world.txt', open_func=open_mock)

    # Then expect the file was opened and written-to properly
    open_mock.assert_called_once_with('/hello/world.txt', 'w+')
    file_mock.write.assert_called_once_with('hello, world!')

上面的示例仅演示了通过模拟open()方法创建和写入文件,但您可以轻松地模拟任何方法。


对我来说,它显示“AttributeError:__enter__”。 - Antony Hatchkins
@antony-hatchkins @weberc2 你需要在 open_mock 中添加 __enter__()__exit__() 函数,以使用 with open_func() 语法:请参见 https://realpython.com/python-with-statement/#the-with-statement-approach 了解详情。还可以参考 https://docs.python.org/3/library/unittest.mock.html#mock-open 或者我的答案中提供的现成版本。 - Sarah Messer
事实上,我知道如何完成它。我只是希望答案是完整和自给自足的。无论如何,谢谢。 - Antony Hatchkins

0

标准的unittest.mock库中有一个mock_open()函数,提供了基本的文件系统模拟。

优点:它是标准库的一部分,并继承了各种Mocks的特性,包括检查调用参数和用法。

缺点:它不像pytestpyfakefsmockfs那样保持文件系统状态,因此很难测试执行读/写交互或同时与多个文件交互的函数。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接