CHAPTER 10
Unit Testing and Test-Driven Development
Quick intro
Unit testing and test-driven development (TDD) are critical practices in software engineering that help ensure code reliability and maintainability. In TDD, tests are typically written before the actual code, guiding the development process to meet specified requirements.
Python has powerful testing libraries, including unittest and pytest, which enable the creation of automated tests for your code.
Additionally, understanding test coverage and the use of mocks for testing isolated functions and components is essential for building robust applications.
Note: You can run the following code examples by opening the built-in terminal within VS Code and executing the command: python <script>.py. Replace <script> with the name of the respective Python file to execute.
Writing unit tests (unittest)
Python’s built-in unittest framework provides a straightforward way to write tests. With it, you can define test cases and check conditions using assertions and test suites.
Let’s create a simple Calculator class with basic operations (addition, subtraction, multiplication, and division) and write unit tests for each function.
Code Listing 10-a: calc.py
class Calculator: """A simple calculator class for basic arithmetic operations."""
def add(self, a, b): return a + b def subtract(self, a, b): return a - b def multiply(self, a, b): return a * b def divide(self, a, b): if b == 0: raise ValueError("Cannot divide by zero") return a / b |
Next, let’s write unit tests for each method in this Calculator class using unittest.
Code Listing 10-b: testcalc.py
import unittest from calculator import Calculator class TestCalculator(unittest.TestCase):
def setUp(self): """Set up a Calculator instance before each test method.""" self.calc = Calculator() def test_add(self): """Test the addition method.""" self.assertEqual(self.calc.add(2, 3), 5) self.assertEqual(self.calc.add(-1, 1), 0) def test_subtract(self): """Test the subtraction method.""" self.assertEqual(self.calc.subtract(5, 3), 2) self.assertEqual(self.calc.subtract(0, 5), -5) def test_multiply(self): """Test the multiplication method.""" self.assertEqual(self.calc.multiply(3, 4), 12) self.assertEqual(self.calc.multiply(-2, 3), -6) def test_divide(self): """Test the division method.""" self.assertEqual(self.calc.divide(10, 2), 5) self.assertRaises(ValueError, self.calc.divide, 10, 0) if __name__ == "__main__": unittest.main() |
Explanation:
· Calculator class:
o This class includes methods for basic arithmetic operations.
o divide includes a check for division by zero, raising a ValueError if b is 0.
· Importing and setting up unittest:
o import unittest and from calculator import Calculator import the testing framework and the class to be tested.
o setUp initializes a Calculator instance before each test.
· Writing test methods:
o Each method tests a specific operation (test_add, test_subtract, etc.).
o self.assertEqual checks if the result matches the expected value.
o self.assertRaises verifies that divide raises a ValueError when dividing by zero.
· Running the tests:
o The code checks for correctness across various cases, making it easy to identify errors if any of the tests fail.
Testing with pytest
The pytest library is a popular testing framework that provides simpler syntax, additional functionalities, and plugins for extended capabilities—pytest can run unittest tests, but it has its own syntax that’s often more concise.
The following is the same test for the calculator, but written using pytest syntax.
Code Listing 10-c: testcalcpy.py
import pytest from calc import Calculator @pytest.fixture def calc(): """Fixture for Calculator instance.""" return Calculator() def test_add(calc): assert calc.add(2, 3) == 5 assert calc.add(-1, 1) == 0 def test_subtract(calc): assert calc.subtract(5, 3) == 2 assert calc.subtract(0, 5) == -5 def test_multiply(calc): assert calc.multiply(3, 4) == 12 assert calc.multiply(-2, 3) == -6 def test_divide(calc): assert calc.divide(10, 2) == 5 with pytest.raises(ValueError): calc.divide(10, 0) |
Explanation:
· Using fixtures:
o @pytest.fixture is a fixture that initializes Calculator() before each test, similar to setUp in unittest.
· Simplified assertions:
o assert statements replace self.assertEqual, making the tests concise.
o pytest.raises(ValueError) checks that dividing by zero raises the expected error.
· Running pytest:
o Simply run pytest from the terminal to see organized test results.
Test coverage
Test coverage measures how much of your code is covered by tests. Python’s coverage library works well with both unittest and pytest for measuring test coverage.
Code Listing 10-d: Install and Run Test Coverage
# Install coverage pip install coverage # Run coverage with pytest coverage run -m pytest # Generate a coverage report coverage report -m |
Explanation:
· Installation: pip install coverage installs the coverage package.
· Running coverage: coverage run -m pytest executes tests with coverage tracking.
· Generating reports: coverage report -m shows the percentage of code covered by tests, allowing you to identify untested sections.
Mocking
Sometimes, you must test code interacting with external systems like APIs and databases. Mocking allows you to replace these parts with mock objects to isolate the code being tested.
Let’s expand the Calculator class to include an API call and demonstrate how to mock it in tests.
Code Listing 10-e: calcapi.py
import requests class Calculator:
def add(self, a, b): return a + b def get_random_number(self): response = requests.get("https://randomapi.com/api/random") if response.status_code == 200: return response.json()['number'] else: return None |
In this example, get_random_number makes a GET request to an API. Here’s how to mock this API call.
Code Listing 10-f: mock.py
import unittest from unittest.mock import patch from calcapi import Calculator class TestCalculatorWithAPI(unittest.TestCase):
@patch("calcapi.get") def test_get_random_number(self, mock_get): """Mock the API call and test get_random_number.""" calc = Calculator()
# Define mock response data mock_response = mock_get.return_value mock_response.status_code = 200 mock_response.json.return_value = {'number': 42} # Test the method with the mocked response self.assertEqual(calc.get_random_number(), 42) if __name__ == "__main__": unittest.main() |
Explanation:
· Mocking the API request:
o @patch("calcapi.get") replaces the requests.get call in Calculator with a mock.
· Configuring the mock:
o mock_get.return_value simulates the response object.
o mock_response.json.return_value = {'number': 42} sets the JSON response.
· Testing with mocked data:
o The statement self.assertEqual(calc.get_random_number(), 42) checks that the get_random_number method returns the mocked value.
Recap
Unit testing and TDD play a vital role in building reliable software. Using unittest and pytest, you can create structured test cases, maintain test coverage, and isolate code behavior using mocks.
These tools and techniques allow you to develop high-quality applications that are easier to maintain and scale over time.
- 1800+ high-performance UI components.
- Includes popular controls such as Grid, Chart, Scheduler, and more.
- 24x5 unlimited support by developers.