tdd-and-programming-by-difference

Add features to tested code using TDD or Programming by Difference. Use whenever a developer is adding a feature to a class that already has tests (or can be brought under test) — 'how do I add this feature', 'test-driven development for legacy', 'red green refactor', 'TDD cycle', 'add behavior with tests', 'inheritance to add feature', 'programming by difference', 'subclass to add behavior', 'Liskov substitution', 'LSP violation'. Triggers for 'I just got this class under test, now I need to add X'.

Safety Notice

This listing is from the official public ClawHub registry. Review SKILL.md and referenced scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "tdd-and-programming-by-difference" with this command: npx skills add quochungto/bookforge-tdd-and-programming-by-difference

TDD and Programming by Difference

When to Use

The class is already under test — or can be brought under test with minimal effort. You need to add a new feature and you want to do it safely, with tests driving the design from the start.

This skill covers two complementary techniques:

  • Test-Driven Development (TDD): the default. Language-agnostic. Add the feature directly to the class by writing one failing test at a time, implementing just enough to pass, then removing duplication.
  • Programming by Difference (PbD): object-oriented only. Create a subclass, write tests against it, implement the feature there, then normalize the inheritance hierarchy. Useful when you want to defer the integration decision until you can see the feature working.

If the class is not yet under test, do not start here. Run characterization-test-writing first to establish a safety net, then return. If the class cannot be brought under test at all, use legacy-code-addition-techniques (Sprout Method or Wrap Method) instead.

Context and Input Gathering

Before choosing an approach, collect:

  1. The target class — confirm it has passing tests or can be brought into a test harness quickly.
  2. The feature specification — what the new behavior should do, including edge cases you can identify now.
  3. The language — PbD requires an object-oriented language with single-class inheritance. TDD works in any language.
  4. Whether modifying the existing class is feasible — if the class is shared, frozen, or owned by another team, PbD may be the safer path.

Process

Step 1: Confirm the Class Is Under Test

Run the existing test suite for the target class. All tests must pass before you write a single new line of feature code. A failing test baseline is a signal: stop, diagnose, and fix before proceeding.

If no tests exist for this class, invoke characterization-test-writing now. Come back here when you have a passing baseline.

Step 2: Decide — TDD or Programming by Difference

Use this decision rule:

Use TDD (default) when:

  • You can modify the existing class directly.
  • The feature belongs in the class — it is not a variant, an override, or an alternative behavior mode.
  • The language does not require an OO inheritance model (procedural, functional, etc.).

Use Programming by Difference when:

  • You cannot or do not want to modify the existing class directly (shared, frozen, or complex dependency on existing behavior).
  • You want to prove the feature works in isolation before deciding where it lives in the hierarchy.
  • The integration direction is unclear — you need to see the feature first, then decide: push it up (shared behavior) or push it down (specialized subclass).

When uncertain, choose TDD. PbD adds a normalization step that TDD avoids.

Step 3 (TDD Path): Run the 5-Step Cycle

For each slice of the feature, execute this cycle in full before moving to the next slice:

1. Write a failing test case. Write a test for the method or behavior you need. Do not write more than one new test at a time. The test must fail when you run it now — if it passes immediately, the test is wrong or the feature already exists.

2. Get it to compile. Add the minimum code required for the test to compile: method signature, return type, stub body. Return a nonsense value (NaN, -1, empty string, null) so the test fails at assertion, not at compilation.

3. Make it pass. Implement only enough code to make this specific test pass. Do not generalize. Do not add untested behavior. Write the simplest implementation that works.

4. Remove duplication. Look for structural duplication between the new code and existing code. Extract shared behavior into a helper method, a shared base, or a well-named private. Do not skip this step — duplication left in place becomes a maintenance burden and obscures design intent. The tests you now have make this safe.

5. Repeat. Move to the next test case. Each iteration narrows in on the full behavior — edge cases, error cases, boundary conditions.

InstrumentCalculator example — two full iterations:

Iteration 1: base case

