Intro to Unit Testing and Test Driven Development in Python using Pytest – Part 3
Unit Testing and Test Driven Development with Pytest
Coding Examples – Incoming Queries
The time has come to put all what we said into practice. Let’s build and test a simple class – a beer Fridge. We all use them at home and we all have an idea more or less how they work so it should be easy to follow how to design such an object. To make things clearer have a look at the picture below showing you the model of what we will try to make.
Intro to Unit Testing and Test Driven Development in Python using Pytest – Part 3
Unit Testing and Test Driven Development with Pytest
Coding Examples – Incoming Queries
The time has come to put all what we said into practice. Let’s build and test a simple class – a beer Fridge. We all use them at home and we all have an idea more or less how they work so it should be easy to follow how to design such an object. To make things clearer have a look at the picture below showing you the model of what we will try to make.
Model of Our Object
The diagram below represents our Fridge object and its collaborators – display pcb and inverter.
Let’s decrypt what is shown on this image now. Our object under test – Fridge cooperates with its collaborators – display pcb and inverter. We already said that all that objects do for us is send and receive messages as we do not care how the implementation is carried out (at least not initially). We now look from the point of view of the Fridge object. The messages that are its interface can be divided into incoming and outgoing.
We have 3 incoming messages. 2 are incoming queries: get_fan_speed_setting and get_temp. One is an incoming command: set_temp.
We also have 2 outgoing messages. 1 is an outgoing query: get_current_speed and since we are unit testing the Fridge object this method will not get tested (outgoing queries do not get tested). The other one is an outgoing command: set_target_speed.
Code the Fridge Object
We will create the skeleton of our Fridge object now in the classical approach – code first and the unit tests second. Our class will be as simple as possible.
Ok, so what is happening in our class? We have a class attribute max_load which is the maximum amount of bottles we can chuck into our fridge. Then we have 4 instance attributes:
- 2 private ones:
- _inverter – object collaborating with the Fridge which will control the speed of the fan.
- _base_speed – factory set base speed for the fan
- 2 public ones:
- load – current load (amount of bottles in the fridge)
- temp – target temperature for the fridge
We create our first incoming query – get_temp and all it does is returning the value of temp attribute to the Display PCB when it asks for it.
Now it’s the time to step out of our module with the code for the class and into the test module. The framework we are going to use for unit testing is called Pytest.
Getting Started with Pytest
These are short steps to get you going in Pytest without digging too much in the docstrings.
- pip install pytest – to get it installed in your Python, make sure you use virtual environment!
- Test files have to start with test_*.py
- Pytest will search for test_*.py files in your package if folder contains __init__.py file (can be empty).
- Your package’s root folder must be added to PYTHONPATH for the imports (of your modules with classes to test) that you will be using to work.
- On Linux Ubuntu:
- use gedit ~/.bashrc to edit your bash config file and add at the bottom:
- export PYTHONPATH=”<path_to_the_root_of_your_package>”
- On Windows you have to get to your system environment variables and add your packages path there. From python software foundation: “To permanently modify the default environment variables, click Start and search for ‘edit environment variables’, or open System properties, Advanced system settings and click the Environment Variables button. In this dialog, you can add or modify User and System variables. To change System variables, you need non-restricted access to your machine (i.e. Administrator rights).” You need to find system variable called PythonPath and add your package’s root to it or create it.
- If you struggle with PythonPath on Windows use this link: https://www.katsbits.com/tutorials/blender/pythonpath.php
- Test functions’ names begin with test_ (i.e. test_load_beer)
- Test classes’ names begin with Test (i.e. TestFridge)
- At the end of the test_*.py file for pytest to do its job we have to put (to be able to run it from command line with python <test_script_name>.py):
- if __name__ == “__main__”:
pytest.main()
- if __name__ == “__main__”:
Using Fixtures to Set up Tests
To keep our tests independent from each other we will use a wonderful feature of Pytest called fixtures. They are just decorated functions which allow us to run certain code before actually running the test functions themselves. This way we avoid having to instantiate the same objects or setting up anything in every single test function and keep our test module’s code DRY and clean.
In this piece of the tutorial we are only going to use the fixtures in the simplest possible way but they are a very powerful tool which can give us access to setup and teardown code at function, module, class and session level.
Let’s create our first fixture then.
Parameters in the Fixture
You can probably notice that we have a parameter scope=”function”. Scope allows us to decide when the code inside the fixture should be re-run. The default setting is function and we do not have to mention it, but to make you fully aware of this important feature I explicitly wrote it down.
With scope=”function” Pytest will re-run your fixture’s code in every single test function you will use that fixture in. Other settings possible are: session – run it once per entire session in the first function that will use the fixture, module – once per module, class – once per class.
This gives you a chance to create advanced setups / teardowns based on your needs (like opening database connection for some integration testing at the beginning of the session and closing it at the end for example).
Additional optional parameter is autouse=True which will run the code in the fixture automatically obeying the scope rule without having to use the fixture inside a test function (again making our life easier and allowing us to avoid repetition). As an example a fixture declared without scope (using the default scope=”function”) but with autouse=True will be run for every test function inside that module. A fixture declared with scope=”session” and autouse=True will run automatically at the beginning of the session only once.
The Role of Our Fixture
Since all we need for our simple unit testing is an access to a fresh instance of the Fridge object that is all our fixture provides. We are instantiating the object with some arguments and return it to have access to it when the fixture is run in the test function.
Test get_temp Method
Now that we have created loaded_fridge fixture we can add code in the test script to test our get_temp method. Remembering that is an incoming query we want to assert about the returned value.
How the Test Works?
As you can see we use our loaded_fridge fixture as the parameter of the test function. Pytest will find out that it is there and run the code inside of it for us automatically. Notice also that the test function’s name is starting with test_ and contains our method’s name. In general we are trying to name our test functions in a meaningful way which suggests instantly what it tests. Then having access to the instance of our object we need to call the method that we want to test on that object and since the method is an incoming query we assert that the returned value is equal to the temp attribute of our object.
Running the Test Script
Next thing we have to do now is to run the test script and see if the test passes. We do that in the terminal using command: pytest <test_script_name> so for us it will be: pytest test_fridge.py
And here is the result:
(python362_env) tomasz_kluczkowski@xps15:~/Dev/unit_testing_examples/tests$ pytest test_fridge.py
=============================================================================================
test session starts =============================================================================================
platform linux -- Python 3.6.2, pytest-3.2.3, py-1.4.34, pluggy-0.4.0
rootdir: /home/tomasz_kluczkowski/Dev/unit_testing_examples/tests, inifile:
collected 1 item
test_fridge.py .
===========================================================================================
1 passed in 0.01 seconds ===========================================================================================
(python362_env) tomasz_kluczkowski@xps15:~/Dev/unit_testing_examples/tests$
Our test passed without any hiccups!
Incoming Query Depending on Implementation Method
Avoiding Direct Testing of the Implementation Code
This time the other incoming query get_fan_speed_setting will also rely on calculation from the object’s implementation. We will prove that testing only the interface is sufficient enough to make sure that both the interface and implementation work properly.
Looking at the get_fan_speed_setting method (incoming query) we can observe that for it to work the _fan_speed_factor (implementation method) must provide a correct result of the factor calculation. All we are trying to achieve here is to have a linear characteristic for the fan speed so that the more bottles we throw into our little fridge the faster the fan goes (from 1 x _base_speed to 2 x _base_speed).
You may be tempted and say: “but surely the logic in the _fan_speed_factor method is more complicated and should be tested!” But that is not true. Let’s write a test for just the interface method and see what happens. We add the code below to the test script.
Let’s check (WARNING – MATHS :)) if our test is written correctly. We know our get_fan_speed_setting method calls on _fan_speed_factor. What is the factor then? Well since our object is instantiated with load=10 then the factor is: (20 + 10) / 20 = 1.5
In get_fan_speed_setting we multiply the factor by _base_speed which is 1000, this gives us the returned value of 1500 and hence that is what is the assertion value for us. Now we run the test script again to check if the logic in our methods is correct.
The result is:
(python362_env) tomasz_kluczkowski@xps15:~/Dev/unit_testing_examples/tests$ pytest test_fridge.py
=============================================================================================
test session starts =============================================================================================
platform linux -- Python 3.6.2, pytest-3.2.3, py-1.4.34, pluggy-0.4.0
rootdir: /home/tomasz_kluczkowski/Dev/unit_testing_examples/tests, inifile:
collected 2 items
test_fridge.py ..
===========================================================================================
2 passed in 0.01 seconds ===========================================================================================
(python362_env) tomasz_kluczkowski@xps15:~/Dev/unit_testing_examples/tests$
The new test passes as well.
Summary
We have found out in this post how to test incoming queries using Pytest framework. You also learned how to avoid direct testing of the implementation and seen that just testing the interface is proof enough that our code is working correctly. In the next post I will show you how to deal with test for incoming commands and exceptions. Stay tuned.
0 Comments