Programming with Python | Chapter 18: Testing with unittest
Chapter Objectives
- Understand the importance of software testing.
- Learn the basics of automated testing and unit testing for Python.
- Introduce Python’s built-in
unittest
module. - Learn how to structure tests using test cases by subclassing
unittest.TestCase
. - Write individual test methods (starting with
test_
). - Use various assertion methods (
assertEqual
,assertTrue
,assertRaises
, etc.) to check expected outcomes. - Understand how to run tests from the command line.
- Learn about test discovery.
- Use setup and teardown methods (
setUp
,tearDown
) for managing test fixtures.
Introduction
Writing code is only part of software development; ensuring that the code works correctly and continues to work as changes are made is equally important. Software testing is the process of evaluating code to verify that it meets requirements and behaves as expected. While manual testing is possible, automated testing allows us to write code that tests our application code, making the process faster, more reliable, and repeatable. Unit testing is a specific level of testing that focuses on verifying individual units of code (like functions or methods within a class) in isolation. This chapter introduces Python’s standard library module for creating automated unit tests: unittest
. You will learn how to write test cases, use assertions to check results, and run your tests to gain confidence in your code’s correctness.
Theory & Explanation
Why Test?
- Catch Bugs Early: Testing helps identify errors close to when they are introduced, making them easier and cheaper to fix.
- Prevent Regressions: Automated tests ensure that new changes or bug fixes don’t break existing functionality. Running tests after changes provides a safety net.
- Improve Design: Writing testable code often encourages better design practices (e.g., smaller functions, clear separation of concerns).
- Provide Documentation: Tests serve as executable documentation, demonstrating how the code is intended to be used and what its expected outputs are.
- Facilitate Refactoring: With a good test suite, you can refactor (restructure) code with confidence, knowing that the tests will alert you if the external behavior changes.
The unittest
Module
unittest
is Python’s built-in testing framework, inspired by JUnit (a popular Java testing framework). It provides a structured way to write, organize, and run tests.
Concept | Role | Implementation Detail |
---|---|---|
Test Case | A single scenario being tested. | Represented by methods starting with test_ within a unittest.TestCase subclass. |
Test Suite | A collection of test cases, test suites, or both. | Used to group related tests for execution. Can be created explicitly or implicitly by test discovery. |
Test Runner | Executes tests and reports results. | Examples include the command-line runner (python -m unittest ) or IDE integrations. |
Test Fixture | Setup and cleanup actions needed for tests. | Managed using setUp() (before each test), tearDown() (after each test), setUpClass() (before all tests in class), and tearDownClass() (after all tests in class). |
Assertion | A check within a test method to verify a condition. | Uses methods like assertEqual() , assertTrue() , assertRaises() provided by unittest.TestCase . A failed assertion means a failed test. |
Key Concepts:
- Test Case: A single scenario being tested. In
unittest
, test cases are typically represented by methods within a class that inherits fromunittest.TestCase
. - Test Suite: A collection of test cases, test suites, or both. Used to group tests that should be executed together.
- Test Runner: A component responsible for executing tests and reporting the results.
- Test Fixture: Represents the preparation needed to perform one or more tests, and any associated cleanup actions (e.g., creating temporary files or database entries, setting up objects).
setUp
andtearDown
methods are used for this. - Assertion: A check within a test method that verifies whether a condition is true. If an assertion fails, the test method fails.
Writing a Test Case (unittest.TestCase
)
Tests are organized into classes that inherit from unittest.TestCase
. Each method within the test class whose name starts with test_
is considered a separate test case by the test runner.
classDiagram class unittest.TestCase { +assertEqual() +assertTrue() +assertRaises() +setUp() +tearDown() +setUpClass() +tearDownClass() +... other methods } class YourTestClass { +setUp() void : Override for setup +tearDown() void : Override for cleanup +test_feature_one() void : Contains assertions +test_another_scenario() void : Contains assertions # helper_method() : Optional non-test method } unittest.TestCase <|-- YourTestClass note for YourTestClass "Inherits from unittest.TestCase<br>Methods starting with 'test_' are run automatically.<br>setUp/tearDown run around each test method."
Structure:
import unittest
# Import the code you want to test (e.g., functions from a module)
# from my_module import function_to_test
class TestMyFunction(unittest.TestCase):
"""Test suite for the function_to_test."""
def test_scenario_one(self):
"""Tests function_to_test under scenario one."""
# Arrange: Set up any inputs or conditions
arg1 = ...
expected_result = ...
# Act: Call the code being tested
actual_result = function_to_test(arg1)
# Assert: Check if the actual result matches the expected result
self.assertEqual(actual_result, expected_result, "Optional failure message")
def test_scenario_two(self):
"""Tests function_to_test under scenario two."""
# Arrange
# Act
# Assert using a different assertion method
self.assertTrue(condition, "Optional failure message")
# Add more test methods starting with 'test_'
# Boilerplate to make the script runnable directly (optional but common)
if __name__ == '__main__':
unittest.main()
Assertion Methods
Inside your test_
methods, you use assertion methods provided by unittest.TestCase
(accessed via self.
) to check conditions. If an assertion fails, the test method stops, and unittest
records it as a failure.
Common Assertion Methods:
Method | Checks | Example |
---|---|---|
assertEqual(a, b) |
a == b |
self.assertEqual(result, 10) |
assertNotEqual(a, b) |
a != b |
self.assertNotEqual(result, 0) |
assertTrue(x) |
bool(x) is True |
self.assertTrue(is_valid) |
assertFalse(x) |
bool(x) is False |
self.assertFalse(has_errors) |
assertIs(a, b) |
a is b (identity) |
self.assertIs(item, None) |
assertIsNot(a, b) |
a is not b |
self.assertIsNot(list1, list2) |
assertIsNone(x) |
x is None |
self.assertIsNone(result) |
assertIsNotNone(x) |
x is not None |
self.assertIsNotNone(user) |
assertIn(a, b) |
a in b (membership) |
self.assertIn('key', my_dict) |
assertNotIn(a, b) |
a not in b |
self.assertNotIn('error', log_messages) |
assertIsInstance(a, b) |
isinstance(a, b) |
self.assertIsInstance(obj, MyClass) |
assertRaises(exc, func, *args, **kwds) |
func(*args, **kwds) raises exc |
self.assertRaises(ValueError, int, 'abc') or with self.assertRaises(ValueError): |
assertAlmostEqual(a, b) |
round(a-b, 7) == 0 (floats) |
self.assertAlmostEqual(result, 3.14159) |
There are more assertion methods available; check the unittest
documentation for a full list.
Running Tests
There are several ways to run tests written using unittest
:
From the Command Line (Test Discovery): This is the most common way. Navigate to your project’s root directory in the terminal and run:
python -m unittest discover
This command automatically discovers test files (matching test*.py
by default) in the current directory and subdirectories, collects test methods from unittest.TestCase
subclasses, and runs them.
graph TD A["Start: <i>python -m unittest discover</i>"] --> B{"Find <i>test*.py</i> files"}; B --> C{"Find <i>unittest.TestCase</i> subclasses"}; C --> D("Run <b>setUpClass</b> once per class"); D --> E{"For each <i>test_</i> method"}; E -- Yes --> F("Run <i>setUp</i>"); F --> G("Execute <i>test_</i> method code"); G --> H{"Assertions Pass?"}; H -- Yes --> I("Record Pass"); H -- No --> J("Record Failure/Error"); I --> K("Run <i>tearDown</i>"); J --> K; K --> E; E -- No more tests in class --> L("Run <b>tearDownClass </b>once per class"); L --> M{More Test Classes?}; M -- Yes --> C; M -- No --> N[Report Results]; N --> O[End]; style A fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px style O fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px style N fill:#D1FAE5,stroke:#065F46,stroke-width:2px style J fill:#FEE2E2,stroke:#991B1B,stroke-width:2px
You can specify a directory or specific files:
python -m unittest discover -s tests/ # Discover tests in the 'tests' directory
python -m unittest tests/test_my_module.py # Run tests in a specific file
python -m unittest tests.test_my_module.TestMyClass.test_specific_method # Run a single test method
Running the Test File Directly: If you include the boilerplate if __name__ == '__main__': unittest.main()
at the end of your test file, you can run that file directly like any other Python script:python tests/test_my_module.py ```unittest.main()` handles test discovery *within that file* and runs the tests.
Test Output:
The test runner will typically print dots (.) for passing tests, F for failures (assertion failed), and E for errors (unexpected exceptions occurred during test execution, not assertion failures). A summary is printed at the end.
Setup and Teardown (setUp
, tearDown
)
Sometimes, multiple tests within a class need the same setup (e.g., creating an object, connecting to a test database, creating temporary files). unittest.TestCase
provides methods for managing these test fixtures:
setUp(self)
: Called before each individualtest_
method in the class runs. Use it to set up resources needed by the test.tearDown(self)
: Called after each individualtest_
method runs, regardless of whether the test passed, failed, or raised an error. Use it to clean up resources created insetUp
.
import unittest
# from my_module import ComplexObject
class TestComplexObject(unittest.TestCase):
def setUp(self):
"""Set up for test methods."""
print("\nRunning setUp...")
# Example: Create a fresh object for each test
self.obj = ComplexObject(initial_value=10)
def tearDown(self):
"""Tear down after test methods."""
print("Running tearDown...")
# Example: Clean up resources, e.g., delete temporary files
del self.obj # Not strictly necessary here, but shows cleanup
def test_initial_state(self):
"""Test the object's initial state after setUp."""
print("Running test_initial_state...")
self.assertEqual(self.obj.get_value(), 10)
def test_modification(self):
"""Test modifying the object."""
print("Running test_modification...")
self.obj.add(5)
self.assertEqual(self.obj.get_value(), 15)
if __name__ == '__main__':
unittest.main()
You’ll see setUp
and tearDown
run around each test method (test_initial_state
and test_modification
).
There are also setUpClass(cls)
and tearDownClass(cls)
(decorated with @classmethod
) which run once per class before and after all tests in that class, respectively. These are used for setup/teardown that is expensive and can be shared across all tests in the class.
Code Examples
Example 1: Testing Simple Functions
File: calculations.py
(The code to be tested)
# calculations.py
"""Some simple calculation functions to test."""
def add(x, y):
"""Return the sum of x and y."""
return x + y
def subtract(x, y):
"""Return the difference of x and y."""
return x - y
def multiply(x, y):
"""Return the product of x and y."""
return x * y
def divide(x, y):
"""Return the division of x by y. Raises ValueError for division by zero."""
if y == 0:
raise ValueError("Cannot divide by zero")
return x / y
File: test_calculations.py
(The test code)
# test_calculations.py
import unittest
from calculations import add, subtract, multiply, divide # Import functions to test
class TestCalculations(unittest.TestCase):
"""Test case for the calculations module."""
def test_add(self):
"""Test the add function."""
self.assertEqual(add(2, 3), 5)
self.assertEqual(add(-1, 1), 0)
self.assertEqual(add(-1, -1), -2)
self.assertEqual(add(0, 0), 0)
def test_subtract(self):
"""Test the subtract function."""
self.assertEqual(subtract(10, 5), 5)
self.assertEqual(subtract(-1, 1), -2)
self.assertEqual(subtract(-1, -1), 0)
def test_multiply(self):
"""Test the multiply function."""
self.assertEqual(multiply(3, 7), 21)
self.assertEqual(multiply(-1, 1), -1)
self.assertEqual(multiply(-1, -1), 1)
self.assertEqual(multiply(5, 0), 0)
def test_divide(self):
"""Test the divide function."""
self.assertEqual(divide(10, 2), 5)
self.assertEqual(divide(-1, 1), -1)
self.assertEqual(divide(-1, -1), 1)
self.assertEqual(divide(5, 2), 2.5)
# Test for expected exception using assertRaises
# Method 1: Pass function and arguments
self.assertRaises(ValueError, divide, 10, 0)
# Method 2: Using assertRaises as a context manager (preferred)
with self.assertRaises(ValueError):
divide(5, 0)
# Optional: Check exception message using context manager
with self.assertRaisesRegex(ValueError, "Cannot divide by zero"):
divide(15, 0)
# Make the test file runnable
if __name__ == '__main__':
unittest.main() # Runs all tests in this file
Running the tests (from terminal in the same directory):
python -m unittest test_calculations.py
# OR
python test_calculations.py
You should see output indicating that all 5 tests passed.
Example 2: Testing a Class (BankAccount
from Chapter 12)
File: bank_account.py
(Assume this exists from Chapter 12 example)
File: test_bank_account.py
# test_bank_account.py
import unittest
from bank_account import BankAccount # Import the class to test
class TestBankAccount(unittest.TestCase):
"""Test cases for the BankAccount class."""
def setUp(self):
"""Create a new BankAccount instance before each test."""
print("\nSetting up account...")
self.account = BankAccount("Test User", 100.0) # Initial balance $100
def tearDown(self):
"""Clean up after each test."""
print("Tearing down account...")
# No explicit cleanup needed here, but demonstrates the method
pass
def test_initialization(self):
"""Test account holder and initial balance."""
self.assertEqual(self.account.account_holder, "Test User")
self.assertEqual(self.account.get_balance(), 100.0)
def test_valid_deposit(self):
"""Test depositing a positive amount."""
self.account.deposit(50.50)
self.assertEqual(self.account.get_balance(), 150.50)
def test_invalid_deposit(self):
"""Test depositing zero or negative amount."""
initial_balance = self.account.get_balance()
self.account.deposit(0) # Should not change balance
self.assertEqual(self.account.get_balance(), initial_balance)
self.account.deposit(-50) # Should not change balance
self.assertEqual(self.account.get_balance(), initial_balance)
# Ideally, check if a message was printed or an exception raised,
# but the original class just printed messages.
def test_valid_withdrawal(self):
"""Test withdrawing less than or equal to balance."""
self.account.withdraw(30)
self.assertEqual(self.account.get_balance(), 70.0)
self.account.withdraw(70)
self.assertEqual(self.account.get_balance(), 0.0)
def test_invalid_withdrawal(self):
"""Test withdrawing zero or negative amount."""
initial_balance = self.account.get_balance()
self.account.withdraw(0)
self.assertEqual(self.account.get_balance(), initial_balance)
self.account.withdraw(-50)
self.assertEqual(self.account.get_balance(), initial_balance)
def test_insufficient_funds_withdrawal(self):
"""Test withdrawing more than the balance."""
initial_balance = self.account.get_balance()
self.account.withdraw(100.01) # Try to withdraw more than balance
self.assertEqual(self.account.get_balance(), initial_balance) # Balance shouldn't change
if __name__ == '__main__':
unittest.main()
Explanation:
- Imports
unittest
and theBankAccount
class. setUp
creates a freshBankAccount
object namedself.account
before each test method runs, ensuring tests are independent.tearDown
is included for demonstration but doesn’t do much here.- Each
test_
method focuses on one aspect: initialization, valid deposit, invalid deposit, valid withdrawal, etc. - Assertions (
assertEqual
) are used to verify the account balance after each operation.
Common Mistakes or Pitfalls
- Test Methods Not Running: Forgetting to prefix test method names with
test_
. - Incorrect Assertions: Using the wrong assertion method (e.g.,
assertEqual
for boolean checks instead ofassertTrue
/assertFalse
) or having incorrect expected values. - Test Interdependence: Writing tests where the outcome of one test affects another (e.g., modifying a shared resource without proper cleanup). Use
setUp
andtearDown
to ensure tests run in isolation. - Not Testing Edge Cases: Failing to test boundary conditions, error conditions (like invalid input or exceptions), or empty inputs.
- Testing Too Much (Integration vs. Unit): Unit tests should focus on small units. If a test relies heavily on external systems (databases, networks), it might be closer to an integration test, which often requires different setup and handling.
- Ignoring Test Failures: Letting tests fail without fixing the underlying code or the test itself defeats the purpose of testing.
Chapter Summary
- Automated testing (especially unit testing) is crucial for code quality, catching bugs, preventing regressions, and enabling refactoring.
- Python’s
unittest
module provides a framework for writing and running tests. - Tests are organized in classes inheriting from
unittest.TestCase
. - Individual tests are methods within the class whose names start with
test_
. - Assertion methods (
self.assertEqual
,self.assertTrue
,self.assertRaises
, etc.) are used within test methods to verify expected outcomes. - Tests are typically run from the command line using
python -m unittest discover
or by running the test file directly (ifunittest.main()
is included). setUp()
andtearDown()
methods provide hooks for setting up and cleaning up test fixtures before and after each test method.
Exercises & Mini Projects
Exercises
Test is_even:
Write a functionis_even(n)
that returnsTrue
ifn
is even,False
otherwise. Create a test classTestIsEven
inheriting fromunittest.TestCase
with test methods:test_positive_even
: Test with a positive even number.test_positive_odd
: Test with a positive odd number.test_zero
: Test with 0.test_negative_even
: Test with a negative even number.- test_negative_odd: Test with a negative odd number.Use assertTrue and assertFalse.
- Test List Function: Write a function
get_first_element(data)
that returns the first element of a list. Create a test class with methods:test_non_empty_list
: Test with a list like[1, 2, 3]
.test_empty_list
: Test with an empty list[]
. UseassertRaises(IndexError, ...)
to verify it raises the correct error.
- Test String Method: Write tests for the built-in string method
.upper()
. Create a test classTestStringUpper
with methods:test_simple_string
: Test converting “hello” to “HELLO”.test_already_upper
: Test converting “WORLD” (it should remain “WORLD”).test_mixed_case
: Test converting “PyThOn” to “PYTHON”.test_empty_string
: Test converting “” (should remain “”).
setUp
Example: Create a test classTestSimpleList
with asetUp
method that initializesself.my_list = [10, 20, 30]
. Write two test methods:test_length
: Assert that the length ofself.my_list
is 3.test_sum
: Assert that the sum ofself.my_list
is 60.
Mini Project: Testing the Book
Class
Goal: Write unit tests for the Book
class created in the Chapter 13 Mini Project (the one inheriting from LibraryItem
).
Steps:
- Create Test File: Create a new file named
test_book.py
. - Import: Import
unittest
and theBook
class from its module (e.g.,from library_items import Book
– adjust the import based on your file structure). - Create Test Class: Define
class TestBook(unittest.TestCase):
. Implement setUp:
In asetUp(self)
method, create a sampleBook
instance that can be used by multiple tests, e.g.,self.book1 = Book("The Hobbit", "978-0547928227", "J.R.R. Tolkien", 310)
.- Write Test Methods: Create methods starting with
test_
to verify different aspects of theBook
class:test_initialization
: Check if thetitle
,identifier
,author
,num_pages
attributes are correctly assigned inself.book1
. Also check ifis_checked_out
is initiallyFalse
.test_display_info
: This is harder to test directly as it prints. You could redirectsys.stdout
(more advanced) or modifydisplay_info
to return the string instead of printing it, then assert the returned string’s content. For simplicity now, you might skip testing the print output directly or just call it to visually inspect.test_check_out_available
: Callself.book1.check_out()
. Assert thatself.book1.is_checked_out
is nowTrue
.test_check_out_unavailable
: Callself.book1.check_out()
once. Then callself.book1.check_out()
again. Assert thatself.book1.is_checked_out
is stillTrue
(the second call shouldn’t change it).test_check_in_available
: First, callself.book1.check_out()
. Then callself.book1.check_in()
. Assert thatself.book1.is_checked_out
is nowFalse
.test_check_in_unavailable
: Callself.book1.check_in()
(without checking it out first). Assert thatself.book1.is_checked_out
is stillFalse
.
- Add Runner Boilerplate: Include
if __name__ == '__main__': unittest.main()
at the end. - Run Tests: Execute the tests from your terminal. Debug any failures.
Additional Sources: