使用pytest进行pyqt测试中的内存泄漏

时间:2016-11-08 03:45:12

标签: memory-leaks pyqt pytest pyqt5

有没有人解释为什么以下泄漏内存(内存和其他内核对象,如GDI和用户句柄在每次迭代时都会不断增加,并且在测试退出之前永远不会回落):

import pytest
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsView

class TestCase:
    @pytest.mark.parametrize('dummy', range(1000))
    def test_empty(self, dummy):
        # self.view = None   # does NOT fix the leak if uncommented!
        self.app = QApplication.instance()
        if self.app is None:
            self.app = QApplication([])
        self.view = QGraphicsView()
        self.view.setFixedSize(600, 400)
        self.view.setScene(QGraphicsScene())
        self.view.show()

        QTimer.singleShot(100, self.app.exit)
        self.app.exec()

        # self.view = None   # FIXES the leak if uncommented!

如果满足以下任何条件,则无泄漏:

  1. 如果我没有 - 如果在测试方法返回之前查看(取消注释最后一行)
  2. 如果我将视图设为本地视图 功能而不是自我的成员(给出修复#1并不奇怪)
  3. 如果我删除装饰器和 相反,有一个"而真正的"在函数的顶部(所以测试 本身只运行一次,但窗口会一遍又一遍地重新创建)
  4. 有趣的是,如果我做出以下任何修改,泄漏不会消失:

    1. 我在函数开头而不是在结尾处将视图设置为None(在测试方法开始时注释掉了行)
    2. 我没有参数化测试方法,而是创建了许多测试方法(100' s,使用生成测试模块的小python脚本轻松完成),或者许多测试类,许多测试模块(这些都是' s我如何注意到这个问题,我们有一个巨大的测试套件,包含100个测试模块,每个测试模块有几个类,每个模块都有许多测试方法 - 测试套件中的内存泄漏直到最近,当测试数量变得足够大时才会被注意到在pytest完成所有测试之前,操作系统现在耗尽了GDI句柄!)。
    3. 我用app.closeAllWindows()取代了对app.exit()的单次调用(我认为这可能是本MCVE中的问题)
    4. 我们app中的实际测试要求在setup_method()中创建一些对象,因此我们无法避免将PyQt对象分配给测试实例的数据成员。因此,对我们来说唯一实用的解决方案是将每个测试方法编辑为由方法创建的None-ify PyQt对象,但这很容易出错,更不用说费力了(尽管比当前情况更好)。我希望有更好的方法。

1 个答案:

答案 0 :(得分:2)

我们使用的解决方案可能会让其他人受益,因此我将其作为答案发布(尽管我在pytest的3.0.4版本中看到问题可能已得到解决)。首先是一些背景知识:

  • 我们在使用nosetests作为测试驱动程序时创建了大量测试(差不多1000个)
  • 我们最终使用nose2pytest插件(https://pypi.python.org/pypi/nose2pytest)将测试套件迁移到pytest
  • 我们在测试类上有很多setup / teardown方法,可以为测试类的所有测试方法创建相同的对象。通过在self上创建属性,对象可用于测试类实例方法:

    class TestCase: 
        def setup_method(self):
            self.a = 123
        def test_something(self):
            ...use self.a...
    

问题在于,在每个测试方法结束时,pytest会收集在测试方法期间创建的self的任何属性,将其存储在某个缓存中,并将其从TestCase实例中删除(至少对于pytest< 3.0)。 4)。当然,问题在于随着测试套件的增长,某些关键资源不会被释放:内存,GDI句柄,USER句柄等。

最终,我们的测试套件变得足够大,以至于无法解释,但总是在运行一段时间后崩溃。起初我们认为这是我们在PyQt代码中做错的事情,但发现将一些测试移动到一个单独的测试套件(作为一个单独的pytest命令运行)并没有导致任何崩溃,所以我们住了一段时间,直到那个还不够,我们发现会员泄漏了。考虑到上面描述的pytest行为(我们当时不知道),这并不奇怪。在我们的一个套件中,内存将达到1.2演出,GDI处理到10000,此时测试套件将崩溃。实际上,在web上搜索表明默认max GDI handles per Windows process is 10k,通过查看Windows注册表得到确认。

足够的背景,现在我们如何解决这个问题。

所以我们刚刚完成了以下转换,它产生了很大的不同:我们创建了一个fixture,可以在pytest有机会收集它们之前自动删除测试方法添加的任何属性。这是通过几个步骤实现的:

  1. 我们将每个setup_method(self)重命名为setup_teardown_each(self, request, cleanup_attribs)并使用@pytest.fixture(autouse=True)进行装饰。使用正则表达式搜索替换很容易做到这一点。
  2. 我们将def teardown_method(self)行替换为yield,这要归功于我们一致的测试布局,对于每个测试类def teardown就在def setup_method之后,意味着这是另一个简单的步骤。否则我们将不得不在设置夹具中添加一个yield,将teardown的body代码移动到yield之后,并删除拆卸方法。
  3. 我们在套件cleanup_attribs中定义了conftest.py灯具:

    @pytest.fixture
    def cleanup_attribs(request):
        test_case = request.node.instance
        attr_names = set(test_case.__dict__.keys())
        yield
    
        # upon teardown:
        attr_names_added = set(test_case.__dict__.keys()).difference(attr_names)
        if not attr_names_added:
            return
    
        log.info('cleanup_attribs fixture removing {} from {}', attr_names_added, request.node.nodeid)
        test_case = request.node.instance
        for attr_name in attr_names_added:
            delattr(test_case, attr_name)
    
  4. 这是有效的,因为此fixture是setup_teardown_each fixture的依赖项,因此yield之前的部分在设置之前运行,yield之后的部分在测试方法运行之后运行,如果设置也生成,则在安装完成之后。夹具首先获得测试用例的当前dict,并且在yield之后它找到添加的内容并将其删除。

    在此之后,测试套件最多使用了几百个GDI手柄和几百兆内存,这是一个巨大的差异。这允许我们合并两个测试套件,因为它们不再耗尽内存和GDI句柄。