MSBA-304 · Lesson 5 of 12 · 20 min

Refactoring and explaining code

▶ Video coming soon A video version of this lesson is on the way.

On Wednesday, August 4, Priya opened a file nobody on the web team liked: trails/summary.py, written years earlier by a contractor and changed many times since. It produced the weekly summary email of trail conditions for subscribers. It worked, mostly, but nobody wanted to touch it. "Two jobs today," Priya said. "First, use Copilot to understand this file. Then use it to improve the file without changing what it does." Explaining and refactoring go together: you cannot safely change code you do not understand. This lesson covers asking Copilot to explain code, the discipline of refactoring, how to request refactors in small steps, how to keep behavior the same, and when to stop.

Asking Copilot to explain code

Explaining code is one of Copilot Chat's most useful and least risky features. Select a function or a whole file and use the /explain slash command, or ask in your own words. The answer describes what the code does, step by step, and can highlight things worth noticing. The GH-300 guide lists accelerating learning and reducing context switching among the productivity benefits, and this is a clear case: instead of tracing a tangled function by hand or interrupting a colleague, you get a first explanation in seconds.

Explanations improve with a focused question. "/explain" on a whole file gives an overview. "Explain how the summary decides which trails to include, and list any conditions that look like bugs" gives an answer aimed at what you need. You can also ask for explanations at a level: "explain this for someone new to Python" or "explain only the date handling". Lena used the second form constantly in her first months.

An explanation request that also asks for inputs, side effects and suspicious behavior.
#file:trails/summary.py
Explain build_summary step by step. Then list:
1. every input it reads (arguments, globals, files, the clock)
2. every output or side effect
3. anything that looks like a bug or a surprise

The answer listed a surprise straight away: build_summary read a global variable called SKIP that another module set, so its output depended on whether that module had run first. Nobody on the current team had known. An explanation is still a model's reading of the code, so Priya checked it by searching for SKIP herself. It was true, and it became the first thing to fix.

What refactoring is, and is not

Refactoring means changing the structure of code without changing its behavior. Renaming a variable, splitting a long function into smaller ones, removing duplication and replacing a global with a parameter are refactors. Adding a feature or fixing a bug is not; those change behavior and belong in a separate step. Keeping the two apart matters because a pure refactor can be checked simply: the tests should give exactly the same results before and after.

Common refactors.
RefactorWhat it improvesTrailCheck example
RenameReadabilityd and t become report_date and trail_name
Extract functionSize and reuseMove the formatting of one line into format_line()
Remove duplicationMaintenanceTwo copies of the date check become one helper
Replace global with parameterTestability, fewer surprisesPass skip_trails into build_summary
Simplify conditionalsReadability, fewer bugsNested if statements become early returns
Introduce a data typeClarityA Report dataclass instead of a list of three strings
A horizontal sequence of five small boxes connected by arrows, each with the words "tests pass" below it: 1 characterization tests, 2 rename, 3 extract function, 4 replace global with parameter, 5 simplify. Above the sequence, a single large crossed-out box labeled "rewrite everything in one prompt" with a red mark.
Refactor in small, tested steps rather than in one large rewrite.

Step zero: tests that pin the behavior

Before changing anything, you need tests that capture what the code currently does. For legacy code these are called characterization tests: they record present behavior, including odd behavior, so you notice if a refactor changes it. This is the one case where writing tests from the current code is correct, because the goal is to preserve behavior, not to judge it. Copilot is good at this. Dmitri asked it to write tests for build_summary that captured its output for a handful of inputs, and to note any behavior that looked wrong rather than "correcting" it in the tests.

The suspicious behaviors went on a list for later. One of them, a summary that silently dropped trails whose names contained an apostrophe, was clearly a bug, but fixing it would change behavior, so it waited until after the refactor, as its own pull request with its own test.

Refactoring in small steps

A tempting prompt is "refactor this file to be clean and modern". It produces a large rewrite that is hard to review and may change behavior in ways nobody notices. A better approach is a sequence of small, named refactors, each followed by running the tests. GitHub's general advice to break complex tasks into simpler ones applies with special force here.

Worked problem

Planning the refactor

build_summary is 90 lines long, reads the global SKIP, uses single-letter names and repeats the same date check twice. Plan a sequence of prompts.

