Migrating from unittest to pytest involves converting test classes and assertions to pytest’s more modern and concise style. This guide will walk you through using Graph-sitter to automate this migration.

You can find the complete example code in our examples repository.

Overview

The migration process involves four main steps:

  1. Converting test class inheritance and setup/teardown methods
  2. Updating assertions to pytest style
  3. Converting test discovery patterns
  4. Modernizing fixture usage

Let’s walk through each step using Codegen.

Step 1: Convert Test Classes and Setup Methods

The first step is to convert unittest’s class-based tests to pytest’s function-based style. This includes:

  • Removing unittest.TestCase inheritance
  • Converting setUp and tearDown methods to fixtures
  • Updating class-level setup methods
# From:
class TestUsers(unittest.TestCase):
    def setUp(self):
        self.db = setup_test_db()

    def tearDown(self):
        self.db.cleanup()

    def test_create_user(self):
        user = self.db.create_user("test")
        self.assertEqual(user.name, "test")

# To:
import pytest

@pytest.fixture
def db():
    db = setup_test_db()
    yield db
    db.cleanup()

def test_create_user(db):
    user = db.create_user("test")
    assert user.name == "test"

Step 2: Update Assertions

Next, we’ll convert unittest’s assertion methods to pytest’s plain assert statements:

# From:
def test_user_validation(self):
    self.assertTrue(is_valid_email("user@example.com"))
    self.assertFalse(is_valid_email("invalid"))
    self.assertEqual(get_user_count(), 0)
    self.assertIn("admin", get_roles())
    self.assertRaises(ValueError, parse_user_id, "invalid")

# To:
def test_user_validation():
    assert is_valid_email("user@example.com")
    assert not is_valid_email("invalid")
    assert get_user_count() == 0
    assert "admin" in get_roles()
    with pytest.raises(ValueError):
        parse_user_id("invalid")

Step 3: Update Test Discovery

pytest uses a different test discovery pattern than unittest. We’ll update the test file names and patterns:

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

# To:
# Remove the unittest.main() block entirely
# Rename test files to test_*.py or *_test.py

Step 4: Modernize Fixture Usage

Finally, we’ll update how test dependencies are managed using pytest’s powerful fixture system:

# From:
class TestDatabase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.db_conn = create_test_db()

    def setUp(self):
        self.transaction = self.db_conn.begin()

    def tearDown(self):
        self.transaction.rollback()

# To:
@pytest.fixture(scope="session")
def db_conn():
    return create_test_db()

@pytest.fixture
def transaction(db_conn):
    transaction = db_conn.begin()
    yield transaction
    transaction.rollback()

Common Patterns

Here are some common patterns you’ll encounter when migrating to pytest:

  1. Parameterized Tests
# From:
def test_validation(self):
    test_cases = [("valid@email.com", True), ("invalid", False)]
    for email, expected in test_cases:
        with self.subTest(email=email):
            self.assertEqual(is_valid_email(email), expected)

# To:
@pytest.mark.parametrize("email,expected", [
    ("valid@email.com", True),
    ("invalid", False)
])
def test_validation(email, expected):
    assert is_valid_email(email) == expected
  1. Exception Testing
# From:
def test_exceptions(self):
    self.assertRaises(ValueError, process_data, None)
    with self.assertRaises(TypeError):
        process_data(123)

# To:
def test_exceptions():
    with pytest.raises(ValueError):
        process_data(None)
    with pytest.raises(TypeError):
        process_data(123)
  1. Temporary Resources
# From:
def setUp(self):
    self.temp_dir = tempfile.mkdtemp()

def tearDown(self):
    shutil.rmtree(self.temp_dir)

# To:
@pytest.fixture
def temp_dir():
    dir = tempfile.mkdtemp()
    yield dir
    shutil.rmtree(dir)

Tips and Notes

  1. pytest fixtures are more flexible than unittest’s setup/teardown methods:

    • They can be shared across test files
    • They support different scopes (function, class, module, session)
    • They can be parameterized
  2. pytest’s assertion introspection provides better error messages by default:

    # pytest shows a detailed comparison
    assert result == expected
    
  3. You can gradually migrate to pytest:

    • pytest can run unittest-style tests
    • Convert one test file at a time
    • Start with assertion style updates before moving to fixtures
  4. Consider using pytest’s built-in fixtures:

    • tmp_path for temporary directories
    • capsys for capturing stdout/stderr
    • monkeypatch for modifying objects
    • caplog for capturing log messages