Python - Python Unit Testing with unittest and Test Organization

Unit testing is a software testing technique in which individual parts of a Python program are tested separately to verify that they work as expected. An individual part can be a function, method, or small piece of application logic. Python provides a built-in testing framework called unittest, which allows developers to create automated tests without installing an external testing library.

Unit testing is particularly useful when developing large Python applications because manually checking every feature after each code change is time-consuming. Automated unit tests can repeatedly verify existing functionality and help developers identify problems early.

1. What Is Unit Testing?

Consider a simple Python function that calculates the total price of two products:

def calculate_total(price1, price2):
    return price1 + price2

Instead of manually calling the function every time a change is made, a unit test can automatically verify its result:

import unittest

class TestCalculateTotal(unittest.TestCase):

    def test_addition(self):
        result = calculate_total(100, 200)
        self.assertEqual(result, 300)

if __name__ == "__main__":
    unittest.main()

Here, TestCalculateTotal contains a test method called test_addition(). The assertEqual() method checks whether the actual result is equal to the expected result.

If the values match, the test passes. If they do not, the test fails.

2. Why Unit Testing Is Important

Unit testing provides several important advantages.

Early error detection

Testing individual functions helps identify errors before they affect other parts of an application.

Easier maintenance

When developers modify existing code, automated tests can determine whether the changes have accidentally broken previously working functionality.

Better code quality

Writing tests encourages developers to create smaller and more focused functions, which often results in cleaner code.

Faster debugging

When a test fails, developers can usually identify the affected function or component more quickly than when testing an entire application manually.

Regression prevention

A regression occurs when a previously working feature stops working after a code modification. A collection of automated tests helps detect these problems.

3. The unittest Module

unittest is included in the Python standard library. Therefore, it does not normally require a separate installation.

A basic test program follows this structure:

import unittest

class TestExample(unittest.TestCase):

    def test_something(self):
        self.assertEqual(2 + 3, 5)

if __name__ == "__main__":
    unittest.main()

The main components are:

  • unittest: Python's built-in testing framework.

  • TestCase: Base class for creating test cases.

  • test_something(): Individual test method.

  • assertEqual(): Assertion used to compare expected and actual values.

  • unittest.main(): Runs the tests when the file is executed directly.

4. Test Cases

A test case represents a specific situation that needs to be verified.

For example:

import unittest

def multiply(a, b):
    return a * b

class TestMultiply(unittest.TestCase):

    def test_positive_numbers(self):
        self.assertEqual(multiply(4, 5), 20)

    def test_zero(self):
        self.assertEqual(multiply(10, 0), 0)

    def test_negative_number(self):
        self.assertEqual(multiply(-2, 5), -10)

if __name__ == "__main__":
    unittest.main()

Each method beginning with test_ represents an independent test.

The three tests check different conditions:

  • Multiplication of positive numbers

  • Multiplication by zero

  • Multiplication involving a negative number

Separating these scenarios makes failures easier to understand.

5. Assertions in unittest

Assertions are one of the most important parts of unit testing. They compare the actual behavior of the program with the expected behavior.

assertEqual()

Checks whether two values are equal.

self.assertEqual(10 + 5, 15)

assertNotEqual()

Checks whether two values are different.

self.assertNotEqual(10, 20)

assertTrue()

Checks whether an expression evaluates to True.

self.assertTrue(10 > 5)

assertFalse()

Checks whether an expression evaluates to False.

self.assertFalse(10 < 5)

assertIsNone()

Checks whether a value is None.

result = None
self.assertIsNone(result)

assertIsNotNone()

Checks that a value is not None.

result = "Python"
self.assertIsNotNone(result)

assertIn()

Checks whether an item exists inside a collection.

languages = ["Python", "Java", "C++"]
self.assertIn("Python", languages)

assertNotIn()

Checks that an item does not exist in a collection.

self.assertNotIn("Ruby", languages)

Using appropriate assertions makes tests more precise and easier to understand.

6. Testing Exceptions

Programs often need to reject invalid input. Unit tests should verify that the appropriate exception is raised.

Suppose a function does not allow division by zero:

def divide(a, b):
    return a / b

A test can verify the expected exception:

import unittest

