In my python-synthesizer toy project I got the feeling that my current way of writing config classes is a bit lacking when it comes to testing. For this reason, in this article we explore two different ways of writing testable config classes in Python and their pros and cons.
Before we get into that, though, we quickly go over some basics. First of all, a config class is a data structure that contains configurable parameters of a software project. We generally have three requirements fore config classes:
- Easy Access
The config should be easy to access from anywhere in the project without the need for things like passing a config object around. - Change Propagation
When we change a parameter in one part of the project, that change has to be reflected in all other parts of the project. - Testability
We want to be able to write robust unit tests for all functionalities of the config class.
Testability may seem a bit exaggerated for something as simple as config classes. However, there may be scenarios where parameters represent something more complicated than primitive data types. In those cases we want to be able to test the config classes ability to read those parameters from environment variables or files.
My usual way of writing config classes falls short in the testability requirement, as we see in the next section.
Simple Config Class
In the simple config class pattern I used to use, we define and initialize parameters at the top of the class scope as class attributes. Because they are not inside function definitions, they run when the interpreter loads the module containing the class. This design has the advantage of being super simple, because we do not need any methods in the class at all:
import os
class SimpleConfig:
SAMPLE_RATE = int(os.environ.get('SAMPLE_RATE') or 44100)
To use a config class of this kind, we simply import it wherever we need it in the project and read or change the class attributes directly.
from project import SimpleConfig
# use the parameter value
print(SimpleConfig.SAMPLE_RATE)
# changes reflect in the entire project
SimpleConfig.SAMPLE_RATE = 5
Testing Simple Config Classes
The changes we make that way reflect throughout the project, because we simply change class attributes. We can test this change propagation with the following pytest test function.
from project import SimpleConfig
def test_simple_config_change_propagation():
new_sample_rate = 2 * SimpleConfig.SAMPLE_RATE + 1
SimpleConfig.SAMPLE_RATE = new_sample_rate
assert SimpleConfig.SAMPLE_RATE == new_sample_rate
Testing the config classes ability to read environment variables is a bit more tricky, however. A test of this ability could look like this:
import importlib
import project
def test_simple_config_reading_environment(monkeypatch):
new_sample_rate = 2 * project.SimpleConfig.SAMPLE_RATE + 1
monkeypatch.setenv('SAMPLE_RATE', new_sample_rate)
importlib.reload(project)
assert project.SimpleConfig.SAMPLE_RATE == new_sample_rate
In this test we use pytest’s monkeypatching to change the environment variable. Reading the environment variable happens at the top level of the class scope. As such, it happens only once, when the interpreter loads the module. However, we already loaded the module to read the default value. Because of this, changing the environment variable will have no effect on the parameter of the config class.
To read the environment variables again, we need to reload the entire module containing the config class. This reloading causes two problems, though. On the one hand we cannot use pythons from
syntax to import the config class from the module. If we did this, the interpreter would not reload the config class along with its parent module. In this case, the test would fail even though we implemented the config class correctly. Then, we would waste time tracking down that error. On the other hand, reloading the module discards its entire state. In a more complicated module this could cause all sorts of unintentional side effects.
All in all, we can conclude that simple testable config classes are possible but a bit hacky and unnecessarily fragile.
Object Oriented Testable Config Classes
In this section, we focus on a more object oriented approach to testable config classes. In a more object oriented config class the actual parameter values are stored in fields of an instance of the config class and all the code is contained in methods of the class. As such, we can easily rerun the code reading the environment variables by calling the respective method. This removes the need to hackily reload the entire module in the test code.
Because we want the entire project to use the same set of parameters, we do not really want there to be more than one single instance of this config class. Therefore, we implement it as a singleton class that returns the same instance every time the an instance of the class is requested.
The resulting implementation looks like this:
import os
class ObjectOrientedConfig:
_instance = None
_initialized = False
def __init__(self):
if not self._initialized:
self.SAMPLE_RATE = int(os.environ.get('SAMPLE_RATE') or 44100)
self._initialized = True
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls, *args, **kwargs)
return cls._instance
def reinit(self):
self._initialized = False
self.__init__()
This implementation looks a bit complicated at first, but it becomes clear when we break it down. Before we do that, however, we quickly want to look at how we would use a config class of that kind:
from project import ObjectOrientedConfig
# get the current state of parameters
config = ObjectOrientedConfig()
# make a change that will be reflected
# in all other parts of the project
config.SAMPLE_RATE = 5
How the Object Oriented Variant Works
The first method or rather class method we want to look at is the __new__
function. Not every Python developer is familiar with it. It is called when we instantiate an object and is expected to return an instance of the class. In fact, that is why the first parameter of __new__
is cls
instead of self
: self
is supposed to be an instance of the class and when __new__
is invoked, such an instance does not exist yet. Instead, cls
holds the class itself. Using the class attribute _instance
, we first check if there already is an instance of the config class. If there is one, we return it, if not, we create one by calling the parent classes __new__
function and return that.
The next method we look at is the __init__
method. Its job is to initialize a new instance of the class. In our case that means reading config parameters from environment variables or using default values. There is a catch, though! Python calls the __init__
method every time the __new__
function has been called. Normally, this ensures that every newly created object gets initialized. In the case of our singleton class, however, this resets all our parameters to their default values everytime we create a config class object to access our parameters. To make sure, we initialize our config only once, we introduce another class attribute _initlialized
that simply serves as a flag that blocks the __init__
method after the first call.
The third and last method is the reinit
method. It clears the _initalized
flag and calls the __init__
method. This causes the environment variables to be read again. This method’ main use is for testing the config class. We do not really expect to use it much in normal operation of the class.
Testing the Object Oriented Variant
With the object oriented variant of the config class there is one new property we need to test fist. We want to ensure that our singleton pattern actually works. A test for this looks like this:
from project import ObjectOrientedConfig
def test_oo_config_singleton():
config1 = ObjectOrientedConfig()
config2 = ObjectOrientedConfig()
assert id(config1) == id(config2)
As with the simple variant, we also want to make sure that changes propagate between different parts of the code:
...
def test_oo_config_change_propagation():
new_sample_rate = 2 * ObjectOrientedConfig().SAMPLE_RATE + 1
ObjectOrientedConfig().SAMPLE_RATE = new_sample_rate
assert ObjectOrientedConfig().SAMPLE_RATE == new_sample_rate
And, for the main part, we want to test if the object oriented variant properly reads the environment variables.
...
def test_oo_config_reading_environment(monkeypatch):
new_sample_rate = 2 * ObjectOrientedConfig().SAMPLE_RATE + 1
monkeypatch.setenv('SAMPLE_RATE', new_sample_rate)
ObjectOrientedConfig().reinit()
assert ObjectOrientedConfig().SAMPLE_RATE == new_sample_rate
In this implementation we do not need to reload any modules. We simply use the reinit
method of the config class after we changed the environment variable.
Conclusion
In conclusion, we can test the simple variant of config classes, but the test code is a bit hacky and not too stable. When a project grows beyond being a quick toy project, it is probably worth it the use the slightly more sophisticated, object oriented variant. It gives us the same functionality and allows for much easier and more stable testing.