如何在单元测试中伪造模块?

How to fake a module in unittest?

我有一个基于名为 config.py 的配置文件的代码,它定义了一个名为 Config 的 class 并包含所有配置选项。由于配置文件可以位于用户存储中的任何位置,因此我使用 importlib.util 导入它(如本 answer 中所指定)。我想针对不同的配置使用 unittest 测试此功能。我该怎么做?一个简单的答案可能是为我想要测试的每个可能的配置制作一个不同的文件,然后将其路径传递给配置加载器,但这不是我想要的。我基本上需要的是实现 Config class,并将其伪装成实际的配置文件。如何实现?

编辑 这是我要测试的代码:

import os
import re
import traceback
import importlib.util
from typing import Any
from blessings import Terminal

term = Terminal()


class UnknownOption(Exception):
    pass


class MissingOption(Exception):
    pass


def perform_checks(config: Any):
    checklist = {
        "required": {
            "root": [
                "flask",
                "react",
                "mysql",
                "MODE",
                "RUN_REACT_IN_DEVELOPMENT",
                "RUN_FLASK_IN_DEVELOPMENT",
            ],
            "flask": ["HOST", "PORT", "config"],
             # More options
        },
        "optional": {
            "react": [
                "HTTPS",
                # More options
            ],
            "mysql": ["AUTH_PLUGIN"],
        },
    }

    # Check for missing required options
    for kind in checklist["required"]:
        prop = config if kind == "root" else getattr(config, kind)
        for val in kind:
            if not hasattr(prop, val):
                raise MissingOption(
                    "Error while parsing config: "
                    + f"{prop}.{val} is a required config "
                    + "option but is not specified in the configuration file."
                )

    def unknown_option(option: str):
        raise UnknownOption(
            "Error while parsing config: Found an unknown option: " + option
        )

    # Check for unknown options
    for val in vars(config):
        if not re.match("__[a-zA-Z0-9_]*__", val) and not callable(val):
            if val in checklist["optional"]:
                for ch_val in vars(val):
                    if not re.match("__[a-zA-Z0-9_]*__", ch_val) and not callable(
                        ch_val
                    ):
                        if ch_val not in checklist["optional"][val]:
                            unknown_option(f"Config.{val}.{ch_val}")
            else:
                unknown_option(f"Config.{val}")

    # Check for illegal options
    if config.react.HTTPS == "true":

        # HTTPS was set to true but no cert file was specified
        if not hasattr(config.react, "SSL_KEY_FILE") or not hasattr(
            config.react, "SSL_CRT_FILE"
        ):
            raise MissingOption(
                "config.react.HTTPS was set to True without specifying a key file and a crt file, which is illegal"
            )
        else:

            # Files were specified but are non-existent
            if not os.path.exists(config.react.SSL_KEY_FILE):
                raise FileNotFoundError(
                    f"The file at { config.react.SSL_KEY_FILE } was set as the key file"
                    + "in configuration but was not found."
                )
            if not os.path.exists(config.react.SSL_CRT_FILE):
                raise FileNotFoundError(
                    f"The file at { config.react.SSL_CRT_FILE } was set as the certificate file"
                    + "in configuration but was not found."
                )


def load_from_pyfile(root: str = None):
    """
    This loads the configuration from a `config.py` file located in the project root
    """
    PROJECT_ROOT = root or os.path.abspath(
        ".." if os.path.abspath(".").split("/")[-1] == "lib" else "."
    )
    config_file = os.path.join(PROJECT_ROOT, "config.py")

    print(f"Loading config from {term.green(config_file)}")

    # Load the config file
    spec = importlib.util.spec_from_file_location("", config_file)
    config = importlib.util.module_from_spec(spec)

    # Execute the script
    spec.loader.exec_module(config)

    # Not needed anymore
    del spec, config_file

    # Load the mode from environment variable and
    # if it is not specified use development mode
    MODE = int(os.environ.get("PROJECT_MODE", -1))
    conf: Any

    try:
        conf = config.Config()
        conf.load(PROJECT_ROOT, MODE)
    except Exception:
        print(term.red("Fatal: There was an error while parsing the config.py file:"))
        traceback.print_exc()
        print("This error is non-recoverable. Aborting...")
        exit(1)

    print("Validating configuration...")
    perform_checks(conf)
    print(
        "Configuration",
        term.green("OK"),
    )

如果没有多看你的代码,很难给出一个非常直接的答案,但很可能你想使用 Mocks

在单元测试中,您将使用 mock 将 Config class 替换为 class 的 caller/consumer。然后,您配置模拟以提供与您的测试用例相关的 return 值或副作用。

根据您发布的内容,您可能不需要任何模拟,只需要固定装置。也就是说,Config 的例子练习给定的案例。事实上,最好完全按照您最初的建议去做——只需制作一些示例配置来练习所有重要的案例。 目前尚不清楚为什么这是不可取的——根据我的经验,阅读和理解具有连贯夹具的测试比在测试中处理模拟和构建对象要容易得多 class。此外,如果您将 perform_checks 函数分解成多个部分,例如您有评论的地方,您会发现这更容易测试。

不过,您可以随意构造Config对象,并将它们传递给单元测试中的检查函数。在 Python 开发中使用 dict fixtures 是一种常见的模式。请记住,在 python 中,对象(包括模块)具有很像字典的接口,假设您进行了单元测试

from unittest import TestCase
from your_code import perform_checks

class TestConfig(TestCase):
  def test_perform_checks(self): 
    dummy_callable = lambda x: x
    config_fixture = {
       'key1': 'string_val',
       'key2': ['string_in_list', 'other_string_in_list'],
       'key3': { 'sub_key': 'nested_val_string', 'callable_key': dummy_callable}, 
       # this is your in-place fixture
       # you make the keys and values that correspond to the feature of the Config file under test.

    }
    perform_checks(config_fixture)
    self.assertTrue(True) # i would suggest returning True on the function instead, but this will cover the happy path case
    
  def perform_checks_invalid(self):
    config_fixture = {}
    with self.assertRaises(MissingOption):
       perform_checks(config_fixture)

# more tests of more cases

如果您想在测试之间共享装置,您还可以覆盖 unittest class 的 setUp() 方法。一种方法是设置一个有效的夹具,然后在每个测试方法中进行要测试的无效更改。