Creating/writing tests

When it comes to writing tests, the basics are the same between all three types of tests. They all must:

  • Have the same ‘test’ naming convention

  • Make some assumptions (assertions) about the code

  • Should ideally test not just the positive (happy code / happy flow), but also the negative outcomes of the code

In this section we will create a unit test for the eboiler model.

Writing unit tests

To write a unit test we must first create a python file, import pytest and create a class which will contain all the tests. Let us call the file test_eboiler_model.py and the class TestEboilerModel(). Based on the name of the file and class, we can see that we are writing tests for the eboiler_model file. In addition, since we are testing the eboiler_python class within the eboiler_model file we should import it. So now the file should look something like this:

import pytest
from illuminator.models.Eboiler.eboiler_model import eboiler_python

class TestEboilerModel():

IMPORTANT NOTE 1

The file name MUST start with “test_” or end with “_test” in order to be a valid test file. The class must also start with the name “Test”, hence why we have chosen the names above. In addition, whenever we create test methods we must also ensure their names start with “test_”

Now let us create our simple test method. We want to test the creation of an eboiler_model object. Within eboiler_model init() method (seen below) we see that it expects a dictionary (eboiler_set:dict) and that it returns nothing (meaning we do not need to check what it returns)

 def __init__(self, eboiler_set:dict) -> None:
        self.capacity = eboiler_set['capacity']
        self.min_load = eboiler_set['min_load']
        self.max_load = eboiler_set['max_load']
        self.standby_loss = eboiler_set['standby_loss']
        self.efficiency = eboiler_set['efficiency']
        self.resolution = eboiler_set['resolution'] 

We can also notice that the dictionary should contain the following key values: [capacity, min_load, max_load, standby_loss, efficiency, resolution]. So we will create some fake data to fill this dictionary with.

mocked_eboiler_dict = {
    'capacity': 0,
    'min_load': 0,
    'max_load': 100,
    'standby_loss': -0.25,
    'efficiency': 0.33,
    'resolution': 1
}

Once created, we may now make our assumptions about what we expect. We expect that the values set within the eboiler object will be equal to the values within our mocked_eboiler_dict object. We can test this with python’s built in function: assert

assert eboiler_object.capacity == mocked_eboiler_dict['capacity']
assert eboiler_object.min_load == mocked_eboiler_dict['min_load']
assert eboiler_object.max_load == mocked_eboiler_dict['max_load']
assert eboiler_object.standby_loss == mocked_eboiler_dict['standby_loss']
assert eboiler_object.efficiency == mocked_eboiler_dict['efficiency']
assert eboiler_object.resolution == mocked_eboiler_dict['resolution']

With this we have now created a simple test for the eboiler object constructor method. The full file should look like this:

import pytest
from illuminator.models.Eboiler.eboiler_model import eboiler_python

class TestEboilerModel():

    # Note that the test must start with 'test_'
    def test_eboiler_constructor(self):
        # Create the fake (mocked) data
        mocked_eboiler_dict = {
            'capacity': 0,
            'min_load': 0,
            'max_load': 100,
            'standby_loss': -0.25,
            'efficiency': 0.33,
            'resolution': 1
        } 

        # Create the eboiler_python object,
        # which automatically calls the __init__() function
        eboiler_object = eboiler_python(mocked_eboiler_dict)


        # What we expect to happen once 
        # we have called the __init__ method above
        assert eboiler_object.capacity == mocked_eboiler_dict['capacity']
        assert eboiler_object.min_load == mocked_eboiler_dict['min_load']
        assert eboiler_object.max_load == mocked_eboiler_dict['max_load']
        assert eboiler_object.standby_loss == mocked_eboiler_dict['standby_loss']
        assert eboiler_object.efficiency == mocked_eboiler_dict['efficiency']
        assert eboiler_object.resolution == mocked_eboiler_dict['resolution']

IMPORTANT NOTE 2

Not every test will be this simple. Some tests will return values which we will also need to check. Other times testing a method will rely on a different method being called. If we do not mock this method then we are no longer writing a unit test. We would instead be stepping into integration testing since by definition, integration testing’s purpose is to see how different methods integrate (interact) with one another.

Mocking

Although the term is sometimes used very loosely to mean “any non-real data and objects”, officially mocking is referring to the creation of mock objects (i.e. functions, methods, attributes, environmental variables). For more information on mocking specifically in python (using pytest’s monkeypatch), please read the following documentation.

In order to mock functions/methods, we must first add a new parameter called monkeypatch to our test method. This will allow that specific test to mock whatever method we wish. A simple example of how to mock functions can be found in the code below:

def test_direct_irr_happy_flow(self, monkeypatch):
        """
        direct_irr multiplies two values together.
                Calculated value, with the given parameters, should be 10
        """
        # Independent mocked methods and variables
        pv = self.create_basic_PV_object() # Helper function to create the PV model
        pv.dni = 10 # Attribute which pv.direct_irr() needs to perform calculations

        # Mocked method (One way of writing is using lambda)
        monkeypatch.setattr(pv, "aoi", lambda: 1) 

        # Another way of writing a mock method
        # def myfunc(*args):
        #     return 1
        # monkeypatch.setattr(pv, "aoi", myfunc)

        # Expected outcome
        expected_dirr = 10
        assert pv.direct_irr() == expected_dirr

This was written to test PV model’s direct_irr() function. In it there are a few things necessary for the function to start. We can find out what will be needed either through a debugger, reading the code, or even trial and error. For this method we need the Independent variables/objects used when calling the pv.direct_irr(…) function, such as the actual PV model object and the mandatory value it needs in the calculation: pv.dni. Both of those we have created/set manually.

Finally, there is another function which the PV model pv calls called aoi(). This is a dependency on a method different than the one we are testing. If we would ignore this function and just let our test do its thing, we would be performing an integration test, not a unit test, thus we need to mock it. By using the monkeypatch.setattr(object, name, value) function we tell our test that when the PV model object tries to call the aoi() function, it will instead just return the value 1.