// Step 1: Failing test
public void testFirstMoment() {
    InstrumentCalculator calculator = new InstrumentCalculator();
    calculator.addElement(1.0);
    calculator.addElement(2.0);
    assertEquals(-0.5, calculator.firstMomentAbout(2.0), TOLERANCE);
}

// Step 2: Stub — compiles, fails assertion
public double firstMomentAbout(double point) {
    return Double.NaN;
}

// Step 3: Implementation that passes
public double firstMomentAbout(double point) {
    double numerator = 0.0;
    for (Iterator it = elements.iterator(); it.hasNext(); ) {
        double element = ((Double)(it.next())).doubleValue();
        numerator += element - point;
    }
    return numerator / elements.size();
}

// Step 4: No duplication yet — proceed

Iteration 2: edge case — empty elements list

// Step 1: New failing test for the zero-division case
public void testFirstMoment_noElements() {
    try {
        new InstrumentCalculator().firstMomentAbout(0.0);
        fail("expected InvalidBasisException");
    } catch (InvalidBasisException e) { }
}

// Step 2: Update declaration to compile
public double firstMomentAbout(double point) throws InvalidBasisException { ... }

// Step 3: Add guard clause to pass
public double firstMomentAbout(double point) throws InvalidBasisException {
    if (elements.size() == 0)
        throw new InvalidBasisException("no elements");
    // ... existing loop ...
}

// Step 4: When secondMomentAbout arrives, extract nthMomentAbout(point, n)
//         to remove the loop duplication between the two methods.

Why the duplication step is non-negotiable: Skipping it leaves clones in the codebase. Future developers find firstMomentAbout and secondMomentAbout side by side, both with the same loop, and change one but not the other. The TDD cycle's power comes from the entire loop — removing duplication is the step that makes the code better, not just tested.

Step 4 (Programming by Difference Path): Create a Testing Subclass

Instead of modifying the existing class, create a subclass whose sole purpose is to introduce the new feature. Write tests against the subclass, not the parent.

// Existing tested class — do not touch yet
public class MessageForwarder {
    private InternetAddress getFromAddress(Message message) { ... }
    // ...
}

// New subclass introduces the feature
public class AnonymousMessageForwarder extends MessageForwarder {
    @Override
    protected InternetAddress getFromAddress(Message message)
            throws MessagingException {
        return new InternetAddress("anon-members@" + listAddress);
    }
}

// Test drives against the subclass
public void testAnonymous() throws Exception {
    MessageForwarder forwarder = new AnonymousMessageForwarder();
    forwarder.forwardMessage(makeFakeMessage());
    assertEquals("anon-members@" + forwarder.getDomain(),
                 expectedMessage.getFrom()[0].toString());
}

The test passes. The feature is proven. Now you have a decision to make (Step 5).

Step 5 (PbD Path): Normalize the Inheritance

Once the feature tests are passing, decide how to integrate:

Option A — Push behavior UP (shared by all subclasses): The feature belongs in the parent class. Refactor the parent to include the logic (e.g., via a configuration flag or a new abstract method), verify all tests still pass, and delete the subclass.

// Move anonymous logic into MessageForwarder via configuration
private InternetAddress getFromAddress(Message message) throws MessagingException {
    if (configuration.getProperty("anonymous").equals("true")) {
        return new InternetAddress("anon-members@" + domain);
    }
    Address[] from = message.getFrom();
    if (from != null && from.length > 0)
        return new InternetAddress(from[0].toString());
    return new InternetAddress(getDefaultFrom());
}
// AnonymousMessageForwarder is now deletable — tests pass without the override

Option B — Push behavior DOWN (specialized subclass): The behavior is genuinely specific to one variant. Make the parent method abstract, force subclasses to provide their own implementation. No subclass should override a concrete method — that is the normalized form.

