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 from unittest.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 and tearDown 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:

Python
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):
  int('abc')
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:

Bash
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:

Bash
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 individual test_ method in the class runs. Use it to set up resources needed by the test.
  • tearDown(self): Called after each individual test_ method runs, regardless of whether the test passed, failed, or raised an error. Use it to clean up resources created in setUp.
Python
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)

Python
# 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)

Python
# 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):

Bash
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

Python
# 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 the BankAccount class.
  • setUp creates a fresh BankAccount object named self.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 of assertTrue/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 and tearDown 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 (if unittest.main() is included).
  • setUp() and tearDown() methods provide hooks for setting up and cleaning up test fixtures before and after each test method.

Exercises & Mini Projects

Exercises

  1. Test is_even: Write a function is_even(n) that returns True if n is even, False otherwise. Create a test class TestIsEven inheriting from unittest.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.
  2. 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 []. Use assertRaises(IndexError, ...) to verify it raises the correct error.
  3. Test String Method: Write tests for the built-in string method .upper(). Create a test class TestStringUpper 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 “”).
  4. setUp Example: Create a test class TestSimpleList with a setUp method that initializes self.my_list = [10, 20, 30]. Write two test methods:
    • test_length: Assert that the length of self.my_list is 3.
    • test_sum: Assert that the sum of self.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:

  1. Create Test File: Create a new file named test_book.py.
  2. Import: Import unittest and the Book class from its module (e.g., from library_items import Book – adjust the import based on your file structure).
  3. Create Test Class: Define class TestBook(unittest.TestCase):.
  4. Implement setUp: In a setUp(self) method, create a sample Book instance that can be used by multiple tests, e.g., self.book1 = Book("The Hobbit", "978-0547928227", "J.R.R. Tolkien", 310).
  5. Write Test Methods: Create methods starting with test_ to verify different aspects of the Book class:
    • test_initialization: Check if the title, identifier, author, num_pages attributes are correctly assigned in self.book1. Also check if is_checked_out is initially False.
    • test_display_info: This is harder to test directly as it prints. You could redirect sys.stdout (more advanced) or modify display_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: Call self.book1.check_out(). Assert that self.book1.is_checked_out is now True.
    • test_check_out_unavailable: Call self.book1.check_out() once. Then call self.book1.check_out() again. Assert that self.book1.is_checked_out is still True (the second call shouldn’t change it).
    • test_check_in_available: First, call self.book1.check_out(). Then call self.book1.check_in(). Assert that self.book1.is_checked_out is now False.
    • test_check_in_unavailable: Call self.book1.check_in() (without checking it out first). Assert that self.book1.is_checked_out is still False.
  6. Add Runner Boilerplate: Include if __name__ == '__main__': unittest.main() at the end.
  7. Run Tests: Execute the tests from your terminal. Debug any failures.

Additional Sources:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top