pyguides

Python Unit Testing with unittest: A Complete Beginner's Guide

Python’s unittest module is the standard library’s answer to automated testing. Inspired by JUnit, it provides a complete framework for writing, discovering, and running tests. While pytest has become more popular in recent years, understanding unittest is valuable—it ships with Python, works without dependencies, and its patterns appear throughout the testing ecosystem.

This guide walks you through writing tests with unittest from scratch.

Why Use unittest?

The unittest framework offers several advantages:

  • No dependencies — comes with Python’s standard library
  • Test discovery — automatically finds and runs tests
  • Object-oriented — organizes tests in classes for better structure
  • Portable — works the same across all Python implementations

Many developers start with unittest and later explore pytest. The core concepts transfer easily between frameworks.

Your First Test

Create a file called test_calculator.py:

import unittest

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

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

class TestCalculator(unittest.TestCase):
    def test_add_two_positive_numbers(self):
        result = add(3, 5)
        self.assertEqual(result, 8)
    
    def test_add_negative_numbers(self):
        result = add(-1, -1)
        self.assertEqual(result, -2)
    
    def test_subtract(self):
        result = subtract(10, 4)
        self.assertEqual(result, 6)

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

Run the tests:

python test_calculator.py
# output: ...
# output: Ran 3 tests in 0.001s
# output: OK

Understanding TestCase

The TestCase class is the foundation. Each test method must start with test_ — this convention lets unittest discover your tests automatically.

import unittest

class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual("hello".upper(), "HELLO")
    
    def test_isupper(self):
        self.assertTrue("HELLO".isupper())
        self.assertFalse("Hello".isupper())
    
    def test_split(self):
        s = "hello world"
        self.assertEqual(s.split(), ["hello", "world"])

setUp and tearDown

These special methods run before and after each test:

class TestDatabase(unittest.TestCase):
    def setUp(self):
        self.db = Database.connect("test.db")
        self.db.seed()
    
    def tearDown(self):
        self.db.cleanup()
    
    def test_insert(self):
        self.db.insert("test", {"name": "Alice"})
        result = self.db.find("test")
        self.assertIsNotNone(result)

This ensures each test starts with a clean database state.

Class-Level Setup

Use setUpClass and tearDownClass for expensive setup that should run once:

class TestExpensiveResource(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.connection = ExpensiveConnection.connect()
    
    @classmethod
    def tearDownClass(cls):
        cls.connection.close()
    
    def test_something(self):
        # uses cls.connection
        pass

Note: these require the @classmethod decorator.

Assertion Methods

TestCase provides many assertion methods beyond the basic assertEqual:

MethodUse Case
assertEqual(a, b)Check a == b
assertNotEqual(a, b)Check a != b
assertTrue(x)Check x is truthy
assertFalse(x)Check x is falsy
assertIsNone(x)Check x is None
assertIsNotNone(x)Check x is not None
assertIn(a, b)Check a in b
assertNotIn(a, b)Check a not in b
assertIs(a, b)Check a is b (identity)
assertIsInstance(a, b)Check isinstance(a, b)
assertRaises(exc)Check exception is raised

assertRaises

Test that code raises an expected exception. The context manager form captures the exception so you can inspect its message or attributes after the fact:

def test_division_by_zero(self):
    with self.assertRaises(ZeroDivisionError):
        x = 1 / 0

You can also capture the exception object to verify the error message or any custom attributes set on it. This is especially useful when the same exception type can be raised for different reasons and you need to confirm the specific cause:

def test_value_error(self):
    with self.assertRaises(ValueError) as context:
        int("not a number")
    self.assertIn("invalid literal", str(context.exception))

assertAlmostEqual

For floating-point comparisons:

def test_float_precision(self):
    self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)
    # Or with delta:
    self.assertAlmostEqual(10.0, 9.99, delta=0.1)

Running Tests

From Command Line

# Run all tests in current module
python -m unittest

# Verbose output
python -m unittest -v

# Run specific test file
python -m unittest test_calculator

# Run specific test class
python -m unittest test_calculator.TestCalculator

# Run specific test method
python -m unittest test_calculator.TestCalculator.test_add

# Discover all tests in directory
python -m unittest discover

From Code

# Option 1: unittest.main() at module end
if __name__ == "__main__":
    unittest.main()

# Option 2: Programmatic test runner
import unittest

loader = unittest.TestLoader()
suite = loader.loadTestsFromTestCase(TestCalculator)
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)

# Access the results
print(f"Tests run: {result.testsRun}")
print(f"Failures: {len(result.failures)}")
print(f"Errors: {len(result.errors)}")

Test Suites

Group multiple test classes or modules into a suite:

import unittest

# Build a suite manually
loader = unittest.TestLoader()
suite = unittest.TestSuite()

suite.addTest(TestMath("test_add"))
suite.addTests(loader.loadTestsFromTestCase(TestStrings))
suite.addTests(loader.loadTestsFromName("myapp.tests"))

# Run the suite
runner = unittest.TextTestRunner(verbosity=1)
runner.run(suite)

