Python unittest tips and tricks
Part of what I am doing as a Software Developer is to also do unit testing on my own codes to avoid critical issues in the application software that I develop. And for this kind of task, many times I use the Python unittest library.
Testability of the software
Software Testability is sometimes neglected, especially when working in a deadline. But in a Test Driven Development, test cases are required before writing your functions. In fact, you will know if the program has a good coding pattern depending on how testable it is. For instance, does it have a nested statements, how deep is the loop, does it implement a single responsibility and many more.
Why automation test
In a larger scale of Software Development, manual testing can become challenging if not all impossible. Imagine reading thousands lines of codes and testing each functions in your system on every release, that is indeed unproductive.
Automated testing has become part of the CI / CD workflow. You will be able to verify that the codes that you wrote months ago will continue to work along with the new updates in your system, as long as you have your test functions in place.
Python unittest library
I have prepared a working example to show you some Python unittest tips and tricks that you can use for your daily Software Testing task.
For my examples, I will be using JSONPlaceholder to fake the retrieval of data through HTTP requests. I am also running the script in Python 3.7 virtual environment and if you need help on this step please follow my tutorial on how to setup a virtual environment.
After you you have installed Python, you need to install also the requests library.
pip install requests
Test Python files example
In here I have prepared two Python files. The app.py which contains our actual application and test_app.py that will run all the testing for our application.
The app.py file. Copy the codes below in your IDE and save it.
from typing import List, Dict, Any, Optional from dataclasses import dataclass from requests.exceptions import HTTPError, Timeout, SSLError import requests import os @dataclass(frozen=True) class Config: backend_url: str = os.environ.get("BACKEND_URL") or "http://localhost" class UserError(Exception): pass class User: _url: str def __init__(self, url: str) -> None: self._url = url def _get(self, path: str, params: Optional[dict] = None) -> Any: try: response = requests.get(f"{self._url}/{path}", params=params) response.raise_for_status() return response.json() except HTTPError: raise UserError("Request got a HTTPError") except Timeout: raise UserError("Request got a Timeout") except SSLError: raise UserError("Request got a SSL Error") def get_user_list(self) -> List[Dict[any, any]]: return self._get("users") def get_user_list_with_posts(self) -> List[Dict[any, any]]: users_with_posts: List[Dict[any, any]] = [] for user in self.get_user_list(): posts: List[Dict[any, any]] = self._get("posts", params={"userId": user["id"]}) if len(posts) > 0: user["posts"] = posts users_with_posts.append(user) return users_with_posts if __name__ == "__main__": config = Config() user = User(config.backend_url) contributors = user.get_user_list_with_posts() print({user["username"]: len(user["posts"]) for user in contributors})
The test_app.py file. Copy the codes below in your IDE and save it.
import os from dataclasses import FrozenInstanceError from unittest import TestCase from unittest.mock import patch, MagicMock, call from requests.exceptions import HTTPError, Timeout, SSLError from app import Config, User, UserError class TestUser(TestCase): @classmethod def setUpClass(cls): cls.test_users = [ {"id": "123", "username": "rex"}, {"id": "623", "username": "jdoe"}, ] cls.test_posts = [ {"userId": 123, "id": 1, "title": "Title 1", "body": "Hello"}, {"userId": 123, "id": 2, "title": "Title 2", "body": "World"}, ] @patch("app.requests") def test_get_user_list(self, requests_mock): requests_mock.get.return_value.json.return_value = self.test_users user_list = User(url="localhost").get_user_list() requests_mock.get.assert_called_once_with("localhost/users", params=None) requests_mock.get.return_value.raise_for_status.assert_called_once() self.assertListEqual(user_list, self.test_users) self.assertDictEqual(user_list[0], self.test_users[0]) assert user_list[0]["username"] == self.test_users[0]["username"] @patch("app.requests") def test_get_user_list_with_posts(self, requests_mock): get_users_mock = MagicMock() get_users_mock.json.return_value = self.test_users get_posts_mock = MagicMock() get_posts_mock.json.return_value = self.test_posts empty_posts_mock = MagicMock() empty_posts_mock.json.return_value = [] requests_mock.get.side_effect = [get_users_mock, get_posts_mock, empty_posts_mock] contributors = User(url="localhost").get_user_list_with_posts() get_calls = [ call("localhost/users", params=None), call("localhost/posts", params={"userId": "123"}), ] requests_mock.get.assert_has_calls(get_calls) assert len(contributors) == 1 assert contributors[0]["id"] == "123" assert len(contributors[0]["posts"]) > 0 assert requests_mock.get.call_count == 3 @patch("app.requests") def test_get_user_list_exceptions(self, requests_mock): requests_mock.get.return_value.raise_for_status.side_effect = [ HTTPError(), Timeout(), SSLError(), ] with self.assertRaises(UserError) as exc: User(url="localhost").get_user_list() assert "HTTPError" in exc.exception with self.assertRaises(UserError) as exc: User(url="localhost").get_user_list() assert "Timeout" in exc.exception with self.assertRaises(UserError) as exc: User(url="localhost").get_user_list() assert "SSLError" in exc.exception class TestConfig(TestCase): @patch.dict("os.environ", {"BACKEND_URL": "https://www.yippeecode.com"}) def test_config_value(self): config = Config(backend_url=os.environ["BACKEND_URL"]) self.assertTrue(config.backend_url == os.environ["BACKEND_URL"]) def test_config_emulate_immutable(self): with self.assertRaises(FrozenInstanceError): config = Config() config.backend_url = "http://somethingelse"
Running the scripts
To run the app.py script, open a terminal or in your IDE and add the environment variable for BACKEND_URL
. You will also need to run the script while inside your virtual environment.
source bin/active export BACKEND_URL="https://jsonplaceholder.typicode.com" && python app.py
This time to run test_app.py script, simply invoke the script using Python unittest. Again, run this while inside your virtual environment.
python -m unittest -vv -c test_app.py
Config dataclass
The Config
class is a dataclass with a property backend_url
that is getting the value from an evironment variable BACKEND_URL
@dataclass(frozen=True) class Config: backend_url: str = os.environ.get("BACKEND_URL") or "http://localhost"
UserError class exception
The UserError
extends the builtin Exception
class. It is being used to raise error messages inside the User
class.
class UserError(Exception): pass
Main User class
Our main User
class have the methods for retrieving the “users” and user “posts” from JSONPlaceholder. Upon initialization, it accepts the base URL as parameter.
class User: _url: str def __init__(self, url: str) -> None: self._url = url
In User
class, we have the _get(...)
method that handles all HTTP GET requests. you will notice, the call to requests.get(...)
is wrapped in a try/except
with three different error handlers HTTPError
, Timeout
and SSLError
. Also in each handler it raises UserError
with different messages.
def _get(self, path: str, params: Optional[dict] = None) -> Any: try: response = requests.get(f"{self._url}/{path}", params=params) response.raise_for_status() return response.json() except HTTPError: raise UserError("Request got a HTTPError") except Timeout: raise UserError("Request got a Timeout") except SSLError: raise UserError("Request got a SSL Error")
Next are the get_user_list()
and get_user_list_with_posts()
methods.
First, the get_user_list()
retrieves the list of “users”, with no parameters.
def get_user_list(self) -> List[Dict[any, any]]: return self._get("users")
Second, the get_user_list_with_posts()
calls get_user_list()
and access each user inside a for
loop in order to retrieve the “posts” given with userId
as parameter. It assigns the response value to a new index user["posts"]
, appending the updated user
to users_with_posts
variable, which then returned as the result in the method.
def get_user_list_with_posts(self) -> List[Dict[any, any]]: users_with_posts: List[Dict[any, any]] = [] for user in self.get_user_list(): posts: List[Dict[any, any]] = self._get("posts", params={"userId": user["id"]}) if len(posts) > 0: user["posts"] = posts users_with_posts.append(user) return users_with_posts
TestUser class
The TestUser
is basically testing the methods in User
class. It extends TestCase
from the unittest library and this is how unittest is able to collect the test methods (with test_* names) inside TestUser
class.
class TestUser(TestCase)
setUpClass is builtin to TestCase
. This method will be called first before all other test methods. Just in case you are interested, there is also setup(), which is called on every test method run.
@classmethod def setUpClass(cls): cls.test_users = [ {"id": "123", "username": "rex"}, {"id": "623", "username": "jdoe"}, ] cls.test_posts = [ {"userId": 123, "id": 1, "title": "Title 1", "body": "Hello"}, {"userId": 123, "id": 2, "title": "Title 2", "body": "World"}, ]
In a real application this could be a required setup before conducting the tests, like a Database connection. And here we used setUpClass
to assign static data into cls.test_users
and cls.test_posts properties
, that will then be used in all the test methods run.
The real tests test_*
A test method’s name has to start with keyword “test”, then we described what is being tested, that’s how we got our name test_get_user_list(...)
. Also notice the @patch
decorator, that is another functionality in mock. It handles the patching of module and replaced it with MagicMock. In our case, we replaced the requests module inside app.py, and passing the MagicMock
as parameter to test_get_user_list(self, requests_mock)
.
@patch("app.requests") def test_get_user_list(self, requests_mock):
Explaining test_get_user_list(self, requests_mock)
The attribute return_value
means return the value of self.test_users
when the mock json()
is called inside User(...).get_user_list()
method.
requests_mock.get.return_value.json.return_value = self.test_users user_list = User(url="localhost").get_user_list()
Assert that the method get(…)
has been called ONLY once with the given parameter
requests_mock.get.assert_called_once_with("localhost/users", params=None)
Assert that the method raise_for_status()
has been called ONLY once. Compared to assert_called_once_with
, this assertion will not test the parameter.
requests_mock.get.return_value.raise_for_status.assert_called_once()
self.assertListEqual
is a builtin method to TestCase
. It will test if user_list
has a type List and value is equal to self.test_users
.
self.assertListEqual(user_list, self.test_users)
self.assertDictEqual
is also a builtin method to TestCase
. It will test if user_list[0]
has a
type Dict
and value is equal to self.test_users[0]
.
self.assertDictEqual(user_list[0], self.test_users[0])
Explaining test_get_user_list_with_posts(self, requests_mock)
Heads up! If you look inside User(...).get_user_list_with_posts()
method, it is using a for
loop to check each User with corresponding post in our API endpoint and this loop is what we are testing here.
@patch("app.requests") def test_get_user_list_with_posts(self, requests_mock)
We can also use MagicMock
outside of @patch
decorator. Mock the HTTP GET request for retrieving the users. And instead, return the value of self.test_users
.
get_users_mock = MagicMock() get_users_mock.json.return_value = self.test_users
Mock the first HTTP GET request inside the for
loop for retrieving user posts. And instead, return the value of self.test_posts
.
get_posts_mock = MagicMock() get_posts_mock.json.return_value = self.test_posts
Mock the second HTTP GET request inside the for
loop for retrieving user posts. But this time return an empty list.
empty_posts_mock = MagicMock() empty_posts_mock.json.return_value = []
In the following tests we are mocking the sequence of events inside the for
loop. First call to request.get
will return the list of Users in self.test_users
. Second call will return the posts by User with ID #123. The third and last call will return an empty post since User with ID #623 do not have any posts.
requests_mock.get.side_effect = [get_users_mock, get_posts_mock, empty_posts_mock] contributors = User(url="localhost").get_user_list_with_posts()
Mocking chained calls has been possible since we have a static data that gives a predictable output. This time the call
function defines how the method should have been called with a given set of parameters.
get_calls = [call("localhost/users", params=None), call("localhost/posts", params={"userId": "123"})]
Assert the Mock get(…)
has been called with the specified calls.
requests_mock.get.assert_has_calls(get_calls)
Some more testing. The last assert is checking if the Mock get(...)
has been called exactly three times.
assert len(contributors) == 1 assert contributors[0]["id"] == "123" assert len(contributors[0]["posts"]) > 0 assert requests_mock.get.call_count == 3
Explaining test_get_user_list_exceptions(self, requests_mock)
Our next set of tests will go over each exception handler inside the User(...).get_user_list()
method. Here we are trying to rehearse if something unexpected happens in the HTTP request.
@patch("app.requests") def test_get_user_list_exceptions(self, requests_mock):
With side_effect
, the idea is to mock something that can happen outside of our application. In here the side_effect
has three scenarios that will raise a different types of error. On every call to raise_for_status()
method, as a side effect, raise HTTPError()
, Timeout()
and SSLError()
respectively.
requests_mock.get.return_value.raise_for_status.side_effect = [HTTPError(), Timeout(), SSLError()]
First call, assert that UserError
was raised and contains the text “HTTPError” in the message.
with self.assertRaises(UserError) as exc: User(url="localhost").get_user_list() assert "HTTPError" in exc.exception
Second call, assert that UserError
was raised and contains the text “Timeout” in the message.
with self.assertRaises(UserError) as exc: User(url="localhost").get_user_list() assert "Timeout" in exc.exception
Third call, assert that UserError
was raised and contains the text “SSLError” in the message.
with self.assertRaises(UserError) as exc: User(url="localhost").get_user_list() assert "SSLError" in exc.exception
Testing the Configuration value.
Testing class Config
. If you look inside this class, the value of property backend_url
is actually coming from the os.environ. And the builtin @patch.dict
decorator has a way to Mock dictionary value.
class TestConfig(TestCase): @patch.dict("os.environ", {"BACKEND_URL": "https://www.yippeecode.com"}) def test_config_value(self): config = Config(backend_url=os.environ["BACKEND_URL"]) self.assertTrue(config.backend_url == os.environ["BACKEND_URL"])
Our last test is making sure that the initialized value in class Config
will not be able to change. This is possible because of the flag in the dataclass @dataclass(frozen=True)
and changing the value will now raise an error.
def test_config_emulate_immutable(self): with self.assertRaises(FrozenInstanceError): config = Config() config.backend_url = "http://somethingelse"
There are a lot more that you can accomplish using Python unittest. If you are interested to expand your testing skills, checkout the documentation.