GitHub Copilot 101Intermediate14 min

Docs, Tests, and Refactoring

Copilot is fast at documenting, testing, and refactoring, inside a controlled loop. Learn to write a behavior contract, document only what the code establishes, mock the right boundary, and prove a refactor changed nothing observable.


What you'll learn

  • Write a behavior contract before asking Copilot for docs, tests, or a refactor
  • Document only the behavior the code actually establishes. Never document a guarantee it doesn't prove.
  • Choose unit versus integration coverage and mock only external boundaries
  • Verify a refactor preserves signatures, errors, and results against a baseline
On this page

A refactor replaces an if/elif chain with a dictionary lookup. The tests pass and the diff looks tidy. A week after the merge, a caller that expects ValueError crashes on an uncaught KeyError. The suite was green, but the public behavior changed. Documentation and tests can hide the same kind of gap.

The document–test–refactor loop

Keep Copilot inside a loop you control. Read the current behavior, write a behavior contract, request a plan or draft, inspect the response and diff, then run a test or direct check. Revise when the evidence identifies a problem. The behavior contract states what callers can observe and rely on, including accepted inputs, return values, errors, side effects, and important boundaries. "Given a positive weight and a supported destination, return the fee rounded to two decimals. Reject non-positive weights and unsupported destinations with ValueError" gives Copilot constraints and gives you acceptance criteria in one sentence.

A request made without that contract is open-ended, and so is the review that follows it. Once it is written down, documentation has facts to check against, tests have behavior to assert, and a refactor has something to hold steady. All three tasks in this lesson run on that one discipline, pointed at a different artifact each time.

Order matters here. Read the current behavior, write the contract, then ask Copilot for something. Jump straight to "write tests for this" and you hand Copilot an open-ended task, then grade its output against a standard you never wrote down. Thirty seconds spent stating what a caller can observe gives every later step something to measure against.

Document only what the code establishes· github-copilot
Bad example

Write a good docstring for the selected function.

Good example

Document the selected function for another developer. Return: its purpose in one sentence, each parameter and type, the return value, exceptions and failure conditions, side effects, and one short usage example. Use only behavior established by the code, do not invent guarantees, label anything uncertain as TODO, and show the docstring without editing the file.

Why this works: A docstring whose every claim you can trace to the implementation, with unclear points flagged TODO rather than confidently invented.

What makes documentation trustworthy?

Useful documentation describes observable behavior. "Returns the fee rounded to two decimal places" remains true after the calculation is reorganized. "Adds base and surcharge" describes today's implementation and can become stale after a refactor. Ask for what a caller needs: purpose, parameters and types, return value, exceptions, side effects, boundaries, and one usage example.

The real risk with generated docs is that they turn guesses into guarantees. Copilot will document an exception the function never raises or an ordering the code never promises, and a future caller will trust it. The defense is a rule you state in the prompt and enforce in review: use only behavior established by the code, and label anything uncertain as TODO rather than filling the gap. Then check every claimed parameter, exception, side effect, and example against the implementation before you keep it. Documentation you didn't verify is a confident guess with better formatting.

Mock the external boundary

A unit test isolates one function and controls its dependencies. An integration test proves that two or more real components agree on a protocol, schema, or format. Choose by asking what behavior you're verifying. Calculation, validation, or error translation inside one function? Unit test. Whether a real database adapter and your code agree on a query result? Integration test, because mocking the dependency there would hide the very thing you need to check.

Derive cases from behavior categories instead of implementation branches. Cover a normal input, the exact boundary, values just inside and outside it, and an invalid input the code should reject. When a dependency exists, include its success and failure paths. A generated test matrix can draft this quickly. Remove any row that assumes behavior the contract never established before you turn the matrix into code.

When you do write a unit test, mock the external boundary (an HTTP client, a database, a clock, an email service) and nothing else. Mocking the business logic under test is how you get a green suite that proves nothing. Aim every assertion at observable behavior: the returned value, the public error, or a necessary call to a collaborator. Asserting a private helper name or an incidental order of operations couples your test to the implementation, so it breaks on a harmless refactor and passes on a real regression.

A fixture is fine for reusable setup, but keep the scenario's inputs and expected results visible in the test. A fixture that hides the expected value hides the point of the test.

A worked case makes the boundaries concrete. Say a checkout function calls a tax client and translates a network timeout into a domain error like QuoteUnavailableError. The unit test mocks that client, returning a fixed rate in one case and raising a timeout in another, then asserts the returned total in the first and the public domain error in the second. It never makes a real network request, and it never inspects a private variable. It checks exactly what a caller would observe. Reserve mocks for boundaries that are external, destructive, slow, or nondeterministic. A pure calculation like a shipping surcharge needs real input values, not a mock.

Ask for a test matrix before any test code· github-copilot
Bad example

Write all the tests this function needs.

Good example

