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

问题描述

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

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

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

在单元测试中,您将使用模拟代替Config类,以代替该类的调用者/消费者。然后,您将模拟配置为提供与测试用例相关的返回值或副作用。

根据您发布的内容,您可能不需要任何模拟,仅需要固定装置。也就是说,Config的示例适用于给定的情况。实际上,最好完全按照您最初的建议进行操作-仅制作一些示例配置即可解决所有重要问题。 目前尚不清楚为什么这是不希望的-根据我的经验,使用连贯的夹具来阅读和理解测试要比处理和构造测试类中的对象容易得多。另外,如果您将perform_checks函数分解为多个部分(例如,您在其中有注释),则会发现测试起来容易得多。

但是,您可以根据需要构造Config对象,并将其传递给单元测试中的check函数。在Python开发中,通常使用dict夹具。记住在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类的setUp()方法。一种方法是设置一个有效的灯具,然后在每种测试方法中进行要测试的无效更改。