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.
mockis a separate package on 2.7;unittest.mockdoes not exist there.- Record
before.xmland 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 2lenof astrcounts 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.Related
Running Python 2 today: interpreter, pip and virtualenv Where Python 2 still appears and what to do about it
Last refreshed 2026-09-18.