// Normalized hierarchy: parent is abstract, subclasses provide implementations
public abstract class MessageForwarder {
    protected abstract InternetAddress getFromAddress(Message message)
            throws MessagingException;
}
public class StandardMessageForwarder extends MessageForwarder {
    protected InternetAddress getFromAddress(Message message) { ... }
}
public class AnonymousMessageForwarder extends MessageForwarder {
    protected InternetAddress getFromAddress(Message message) { ... }
}

What is a normalized hierarchy? A hierarchy in which no class has a method that overrides a concrete method it inherited. Every method is either implemented once (in the class that owns it) or abstract (delegated to all subclasses). In a normalized hierarchy, "How does this class do X?" has a single, unambiguous answer.

Step 6: Watch for LSP Violations

During PbD normalization, check whether the subclass changes method semantics in a way that breaks callers who expect the parent's behavior.

Classic LSP violation pattern:

// Parent: setWidth(3) and setHeight(4) gives area = 12
Rectangle r = new Square();
r.setWidth(3);
r.setHeight(4);
// Actual area: 16 — Square.setWidth() also changed height to maintain squareness
// Caller expected 12. LSP is violated.

Red flags during normalization:

  1. The subclass override changes a return value's meaning, not just its value.
  2. A caller that holds a reference typed as the parent class would behave differently if it received a subclass instance.
  3. The override removes a guarantee the parent's contract provided (e.g., parent never throws, subclass throws).

Rules of thumb to avoid LSP violations:

  • Prefer not to override concrete methods. If you must, call super.method() inside the override wherever possible.
  • If the override changes semantics rather than extends them, use composition (delegate to a collaborator object) instead of inheritance.

If you detect an LSP violation during normalization, do not suppress it. Restructure: either use composition, make the parent abstract, or move the feature out of the inheritance hierarchy entirely.

Step 7: Integrate

After normalization (PbD) or after the last duplication-removal pass (TDD):

  1. Run the full test suite for the class. All tests — old and new — must pass.
  2. Search for callers of any methods you modified or added. Verify callers compile and behave correctly with the new behavior.
  3. Delete any scaffolding: temporary subclasses used only during PbD, helper stubs added to make tests compile.

Inputs

InputRequiredDescription
Target class with testsYesThe class to extend — must have a passing test baseline
Feature specificationYesWhat the new behavior should do, including known edge cases
Language + OO capabilityYesDetermines whether PbD is available
Integration intentNoWhether the new behavior is shared or specialized — informs normalization direction

Outputs

OutputDescription
Feature codeNew methods or overrides implementing the specified behavior
Passing testsNew test cases covering the feature (base case, edge cases, error cases)
Normalized hierarchyFor PbD: inheritance resolved to push-up or push-down form with no LSP violations
chosen-approach.mdBrief record of why TDD or PbD was selected and how normalization was resolved (create in the working directory or PR description)

Key Principles

TDD is the default — PbD only when integration decision is deferred. TDD adds the feature in place with full test support. PbD defers the integration decision to after the feature is proven. Do not use PbD because inheritance "seems easier" — it is only appropriate when you genuinely cannot or should not modify the existing class directly.

The duplication-removal step is a first-class step, not a suggestion. Every TDD cycle must complete through step 4. Leaving duplication behind — two methods with the same loop, two classes with the same logic — creates a compound maintenance burden. The tests make removal safe. Do it now, not "later."

PbD's test subclass is scaffolding — normalize the inheritance after the feature works. A subclass that overrides a concrete method is a temporary construction. Once you have a passing test, decide where the behavior really belongs (parent or subclass) and restructure. Do not ship a normalized-by-accident hierarchy.

LSP violation = subclass changes method semantics in a way that breaks callers. Overriding a concrete method silently redirects callers who are unaware of the subclass. If a caller typed as Parent p = new ChildInstance() would receive different behavior from Parent p = new ParentInstance() in a way they cannot predict or handle, you have an LSP violation. Detect it during normalization; fix it before shipping.

Examples

Example 1: TDD on InstrumentCalculator

Task: Add firstMomentAbout(double point) to a class with no methods yet.

