How to fake a module in unittest?

959 views Asked by At

I have a code that is based on a configuration file called config.py which defines a class called Config and contains all the configuration options. As the config file can be located anywhere in the user's storage, so I use importlib.util to import it (as specified in this answer). I want to test this functionality with unittest for different configurations. How do I do it? A simple answer could be make a different file for every possible config I want to test and then pass its path to the config loader but this is not what I want. What I basically need is that I implement the Config class, and fake it as if it were the actual config file. How to achieve this?

EDIT Here is the code I want to test:

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"),
    )

1

There are 1 answers

4
Ben On BEST ANSWER

Without seeing a bit more of your code, it's tough to give a terribly direct answer, but most likely, you want to use Mocks

In the unit test, you would use a mock to replace the Config class for the caller/consumer of that class. You then configure the mock to give the return values or side effects that are relevant to your test case.

Based on what you've posted, you may not need any mocks, just fixtures. That is, examples of Config that exercise a given case. In fact, it would probably be best to do exactly what you suggested originally--just make a few sample configs that exercise all the cases that matter. It's not clear why that is undesirable--in my experience, it's much easier to read and understand a test with a coherent fixture than it is to deal with mocking and constructing objects in the test class. Also, you'd find this much easier to test if you broke the perform_checks function into parts, e.g., where you have comments.

However, you can construct the Config objects as you like and pass them to the check function in a unit test. It's a common pattern in Python development to use dict fixtures. Remembering that in python objects, including modules, have an interface much like a dictionary, suppose you had a unit test

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

You can also override the setUp() method of the unittest class if you want to share fixtures among tests. One way to do this would be set up a valid fixture, then make the invalidating changes you want to test in each test method.