Design a test matrix for the selected function. Include normal inputs, exact boundaries, just-inside and just-outside boundaries, invalid inputs, and dependency success and failure where applicable. For each case, state whether it's a unit or integration test, any boundary to mock, the expected result or error, and why the assertion checks public behavior. Do not write test code yet, and do not infer behavior absent from the code.

Why this works: A reviewable table of cases you can prune before a line of test code is written, so the tests you generate assert public behavior, not private structure.

A lookup refactor can change the exception

Replacing a conditional with base_fees[destination] looks equivalent, but a missing key now raises KeyError where callers expected ValueError. Catch the lookup failure and re-raise the original error (with "from None"), then test an unsupported input specifically. Passing tests that never cover that path won't catch the regression.

Refactors need a baseline

Refactoring changes internal structure while keeping externally observable behavior identical: naming a complex expression, extracting a helper, replacing repeated conditionals with a lookup. Change a signature, a return value, an exception type, a rounding rule, an evaluation order, or a side effect and you have changed behavior, whatever the commit message calls it.

A refactor needs a baseline. Run the existing tests, or record a few direct calls and their results, before you change the code. Repeat the same checks afterward. Read the diff itself. Look specifically for changes to public signatures, return values or types, exception types and messages, validation order, rounding, ordering, and dependency calls.

The dictionary-lookup example makes the trap concrete. Turning if destination == "domestic" … elif … else raise ValueError into base = base_fees[destination] is cleaner, but a bare lookup exposes KeyError for an unknown destination, a caller-visible change from the old ValueError. The behavior-preserving version catches the lookup failure and re-raises the existing ValueError with its original message, using from None so the internal KeyError doesn't leak as chained context. Same structure improvement, same public contract.

The evidence that you got it right is the same baseline run passing before and after, including a test for the unsupported destination, the case a happy-path suite leaves out. Copilot drafts this quickly. Checking it is still your job, and you carry that into the next lesson, Privacy, Plans, and the GH-300 Path.

Request a behavior-preserving refactor patch· github-copilot
Bad example

Clean up the destination logic with a dictionary and keep it working.

Good example

Replace the destination conditional with a fee mapping. Preserve the function signature, supported destinations, exception types and messages, surcharge threshold, and rounding. An unsupported destination must still raise ValueError, not KeyError. Show only the minimal patch and name the invariant each change preserves.

Why this works: A small patch that keeps the public contract intact, including the try/except that turns a lookup miss back into the original ValueError. Confirm it by rerunning the full baseline.

Try it yourself

Document, test, and refactor one function safely

Run the full loop on a small pure function until the baseline discipline stops feeling like a checklist. Budget about nine minutes.

  1. 01

    Create shipping_fee(weight_kg, destination) that rejects non-positive weights and unsupported destinations with ValueError, adds a surcharge above 5 kg, and rounds to two decimals. Write its behavior contract in one or two sentences.

  2. 02

    Ask Copilot for a docstring using only facts in the code, then check each claimed parameter, return, and exception against the implementation.

  3. 03

    Create the six behavior-table tests (two valid destinations, the surcharge boundary, a zero weight, a negative weight, and an unknown destination) and run them for a green baseline.

    Hint: The unknown-destination test is the one that catches the refactor trap.

  4. 04

    Ask for the mapping-based refactor, requiring that an unknown destination still raises ValueError, not KeyError. Read the diff.

  5. 05

    Rerun the same six tests and confirm they still pass, including the unknown-destination case.

A documented, tested function whose refactor provably changed nothing a caller can see, and a baseline habit you'll reuse on real code.

Key takeaways

  • Write a behavior contract first. It gives docs facts to check, tests behavior to assert, and refactors an invariant to hold.
  • Document observable behavior, and label anything the code doesn't establish as TODO. Don't invent it.
  • Choose unit or integration coverage by the behavior you're proving, and mock only external boundaries.
  • Assert public results and errors, not private helper names or incidental ordering.
  • A refactor needs a baseline run before and after, plus a diff review for signature, error, and rounding changes.

Check your understanding

  1. 1. A refactor replaces a destination conditional with base_fees[destination]. Callers used to get ValueError for an unknown destination but now get KeyError. What's the smallest correct fix?

  2. 2. You need to prove a real database adapter and your code agree on a query result. Which test level fits?

  3. 3. In a unit test for a calculation, what should you mock?

  4. 4. Which docstring statement is more durable?

  5. 5. Before accepting a refactor, what evidence do you need?

  6. 6. An assertion in a unit test should target what?

Frequently asked questions

Terms used in this lesson

behavior contract
A plain statement of the inputs, outputs, errors, side effects, and boundaries a caller can rely on, used as acceptance criteria.
refactoring
Changing internal structure without changing externally observable behavior: signatures, results, errors, ordering, and side effects stay the same.
mock
A controlled replacement for an external dependency (HTTP client, database, clock) that makes a unit test deterministic.
regression test
A test that preserves behavior which must survive a bug fix or refactor, failing before the change and passing after.

Further reading