Iteration 1 writes testFirstMoment() asserting −0.5 for inputs [1.0, 2.0] about point 2.0. The stub returns NaN (compiles, fails). The implementation adds the numerator loop and returns numerator / elements.size(). No duplication yet — proceed.

Iteration 2 writes testFirstMoment_noElements() expecting InvalidBasisException. The stub adds the declaration but no guard — the test throws ArithmeticException (wrong exception, still fails). Adding the guard if (elements.size() == 0) throw new InvalidBasisException(...) makes it pass.

When secondMomentAbout is needed, copy the loop, change the accumulator to Math.pow(element - point, 2.0), get the test passing, then extract nthMomentAbout(double point, double n) as a shared private. Both public methods delegate to it. Duplication removed.

Example 2: PbD on a Tested PaymentProcessor

Task: Add TrialUser behavior — 50% discount on everything — without modifying PaymentProcessor directly (it is owned by another team and used by 40 callers).

  1. Subclass: TrialPaymentProcessor extends PaymentProcessor. Override calculateCharge(double basePrice) to return basePrice * 0.5.
  2. Write tests against TrialPaymentProcessor. Tests pass.
  3. Normalize: Does the discount belong in the parent? No — it is specific to trial users. Push DOWN: make calculateCharge abstract in PaymentProcessor. Create StandardPaymentProcessor for the existing logic. TrialPaymentProcessor provides the discounted implementation.
  4. Check LSP: callers using PaymentProcessor references receive either standard or trial logic — both return a valid charge amount of the same type. No semantic breach. No violation.

Example 3: LSP Violation Detected and Restructured

Task: Add PremiumEmployee as a PbD subclass of Employee. Override pay() to return a struct with base + bonus, whereas Employee.pay() returns a plain salary double.

This violates LSP: callers typed as Employee e = new PremiumEmployee() would receive a struct where they expected a double, breaking code that assigns the result to a double variable.

Fix: Do not override pay(). Instead, add a new payWithBonus() method on PremiumEmployee. Or restructure Employee.pay() to return a PayResult object in all cases, with a bonus field that is 0.0 for standard employees. Either approach preserves substitutability.

References

  • references/legacy-code-fundamentals.md — core definitions: seams, normalized hierarchy, LSP

License

CC BY-SA 4.0 — BookForge Skills, derived from "Working Effectively with Legacy Code" by Michael C. Feathers. Skill interpretation, structure, and synthesis are original BookForge content.

Related BookForge Skills

Direct dependencies (run before this skill):

  • legacy-code-change-algorithm — this skill executes steps 4–5 of that algorithm (write tests, then make the change)
  • safe-legacy-editing-discipline — guides careful editing once tests are in place
  • characterization-test-writing — required prerequisite if the class is not yet under test

Invoke instead if class is not testable:

  • legacy-code-addition-techniques — Sprout Method, Sprout Class, Wrap Method for code that cannot be brought under test

Cross-reference:

  • unit-test-quality-checker — validates that the new tests written in this skill meet quality thresholds

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

Legacy Code Addition Techniques

Add new functionality to untested legacy code using Sprout Method, Sprout Class, Wrap Method, or Wrap Class — whichever best fits the dependency profile. Use...

Registry SourceRecently Updated
700Profile unavailable
Coding

Test Harness Entry Diagnostics

Diagnose exactly why a class or method cannot be placed under test and route to the right dependency-breaking technique. Use whenever a developer says 'I can...

Registry SourceRecently Updated
430Profile unavailable
Coding

Seam Type Selector

Select the right seam type (Preprocessor / Link / Object) for breaking a dependency in legacy code. Use whenever a developer needs to substitute behavior for...

Registry SourceRecently Updated
660Profile unavailable
Coding

Monster Method Decomposition

Decompose a very large method (100+ lines, deeply nested) safely using automated refactoring and Feathers' Bulleted/Snarled classification. Use whenever a de...

Registry SourceRecently Updated
630Profile unavailable