
Testing Python Functions with Unit Tests
Let’s talk about testing in Python. If you've ever written a function and wondered, "Does this actually do what I think it does?", you're in the right place. Unit testing is the practice of writing small, focused tests to verify that individual parts of your code—like functions—work as expected. It might sound formal, but it’s one of the most practical skills you can develop as a programmer.
Why test? Well, imagine you write a function that calculates the area of a circle. You run it once with radius 5, it returns 78.5, and you think, "Great, it works!" But what if someone passes a negative number? Or zero? Or a string? Unit tests help you catch those edge cases before they turn into bugs. They also make it safer to refactor your code later because you can verify that your changes don’t break existing functionality.
Python comes with a built-in module for this called unittest
. It’s versatile, widely used, and doesn’t require any extra installation. Let’s start with a simple example. Suppose we have a function that adds two numbers:
def add(a, b):
return a + b
To test this, we create a test case by subclassing unittest.TestCase
. Each test is a method whose name starts with test_
. Here’s how you might test the add
function:
import unittest
class TestMathOperations(unittest.TestCase):
def test_add_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative_numbers(self):
self.assertEqual(add(-1, -1), -2)
def test_add_zero(self):
self.assertEqual(add(0, 5), 5)
You can run these tests from the command line with:
python -m unittest discover
If everything passes, you’ll see a message like Ran 3 tests in 0.000s
and OK
. If something fails, unittest
will tell you exactly which test failed and why.
Now, let’s talk about assertions. unittest
provides a variety of assertion methods to check different conditions. You’ve already seen assertEqual(a, b)
, which checks if a == b
. Here are a few other useful ones:
assertTrue(x)
: Checks thatx
is true.assertFalse(x)
: Checks thatx
is false.assertRaises(Exception, func, *args, **kwargs)
: Checks thatfunc
raises an exception when called with the given arguments.
For example, if you have a function that should raise a ValueError
when passed invalid input, you can test it like this:
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero!")
return a / b
class TestDivision(unittest.TestCase):
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(10, 0)
This test passes if divide(10, 0)
indeed raises a ValueError
.
Common Assertion Methods in unittest | Description |
---|---|
assertEqual(a, b) | a == b |
assertTrue(x) | bool(x) is True |
assertFalse(x) | bool(x) is False |
assertRaises(Exc, func, *args) | func(*args) raises Exc |
assertIn(a, b) | a in b |
assertIsNone(x) | x is None |
Another powerful feature of unittest
is setUp and tearDown methods. These allow you to define code that runs before and after each test method. This is useful for setting up resources like database connections or test data. For example:
class TestWithSetup(unittest.TestCase):
def setUp(self):
self.test_list = [1, 2, 3]
def test_list_length(self):
self.assertEqual(len(self.test_list), 3)
def test_list_append(self):
self.test_list.append(4)
self.assertIn(4, self.test_list)
def tearDown(self):
del self.test_list
Here, setUp
runs before each test, so self.test_list
is [1, 2, 3]
at the start of both test_list_length
and test_list_append
. The tearDown
method runs after each test, cleaning up any resources.
You might also hear about test fixtures—a fixture is just the environment in which a test runs. setUp
and tearDown
help you manage that environment.
Sometimes, you want to skip a test temporarily. unittest
provides decorators for that:
class TestSkipExample(unittest.TestCase):
@unittest.skip("Temporarily skipping this test")
def test_old_feature(self):
self.fail("This should not run")
@unittest.skipIf(1 > 0, "Skipping if condition is true")
def test_conditional_skip(self):
self.fail("This should not run either")
You can also mark tests as expected to fail with @unittest.expectedFailure
.
Now, let’s look at a more realistic example. Suppose you’re building a function that formats a name:
def format_name(first, last):
return f"{first.title()} {last.title()}"
You’d want to test several cases:
- Normal names: "john doe" becomes "John Doe"
- Names with multiple parts: "mary jane smith" becomes "Mary Jane Smith" (if your function supports that)
- Edge cases like empty strings
Here’s how you might write those tests:
class TestFormatName(unittest.TestCase):
def test_normal_case(self):
self.assertEqual(format_name("john", "doe"), "John Doe")
def test_with_middle_name(self):
self.assertEqual(format_name("mary jane", "smith"), "Mary Jane Smith")
def test_empty_string(self):
self.assertEqual(format_name("", ""), " ")
But wait—what if your function doesn’t handle middle names yet? That test would fail, and that’s exactly what you want! It tells you what you need to implement next.
Another useful concept is parameterized tests. While unittest
doesn’t have built-in support for parameterization, you can use a library like parameterized
or simply loop over test cases. For example:
class TestParameterizedExample(unittest.TestCase):
def test_multiple_cases(self):
test_cases = [
((2, 3), 5),
((-1, 1), 0),
((0, 0), 0)
]
for input, expected in test_cases:
with self.subTest(input=input):
self.assertEqual(add(*input), expected)
The subTest
context manager lets you run multiple assertions in a single test method, and if one fails, it tells you which subtest failed.
As your test suite grows, you might want to organize tests into multiple files. The unittest
module can discover tests automatically if you follow a naming convention: name your test files test_*.py
and your test methods test_*
. Then, running python -m unittest discover
will find and run all tests in the current directory and subdirectories.
Here are a few best practices to keep in mind when writing unit tests:
- Write tests for normal cases, edge cases, and error cases.
- Keep tests small and focused—one assertion per test is a good rule of thumb.
- Use descriptive test method names that explain what they’re testing.
- Avoid repeating code; use
setUp
for common setup tasks. - Run tests frequently, especially before committing code.
Common Test Scenarios | Example Input | Expected Output |
---|---|---|
Normal operation | (2, 3) | 5 |
Edge cases | (0, 0) | 0 |
Error conditions | (10, 0) | Raises ValueError |
Sometimes you’ll need to test functions that depend on external resources, like network calls or database queries. In these cases, you can use mocking to simulate those resources. The unittest.mock
module is included in Python and makes this straightforward.
For example, suppose you have a function that fetches data from an API:
import requests
def fetch_data(url):
response = requests.get(url)
return response.json()
You don’t want your tests to actually call the API every time—that would be slow and unreliable. Instead, you can mock the requests.get
method:
from unittest.mock import patch
class TestFetchData(unittest.TestCase):
@patch('requests.get')
def test_fetch_data(self, mock_get):
mock_get.return_value.json.return_value = {"key": "value"}
result = fetch_data("http://example.com/api")
self.assertEqual(result, {"key": "value"})
mock_get.assert_called_once_with("http://example.com/api")
The @patch
decorator replaces requests.get
with a mock object for the duration of the test. You can set its return value and make assertions about how it was called.
Mocking is especially useful for: - Simulating network or I/O operations - Injecting fake data - Verifying that certain methods were called with the right arguments
Another advanced technique is using test suites. A test suite is a collection of test cases that you can run together. You can create them manually:
def suite():
suite = unittest.TestSuite()
suite.addTest(TestMathOperations('test_add_positive_numbers'))
suite.addTest(TestFormatName('test_normal_case'))
return suite
if __name__ == '__main__':
runner = unittest.TextTestRunner()
runner.run(suite())
But in most cases, unittest.discover
is sufficient.
As you write more tests, you might notice some patterns repeating. For instance, you might have multiple tests that need similar setup. In such cases, you can create a base test class:
class BaseTest(unittest.TestCase):
def setUp(self):
self.common_data = {"user": "test", "id": 1}
class TestUser(BaseTest):
def test_user_name(self):
self.assertEqual(self.common_data["user"], "test")
This helps avoid duplication and makes your tests more maintainable.
Finally, let’s talk about measuring test coverage. Coverage tools show you which parts of your code are exercised by your tests. While high coverage doesn’t guarantee bug-free code, it does help identify untested areas. You can use the coverage
package:
pip install coverage
coverage run -m unittest discover
coverage report
This will output a report showing which lines of code were executed during the tests.
In summary, unit testing is an essential part of writing reliable Python code. The unittest
framework provides a solid foundation with assertions, fixtures, mocking, and more. Start by testing simple functions, gradually incorporate setUp
and tearDown
, and use mocking for external dependencies. With practice, you’ll write tests that give you confidence in your code and make collaboration smoother.
Remember: good tests are like a safety net—they let you make changes without fear. So go ahead, write that test!