⌂ Home
Gemini Code Assist: AI Pair Programming in Your IDE
Code Assist Track
⏰ 50-60 min Module 5 of 12 🔧 Hands-on lab
🔬
Gemini Code Assist Track — G04: Refactoring, Test Generation & Debugging Turn ugly code into clean code, generate a real pytest suite for Recipe Box, and learn why generated tests fail at first — and how fixing them makes your code better.

Refactoring, Test Generation & Debugging

G03 taught the conversation tools. This module points them at the two chores developers postpone forever — refactoring and writing tests — and shows how to delegate the labor while keeping the judgment.

Code Transformations

Analogy First

A house renovation has two distinct jobs: the architect decides "this wall should move," and the crew swings the hammers. Most developers are forced to be both — they know the function should take a config object instead of seven positional arguments, but actually threading that change through every call site is an afternoon of careful drudgery. So the wall never moves, and the codebase slowly becomes a maze. Code transformations split the jobs back apart: you stay the architect ("extract this into smaller functions, add type hints, handle the error cases"), and Gemini becomes the crew — with the crucial twist that you inspect every swing of the hammer as a diff before it touches the house.

Technical Definition

Code transformations are edits requested in natural language against existing code: select code, then ask in chat (or via right-click smart actions) for things like:

  • Restructuring: "extract the validation into its own function", "convert this class to a dataclass"
  • Hardening: "add error handling for network failures", "add type hints everywhere"
  • Modernizing: "rewrite for Python 3.12 idioms", "migrate from requests to httpx"
  • Renaming & readability: "rename these variables to describe their purpose"

In current VS Code and IntelliJ builds, chat proposes changes in a preview/diff view — added lines green, removed lines red — that you apply or reject. The behavioral rule for all refactoring applies doubly with AI: behavior stays identical; only structure improves. Tests are how you prove it, which is why this module teaches both together.

Unit Test Generation

Analogy First

Writing tests for your own code is like proofreading your own essay: you read what you meant, not what you wrote, so your blind spots survive. A fresh reader catches what you can't — not because they're smarter, but because they don't share your assumptions. Generated tests are that fresh reader at scale: the model reads your function's actual contract (signature, docstring, branches) and probes it, including the negative paths you mentally skipped — empty lists, negative numbers, the exception you declared but never tested.

Technical Definition

Select a function/class → right-click → Gemini Code Assist → Generate unit tests (or ask in chat). Gemini infers the testing framework from your project (pytest if it sees pytest, JUnit in Java projects, Jest in JS) and produces test cases covering happy paths, edge cases, and declared exceptions. Generated tests are proposals: they encode the model's reading of your contract, and where that reading differs from your intent, you've found either a test to fix or — more interestingly — a bug or unclear API in your code.

Why It Matters

Teams that "never have time for tests" are the exact teams drowning in regressions that eat the time they saved. Test generation drops the cost of a first test suite from days to minutes — Recipe Box's storage layer gets ~10 meaningful tests in this lab in under 15 minutes. Once the suite exists, every future refactor (and every agent-mode change in G06) has a safety net, and the net was nearly free.

Lab Part 1: Refactor Legacy Search Code

A "previous developer" left Recipe Box a working-but-awful search function. Your job: make it clean without changing behavior.

Add the legacy code

Create search.py and paste:

search.py — intentionally awful
import storage

def s(q, mx, f):
    r = storage.list_recipes()
    o = []
    for x in r:
        ok = 0
        if q != None and q != "":
            if q.lower() in x["name"].lower():
                ok = 1
            else:
                for i in x["ingredients"]:
                    if q.lower() in i.lower():
                        ok = 1
        else:
            ok = 1
        if ok == 1:
            if f == 1:
                if x["minutes"] <= 30:
                    o.append(x)
            else:
                o.append(x)
    if mx != None and mx > 0:
        o = o[0:mx]
    return o
WHAT: search by name/ingredient, optional "fast recipes only" filter (f == 1 means ≤30 minutes), optional result cap. WHY it's painful: single-letter names, flag-as-int, != None, five levels of nesting. It works — that's the point. Refactoring is for working code.