Show solution
  1. Prompt 1: "Write characterization tests for build_summary that capture its current output for these five inputs."
  2. Prompt 2: "Rename the single-letter variables in build_summary to descriptive names. Change nothing else."
  3. Prompt 3: "Extract the repeated date check into a helper function is_recent(report_date, today). Change nothing else."
  4. Prompt 4: "Replace the global SKIP with a parameter skip_trails that defaults to an empty set, and update the one caller."
  5. Run the tests after each step and commit each step separately.

Answer: Four small prompts, tests after each, one commit per step. Each change is easy to review and easy to undo.

In VS Code, edit mode or agent mode can apply a refactor across several files, such as renaming a function and updating every caller. Lesson 8 covers agents; for refactoring the same rule holds. Ask for one kind of change at a time, review the diff, and run the tests before moving on.

Before and after

The code below shows a cut-down version of the change. The "before" function relies on a global and repeats itself. The "after" version takes its inputs as parameters, uses a helper and has clear names. The check at the end runs both on the same reports and confirms they give identical results, which is what makes it a refactor.

Python Example · A cut-down before-and-after refactor, checked by comparing outputs.
from datetime import date, timedelta

SKIP = {"Old Mill Path"}

def summary_before(rs, t):
    out = []
    for r in rs:
        if r[1] in SKIP:
            continue
        if (t - date.fromisoformat(r[0])).days <= 7 and (t - date.fromisoformat(r[0])).days >= 0:
            out.append(r[1] + ": " + r[2])
    return out

def is_recent(report_date: date, today: date, days: int = 7) -> bool:
    return timedelta(0) <= today - report_date <= timedelta(days=days)

def summary_after(reports, today, skip_trails=frozenset()):
    lines = []
    for when, trail, condition in reports:
        if trail in skip_trails:
            continue
        if is_recent(date.fromisoformat(when), today):
            lines.append(f"{trail}: {condition}")
    return lines

reports = [("2027-08-01", "Headland Loop Trail", "dry"), ("2027-07-20", "Cape Trail", "muddy"),
           ("2027-08-03", "Old Mill Path", "flooded"), ("2027-08-04", "Cove Path", "wet")]
today = date(2027, 8, 4)
old = summary_before(reports, today)
new = summary_after(reports, today, skip_trails={"Old Mill Path"})
print(new)
print("same behaviour:", old == new)
Output
['Headland Loop Trail: dry', 'Cove Path: wet']
same behavior: True

What this code does

Both versions take a list of trail reports and today's date and build the lines for the weekly summary: they skip trails on a skip list and keep only reports from the last seven days. The first version reads its skip list from a shared global and repeats the date arithmetic; the second takes the skip list as an input and uses a small helper for the date test. The output shows the summary lines from the new version and confirms that both versions give exactly the same result for the same reports.

The comparison at the end is a miniature of what the characterization tests do at full scale. If a step had changed behavior, for example by making the seven-day window exclusive instead of inclusive, the comparison would have printed False and the step would have been rejected.

Keeping behavior the same

Models sometimes "improve" code while refactoring it: they fix a bug they noticed, change an error message, reorder output or swap a library. Each of these can be a good idea, but none of them is a refactor, and slipping them into a refactor makes review harder. Tell Copilot explicitly: "change structure only; do not change behavior, output, messages or dependencies." When reviewing the diff, look for any change to string literals, return values, conditions or imports. Those are the places where behavior changes hide.

Where behavior changes hide in a refactor diff.
Look for in the diffWhy it matters
Changed string literals or messagesOutput or error text changed
Changed comparison operators or boundariesBehavior at the edges changed
New or removed importsA dependency changed
Reordered loops or sortingOutput order changed
Removed exception handlingFailures now behave differently
Code deleted as "unused"It may be called dynamically or from another module

Small refactors with completions and next edit suggestions

Not every refactor needs chat. Many of the smallest ones happen as you type. When Dmitri renamed the parameter rs to reports at the top of summary_before, next edit suggestions, covered in the first course, offered to update each later use of rs in the function, one Tab press at a time. When he started typing a helper called is_recent, completions offered a body based on the repeated date check already in the file, because that check was the obvious pattern to continue. These small, local suggestions are often the safest form of refactoring help, because each one is visible and easy to judge. Their limit is scope: they work within the open file, so a rename that affects other modules still needs the editor's own rename tool, edit mode or an agent, followed by the tests.