class TestDivide(unittest.TestCase):

    def test_divide_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            divide(10, 0)

if __name__ == "__main__":
    unittest.main()

assertRaises() confirms that the specified exception occurs.

This is important because a test should not only verify successful operations. It should also verify how software behaves when it receives invalid input.

7. Setup and Teardown

Some tests require preparation before they run. Other tests may need cleanup afterward.

unittest provides setUp() and tearDown() methods for this purpose.

import unittest

class TestExample(unittest.TestCase):

    def setUp(self):
        self.numbers = [10, 20, 30]

    def tearDown(self):
        self.numbers = None

    def test_length(self):
        self.assertEqual(len(self.numbers), 3)

    def test_first_value(self):
        self.assertEqual(self.numbers[0], 10)

if __name__ == "__main__":
    unittest.main()

setUp() runs before each test method.

tearDown() runs after each test method.

This is useful when each test requires the same initial environment.

For example, tests involving files, temporary resources, or database connections may need setup and cleanup operations.

8. Class-Level Setup and Cleanup

Sometimes preparation is required only once for an entire group of tests rather than before every individual test.

For this purpose, setUpClass() and tearDownClass() can be used.

import unittest

class TestDatabase(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        print("Preparing shared resources")

    @classmethod
    def tearDownClass(cls):
        print("Cleaning up shared resources")

    def test_connection(self):
        self.assertTrue(True)

    def test_query(self):
        self.assertEqual(2 + 2, 4)

if __name__ == "__main__":
    unittest.main()

setUpClass() executes once before the test methods in the class.

tearDownClass() executes once after the test methods in the class have completed.

This can be useful for expensive resources that do not need to be created repeatedly.

9. Organizing Test Files

A well-organized project should keep application code and test code logically separated.

For example:

my_project/
│
├── calculator.py
├── user.py
├── database.py
│
└── tests/
    ├── test_calculator.py
    ├── test_user.py
    └── test_database.py

The application files contain production code, while the tests directory contains automated tests.

A test file commonly uses the naming pattern:

test_<module_name>.py

For example:

calculator.py
test_calculator.py

This naming convention makes test discovery easier and clearly identifies the relationship between production code and its tests.

10. Importing Application Code into Tests

Suppose calculator.py contains:

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

The corresponding test file can import these functions:

import unittest
from calculator import add, subtract

class TestCalculator(unittest.TestCase):

    def test_add(self):
        self.assertEqual(add(10, 5), 15)

    def test_subtract(self):
        self.assertEqual(subtract(10, 5), 5)

if __name__ == "__main__":
    unittest.main()

This keeps the test code separate from the application implementation.

11. Test Discovery

When a project contains many test files, running each file individually is inefficient. unittest provides test discovery to automatically locate test modules.

A typical command is:

python -m unittest discover

If tests are stored inside a tests directory:

python -m unittest discover -s tests

Test discovery searches for files that match the expected naming pattern and runs the tests it finds.

This becomes especially useful in larger projects containing hundreds or thousands of tests.

12. Test Suites

A test suite is a collection of related test cases.

For example, a project might contain separate test classes for:

User Tests
Product Tests
Order Tests
Payment Tests

These can be grouped into a suite when a particular collection of tests needs to be executed together.

A simple example is:

import unittest

class TestNumbers(unittest.TestCase):

    def test_addition(self):
        self.assertEqual(2 + 3, 5)

    def test_multiplication(self):
        self.assertEqual(2 * 3, 6)

suite = unittest.TestSuite()

suite.addTest(TestNumbers("test_addition"))
suite.addTest(TestNumbers("test_multiplication"))

runner = unittest.TextTestRunner()
runner.run(suite)

Although automatic test discovery is often more convenient, explicit test suites can be useful when specific groups of tests must be executed selectively.

13. Test Skipping

Sometimes a test cannot or should not be executed under certain conditions.

unittest supports skipping tests.

import unittest

class TestFeatures(unittest.TestCase):

    @unittest.skip("Feature not implemented yet")
    def test_new_feature(self):
        self.assertTrue(False)

if __name__ == "__main__":
    unittest.main()

The test is reported as skipped instead of being treated as a failure.

Conditional skipping is also possible.

@unittest.skipIf(condition, "Reason")
def test_feature(self):
    ...

This can be useful when a feature depends on a particular operating system, Python version, optional dependency, or external resource.

14. Expected Failures

Sometimes a known issue is intentionally documented as a test that is expected to fail.

Python provides expectedFailure:

import unittest

class TestFeature(unittest.TestCase):

    @unittest.expectedFailure
    def test_known_problem(self):
        self.assertEqual(1, 2)

if __name__ == "__main__":
    unittest.main()

This feature can help document known limitations while preventing an expected failure from being treated like an ordinary unexpected failure.

15. Testing Multiple Input Scenarios

A good unit test should consider different types of input.

For example:

def is_even(number):
    return number % 2 == 0

Tests can cover multiple scenarios:

import unittest

class TestEvenNumber(unittest.TestCase):

    def test_even_number(self):
        self.assertTrue(is_even(10))

    def test_odd_number(self):
        self.assertFalse(is_even(7))

    def test_zero(self):
        self.assertTrue(is_even(0))

    def test_negative_number(self):
        self.assertTrue(is_even(-4))

if __name__ == "__main__":
    unittest.main()

Testing only the most obvious case can leave bugs undiscovered. Tests should consider normal values, boundary values, empty values, invalid values, and exceptional conditions where appropriate.

16. Good Practices for Writing Unit Tests

Effective unit tests should be simple, focused, and predictable.

Test one behavior at a time

A test should ideally verify one logical behavior. If a test checks many unrelated behaviors, determining the cause of a failure becomes difficult.

Use descriptive test names

Prefer:

def test_discount_is_applied_to_valid_customer():

over:

def test_1():

A descriptive name immediately communicates what the test verifies.

Keep tests independent

One test should not depend on another test running first.

Poorly designed tests can produce unpredictable results when their execution order changes.

Test both valid and invalid inputs

A function should be tested under normal conditions as well as situations where errors are expected.

Avoid unnecessary complexity

Tests should generally be simpler than the code they test. Complicated tests can themselves contain bugs.

Keep tests repeatable

Running the same test multiple times should produce the same result when the underlying environment has not changed.

17. Unit Tests and Integration Tests

Unit testing should not be confused with integration testing.

A unit test typically checks one small component independently.

For example:

calculate_tax()

could be tested without connecting to a database or payment system.

Integration testing examines whether multiple components work correctly together.

For example:

User Registration
      |
      v
Application
      |
      v
Database
      |
      v
Email Service

An integration test could verify that registering a user correctly stores the user in the database and triggers the required email operation.

Both approaches are valuable, but unit tests generally provide faster and more focused feedback.

18. A Complete Example

Consider a small calculator module.

calculator.py:

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

The corresponding test file can be:

import unittest
from calculator import add, subtract, multiply, divide

class TestCalculator(unittest.TestCase):

    def test_add(self):
        self.assertEqual(add(10, 5), 15)

    def test_subtract(self):
        self.assertEqual(subtract(10, 5), 5)

    def test_multiply(self):
        self.assertEqual(multiply(10, 5), 50)

    def test_divide(self):
        self.assertEqual(divide(10, 5), 2)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            divide(10, 0)

if __name__ == "__main__":
    unittest.main()

Running the test file produces a test report showing whether the individual tests succeeded or failed.

This structure is suitable for gradually expanding a project because new functionality can be accompanied by corresponding tests.

19. Recommended Project Structure

For a medium-sized Python application, a structure such as the following can make testing easier:

project/
│
├── src/
│   ├── calculator.py
│   ├── users.py
│   └── orders.py
│
├── tests/
│   ├── test_calculator.py
│   ├── test_users.py
│   └── test_orders.py
│
└── README.md

This separates production code from testing code and makes the project easier to navigate.

20. Conclusion

Python's unittest framework provides a built-in way to create, organize, and execute automated unit tests. It supports test cases, assertions, setup and cleanup operations, exception testing, test suites, test discovery, skipping, and expected failures.

A well-designed unit-testing strategy does more than detect bugs. It provides a safety net for future development. When developers modify or expand an application, a reliable test suite can quickly indicate whether existing functionality continues to behave correctly.

For maintainable Python projects, unit tests should be treated as an integral part of development rather than something added only after the application is completed.