Should I test that?

Should I write tests before refactoring?

Verdict

Yes

Yes, write tests before you refactor code that has none: characterization tests at the code's entry point that record what it returns today for each branch, run in CI after every refactoring step.

Why

Yes, write tests before you refactor code that has none. The typical case is a module without tests that customers use and the team changes about once a month. Blast radius is users and Change frequency is regularly. Detectability is eventually, because a refactor that changes behaviour by mistake still returns a plausible value of the right type. Reversibility is with-effort, since records saved in between need a repair script, and Test cost is moderate, about an hour for a characterization test at the entry point, so rule R11 gives Test.

When the decision changes
WhenDecisionWhy
The refactor touches code that calculates charges, refunds or invoice totalsTest mandatory: pin today's amounts and boundary values first, and have a second person review the testsBlast radius rises to money and Reversibility to costly, because a wrong charge ends in refunds
The refactor touches the check that decides who may read a recordTest mandatory: pin an allowed and a denied request for each role firstBlast radius rises to safety-or-legal and Detectability to never, because a leaked record raises no error
Tests in CI already call the code through its public interface, with data that gives each branch a different resultDo not add tests first; run the existing suite after each stepDetectability moves to immediately and Reversibility to trivial, because a changed result fails the suite before the merge
Each step is a rename or an extracted method that the IDE performs, in compiled code that nothing calls by reflectionDo not write tests for these steps; the compiler checks every callerDetectability moves to immediately and Reversibility to trivial, because a missed caller fails the build
The module changes a few times a year, and a test can call its entry point in about an hourTest minimally: one characterization test of the main path firstChange frequency falls to rarely, so a failure has fewer chances to happen
One long method mixes database calls, the clock and outside services, so a test needs days of setupTest it differently: run the old and new code on the same production requests and alert when results differTest cost rises to heavy, while Detectability stays eventually and Reversibility with-effort

What breaks if you don't test

Martin Fowler defines refactoring as a change to internal structure "without changing its observable behavior". A developer moves a condition into a helper, inverts it by mistake, and the code keeps running. The reviewer reads a pure restructure and checks names, not results. Customers get the wrong result until one of them asks support about it.

What you lose if you over-test

Tests written against the classes you are about to change break on the refactor itself. A unit test for each private helper, or a mock that asserts internal calls, fails when you move that helper, so you spend the refactor rewriting tests that say nothing about behaviour.

How to test

  1. Pick an entry point that stays the same after the change: a public method, a command, or an HTTP endpoint.
  2. Record what it returns today for inputs that reach each branch. These are characterization tests; pytest's parametrize keeps one row per input.
  3. Break the code on purpose once and check that a test fails.
  4. Refactor in small steps, run the tests after each one, and keep them in CI after the merge.

When a test would need days of setup, GitHub's Scientist runs the old and new code on the same production requests and reports every difference, for code paths that do not write data.

When the answer changes

  • Tests already assert each branch's result through the public interface.
  • The refactor touches money or an access check.
  • Every step is an automated IDE refactoring in compiled code.

Real incident + Code example

The reminders that stopped for users who never muted

On a project management product I worked on, a developer split a 150-line due_reminders() function into helpers before adding a snooze feature. The inline check not user.muted_until or user.muted_until < now became is_muted(user), which returned True when muted_until was None. Users who had never muted reminders, about four in five accounts, stopped getting due-date emails. Nothing crashed, and a customer asked about missed deadlines 13 days later. After the fix we pinned the recipients for each state of muted_until:

from datetime import datetime, timedelta

import pytest

from reminders import due_reminders

NOW = datetime(2026, 3, 2, 9, 0)


# Expected lists recorded from the old inline check
@pytest.mark.parametrize("muted_until, expected", [
    (None, ["ana@example.com"]),
    (NOW - timedelta(hours=1), ["ana@example.com"]),
    (NOW + timedelta(hours=1), []),
])
def test_recipients_match_the_old_code(make_user, make_task, muted_until, expected):
    user = make_user(email="ana@example.com", muted_until=muted_until)
    make_task(assignee=user, due=NOW + timedelta(hours=20))

    recipients = [r.email for r in due_reminders(now=NOW)]

    assert recipients == expected

Against the broken helper the None row fails.

FAQ

In TDD, should I add unit tests to refactored code?

No, do not add unit tests for a class that a TDD refactor step extracted while the tests that drove the original code still run through it with data that gives each branch a different result. Add direct tests when the extracted class gets a second caller or a rule of its own.

Can I refactor code that has no tests?

Yes, you can refactor code with no tests through steps the IDE performs automatically, such as renaming or extracting a method, in compiled code. Use those steps to cut a seam where a characterization test can attach, then write the test before any manual change.

How many tests do I need before refactoring?

Before refactoring, you need a recorded result for each branch of the code you will change, so that a mistake in any branch fails a test. For the typical module that is one parametrized characterization test at the entry point with a row per branch.