Modernizing idioms

Older Python code often uses idioms that still work but are harder to read than their modern equivalents: string building with plus signs instead of f-strings, index-based loops instead of unpacking, manual dictionaries of counts instead of collections.Counter, and functions that return lists of three strings instead of a small data class. Copilot knows these idioms well and can suggest modern versions, and the GH-300 guide lists modernizing legacy code as a productivity skill. The same discipline applies: one idiom at a time, tests after each, and a check that the modern version really is equivalent. Priya pointed out one trap. A dictionary that was built in a particular order and then printed may print in a different order after a rewrite that looks equivalent, and the weekly email's subscribers would notice their trails moving around. Order is behavior, and the characterization tests had to cover it.

Explaining to others

Copilot can also explain code for an audience other than you. Dmitri asked it to "write a short explanation of the new build_summary for the pull request description, covering what changed and how it was tested". The draft was a good start. He edited it to mention the apostrophe bug that would be fixed separately, because Copilot did not know about the team's plan. Pull request summaries are one of the places where GitHub.com offers Copilot directly, and lesson 7 returns to documentation more generally.

When to stop

Refactoring has no natural end, and assistants make it so easy to keep going that it is worth setting a limit. Priya's rule was to refactor only as much as the next change needs. The weekly summary needed a new section for trail closures in September, so the refactor aimed at making that addition easy: clear inputs, a helper for dates, a place to add a section. It stopped there. A full rewrite, which Copilot would have produced cheerfully, would have carried more risk than value.

Stopping also includes knowing when not to start. Code that is about to be deleted, code that nobody will change again, and code whose behavior is not understood well enough to write characterization tests are poor candidates. In the last case, the right first step is more explanation and more tests, not a refactor, however confident Copilot's suggested rewrite looks.

Worked problem

Measuring the change

Before the refactor, build_summary was 90 lines with a cyclomatic complexity of 14. After four steps it was 52 lines across three functions with a highest complexity of 5. By how much did the length fall, and what share of the original is that?

Show solution
  1. Lines removed: 90 - 52 = 38.
  2. Share: 38 / 90 is about 0.42, or about 42 in 100.
  3. Complexity of the most complex function fell from 14 to 5.

Answer: About 38 lines, roughly four-tenths of the original, and the hardest function became much simpler. Behavior stayed the same, as the tests showed.

Common mistakes

  • MistakeAsking Copilot to "clean up" a whole file in one prompt.

    InsteadRequest one named refactor at a time and test after each.

  • MistakeRefactoring code that has no tests.

    InsteadWrite characterization tests first.

  • MistakeMixing bug fixes or features into a refactor.

    InsteadKeep behavior changes in separate commits or pull requests.

  • MistakeTrusting an explanation without checking it.

    InsteadVerify surprising claims in the code itself.

  • MistakeAccepting deletions of code described as unused.

    InsteadSearch for callers, including dynamic ones, before deleting.

Summary and key terms

  • Copilot Chat's /explain and plain-language questions help you understand unfamiliar code quickly.
  • Focused explanation prompts can list inputs, side effects and suspicious behavior.
  • Refactoring changes structure without changing behavior; bug fixes and features are separate.
  • Characterization tests pin current behavior before you change legacy code.
  • Request small, named refactors one at a time and run the tests after each.
  • Review refactor diffs for hidden behavior changes and stop when the next change is easy.
Refactoring
Changing the structure of code without changing its behavior.
Characterization test
A test that records how existing code currently behaves.
Extract function
A refactor that moves part of a function into a new, named function.
Side effect
Something a function does besides returning a value, such as writing a file or changing a global.
Cyclomatic complexity
A count of the independent paths through code; higher means harder to test.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Why are characterization tests allowed to be based on current behavior?

    Show answer

    Their purpose is to preserve behavior during a refactor, not to judge whether it is correct.

  2. Name three places in a diff where behavior changes often hide.

    Show answer

    String literals or messages, comparison boundaries, and imports. Reordering and removed exception handling are others.

  3. What should you add to a refactoring prompt to stop unwanted improvements?

    Show answer

    An explicit instruction such as "change structure only; do not change behavior, output, messages or dependencies".

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz