Python unittest tips and tricks

By Rex Resurreccion Jun 06, 2020
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 Userclass.

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.

© YippeeCode.com 2020