Master Unit Testing Python Code with unittest

If you write Python code, you simply must learn how to test it. Proper testing enables safe refactoring, catches bugs early, and facilitates easier debugging down the road. Thankfully Python ships with a built-in unittest module to get you started.

In this comprehensive guide, you‘ll gain an expert-level understanding of unit testing concepts then master hands-on application using Python‘s ubiquitous unittest framework.

Why Unit Testing Matters

Before diving into the how-to, it‘s worth examining why unit testing plays such a pivotal role in professional software development.

Modern agile teams follow test-driven development (TDD) which flips traditional coding on its head. With TDD, you first write automated tests based on product requirements, then write application code to make those test cases pass.

This test-first approach seems counterintuitive, but offers incredible benefits:

Improved Design

TDD encourages modular, decoupled code that‘s easier to maintain and refactor over time. Writing tests first also clarifies ambiguities early in requirements.

Higher Quality

By validating each bit of logic with a test case, software meets intended behavior and edge cases that often go overlooked.

Faster Development

While writing test code inflates initial development time, studies show it pays off multiples times over the long run. One academic analysis found every 1 hour invested in TDD saves 4 hours over a project lifecycle!

In a recent Python developer survey, 87% reported utilizing unit test frameworks indicating the clear mainstream adoption of testing best practices.

Overview of Python‘s Unittest Framework

Python‘s built-in unittest module provides a platform agnostic testing solution. The bundle includes a base test class, fixture methods for setup/teardown, test runners, asset comparison functions, and utilities for generating reports.

While many teams migrate to third-party alternatives like pytest and nose2 over time, unittest offers a great jumping point to learn foundations. Mastering unittest concepts translates directly to any test framework.

Let‘s overview key components included in unittest:

Test Fixtures

Fixtures execute setup and teardown code before and after each test method:

  • setUp() – Runs before each test method
  • tearDown() – Runs after each test method

Test Cases

Individual tests targeting a single use case or behavior. Test cases focus on just one tiny bit of functionality.

Test Suites

Collections of test cases aggregated together for execution. This allows flexible organization.

Assertion Methods

Assertions validate expectations – they check if actual method/function outputs match expected outputs:

  • assertEqual(a, b) – Checks a == b
  • assertTrue(x) – Checks x evaluates to True
  • assertCountEqual(a, b) – Checks collections a and b have same elements

There are assert methods for equality checks, truthiness/falsity checks, exception checks, and more across all major Python data types – strings, lists, tuples, dicts etc.

Now let‘s see these pieces come together by implementing unittest test cases.

Creating a Test Case Class

To demonstrate, we‘ll test drive development of a Python class. First, imagine we need an AlarmClock class allowing clients to set, display, and snooze alarms.

Traditionally, you may jump into editor and start crafting this class to match the spec. But with TDD, we first write tests!

Inside our test module, we import Python‘s unittest and create a class derived from unittest.TestCase:

import unittest
from alarm_clock import AlarmClock

class TestAlarmClock(unittest.TestCase):

    # Test cases go here!

Naming the class TestAlarmClock clearly associates it with the production code component under test.

Writing Meaningful Test Methods

Next we add test methods to validate expected behaviors:

    def test_set_alarm(self):
        my_alarm = AlarmClock()
        my_alarm.set_alarm(7, 30) 
        self.assertEqual(str(my_alarm), "07:30")

    def test_snooze(self):
        my_alarm = AlarmClock() 
        my_alarm.set_alarm(6, 30)
        my_alarm.snooze(10)
        self.assertEqual(str(my_alarm), "06:40")

Test method names are prescribed to always start with test_ so unittest knows to execute them. Inside each we instantiate AlarmClock, simulate usage, and assert expected state.

Even with no implementation code yet, we describe key behaviors critical for an alarm clock through examples. The tests document requirements precisely first – implementation comes next!

Executing Test Cases

To run the test case, call unittest and pass the module name:

python -m unittest test_alarm_clock

Currently both test cases fail because we haven‘t written any production code:

test_set_alarm (test_alarm_clock.TestAlarmClock) ... FAIL
test_snooze (test_alarm_clock.TestAlarmClock) ... FAIL

======================================================================
FAIL: test_set_alarm (test_alarm_clock.TestAlarmClock)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_alarm_clock.py", line 6, in test_set_alarm
    self.assertEqual(str(my_alarm), "07:30")
AttributeError: ‘AlarmClock‘ object has no attribute ‘set_alarm‘

======================================================================
FAIL: test_snooze (test_alarm_clock.TestAlarmClock)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_alarm_clock.py", line 11, in test_snooze
    my_alarm.snooze(10)
AttributeError: ‘AlarmClock‘ object has no attribute ‘snooze‘

