pythonenvironment-variablespython-unittest

os.environ and os.getenv() interact strangely in a unittest


I have a python class

class EnvironmentParser:
    def __init__(self):
        self.A = os.getenv('A', 'a') + ".json"
        self.B = os.getenv('B', 'b') + ".json"

The purpose of this class is to have some default file identifiers (eg. a.json and b.json) but if the need arises, this should be changeable at runtime by running the python script with some set environment variables (the actual keys are different but I don't want to write production code here).

In another class, an instance of EnvironmentParser is passed as a constructor argument, and these file identifiers are read off from the instance variables. I have tried to unit test this as follows:

os.environ['A'] = 'herp'
os.environ['B'] = 'derp'
path = Path("some path here")
environment = EnvironmentParser()
folder = AdviceFolder(path, environment)
self.assertEqual(folder.file_ids['A'], 'herp.json')
self.assertEqual(folder.file_ids['B'], 'derp.json')

where folder.file_ids is a dictionary

{'A': environment.A, 'B': environment.B}

However the asserts fail, apparantly, folder.file_ids['A'] is 'a.json' as if the os.environ lines weren't there.

I am surprised because as far as I am aware, os.getenv reads from os.environ, so the execution order should be

  1. os.environ['A'] and os.environ['B'] are set to 'herp' and 'derp' respectively;
  2. the EnvironmentParser class gets instantiated, so upon instantiation it asks the 'A' and 'B' keys from os.environ, hence these values should be 'herp' and 'derp' respectively.
  3. The AdviceFolder class is instantiated with the 'environment' variable pointing to the just instantiated object of EnvironmentParser, which should thus have environment.A == 'herp' and environment.B = 'derp'.
  4. The assert should succeed.

But evidently, this goes wrong somewhere and I can't point out where.

At any rate, if I want to have unit tests for both default values for getenv as well as manually set values, how can I do them at the same time? I could run the test again with externally set env vars, but then one of the two tests would always fail.


Reproducible example:

Create two python files:

example.py
-----------------------
import os


class EnvironmentParser:
    def __init__(self):
        self.A = os.getenv('A', 'a') + ".json"
        self.B = os.getenv('B', 'b') + ".json"


class Example:
    def __init__(self, environment: EnvironmentParser):
        self.map = {'A': environment.A, 'B': environment.B}
test_example.py
-----------------------
import unittest
import os

from example import EnvironmentParser, Example


class TestExample(unittest.TestCase):
    def test_example_with_default_values(self):
        environment = EnvironmentParser()
        example = Example(environment)
        self.assertEqual(example.map['A'], 'a.json')
        self.assertEqual(example.map['B'], 'b.json')

    def test_example_with_custom_values(self):
        os.environ['A'] = 'herp'
        os.environ['B'] = 'derp'
        environment = EnvironmentParser()
        example = Example(environment)
        self.assertEqual(example.map['A'], 'herp.json')
        self.assertEqual(example.map['B'], 'derp.json')


if __name__ == '__main__':
    unittest.main()

Actually, I was wrong before. It is the first test method that fails because for some reason the values A = 'herp' and B = 'derp' are already set even in the first test method.

Nonetheless, the problem exists that I can't seem to be able to simultaneously test default and nondefault values. I guess I can del from os.environ, but surely there is a better way?


Solution

  • What is going on here is that unit tests are run in lexicographic order. This means that even though test_example_with_custom_values() is defined after test_example_with_default_values(), it's run before it and the environment variables are set.

    One way to manage this would be to use the approaches suggested in the link above, e.g. rename the methods test_1() and test_2(), or change the unittest.TestLoader.sortTestMethodsUsing function to one that will sort meaningful names in your desired order.

    However, in this case, I think it is preferable to not depend on the order and instead not leave environment variables set after the method which changes them, by using the unittest.mock.patch() decorator:

    patch() acts as a function decorator, class decorator or a context manager. Inside the body of the function or with statement, the target is patched with a new object. When the function/with statement exits the patch is undone.

    So your tests become:

    import unittest
    import os
    from example import EnvironmentParser, Example
    from unittest.mock import patch
    
    class TestExample(unittest.TestCase):
        def test_example_with_default_values(self):
            environment = EnvironmentParser()
            example = Example(environment)
            self.assertEqual(example.map['A'], 'a.json')
            self.assertEqual(example.map['B'], 'b.json')
    
        @patch.dict(os.environ, {'A': 'herp', 'B': 'derp'})
        def test_example_with_custom_values(self):
            environment = EnvironmentParser()
            example = Example(environment)
            self.assertEqual(example.map['A'], 'herp.json')
            self.assertEqual(example.map['B'], 'derp.json')
    
    
    if __name__ == '__main__':
        unittest.main()
    

    This should run without errors now:

    $ python test_example.py 
    ..
    ----------------------------------------------------------------------
    Ran 2 tests in 0.000s
    
    OK
    

    If you like you can also add the @patch.dict(os.environ, {}, clear=True) decorator to test_example_with_default_values() to ensure it is run in a context with all environment variables cleared, though this isn't necessary.