unittest is Python’s built-in testing framework, part of the standard library, while pytest is a popular third-party framework that offers a more flexible and expressive way to write tests. The choice between them often comes down to project needs, team familiarity, and desired testing style.

Let’s see unittest in action with a simple example. Imagine a basic calculator class:

# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b

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

Here’s how you’d test this using unittest:

# test_calculator_unittest.py
import unittest
from calculator import Calculator

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Set up a Calculator instance before each test."""
        self.calculator = Calculator()

    def test_add(self):
        """Test the add method."""
        self.assertEqual(self.calculator.add(5, 3), 8)
        self.assertEqual(self.calculator.add(-1, 1), 0)
        self.assertEqual(self.calculator.add(-1, -1), -2)

    def test_subtract(self):
        """Test the subtract method."""
        self.assertEqual(self.calculator.subtract(10, 5), 5)
        self.assertEqual(self.calculator.subtract(-1, 1), -2)
        self.assertEqual(self.calculator.subtract(-1, -1), 0)

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

To run these tests, you’d typically execute python -m unittest test_calculator_unittest.py from your terminal. unittest uses a class-based structure where test methods start with test_ and assertions are made using methods like assertEqual, assertTrue, etc. The setUp method is a common pattern for initializing test fixtures.

Now, let’s look at the same calculator tested with pytest:

# test_calculator_pytest.py
from calculator import Calculator

def test_add():
    """Test the add method using pytest."""
    calc = Calculator()
    assert calc.add(5, 3) == 8
    assert calc.add(-1, 1) == 0
    assert calc.add(-1, -1) == -2

def test_subtract():
    """Test the subtract method using pytest."""
    calc = Calculator()
    assert calc.subtract(10, 5) == 5
    assert calc.subtract(-1, 1) == -2
    assert calc.subtract(-1, -1) == 0

Running these tests with pytest is as simple as navigating to your project directory and typing pytest. pytest discovers tests automatically (files named test_*.py or *_test.py and functions/methods named test_*). It uses plain assert statements, which many find more readable and Pythonic than unittest’s assertion methods. Fixtures in pytest are more flexible and can be defined as functions decorated with @pytest.fixture.

The core problem pytest aims to solve, beyond basic unit testing, is reducing boilerplate and making tests more expressive and maintainable. unittest’s class-based structure, while providing clear organization, can sometimes feel verbose. pytest’s functional style and powerful fixture system allow for more concise test writing and easier setup/teardown management.

Consider a scenario where you need to set up a database connection for multiple tests. With unittest, you’d typically manage this in setUpClass and tearDownClass or setUp and tearDown methods within your test classes. pytest offers a more declarative approach with fixtures.

# conftest.py (for pytest fixtures)
import pytest
import sqlite3

@pytest.fixture(scope="session")
def db_connection():
    """Provides a database connection for the entire test session."""
    conn = sqlite3.connect(":memory:") # In-memory SQLite database
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    conn.commit()
    yield conn # Provide the connection to tests
    conn.close()

# test_database.py
def test_create_user(db_connection):
    """Test creating a user in the database."""
    user_name = "Alice"
    db_connection.execute("INSERT INTO users (name) VALUES (?)", (user_name,))
    db_connection.commit()
    cursor = db_connection.execute("SELECT name FROM users WHERE name=?", (user_name,))
    result = cursor.fetchone()
    assert result is not None
    assert result[0] == user_name

def test_count_users(db_connection):
    """Test counting users in the database."""
    initial_count = len(list(db_connection.execute("SELECT id FROM users")))
    db_connection.execute("INSERT INTO users (name) VALUES (?)", ("Bob",))
    db_connection.commit()
    new_count = len(list(db_connection.execute("SELECT id FROM users")))
    assert new_count == initial_count + 1

In this pytest example, the db_connection fixture is defined once in conftest.py and can be requested by any test function by simply including it as an argument. The scope="session" ensures the database is set up only once for the entire test run, which is highly efficient. pytest’s fixtures can have different scopes (function, class, module, session), offering fine-grained control over setup and teardown.

One of the most powerful, yet often overlooked, aspects of pytest is its parametrization feature. It allows you to run the same test function multiple times with different arguments, which drastically reduces code duplication for repetitive test cases.

# test_math_functions.py
import pytest

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

@pytest.mark.parametrize("x, y, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (-1, -1, -2),
    (100, 200, 300),
])
def test_addition(x, y, expected):
    """Test the add function with various inputs."""
    assert add(x, y) == expected

When you run pytest on this file, test_addition will execute five times, once for each tuple provided in the parametrize decorator. This is incredibly useful for testing edge cases, boundary conditions, and a wide range of valid inputs without writing redundant test functions. unittest requires more complex machinery, often involving loops or separate test methods, to achieve similar results.

While pytest offers many advantages, unittest is still a viable choice. Its inclusion in the standard library means no external dependencies are required, which can be a significant factor in some environments. If your team is already deeply familiar with unittest and its patterns, the migration cost to pytest might outweigh the benefits for smaller projects. However, for most new Python projects, pytest’s expressiveness, powerful fixtures, and extensive plugin ecosystem make it the more compelling option.

The next step after mastering test frameworks is understanding how to integrate them into your development workflow, often through continuous integration pipelines.

Want structured learning?

Take the full Pytest course →