Introduction to Unit Testing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
What is Unit Testing
a unit test is an automated test which
it's different from integration testing which tests that different parts of the program work well together the goal of a unit test is to find bugs a unit test can help refactor existing code to make it more testable and robust Python provides a built-in module unittest which allows carrying out unit testing effectively xUnit Terminology the unittest module follows the xUnit philosophy it has the following major components
Square class that has a property called length and a method area() which returns the area of the square square.py module class Square: def __init__(self, length) -> None: self.length = length def area(self): return self.length * self.lengthto test the Square class create a file named test_square.py import unittest from square import Square class TestSquare(unittest.TestCase): def test_area(self): square = Square(10) area = square.area() self.assertEqual(area, 100) if __name__ == '__main__': unittest.main(verbosity=2)the unit test first imports the required mdoule the TestSquare class inherits from unittest.TestCase to test the area() method, add a method called test_area() to the TestSquare class In the test_area() method
test_area (__main__.TestSquare) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.001s OKthe output indicates that one test has passed denoted by the dot (.) if a test failed, the letter F would be used instead of the dot (.) Running Tests without Calling unittest.main() Function remove the if block which calls the unittest.main() function import unittest from square import Square class TestSquare(unittest.TestCase): def test_area(self): square = Square(10) area = square.area() self.assertEqual(area, 100)execute the test using python -m unittest -vcommand discovers all the test classes whose names start with Test* located in the test_* file and executes the test methods that start with test* the -m option stands for the module the -v option stands for verbosity output test_area (test_square.TestSquare) ... ok ---------------------------------------------------------------------- Ran 1 tests in 0.000s OKTesting Expected Exceptions the Square constructor accepts a length parameter which should be either an int or float if the parameter is of a different type the c'tor should raise a TypeError exception import unittest from square import Square class TestSquare(unittest.TestCase): def test_area(self): square = Square(10) area = square.area() self.assertEqual(area, 100) def test_length_with_wrong_type(self): with self.assertRaises(TypeError): square = Square('10')output test_area (test_square.TestSquare) ... ok test_length_with_wrong_type (test_square.TestSquare) ... FAIL ====================================================================== FAIL: test_length_with_wrong_type (test_square.TestSquare) ---------------------------------------------------------------------- Traceback (most recent call last): File "D:\python-unit-testing\test_square.py", line 13, in test_length_with_wrong_type with self.assertRaises(TypeError): AssertionError: TypeError not raised ---------------------------------------------------------------------- Ran 2 tests in 0.001sthe test_length_with_wrong_type function fails because the Square c'tor does not raise an exception the c'tor does no type checking change the c'tor class Square: def __init__(self, length) -> None: if type(length) not in [int, float]: raise TypeError('Length must be an integer or float') if length < 0: raise ValueError('Length must not be negative') self.length = length def area(self): return self.length * self.length Test Fixtures
two types of Test Fixtures
the setUpModule() function runs before all test methods in the test module the tearDownModule() function runs after all test methods in the test module import unittest def setUpModule(): print('Running setUpModule') def tearDownModule(): print('Running tearDownModule') class TestMyModule(unittest.TestCase): def test_case_1(self): self.assertEqual(5+5, 10) def test_case_2(self): self.assertEqual(1+1, 2)output Running setUpModule test_case_1 (test_my_module.TestMyModule) ... ok test_case_2 (test_my_module.TestMyModule) ... ok Running tearDownModule ---------------------------------------------------------------------- Ran 2 tests in 0.001s OKClass-level Fixtures the setUpModule() function runs before all test methods in the class the tearDownModule() function runs after all test methods in the class import unittest def setUpModule(): print('Running setUpModule') def tearDownModule(): print('Running tearDownModule') class TestMyModule(unittest.TestCase): @classmethod def setUpClass(cls): print('Running setUpClass') @classmethod def tearDownClass(cls): print('Running tearDownClass') def test_case_1(self): self.assertEqual(5+5, 10) def test_case_2(self): self.assertEqual(1+1, 2)added the class methods: setUpClass() and tearDownClass() to the TestMyModule class output Running setUpModule Running setUpClass test_case_1 (test_my_module.TestMyModule) ... ok test_case_2 (test_my_module.TestMyModule) ... ok Running tearDownClass Running tearDownModule ---------------------------------------------------------------------- Ran 2 tests in 0.001s OKMethod-level fixtures setUp() runs before every test method in the test class tearDown() runs after every test method in the test class import unittest def setUpModule(): print('Running setUpModule') def tearDownModule(): print('Running tearDownModule') class TestMyModule(unittest.TestCase): @classmethod def setUpClass(cls): print('Running setUpClass') @classmethod def tearDownClass(cls): print('Running tearDownClass') def setUp(self): print('') print('Running setUp') def tearDown(self): print('Running tearDown') def test_case_1(self): print('Running test_case_1') self.assertEqual(5+5, 10) def test_case_2(self): print('Running test_case_2') self.assertEqual(1+1, 2)the output Running setUpModule Running setUpClass test_case_1 (test_my_module.TestMyModule) ... Running setUp Running test_case_1 Running tearDown ok test_case_2 (test_my_module.TestMyModule) ... Running setUp Running test_case_2 Running tearDown ok Running tearDownClass Running tearDownModule ---------------------------------------------------------------------- Ran 2 tests in 0.002s OKtest fixtures example create a module named bank_account.py add classes named BankAccount and InsufficientFund classes to the file class InsufficientFund(Exception): pass class BankAccount: def __init__(self, balance: float) -> None: if balance < 0: raise ValueError('balance cannot be negative') self._balance = balance @property def balance(self) -> float: return self._balance def deposit(self, amount: float) -> None: if amount <= 0: raise ValueError('The amount must be positive') self._balance += amount def withdraw(self, amount: float) -> None: if amount <= 0: raise ValueError('The withdrawal amount must be more than 0') if amount > self._balance: raise InsufficientFund('Insufficient ammount for withdrawal') self._balance -= amountcreate a module named test_bank_account.py add the TestBankAccount class to the module import unittest from bank_account import BankAccount, InsufficientFund class TestBankAccount(unittest.TestCase): def setUp(self) -> None: self.bank_account = BankAccount(100) def test_deposit(self): self.bank_account.deposit(100) self.assertEqual(self.bank_account.balance, 200) def test_withdraw(self): self.bank_account.withdraw(50) self.assertEqual(self.bank_account.balance, 50) def tearDown(self) -> None: self.bank_account = None Skipping Tests
the unittest module allows skipping a test method or a test class
example uses the @unittest.skip() decorator to skip the test_case_2() method unconditionally import unittest class TestDemo(unittest.TestCase): def test_case_1(self): self.assertEqual(1+1, 2) @unittest.skip('Work in progress') def test_case_2(self): passoutput test_case_1 (test_skipping.TestDemo) ... ok test_case_2 (test_skipping.TestDemo) ... skipped 'Work in progress' ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK (skipped=1)can also call skipTest() in a test method to skip it import unittest class TestDemo(unittest.TestCase): def test_case_1(self): self.assertEqual(1+1, 2) def test_case_2(self): self.skipTest('Work in progress')output test_case_1 (test_skipping.TestDemo) ... ok test_case_2 (test_skipping.TestDemo) ... skipped 'Work in progress' ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK (skipped=1)can also raise the SkipTest exception import unittest class TestDemo(unittest.TestCase): def test_case_1(self): self.assertEqual(1+1, 2) def test_case_2(self): raise unittest.SkipTest('Work in progress')output test_case_1 (test_skipping.TestDemo) ... ok test_case_2 (test_skipping.TestDemo) ... skipped 'Work in progress' ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK (skipped=1)Skipping a test class examples to skip a test class use the @unittest.skip() decorator at the class level import unittest @unittest.skip('Work in progress') class TestDemo(unittest.TestCase): def test_case_1(self): self.assertEqual(1+1, 2) def test_case_2(self): self.assertIsNotNone([])output test_case_1 (test_skipping.TestDemo) ... skipped 'Work in progress' test_case_2 (test_skipping.TestDemo) ... skipped 'Work in progress' ---------------------------------------------------------------------- Ran 2 tests in 0.000s OK (skipped=2)Skipping a test with a condition to skip a test conditionally use the @unittest.skipIf() decorator syntax @unittest.skipIf(condition, reason)will skip the test if the condition is true will display the reason for skipping the test in the test result import unittest from sys import platform class TestDemo(unittest.TestCase): def test_case_1(self): self.assertEqual(1+1, 2) @unittest.skipIf(platform.startswith("win"), "Do not run on Windows") def test_case_2(self): self.assertIsNotNone([])in this example, we skip the test_case_2() method if the current platform is windows to get the current platform where the test is running use the sys.platform property test output test_case_1 (test_skipping.TestDemo) ... ok test_case_2 (test_skipping.TestDemo) ... skipped 'Do not run on Windows' ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK (skipped=1)the @unittest.skipUnless skips a test uncles a condition is true syntax @unittest.skipUnless(condition, reason) Organizing Code & Running unittest
Organizing codewith a few modules can create test modules and place them within the same directory as the module being tested may have many modules organized into packages in that case it's important to keep the development code and test code more organized it's a good practice to keep the development code and the test code in separate directories should place the test code in a directory called test to make it obvious sample project has this structure D:\python-unit-testing ├── shapes | ├── circle.py | ├── shape.py | └── square.py └── test ├── test_circle.py ├── test_square.py └── __init__.pywith the structure in place add shape.py to the shapes directory from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area() -> float: passadd circle.py import math from .shape import Shape class Circle(Shape): def __init__(self, radius: float) -> None: if radius < 0: raise ValueError('The radius cannot be negative') self._radius = radius def area(self) -> float: return math.pi * math.pow(self._radius, 2)add square.py import math from .shape import Shape class Square(Shape): def __init__(self, length: float) -> None: if length < 0: raise ValueError('The length cannot be negative') self._length = length def area(self) -> float: return math.pow(self._length, 2)next add the two test files to the test directory test_circle.py import unittest import math from shapes.circle import Circle from shapes.shape import Shape class TestCircle(unittest.TestCase): def test_circle_instance_of_shape(self): circle = Circle(10) self.assertIsInstance(circle, Shape) def test_create_circle_negative_radius(self): with self.assertRaises(ValueError): circle = Circle(-1) def test_area(self): circle = Circle(2.5) self.assertAlmostEqual(circle.area(), math.pi * 2.5*2.5)test_square.py import unittest from shapes.square import Square from shapes.shape import Shape class TestSquare(unittest.TestCase): def test_create_square_negative_length(self): with self.assertRaises(ValueError): square = Square(-1) def test_square_instance_of_shape(self): square = Square(10) self.assertIsInstance(square, Shape) def test_area(self): square = Square(10) area = square.area() self.assertEqual(area, 100)finally add an empty __init__.py file to the test folder Running unit tests to run all the tests use the following command from within the python-unit-testing folder python -m unittest discover -vdiscover is a subcommand which finds all the tests in the project output test_area (test_circle.TestCircle) ... ok test_circle_instance_of_shape (test_circle.TestCircle) ... ok test_create_circle_negative_radius (test_circle.TestCircle) ... ok test_area (test_square.TestSquare) ... ok test_create_square_negative_length (test_square.TestSquare) ... ok test_square_instance_of_shape (test_square.TestSquare) ... ok ---------------------------------------------------------------------- Ran 6 tests in 0.002s OKto run a single test module the command syntax is python -m unittest test_package.test_module -vto execute all the tests in the test_circle module python -m unittest test.test_circle -vtest output test_area (test.test_circle.TestCircle) ... ok test_circle_instance_of_shape (test.test_circle.TestCircle) ... ok test_create_circle_negative_radius (test.test_circle.TestCircle) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.000s OKRunning a single test class a test module can contain multiple classes to run a single test class in such a module the syntax is python -m unittest test_package.test_module.TestClass -vto run the tests for the TestSquare class use the command python -m unittest test.test_circle.TestCircle -voutput test_area (test.test_square.TestSquare) ... ok test_create_square_negative_length (test.test_square.TestSquare) ... ok test_square_instance_of_shape (test.test_square.TestSquare) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.001s OKRunning a single test method to run a single test method in a test class the syntax is python -m unittest test_package.test_module.TestClass.test_method -vthe following command tests the test_area() method of the TestCircle class python -m unittest test.test_circle.TestCircle.test_area -voutput test_area (test.test_circle.TestCircle) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.001s OK |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
assert Methods | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
the TestCase class of the unittest module provides you with a large number of assert methods to test the following table shows the most commonly used assert methods all of these methods have an optional msg parameter whose type is a string the msg will be displayed in the test result if the test fails
assertEqual()
assertEqual() tests if two values are equalif the first value does not equal the second value, the test will fail the msg is optional if the msg is provided, it'll be shown on the test result if the test fails syntax assertEqual(first, second, msg=None)the assertNotEqual() method tests if two values are not equal if the first is equal to the second, the test will fail otherwise, it'll pass syntax assertNotEqual(first, second, msg=None) assertNotEqual()
the assertNotEqual() method tests if two values are not equalif the first is equal to the second, the test will fail otherwise, it'll pass syntax assertNotEqual(first, second, msg=None) assertAlmostEqual()
the assertAlmostEqual() is a method of the TestCase class of the unittest modulethe assertAlmostEqual() test if two values are approximately equal by doing the following
assertAlmostEqual(first, second, places=7, msg=None, delta=None)the method uses places (or decimal places) to round the difference before comparing it to zero places are not significant digits if a delta is passed instead of places then the difference between first and second must be less or equal to (or greater than) delta cano use either places or delta trying to pass both arguments will result with a TypeError assertNotAlmostEqual()
the assertNotAlmostEqual() method is the opposite of the assertAlmostEqual() methodtests if two values are not approximately equal assertIs()
the assertIs() allows testing if two objects are the samesyntax assertIs(first, second, msg=None)if the first and second reference the same object, the test will pass otherwise, it'll fail the msg is optional it's displayed in the test result in case the test fails assertIsNot()
the assertIsNot() tests if the first object is not the same as the second onesyntax assertIsNot(first, second, msg=None) assertIsInstance()
the assertIsInstance() is a method of the TestCase class of the unittest modulemethod tests if an object is an instance of a class syntax assertIsInstance(obj, cls, msg=None)in the syntax
if the cls is not the class of the obj but the base class of the class of the obj, the test will also pass since the object class is the base class of all classes, the assertIsInstance(obj, object) will always pass assertIsNotInstance()
the assertIsNotInstance() is the opposite of the assertIsInstance() methodtests if an object is not an instance of a class syntax assertNotIsInstance(obj, cls, msg=None) assertIsNone()
the assertIsNone() is a method of the TestCase class of the unittest moduletests if an expression is None syntax assertIsNone(expr, msg=None)if the expr is None, the test passes otherwise, the test will fail the msg is optional it'll be displayed in the test result if the test fails assertIsNotNone()
the assertIsNotNone() is opposite of the assertIsNone() methodmethod tests if a variable is not None syntax assertIsNotNone(expr, msg=None) assertTrue()
the assertTrue() is a method of the TestCase class in the unittest modulemethod tests if an expression is True syntax assertTrue(expr, msg=None)if the expr is True, the test passes otherwise, the test fails. the msg is optional if passed the msg parameter, it'll be displayed when the test fails method is equivalent to bool(expr) is Truenot equivalent to the following expr is True assertFalse()
opposite of assertTrue()
assertIn()
the assertIn() is a method of the TestCase class of the unittest modulemethod tests if a member is in a container syntax assertIn(member, container, msg=None)if the member is in the container, the test will pass otherwise, it'll fail the msg is optional it'll be displayed in the test result when the test fails internally, the assertIn() method uses the in operator to check member in container assertNotIn()
the assertNotIn() method is the opposite of the assertIn() methodmethod tests if a member is not in a container assertNotIn(member, container, msg=None) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Test Doubles - Mock | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Introduction to Python unittest Mock class
mocks simulate the behaviors of real objectsto test an object that depends on other objects in an isolated manner, use mock objects to mock the real objects to mock objects use the unittest.mock module module provides the Mock class which allows mocking other objects also provides the MagicMock class that is a subclass of the Mock class the MagicMock class has the implementations of all the dunder methods e.g., __str__ and __repr__ simple example from unittest.mock import Mock # create a new mock object mock = Mock() # mock the api function mock.api.return_value = { 'id': 1, 'message': 'hello' } # call the api function print(mock.api) print(mock.api())output <Mock id='1830094470496'> <Mock name='mock.api' id='1830100086416'> {'id': 1, 'message': 'hello'}the output shows two different mocks if assigned a property which doesn't exist on the Mock object, Python will return a new mock object very dynamic When to use mock cases where using mocks should be considered
benefits of mocks
Python Unittest Mock example
odometer.py
from random import randint def speed(): return randint(40, 120) def alert(): s = speed() if s < 60 or s > 100: return True return Falsein the sensor.py module
import unittest from unittest.mock import Mock import odometer class TestOdometer(unittest.TestCase): def test_alert_normal(self): odometer.speed = Mock() odometer.speed.return_value = 70 self.assertFalse(odometer.alert()) def test_alert_overspeed(self): odometer.speed = Mock() odometer.speed.return_value = 100 self.assertFalse(odometer.alert()) def test_alert_underspeed(self): odometer.speed = Mock() odometer.speed.return_value = 59 self.assertTrue(odometer.alert())run the test using python -m unittest test_odometer.py -voutput test_alert_normal (test_odometer.TestOdometer) ... ok test_alert_overspeed (test_odometer.TestOdometer) ... ok test_alert_underspeed (test_odometer.TestOdometer) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Test Doubles - Patch() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Introduction to the Python patch the unittest.mock module has a patch() which allows temporarily replacing a target with a mock object a target can be a function, a method, or a class it's a string with the following format 'package.module.className'to use the patch() correctly, need to understand two important steps
to identify a target
three ways to call patch()
if using patch in a context manager, inside the with statement, the target is replaced with a new object in both cases when the function or the with statement exits, the patch is undone Python patch examples
total.py
def read(filename): """ read a text file and return a list of numbers """ with open(filename) as f: lines = f.readlines() return [float(line.strip()) for line in lines] def calculate_total(filename): """ return the sum of numbers in a text file """ numbers = read(filename) return sum(numbers)the read() function reads a text file, converts each line into a number, and returns a list of numbers [1, 2, 3]to test calculate_total(), can create a test_total_mock.py module and mock the read() function as follows import unittest from unittest.mock import MagicMock import total class TestTotal(unittest.TestCase): def test_calculate_total(self): total.read = MagicMock() total.read.return_value = [1, 2, 3] result = total.calculate_total('') self.assertEqual(result, 6)to run the test use the command python -m unittest test_total_mock.py -voutput test_calculate_total (test_total_mock.TestTotal) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.001s OK Using patch() as a decorator
test_total_with_patch_decorator.py tests the total.py module using the patch() as a function decorator
import unittest from unittest.mock import patch import total class TestTotal(unittest.TestCase): @patch('total.read') def test_calculate_total(self, mock_read): mock_read.return_value = [1, 2, 3] result = total.calculate_total('') self.assertEqual(result, 6)total.calculate_total() calls total.read() to obtain the list of numbers the total.read() function is mocked so total.calculate_total() call the mock for the numbers the argument to total.calculate_total() is the filename for read() to read because read() is mocked the filename is never used to run the test use the command python -m unittest test_total_patch_decorator -v Using patch() as a context manager
how to use the patch() as a context manager
import unittest from unittest.mock import patch import total class TestTotal(unittest.TestCase): def test_calculate_total(self): # context manager with patch('total.read') as mock_read: mock_read.return_value = [1, 2, 3] result = total.calculate_total('') self.assertEqual(result, 6)to run the test result = total.calculate_total('') self.assertEqual(result, 6) Using patch() manually
test_total_patch_manual.py shows how to use patch() manually
import unittest from unittest.mock import patch import total class TestTotal(unittest.TestCase): def test_calculate_total(self): # start patching patcher = patch('total.read') # create a mock object mock_read = patcher.start() # assign the return value mock_read.return_value = [1, 2, 3] # test the calculate_total result = total.calculate_total('') self.assertEqual(result, 6) # stop patching patcher.stop() |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Test Doubles - Stubs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Introduction to the Python stubs stubs are test doubles that return hard-coded values primary purpose of stubs is to prepare a specific state of the system under test stubs are beneficial because they return consistent results, making the test easier to write can run tests even if the components that stubs are present are not working yet need to develop an alarm system that monitors the temperature of a room define a Sensor class in sensor.py import random class Sensor: @property def temperature(self): return random.randint(10, 45)in the real world the sensor class needs to communicate with the hardware sensor here Sensor.check() is a dummy class which returns a random integer in alarm.py define an Alarm type which uses the a Sensor object from sensor import Sensor class Alarm: def __init__(self, sensor=None) -> None: self._low = 18 self._high = 24 self._sensor = sensor or Sensor() self._is_on = False def check(self): temperature = self._sensor.temperature if temperature < self._low or temperature > self._high: self._is_on = True @property def is_on(self): return self._is_onAlarm.check() will sound if the sensor reports a temperature lower than 18C or greater than 24C define the TestSensor in test_sensor.py module class TestSensor: def __init__(self, temperature) -> None: self._temperature = temperature @property def temperature(self): return self._temperaturethe temperature property returns a value specified in the TestSensor constructor define a TestAlarm class in the test_alarm.py test module import unittest from alarm import Alarm from test_sensor import TestSensor class TestAlarm(unittest.TestCase): def test_is_alarm_off_by_default(self): alarm = Alarm() self.assertFalse(alarm.is_on) def test_check_temperature_too_high(self): alarm = Alarm(TestSensor(25)) alarm.check() self.assertTrue(alarm.is_on) def test_check_temperature_too_low(self): alarm = Alarm(TestSensor(15)) alarm.check() self.assertTrue(alarm.is_on) def test_check_normal_temperature(self): alarm = Alarm(TestSensor(20)) alarm.check() self.assertFalse(alarm.is_on)TestAlarm.test_is_alarm_off_by_default() uses the Sensor.temperature property the other three test methods use the TestSensor type each with a pre-defined temperature to run the tests use the command python -m unittest -vthe output test_check_normal_temperature (test_alarm.TestAlarm) ... ok test_check_temperature_too_high (test_alarm.TestAlarm) ... ok test_check_temperature_too_low (test_alarm.TestAlarm) ... ok test_is_alarm_off_by_default (test_alarm.TestAlarm) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.001s OK |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Test Doubles - Using MagicMock to Create Stubs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Using MagicMock class to create stubs
Python provides the MagicMock object in the unittest.mock modulemakes it simpler to create stubs to create a stub for the Sensor class using the MagicMock class, pass the Sensor class to the MagicMock() constructor mock_sensor = MagicMock(Sensor)the mock_sensor is the new instance of the MagicMock class that mocks the Sensor class by using the mock_sensor object, can set its property or call a method mock_sensor.temperature = 25the new version of the TestAlarm that uses the MagicMock class import unittest from unittest.mock import MagicMock from alarm import Alarm from sensor import Sensor class TestAlarm(unittest.TestCase): def setUp(self): self.mock_sensor = MagicMock(Sensor) self.alarm = Alarm(self.mock_sensor) def test_is_alarm_off_by_default(self): alarm = Alarm() self.assertFalse(alarm.is_on) def test_check_temperature_too_high(self): self.mock_sensor.temperature = 25 self.alarm.check() self.assertTrue(self.alarm.is_on) def test_check_temperature_too_low(self): self.mock_sensor.temperature = 15 self.alarm.check() self.assertTrue(self.alarm.is_on) def test_check_normal_temperature(self): self.mock_sensor.temperature = 20 self.alarm.check() self.assertFalse(self.alarm.is_on) Using the patch() method
to make it easier to work with MagicMock, can use the patch() as a decorator
import unittest from unittest.mock import patch from alarm import Alarm class TestAlarm(unittest.TestCase): @patch('sensor.Sensor') def test_check_temperature_too_low(self, sensor): sensor.temperature = 10 alarm = Alarm(sensor) alarm.check() self.assertTrue(alarm.is_on)above a @patch decorator is used on the test_check_temperature_too_low() method in the decorator pass the sensor.Sensor as a target to patch to run the test on TestAlarm.test_check_temperature_too_low() use the command python -m unittest -voutput test_check_temperature_too_low (test_alarm_with_patch.TestAlarm) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.001s |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Test Doubles - Mocking the requests Module | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
The scenario
For the demo purposes use a public API provided by
jsonplaceholder.typicode.com
https://jsonplaceholder.typicode.com/to make an API call, use the requests method to send an HTTP GET method to the following end-point https://jsonplaceholder.typicode.com/albums/1JSON data will be returned in the following format { "userId": 1, "id": 1, "title": "quidem molestiae enim" }the requests module is not a built-in module need to install it by running the following pip command pip install requests Making an API call using the requests module
define a new module named album.py with a function find_album_by_id() which returns an album by an id
import requests def find_album_by_id(id): url = f'https://jsonplaceholder.typicode.com/albums/{id}' response = requests.get(url) if response.status_code == 200: return response.json()['title'] else: return Nonethe response.json() returns a dictionary that represents the JSON data Creating a test module
create a test_album.py test module which tests the functions in the album.py module
import unittest from album import find_album_by_id class TestAlbum(unittest.TestCase): pass Mocking the requests module
the find_album_by_id() function has two dependencies
consider the mock_requests is a mock of the requests module the mock_requests.get() should return a mock for the response to mock the response can use the MagicMock class how to test the find_album_by_id() using the test_find_album_by_id_success() test method import unittest from unittest.mock import MagicMock, patch from album import find_album_by_id class TestAlbum(unittest.TestCase): @patch('album.requests') def test_find_album_by_id_success(self, mock_requests): # mock the response mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'userId': 1, 'id': 1, 'title': 'hello', } # specify the return value of the get() method mock_requests.get.return_value = mock_response # call the find_album_by_id and test if the title is 'hello' self.assertEqual(find_album_by_id(1), 'hello') @patch('album.requests') def test_find_album_by_id_fail(self, mock_requests): mock_response = MagicMock() mock_response.status_code = 400 mock_requests.get.return_value = mock_response self.assertIsNone(find_album_by_id(1))output test_find_album_by_id_fail (test_album.TestAlbum) ... ok test_find_album_by_id_success (test_album.TestAlbum) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.002s OK |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Test Coverage & Parameterized Tests | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
What is a test coverage
test coverage is a ratio between the number of lines executed by at least one test case and
the total number of lines of the code base
test coverage = lines of code executed / total number of linesalso known as code coverage test coverage is often used to assess the quality of a test suite if the test coverage is low e.g., 5%, it is an indicator there's not testing enough the reverse may not be true 100% test coverage is not a guarantee of a good test suite a test suite with high coverage can still be of poor quality Unittest coverage example
project structure to demo the unittest coverage
D:\python-unit-testing ├── shapes | ├── circle.py | ├── shape.py | └── square.py └── test ├── test_circle.py ├── test_square.py └── __init__.pytwo steps to generate a coverage report
Name Stmts Miss Cover ----------------------------------------- shapes\circle.py 9 0 100% shapes\shape.py 4 0 100% shapes\square.py 9 0 100% test\__init__.py 0 0 100% test\test_circle.py 14 0 100% test\test_square.py 14 0 100% ----------------------------------------- TOTAL 50 0 100%to generate the coverage report in HTML format, you change the option of the coverage module to HTML python -m coverage htmloutput Wrote HTML report to htmlcov\index.htmloutput indicates the location of the HTML coverage report htmlcov\index.html under the project folder Introduction to the unittest subtest context manager
create a new module called pricing.py and define a calculate() functioncalculate() function calculates the net price from the price, tax, and discount def calculate(price, tax=0, discount=0): return round((price - discount) * (1+tax), 2)create the test_pricing.py test module to test the calculate() function import unittest from pricing import calculate class TestPricing(unittest.TestCase): def test_calculate(self): passTo test the calculate() function, multiple test cases are needed
import unittest from pricing import calculate class TestPricing(unittest.TestCase): def test_calculate(self): items = ( {'case': 'No tax, no discount', 'price': 10, 'tax': 0, 'discount': 0, 'net_price': 10}, {'case': 'Has tax, no discount', 'price': 10, 'tax': 0.1, 'discount': 0, 'net_price': 10}, {'case': 'No tax, has discount', 'price': 10, 'tax': 0, 'discount': 1, 'net_price': 10}, {'case': 'Has tax, has discount', 'price': 10, 'tax': 0.1, 'discount': 1, 'net_price': 9.9}, ) for item in items: with self.subTest(item['case']): net_price = calculate( item['price'], item['tax'], item['discount'] ) self.assertEqual( net_price, item['net_price'] )the problem with this approach is that the testing stops after the first failure to resolve this, the unittest provides the subTest() context manager import unittest from pricing import calculate class TestPricing(unittest.TestCase): def test_calculate(self): items = ( {'case': 'No tax, no discount', 'price': 10, 'tax': 0, 'discount': 0, 'net_price': 10}, {'case': 'Has tax, no discount', 'price': 10, 'tax': 0.1, 'discount': 0, 'net_price': 10}, {'case': 'No tax, has discount', 'price': 10, 'tax': 0, 'discount': 1, 'net_price': 10}, {'case': 'Has tax, has discount', 'price': 10, 'tax': 0.1, 'discount': 1, 'net_price': 9.9}, ) for item in items: with self.subTest(item['case']): net_price = calculate( item['price'], item['tax'], item['discount'] ) self.assertEqual( net_price, item['net_price'] )command to run the tests python -m unittest test_pricing -voutput test_calculate (test_pricing.TestPricing) ... ====================================================================== FAIL: test_calculate (test_pricing.TestPricing) [Has tax, no discount] ---------------------------------------------------------------------- Traceback (most recent call last): File "D:\python-unit-testing\test_pricing.py", line 26, in test_calculate self.assertEqual( AssertionError: 11.0 != 10 ====================================================================== FAIL: test_calculate (test_pricing.TestPricing) [No tax, has discount] ---------------------------------------------------------------------- Traceback (most recent call last): File "D:\python-unit-testing\test_pricing.py", line 26, in test_calculate self.assertEqual( AssertionError: 9 != 10 ---------------------------------------------------------------------- Ran 1 test in 0.001s FAILED (failures=2)by using the subTest() context manager, the test didn't stop after the first failure it shows a very detailed message after each failure so the case may be examined The subTest() context manager syntax
the subTest() context manager syntax
def subTest(self, msg=_subtest_msg_sentinel, **params):the subTest() returns a context manager the optional message parameter identifies the closed block of the subtest returned by the context manage if a failure occurs, the context manager will mark the test case as failed then it resumes execution at the end of the enclosed block allows further test code to be executed |