Test suites let you group related tests into a single run, which is especially helpful in larger projects where you might want to run only the tests for a particular module or component without executing the entire test suite. The TestLoader can also discover tests automatically from a directory or module, saving you from writing manual suite definitions.

Skip Tests

Skip tests conditionally or unconditionally:

class TestFeature(unittest.TestCase):
    @unittest.skip("Not implemented yet")
    def test_future_feature(self):
        pass
    
    @unittest.skipIf(sys.version_info < (3, 11), "Requires Python 3.11+")
    def test_new_feature(self):
        pass
    
    @unittest.skipUnless(os.name == "posix", "Unix only")
    def test_unix_feature(self):
        pass

Skipping tests is useful during development when you have not yet implemented a feature, or when certain tests only apply to specific platforms or Python versions. The skipped tests still appear in the test output with a clear “s” marker, so they are visible in your test report rather than silently absent.

Mocking with unittest.mock

The unittest.mock submodule provides powerful mocking capabilities:

from unittest.mock import Mock, patch, MagicMock

# Create a mock
mock_obj = Mock()
mock_obj.method.return_value = 42
result = mock_obj.method()
print(result)  # 42

# Check calls
mock_obj.method.assert_called_once()
mock_obj.method.assert_called_with(42)

# Patch external dependencies
@patch("mymodule.requests.get")
def test_api_call(mock_get):
    mock_get.return_value = Mock(status_code=200, json={"key": "value"})
    result = call_api()
    self.assertEqual(result, {"key": "value"})

# MagicMock for complex objects
mock_list = MagicMock()
mock_list.__len__.return_value = 5
print(len(mock_list))  # 5

Mock returns a new mock object for any attribute you access, and you can configure return values, side effects, and track call arguments. The @patch decorator temporarily replaces the specified target with a mock during the test and restores the original afterward, which keeps your tests isolated from external dependencies like network calls or database connections.

Mocking Classes

from unittest.mock import patch, MagicMock

# Patch at the class definition
@patch("mymodule.Database")
def test_with_mock_db(MockDatabase):
    # Configure mock
    instance = MagicMock()
    instance.query.return_value = [{"id": 1, "name": "Alice"}]
    MockDatabase.return_value = instance
    
    # Test code uses the mock
    result = fetch_users()
    self.assertEqual(len(result), 1)

When patching classes, you replace the class itself, not an instance of it. The mock returned by the patched class constructor is what your code under test interacts with, so you configure the instance mock’s return values and side effects. This pattern isolates your test from the real class entirely, including its constructor logic and side effects.

Best Practices

Keep Tests Independent

Each test should work in isolation:

class TestUserManager(unittest.TestCase):
    def setUp(self):
        self.manager = UserManager()
    
    def test_create_user(self):
        user = self.manager.create("alice", "alice@example.com")
        self.assertEqual(user.name, "alice")
    
    def test_duplicate_email_fails(self):
        self.manager.create("alice", "alice@example.com")
        with self.assertRaises(DuplicateEmailError):
            self.manager.create("bob", "alice@example.com")

Each test method gets a fresh UserManager instance from setUp, so the second test does not depend on state left behind by the first. This isolation is the foundation of reliable unit testing — when tests share state, failures become hard to debug because the order of execution matters.

Name Tests Descriptively

# Good
def test_division_by_zero_raises_zero_division_error(self):

# Bad
def test_div0(self):

A well-named test acts as documentation for the expected behaviour. When test_div0 fails, you have no idea what went wrong — but test_division_by_zero_raises_zero_division_error tells you exactly what the code should do, which speeds up debugging and serves as a living specification of the function’s contract.

Test One Thing Per Method

# Good - focused
def test_user_name_is_required(self):
    with self.assertRaises(ValidationError):
        User(name=None)

def test_user_email_must_be_valid(self):
    with self.assertRaises(ValidationError):
        User(email="not-an-email")

Each test method should verify a single behaviour or condition. When a test covers multiple behaviours, a failure does not tell you which specific expectation was violated — you have to read the traceback and the test body to figure it out, which wastes time and erodes trust in the test suite.

Test Edge Cases

def test_list_index_out_of_bounds(self):
    with self.assertRaises(IndexError):
        lst = [1, 2, 3]
        x = lst[100]

unittest vs pytest

Both are valid choices. Here’s when to use each:

Featureunittestpytest
DependenciesNonepytest package
Syntaxassert methodsPlain assert
FixturessetUp/tearDown@pytest.fixture
OutputTraditionalDetailed, helpful
PluginsLimitedRich ecosystem

Many projects use unittest for its simplicity and no dependencies. pytest shines for complex testing scenarios with its rich plugin ecosystem.

Conclusion

The unittest module provides everything you need for basic to intermediate testing. Its integration with the standard library means you can start testing immediately without installing anything. Understanding unittest also makes it easier to learn pytest later, since many concepts transfer directly.

Start with simple tests, organize them in classes, and gradually add fixtures and mocking as your tests grow more complex.

See Also