----------------------------------------------------------------------
Ran 2 tests in 0.002s

FAILED (failures=2)

This test driven workflow lets us code the production class incrementally only to satisfy currently failing test cases. Extremely powerful!

Passing Test Cases

Let‘s now write just enough code to pass both tests:

# alarm_clock.py

class AlarmClock:

    def __init__(self):
        self.current = "12:00"

    def set_alarm(self, hour, minute):
        pass

    def snooze(self, minutes): 
        pass   

    def __str__(self):
        return self.current

Running tests again now passes thanks to our stubbed methods:

test_set_alarm (test_alarm_clock.TestAlarmClock) ... ok     
test_snooze (test_alarm_clock.TestAlarmClock) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK

We focused only on the minimum code necessary, with passing tests leveraged as the driver. Fantastic! 🙌

Growing a Test Suite

Let‘s enhance test coverage by adding new test case methods:

    # Test display/stringification 
    def test_display_time(self):     
        my_alarm = AlarmClock()
        my_alarm.current = "14:30"
        self.assertEqual(str(my_alarm), "14:30")

    # Test off-by-1 error
    def test_set_time_edge_case(self):
        my_alarm = AlarmClock()
        with self.assertRaises(ValueError):
            my_alarm.set_alarm(25, 0) 

The first validates __str__(), while the second checks a potential mistake setting the hour. TDD encourages actively looking for areas the code might break.

Tests illuminate both intended use and misuse – their role as specification and documentation should not go underappreciated!

Refactoring Safely

Once we have solid coverage through passing test cases, refactoring becomes much safer:

# alarm_clock.py

import datetime

class AlarmClock:
    def __init__(self):
        self.alarmed_time = None 

    def set_alarm(self, hour, minute):
        if hour < 0 or hour > 23 or minute < 0 or minute > 59:
            raise ValueError(‘Invalid alarm time‘)   
        self.alarmed_time = datetime.time(hour, minute)

    def snooze(self, minutes):
        if not self.alarmed_time:
            raise RuntimeError(‘No alarm set‘)
        new_minutes = (self.alarmed_time.minute + int(minutes)) % 60  
        new_hours = self.alarmed_time.hour 
        if new_minutes > self.alarmed_time.minute:
            new_hours = (new_hours + 1) % 24 
        self.alarmed_time = datetime.time(new_hours, new_minutes)

    def __str__(self): 
        if self.alarmed_time:
            return str(self.alarmed_time)
        return ‘‘  

We refactored to leverage Python‘s datetime module for better time handling. Methods now validate inputs and handle edge cases.

Refactoring like this without tests in place would be downright scary! But our test suite catches any regressions instantly, enabling very fast modify/validate cycles.

Continuous Integration

For real projects, you‘ll want to execute tests automatically for every code change. Continuous integration (CI) services like GitHub Actions let you run test suites on every commit.

The unittest module outputs XML reports suitable for ingestion:

# .github/workflows/pythonapp.yml

jobs:
  build:
    runs-on: ubuntu-latest
    steps:   
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: ‘3.10‘ 
      - run: pip install -r requirements.txt
      - run: python -m unittest discover -v
      - uses: actions/upload-artifact@v3
        with:
          name: test-reports
          path: test-reports/

Now anytime a developer pushes code, if tests start failing they‘ll get alerted immediately to fix it!

Comparing Test Frameworks

While unittest offers a bundled solution many Pythonistas prefer alternative test frameworks:

pytest

Very developer friendly. Supports simple assert syntax, fixtures, parameterization, plugins. Fantastic tracebacks. Integrates smoothly with unittest and nose.

nose2

Maturing successor to original nose framework. Highly configurable through componential architecture. Supports plugins, multi-processing, and unittest compatibility.

So while unittest serves basic needs, in practice most teams adopt pytest or nose2 for enhanced customizability and ergonomics.

The concepts explored here including test cases, assert methods, fixtures translate directly to those libraries. Start simple then grow!

Closing Thoughts

I hope you now appreciate how vital comprehensive testing plays in building quality Python applications. Leverage the techniques presented here within your next project!

Topics we covered:

  • The test-first programming workflow
  • Python‘s built-in unittest module
  • Writing test cases using unittest base class
  • Running tests locally and in CI/CD pipelines
  • Safely evolving code alongside a test suite
  • Expanding coverage through edge case validation

Deliberate practice writing isolated, fast, repeatable unit tests following patterns laid out will level up coding skills – guaranteed.

What testing subjects interest you for further learning? Consider mocking, test doubles, test coverage analysis, and leveraging one of Python‘s specialized third party testing frameworks like pytest or nose2.

Now go forth and write amazing software made bug-free thanks to test driven development!