python unit testing

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

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()
Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *