Testing legacy Python 2 code before you change it

Pin a reproducible 2.7 test environment, write characterisation tests around behaviour you do not understand, and prove that unicode round-trips survive your edits.

A reproducible 2.7 test environment

python2 -m virtualenv venv2
source venv2/bin/activate
pip install --upgrade "pip<21" "setuptools<45"
pip install -r requirements-legacy.txt
pip install "pytest<5" "mock<4" "tox<4"

pytest -q tests/ --junitxml=before.xml
  • Cap pytest below 5: later releases require Python 3.
  • mock is a separate package on 2.7; unittest.mock does not exist there.
  • Record before.xml and keep it. It is your evidence that a refactor changed nothing.
  • Run the same suite from a container so the interpreter and C libraries are pinned too.
# tox.ini covering both interpreters while you migrate
[tox]
envlist = py27,py311

[testenv]
deps = -rrequirements.txt
commands = pytest -q tests/

Characterisation tests

Legacy code usually has no tests and an unclear specification. A characterisation test records what the code currently does, correct or not, so that any change to the output is visible. You are pinning behaviour, not asserting that it is right.

# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import unittest
from legacy import invoice

class TestInvoiceCharacterisation(unittest.TestCase):
    def test_rounding_behaviour(self):
        # captured from the current implementation, quirks included
        self.assertEqual(invoice.total([("a", "0.1"), ("b", "0.2")]), "0.30")
        self.assertEqual(invoice.total([]), "0")

    def test_unicode_roundtrip(self):
        name = u"Zürich \u2013 Bäckerei"
        encoded = name.encode("utf-8")
        self.assertEqual(encoded.decode("utf-8"), name)
        self.assertEqual(invoice.label(name), name.upper())

    def test_bad_input_raises(self):
        with self.assertRaises(ValueError):
            invoice.total(None)

if __name__ == "__main__":
    unittest.main()
💡
When a characterisation test fails after your change, the failure is the finding. Either you changed behaviour unintentionally, or you have discovered a bug in the original. Decide which deliberately and update the test with a comment explaining why.

Unicode tests that catch real bugs

# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import codecs, os, tempfile, unittest

SAMPLES = [u"plain", u"Zürich", u"Привет", u"emoji \U0001F600", u"tab\tand, comma"]

class TestTextIO(unittest.TestCase):
    def test_roundtrip_through_disk(self):
        fd, path = tempfile.mkstemp()
        os.close(fd)
        try:
            with codecs.open(path, "w", encoding="utf-8") as f:
                for s in SAMPLES:
                    f.write(s + u"\n")
            with codecs.open(path, "r", encoding="utf-8") as f:
                got = [line.rstrip(u"\n") for line in f]
            self.assertEqual(got, SAMPLES)
        finally:
            os.remove(path)

    def test_asymmetric_equality(self):
        # str == unicode triggers implicit ASCII decoding in Python 2
        with self.assertRaises(UnicodeDecodeError):
            self.assertTrue(u"café" == "caf\xc3\xa9")
  • Include at least one string outside the BMP (an emoji) and one with a combining character; length calculations differ.
  • Test the file round trip, not just in-memory comparison: most encoding bugs appear at the I/O boundary.
  • Assert that len() of a unicode string counts characters, since in Python 2 len of a str counts bytes.

FAQ

How many characterisation tests are enough?
Enough that the parts you intend to change are pinned. Cover the public entry points, the error paths, and anything that touches text, money or dates. You are building a safety net, not documenting the system.
Should I write new tests in Python 2 or Python 3 style?
Write the tests in a style both interpreters accept — unittest classes, no f-strings — so the same suite runs before and after migration. That lets you compare results across the port.

Running Python 2 today: interpreter, pip and virtualenv Where Python 2 still appears and what to do about it

Last refreshed 2026-09-18.