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 methodtearDown()
– 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)
– Checksa == b
assertTrue(x)
– Checksx
evaluates to TrueassertCountEqual(a, b)
– Checks collectionsa
andb
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!