unittest API
General
– add the tests files/fixtures in a test folder at the root of the project. The folder may have any name in last Python 3 versions.
– create an empty file __init__.py at the root of the test folder
– on shell, run all tests from the root folder of the projet : python -m unittest
– on Pycharm when running tests, make sure that the working directory is also the root folder of the project.
– By default the discover option of by unittest module looks for test files which the pattern
is test*.py
.
To change this behavior, we should provide the
pattern
parameter, for example to look for any python file we pass the argument such as :
--pattern *.py
.
Mocking
Flavor of mocks
Mock and MagicMock
Mock is the mock base class while MagicMock is a subclass that configure all magic methods with default values.
Mock function example : sample classes in play
Here the main ways to use the mock API.
All mock examples are based on that set of classes :
– MovieService.py : class under test
– Movie.py and MovieGenre.py : data model
– MovieRepository.py : the dependency to mock
Movie.py :
from datetime import date from movie.MovieGenre import MovieGenre class Movie(object): def __init__(self, title: str, release_date: date, movie_genre: MovieGenre): self.movie_genre = movie_genre self.release_date = release_date self.title = title def __repr__(self) -> str: return f" {self.title} ({self.movie_genre}) : released the {self.release_date}" |
MovieGenre.py :
from enum import Enum class MovieGenre(Enum): SCIENCE_FICTION = 1 ADVENTURE = 2 |
MovieService.py :
from movie.Movie import Movie from movie.MovieGenre import MovieGenre from movie.MovieRepository import MovieRepository class MovieService(object): def __init__(self): self.movie_repo = MovieRepository("Own repository") def find(self, movie_genre: MovieGenre, search_word: str = ""): return self.movie_repo.find_movies(movie_genre, search_word) def store(self, movie: Movie): self.movie_repo.store_movie(movie=movie) |
MovieRepository.py :
from datetime import date from movie.Movie import Movie from movie.MovieGenre import MovieGenre class MovieRepository(object): def __init__(self, name: str): self.movies = [Movie("Star Wars : the jedi return", date(2000, 12, 30), MovieGenre.SCIENCE_FICTION), Movie("Star Wars : the jedi rise", date(2001, 12, 30), MovieGenre.SCIENCE_FICTION)] self.title = name def __str__(self) -> str: return self.title def find_movies(self, genre: MovieGenre, search_word: str) -> []: return self.movies def store_movie(self, movie: Movie): return self.movies.append(movie) |
Stubbing a return
The idea is defining a behavior for the mock.
When we stub, we don’t want to verify calls but here because the unittest package doesn’t provide a feature to stub only when some specific
parameters are passed to the mock.
def test_find_with_stubbing_a_return(self): movies_by_mock = [Movie("Star Wars : the jedi return", date(2000, 12, 30), MovieGenre.SCIENCE_FICTION), Movie("Star Wars : the jedi rise", date(2001, 12, 30), MovieGenre.SCIENCE_FICTION)] # GIVEN mock_find_movies = Mock(return_value=movies_by_mock) MovieRepository.find_movies = mock_find_movies # WHEN actual_movies = MovieService().find(MovieGenre.SCIENCE_FICTION) # THEN # Make mock stub to be more robust : Assert mock was called with expected params # WARN : # It is required for any stubbing case with unittest API because stubbing, contrary to verifying, # doesn't care about checking call args to the mock self.assertEqual(MovieGenre.SCIENCE_FICTION, mock_find_movies.call_args.args[0]) self.assertEqual("", mock_find_movies.call_args.args[1]) # Main Assertion self.assertEqual(movies_by_mock, actual_movies) |
Stubbing an exception rise
The idea here also is defining a behavior for the mock.
Same remark that above about effective parameters checks (not repeated here).
def test_find_with_stubbing_an_exception_rise(self): movies_by_mock = [Movie("Star Wars : the jedi return", date(2000, 12, 30), MovieGenre.SCIENCE_FICTION), Movie("Star Wars : the jedi rise", date(2001, 12, 30), MovieGenre.SCIENCE_FICTION)] # GIVEN mock_find_movies = Mock(side_effect=ValueError("Badaboom")) MovieRepository.find_movies = mock_find_movies # WHEN THEN with self.assertRaises(ValueError) as error: MovieService().find(MovieGenre.ADVENTURE) self.assertEqual('Badaboom',str(error.exception)) |
Stubbing a series of return
The idea here also is defining a set of behavior (sequential) for the mock.
Same remark that above about effective parameters checks (not repeated here).
def test_find_with_stubbing_series_of_return(self): movies_by_mock = [Movie("Star Wars : the jedi return", date(2000, 12, 30), MovieGenre.SCIENCE_FICTION), Movie("Star Wars : the jedi rise", date(2001, 12, 30), MovieGenre.SCIENCE_FICTION)] # GIVEN mock_find_movies = Mock(side_effect=[movies_by_mock, [], movies_by_mock]) MovieRepository.find_movies = mock_find_movies # WHEN actual_movies = MovieService().find(MovieGenre.SCIENCE_FICTION) # THEN self.assertEqual(movies_by_mock, actual_movies) # WHEN actual_movies = MovieService().find(MovieGenre.SCIENCE_FICTION) # THEN self.assertEqual([], actual_movies) # WHEN actual_movies = MovieService().find(MovieGenre.SCIENCE_FICTION) # THEN self.assertEqual(movies_by_mock, actual_movies) |
Verifying invocation
We want to verify when the mock provokes a side effect on the system rather than returning something used by the class under test
# noinspection PyMethodMayBeStatic def test_store_with_common_mock_verification(self): # GIVEN movie_repository_mock_store_movie = Mock() MovieRepository.store_movie = movie_repository_mock_store_movie movie_to_store = Movie("Star Wars : the jedi rise", date(2001, 12, 30), MovieGenre.SCIENCE_FICTION) # WHEN MovieService().store(movie_to_store) # THEN # loose assertions : we don't check the params passed to # - assert that mock was called movie_repository_mock_store_movie.assert_called() # - assert that mock was called once movie_repository_mock_store_movie.assert_called_once() # stricter assertion : we check the params passed to # - assert that mock was called once # Note : the movie keyword is required here because the mock is called like that movie_repository_mock_store_movie.assert_called_once_with(movie=movie_to_store) |
Advanced verifying invocation
These are not the most common way to checks mocks behavior but sometimes they may help.
# noinspection PyMethodMayBeStatic def test_store_with_advanced_mock_verification(self): # GIVEN movie_repository_mock_store_movie = Mock() MovieRepository.store_movie = movie_repository_mock_store_movie MovieRepository.find_movies = Mock() movie_to_store = Movie("Star Wars : the jedi rise", date(2001, 12, 30), MovieGenre.SCIENCE_FICTION) second_movie_to_store = Movie("Raiders of the Lost Ark", date(1981, 6, 12), MovieGenre.ADVENTURE) # WHEN : two calls to double the mock calls MovieService().store(movie_to_store) MovieService().store(second_movie_to_store) # THEN # - pass only for last call movie_repository_mock_store_movie.assert_called_with(movie=second_movie_to_store) # fail for other calls, so commented below : # movie_repository_mock_store_movie.assert_called_with(movie_to_store) # - assert that the mock was called whenever with that movie_repository_mock_store_movie.assert_called_with(movie=second_movie_to_store) movie_repository_mock_store_movie.assert_any_call(movie=movie_to_store) # - multiple call assertions calls = [call(movie=movie_to_store), call(movie=second_movie_to_store)] movie_repository_mock_store_movie.assert_has_calls(calls, any_order=True) # - assert that store method is not called MovieRepository.find_movies.assert_not_called() # assert number of interaction with the mocked method self.assertEqual(2, movie_repository_mock_store_movie.call_count) |
The two next way may be helpful to focus verification on args/keyword args passed to the mock.
Verifying positional args
# noinspection PyMethodMayBeStatic def test_find_with_call_args_positional_args_mock_verification(self): # Check with Mock.call_args.args # GIVEN movie_repository_find_movies_mock = Mock() MovieRepository.find_movies = movie_repository_find_movies_mock # WHEN MovieService().find(movie_genre=MovieGenre.ADVENTURE, search_word='the jedi rise') # 2.Mock.call_args.args captures the ordered arguments as a tuple self.assertEqual(MovieGenre.ADVENTURE, movie_repository_find_movies_mock.call_args.args[0]) self.assertEqual("the jedi rise", movie_repository_find_movies_mock.call_args.args[1]) # 3.Mock.call_args.kwargs captures the keyword arguments as a dictionary # Here empty dictionary as movieService invokes the mock repository with positional args self.assertEqual({}, movie_repository_find_movies_mock.call_args.kwargs) |
Verifying keyword args
def test_find_with_call_args_keyword_args_mock_verification(self): # Check with Mock.call_args.kwargs # GIVEN store_movie_mock = Mock() MovieRepository.store_movie = store_movie_mock # WHEN movie_to_store = Movie("Star Wars : no return", date(2000, 12, 30), MovieGenre.ADVENTURE) MovieService().store(movie_to_store) # 1.Mock.call_args.args captures the ordered arguments as a tuple # Here empty tuple as movieService invokes the mock repository with keyword args self.assertEqual((), store_movie_mock.call_args.args) # 2.Mock.call_args.kwargs captures the keyword arguments as a dictionary self.assertEqual(movie_to_store, store_movie_mock.call_args.kwargs['movie']) |
Mocking an import
In some circumstances we want to prevent an import from being loaded because we don’t have the
dependency or loading it may cause some issues.
To achieve that we specify a mock for the module we want to mock before any import.
This mock will represent the module, so after that we need to specify a mock for any
attributes or functions of this module we want to mock.
Example
We want to mock the time module especially we want to mock the time()
function but we dont want to
mock all functions of the module.
import sys # Optionally we can import the original module with an alias to use some real functions import time as original_time from unittest.mock import Mock mock = Mock() # We import the mock time module before the real one, in that way we skip any # real time module resolution that could occur after sys.modules['time'] = mock import time # We mock function we want to use mock.sleep = Mock(side_effect=lambda x: print(f'sleep executed for {x} second(s) but we sleep only 1 ' f'second') or original_time.sleep( 1)) mock.time = Mock() # we execute the code now start_time = original_time.time() time.sleep(200) end_time = original_time.time() duration = end_time - start_time print(f'duration={duration}' |
output:
sleep executed for 200 second(s) but we sleep only 1 second duration=1.0011093616485596 |
Mocking by copying the exact signature of a function
def foo(a: int, b: str): pass some_mock=mock.create_autospec(foo) some_mock(1, 'bar') |
Other test example with text parsing application
The code to test :
import argparse import http import logging import os import pathlib import sys import traceback import time from os import listdir from os.path import isfile, join from typing import List import re import requests from FilesAnalyserParams import FilesAnalyserParams def main(): input_params = None try: http.client.HTTPConnection.debuglevel = 1 input_params = parse_arguments() print("input_params", input_params) main_impl(input_params) except Exception: logging.error(traceback.format_exc()) exit_with_error(-1, "Technical error during Analysis", input_params) def main_impl(input_parameters): if not os.path.isdir(input_parameters.input_folder): p = pathlib.Path(os.getcwd(), input_parameters.input_folder) raise Exception("input_folder " + str(p) + " doesn't exist") lines = extract_log_lines(input_parameters) patter_not_found = check_expected_patterns(lines) if patter_not_found: exit_with_error(1, f'At least one pattern has not been found : {patter_not_found}', input_parameters) print(f"Exit successful") post_notification(input_parameters, "SUCCESS") sys.exit(0) def parse_arguments(): parser = argparse.ArgumentParser(description='analyse files') parser.add_argument('--input_folder', help='folder where files to analyse are present', required=True) parser.add_argument('--notification_url', help='the url to notify the results', required=True) args = parser.parse_args() if not os.environ["IDENTIFIER"]: raise ValueError("The IDENTIFIER env var is not defined") input_parameters = FilesAnalyserParams(args.input_folder, args.notification_url, os.environ["IDENTIFIER"]) return input_parameters def extract_log_lines(input_parameters): input_folder = input_parameters.input_folder log_files: List = [join(input_folder, f) for f in listdir(input_folder) if isfile(join(input_folder, f))] lines = [] for log_file in log_files: file = open(log_file) new_lines = [line.rstrip('\n') for line in file] lines = lines + new_lines file.close() return lines def check_expected_patterns(lines) -> str: reg = re.compile(r"^\s*BUILD SUCCESSFUL.*") is_found = False for line in lines: if reg.match(line): return None if not is_found: return "BUILD SUCCESSFUL" def exit_with_error(error_code: int, error_message: str, input_parameters: FilesAnalyserParams): logging.error(f"Exit code={error_code}, cause={error_message}") if input_parameters: post_notification(input_parameters, "ERROR") sys.exit(error_code) def post_notification(input_parameters, result_type): resp = requests.post(input_parameters.notification_url + "/foo/bar/foobar", json=[{"result": result_type, "identifier": input_parameters.identifier }]) resp.status_code if __name__ == "__main__": main() |
The unit test class
import os import sys import unittest from unittest.mock import patch import files_analyzer class MyTestCase(unittest.TestCase): __argv = ['Main', '--notification_url=http://localhost:8888' ] def setUp(self) -> None: sys.argv = self.__argv os.environ["IDENTIFIER"] = "FOO_APP" def tearDown(self) -> None: sys.argv = None def test_when_technical_error_during_parsing_args(self): sys.argv = ['Main', '--input_folder=dummy', '--notification_url=http://localhost:8888' ] with self.assertRaises(SystemExit) as cm, self.assertLogs(level='ERROR') as logger_context: files_analyzer.main() the_exception = cm.exception self.assertEqual(the_exception.code, -1) self.assertEqual('ERROR:root:Exit code=-1, cause=Technical error ' 'during Analysis', logger_context.output[1]) def test_when_technical_error_after_parsing_args(self): sys.argv.append('--input_folder=dummy-not-exit', ) with self.assertRaises(SystemExit) as cm, self.assertLogs(level='ERROR') as logger_context: files_analyzer.main() the_exception = cm.exception self.assertEqual(the_exception.code, -1) self.assertEqual('ERROR:root:Exit code=-1, cause=Technical error ' 'during Analysis', logger_context.output[1], ) @patch('requests.post') def test_when_valid_patterns_found(self, mock_post): sys.argv.append('--input_folder=tests/fixtures/success') with self.assertRaises(SystemExit) as cm, self.assertLogs(level='ERROR') as logger_context: files_analyzer.main() self.assertEqual([], logger_context.output) the_exception = cm.exception self.assertEqual(0, the_exception.code) expected_json = [ {'result': 'SUCCESS'}] self.assertEqual(0, the_exception.code) self.assertEqual("http://localhost:8888/foo/bar/foobar", mock_post.call_args[0][0]) actual_json_args_dic = mock_post.call_args[1]['json'][0] self.assertEqual('FOO_APP', actual_json_args_dic['identifier']) self.assertEqual('SUCCESS', actual_json_args_dic['result']) def test_when_valid_patterns_not_found(self): sys.argv.append('--input_folder=tests/fixtures/failure/valid_patterns_not_found') with self.assertRaises(SystemExit) as exceptionContext, self.assertLogs(level='ERROR') as logger_context: files_analyzer.main() the_exception = exceptionContext.exception self.assertEqual(1, the_exception.code) self.assertEqual(['ERROR:root:Exit code=1, cause=At least one pattern ' 'has not been found : BUILD SUCCESSFUL'], logger_context.output) if __name__ == '__main__': unittest.main() |