【问题标题】:How to test or mock "if __name__ == '__main__'" contents如何测试或模拟“if __name__ == '__main__'”内容
【发布时间】:2011-08-16 13:19:35
【问题描述】:

假设我有一个包含以下内容的模块:

def main():
    pass

if __name__ == "__main__":
    main()

我想为下半部分编写一个单元测试(我想实现 100% 的覆盖率)。我发现了执行 import/__name__-setting 机制的 runpy 内置模块,但我不知道如何模拟或以其他方式检查 main()函数被调用。

这是我迄今为止尝试过的:

import runpy
import mock

@mock.patch('foobar.main')
def test_main(self, main):
    runpy.run_module('foobar', run_name='__main__')
    main.assert_called_once_with()

【问题讨论】:

    标签: python unit-testing testing mocking python-import


    【解决方案1】:

    我将选择另一种选择,即从覆盖率报告中排除 if __name__ == '__main__',当然,只有在测试中已经有 main() 函数的测试用例时,你才能这样做。

    至于为什么我选择排除而不是为整个脚本编写一个新的测试用例是因为如果正如我所说你已经为你的main() 函数提供了一个测试用例,那么你为脚本(只是为了有 100 % 的覆盖率)将只是一个重复的。

    关于如何排除if __name__ == '__main__',你可以编写一个覆盖配置文件并添加到部分报告中:

    [report]
    
    exclude_lines =
        if __name__ == .__main__.:
    

    更多关于覆盖配置文件的信息可以在here找到。

    希望这能有所帮助。

    【讨论】:

    • 嘿,我添加了一个新答案,它提供了 100% 的测试覆盖率(带有测试!)并且不需要忽略任何内容。让我知道你的想法:stackoverflow.com/a/27084447/1423157 谢谢。
    • 对于那些想知道的人:nose-cov 在下面使用coverage.py,因此包含上述内容的.coveragerc 文件就可以正常工作。
    • 恕我直言,即使我发现它有趣且有用,这个答案 not 实际上对 OP 做出了回应。他想测试那个 main 被调用,而不是跳过这个检查。否则,脚本实际上可以执行除实际预期之外的所有操作,当启动时,测试会显示“OK,一切正常!”。并且 main 函数可以被完全单元测试,即使实际上从未被调用。
    • 它可能不会对 OP 做出回应,但它对于实际目的来说是一个很好的答案,这至少是我发现这个问题的方式。类似的解决方案是使用# pragma: no cover,就像if __name__ == '__main__': # pragma: no cover。就我个人而言,我不愿意这样做,因为它使代码混乱并且非常难看,所以我认为 mouad 的答案是最好的解决方案,但其他人可能会觉得它很有用。
    • @mouad 如果我们非常具体,我认为从技术上讲,正则表达式行应该使用['"] 而不是.,例如:__name__ == ['"]__main__['"]:
    【解决方案2】:

    您可以使用imp 模块而不是import 语句来执行此操作。 import 语句的问题在于,'__main__' 的测试在您有机会分配给 runpy.__name__ 之前作为导入语句的一部分运行。

    例如,您可以像这样使用imp.load_source()

    import imp
    runpy = imp.load_source('__main__', '/path/to/runpy.py')
    

    第一个参数赋值给导入模块的__name__

    【讨论】:

    • imp 模块的工作方式似乎与我在问题中使用的 runpy 模块非常相似。问题是在模块加载之后和代码运行之前不能(显然)插入模拟。您对此有什么建议吗?
    【解决方案3】:

    哇,我参加聚会有点晚了,但我最近遇到了这个问题,我想我想出了一个更好的解决方案,所以就在这里......

    我正在开发一个包含十几个脚本的模块,所有这些脚本都以这个完全相同的 copypasta 结尾:

    if __name__ == '__main__':
        if '--help' in sys.argv or '-h' in sys.argv:
            print(__doc__)
        else:
            sys.exit(main())
    

    当然不可怕,但也无法测试。我的解决方案是在我的一个模块中编写一个新函数:

    def run_script(name, doc, main):
        """Act like a script if we were invoked like a script."""
        if name == '__main__':
            if '--help' in sys.argv or '-h' in sys.argv:
                sys.stdout.write(doc)
            else:
                sys.exit(main())
    

    然后将此 gem 放在每个脚本文件的末尾:

    run_script(__name__, __doc__, main)
    

    从技术上讲,无论您的脚本是作为模块导入还是作为脚本运行,此函数都将无条件运行。但这没关系,因为除非脚本作为脚本运行,否则该函数实际上不会任何事情。所以代码覆盖率看到函数运行并说“是的,100% 的代码覆盖率!”同时,我写了三个测试来覆盖函数本身:

    @patch('mymodule.utils.sys')
    def test_run_script_as_import(self, sysMock):
        """The run_script() func is a NOP when name != __main__."""
        mainMock = Mock()
        sysMock.argv = []
        run_script('some_module', 'docdocdoc', mainMock)
        self.assertEqual(mainMock.mock_calls, [])
        self.assertEqual(sysMock.exit.mock_calls, [])
        self.assertEqual(sysMock.stdout.write.mock_calls, [])
    
    @patch('mymodule.utils.sys')
    def test_run_script_as_script(self, sysMock):
        """Invoke main() when run as a script."""
        mainMock = Mock()
        sysMock.argv = []
        run_script('__main__', 'docdocdoc', mainMock)
        mainMock.assert_called_once_with()
        sysMock.exit.assert_called_once_with(mainMock())
        self.assertEqual(sysMock.stdout.write.mock_calls, [])
    
    @patch('mymodule.utils.sys')
    def test_run_script_with_help(self, sysMock):
        """Print help when the user asks for help."""
        mainMock = Mock()
        for h in ('-h', '--help'):
            sysMock.argv = [h]
            run_script('__main__', h*5, mainMock)
            self.assertEqual(mainMock.mock_calls, [])
            self.assertEqual(sysMock.exit.mock_calls, [])
            sysMock.stdout.write.assert_called_with(h*5)
    

    该死!现在您可以编写可测试的main(),将其作为脚本调用,具有 100% 的测试覆盖率,并且无需忽略覆盖率报告中的任何代码。

    【讨论】:

    • 我很欣赏寻找解决方案的创造力和毅力,但如果你在我的团队中,我会否决这种编码方式。 Python 的优势之一是它的高度惯用性。 if __name__ == ... 让模块脚本的方式。任何 pythonista 都会识别该行并理解它的作用。您的解决方案只是混淆了显而易见的事情,除了抓挠智力之痒之外没有其他充分的理由。正如我所说:一个聪明的解决方案,但聪明并不总是等同于正确
    • 如果你只有一个模块,或者每个模块在作为脚本调用时做的事情不同,那很好,但正如我所说,我有十几个文件 完全相同 @ 987654327@ 块在末尾,这是对 Don't Repeat Yourself 的巨大违反,并且当您需要在很多地方进行相同的修复时,也很难修复错误。像这样统一逻辑可以提高可测试性并减少错误的可能性。如果您担心人们不理解它,请将函数命名为if_name_equals_main(),人们会弄清楚的。
    • 如果在if __name__ ... 下缩进的块中有任何逻辑,那么你做错了,应该重构。 if __name__... 下的唯一代码行应为:main()
    • @mac 我不知道我是否同意这一点。是的,如果你有逻辑,你应该重构。但这并不意味着您在if __name__ ... 下唯一可以拥有的是main()。例如,我喜欢使用 argeparse 并在 if __name__ ... 部分构建我的解析器。然后抽象我的 main 以使用显式 args 而不是:main(parser.parse_args())。如果需要,这使得从另一个模块调用main() 变得更加容易。否则,您必须构造一个 argeparse.Namespace() 对象并正确获取所有默认参数。还是有更惯用的方法?
    • @MichaelLeonard - 我不确定我是否正确理解了你的问题。 main 是 - 按照惯例 - 将模块作为脚本调用时应该运行的函数,因此它是解析代码的常规位置。如果您想从模块中公开一个函数,则不应将其称为 main 而是其他名称,而 main 函数应依次调用它并传递已解析的参数。还是我完全误解了你的问题?
    【解决方案4】:

    Python 3 解决方案:

    import os
    from importlib.machinery import SourceFileLoader
    from importlib.util import spec_from_loader, module_from_spec
    from importlib import reload
    from unittest import TestCase
    from unittest.mock import MagicMock, patch
        
    
    class TestIfNameEqMain(TestCase):
        def test_name_eq_main(self):
            loader = SourceFileLoader('__main__',
                                      os.path.join(os.path.dirname(os.path.dirname(__file__)),
                                                   '__main__.py'))
            with self.assertRaises(SystemExit) as e:
                loader.exec_module(module_from_spec(spec_from_loader(loader.name, loader)))
    

    使用定义自己的小函数的替代解决方案:

    # module.py
    def main():
        if __name__ == '__main__':
            return 'sweet'
        return 'child of mine'
    

    您可以通过以下方式进行测试:

    # Override the `__name__` value in your module to '__main__'
    with patch('module_name.__name__', '__main__'):
        import module_name
        self.assertEqual(module_name.main(), 'sweet')
    
    with patch('module_name.__name__', 'anything else'):
        reload(module_name)
        del module_name
        import module_name
        self.assertEqual(module_name.main(), 'child of mine')
    

    【讨论】:

      【解决方案5】:

      一种方法是将模块作为脚本运行(例如 os.system(...))并将它们的 stdout 和 stderr 输出与预期值进行比较。

      【讨论】:

      【解决方案6】:

      我不想排除有问题的行,所以基于this explanation of a solution,我实现了the alternate answer given here的简化版...

      1. 我将if __name__ == "__main__": 包装在一个函数中以使其易于测试,然后调用该函数以保留逻辑:
      # myapp.module.py
      
      def main():
          pass
      
      def init():
          if __name__ == "__main__":
              main()
      
      init()
      
      1. 我使用 unittest.mock 模拟了 __name__ 以获取有问题的行:
      from unittest.mock import patch, MagicMock
      from myapp import module
      
      def test_name_equals_main():
        # Arrange
        with patch.object(module, "main", MagicMock()) as mock_main:
          with patch.object(module, "__name__", "__main__"):
               # Act
               module.init()
      
        # Assert
        mock_main.assert_called_once()
      

      如果您将参数发送到模拟函数中,像这样,

      if __name__ == "__main__":
          main(main_args)
      

      那么您可以使用assert_called_once_with() 进行更好的测试:

      expected_args = ["expected_arg_1", "expected_arg_2"]
      mock_main.assert_called_once_with(expected_args)
      

      如果需要,您还可以将return_value 添加到MagicMock(),如下所示:

      with patch.object(module, "main", MagicMock(return_value='foo')) as mock_main:
      

      【讨论】:

        【解决方案7】:

        我的解决方案是使用imp.load_source() 并通过不提供必需的 CLI 参数、提供格式错误的参数、以找不到所需文件的方式设置路径来强制在 main() 早期引发异常,等等

        import imp    
        import os
        import sys
        
        def mainCond(testObj, srcFilePath, expectedExcType=SystemExit, cliArgsStr=''):
            sys.argv = [os.path.basename(srcFilePath)] + (
                [] if len(cliArgsStr) == 0 else cliArgsStr.split(' '))
            testObj.assertRaises(expectedExcType, imp.load_source, '__main__', srcFilePath)
        

        然后在你的测试类中你可以像这样使用这个函数:

        def testMain(self):
            mainCond(self, 'path/to/main.py', cliArgsStr='-d FailingArg')
        

        【讨论】:

          【解决方案8】:

          我发现这个解决方案很有帮助。如果您使用函数来保留所有脚本代码,则效果很好。 该代码将作为一个代码行处理。是否为覆盖率计数器执行了整行并不重要(尽管这不是您实际期望的 100% 覆盖率) 诀窍也被接受 pylint。 ;-)

          if __name__ == '__main__': \
              main()
          

          【讨论】:

            【解决方案9】:

            如果只是为了获得 100% 并且那里没有什么“真实的”可以测试,那么忽略那条线会更容易。

            如果您使用的是常规覆盖率库,您只需添加一个简单的注释,覆盖率报告中将忽略该行。

            if __name__ == '__main__':
                main()  # pragma: no cover
            

            https://coverage.readthedocs.io/en/coverage-4.3.3/excluding.html

            @Taylor Edmiston 的另一条评论也提到了它

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2018-11-22
              相关资源
              最近更新 更多