Understand before you touch (the professional's rule)

Select the whole function and run /explain. Read the answer and confirm it matches the annotation above. Never refactor code you can't describe — with or without AI.

Direct the transformation

With the function still selected, give the architect's instruction in chat:

chat
Refactor this function. Keep behavior identical. Requirements:
1. Rename it search_recipes with descriptive parameter names
   (query, max_results, quick_only) and full type hints; quick_only is a bool.
2. Extract the "does this recipe match the query" logic into a
   helper function _matches(recipe, query).
3. Flatten the nesting with early returns / continue.
4. Add a Google-style docstring stating the quick_only threshold (30 minutes).
Review the diff before applying Same matching rules? (case-insensitive, name OR any ingredient, empty/None query matches everything.) Same cap semantics? (max_results=None or ≤0 means unlimited.) If the proposal "improved" any behavior — e.g., made empty query return nothing — reject that hunk and say so: "keep the empty-query-matches-all behavior."

Prove behavior survived

Append and run a quick harness (your G02 data is still in recipes.db):

search.py — harness
if __name__ == "__main__":
    print(len(search_recipes(None, None, False)))      # all recipes
    print(len(search_recipes("chai", None, False)))    # name match: 1
    print(len(search_recipes("cardamom", None, False)))# ingredient match: 1
    print(len(search_recipes("pizza", None, False)))   # no match: 0
    print(len(search_recipes(None, None, True)))       # only quick (<=30 min)

Lab Part 2: Generate a Real Test Suite

Install pytest and generate

terminal
pip install pytest

Open storage.py, select all of it, right-click → Gemini Code Assist → Generate unit tests (or in chat: "Generate pytest tests for this module, covering init_db, add_recipe, list_recipes, including StorageError paths and the minutes > 0 constraint"). Save the result as tests/test_storage.py.

Run them — and expect trouble

terminal
pytest tests/ -v
pytest — typical first run
PS>pytest tests/ -v
tests/test_storage.py::test_init_db_creates_table PASSED
tests/test_storage.py::test_add_recipe_returns_id PASSED
tests/test_storage.py::test_list_recipes_empty FAILED
  AssertionError: assert 3 == 0 — expected empty DB, found 3 recipes
tests/test_storage.py::test_add_recipe_invalid_minutes FAILED
Diagnosis: every test hits the REAL recipes.db from G02...
✦ The tests aren't wrong. The module is hard to test. That's the finding.
Press ▶ to see the typical first run
The Classic Failure (read this even if your tests passed)

Generated tests often fail at first — and the cause is usually a testability flaw in your code, not in the tests. Here: storage.py hardcodes DB_PATH = "recipes.db", so tests pollute (and are polluted by) your real database. The generated suite just exposed a design smell you'd have hit eventually in production.

Fix the design, not the symptom

Ask Gemini to make it right. In chat, with tests/test_storage.py open and storage.py in another tab:

chat
These tests fail because storage.py hardcodes DB_PATH and all tests share my
real recipes.db. Write a pytest fixture (conftest.py) that points storage at a
temporary database per test using tmp_path and monkeypatch, and update the
tests to use it.

The expected shape of the fix:

tests/conftest.py — reference solution
import pytest

import storage


@pytest.fixture(autouse=True)
def temp_db(tmp_path, monkeypatch):
    """Point storage at a fresh database file for every test."""
    monkeypatch.setattr(storage, "DB_PATH", str(tmp_path / "test.db"))
    storage.init_db()
    yield
Expected pytest tests/ -v now shows all green — typically 8–12 passing tests covering inserts, listing order, JSON round-tripping of ingredients, the minutes > 0 constraint, and StorageError wrapping. Commit this suite mentally: G06's agent will be graded against it.
What Just Happened?

Part 1: you acted as architect and reviewed the crew's diff — behavior preserved, structure transformed. Part 2: generated tests failed usefully, exposing a real testability flaw (hardcoded DB path), and the fix — a standard pytest fixture — made both the tests and the module better. This is the mature pattern: AI output failures are often information about your code, not noise.

The Debugging Workflow, Assembled

You've now used every piece. Here's the loop to internalize, in order of escalation:

  1. Reproduce — get the exact error text or wrong output. (No evidence, no debugging.)
  2. /explain the suspect code — confirm what it actually does vs. what you assumed. A surprising number of bugs die right here.
  3. /fix with evidence — selection + verbatim error + expected behavior.
  4. Apply via diff, then re-run — the failing test or curl from step 1 is your referee.
  5. Lock it in with a test — "write a pytest test that would have caught this bug." Now the bug can never return silently.
Why It Matters

Step 5 is the compounding one. A fixed bug saves you once; a regression test for it saves you every release forever. Since generated tests cost minutes, the old excuse ("no time to write a test for every fix") is gone — and a codebase where every past bug has a guard test is the codebase you can safely hand to agent mode next module.

Knowledge Check

1. The golden rule of refactoring (AI-assisted or not) is…

Always reduce the line count
Behavior stays identical; only structure improves — and tests prove it
Rename everything to longer names
Refactor and add features in the same change to save time

2. In the lab, the generated tests failed at first because…

Gemini generated syntactically invalid tests
pytest was the wrong framework
storage.py hardcoded its database path, so tests collided with the real recipes.db — a testability flaw in the code under test
SQLite can't be used in tests

3. Before applying a refactoring diff, the single most important check is…

That the new code is shorter
That no behavior changed — including edge-case semantics like "empty query matches everything"
That variable names are alphabetical
That it uses the newest language features

4. What did the conftest.py fixture accomplish?

It made tests run in parallel
Every test gets its own fresh temporary database via monkeypatched DB_PATH — isolating tests from each other and from real data
It deleted recipes.db permanently
It disabled the failing tests

5. After fixing any bug, the compounding final step is…

Writing a postmortem document
Refactoring the whole module
Generating a regression test that would have caught the bug, so it can never silently return
Switching to a different model

Quiz Complete!

Ready for G05: Context Mastery — GEMINI.md & .aiexclude →