Testing Python Functions with Unit Tests

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 that x is true.
  • assertFalse(x): Checks that x is false.
  • assertRaises(Exception, func, *args, **kwargs): Checks that func 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!