Skip to Content
Course content

231: Introducing Tests to Untested Legacy Code

Click on the "Edit" button in the top corner of the screen to edit your slide content.

How do I even start when the code is a total mess and I'm terrified of breaking something?

I've been there. You're staring at a 500-line method in a class called OrderManager that handles everything from database inserts to sending confirmation emails. You want to fix a bug, but you have no idea what else will break if you change a single line. This is where you use "Characterization Tests."

Forget about "correctness" for a second. Your goal isn't to test if the code does what it should do, but to document what it actually does. I call this the "Golden Master" approach. You feed the method a set of inputs, capture the output (or the state of the database), and save that as your baseline. If you change the code and the output changes, you've found a regression.

// Legacy code you're afraid to touch
public function processOrder($orderId) {
    $order = $this->db->find($orderId);
    $total = $order->amount * 1.15; // Hardcoded tax? Yikes.
    $this->db->update($orderId, ['total' => $total, 'status' => 'processed']);
    return "Order $orderId processed with total $total";
}

// Your first characterization test
public function testProcessOrderCurrentBehavior() {
    $service = new OrderManager(); 
    // I don't care if this is 'wrong', I just want to know if it changes
    $result = $service->processOrder(123); 
    $this->assertEquals("Order 123 processed with total 115", $result);
}

It feels wrong to assert something that might be a bug, but trust me: you need a safety net before you start swinging the hammer.

The code is tightly coupled to the database—how do I test it without hitting the live DB?

This is the classic legacy PHP struggle. You can't run a unit test if the constructor does $this->db = new MySQLi(...); because your tests will suddenly depend on a running database, which makes them slow and brittle.

You need to create a "seam." The easiest way is to stop the class from creating its own dependencies and start asking for them instead. This is Dependency Injection, but we're doing it as a surgical strike. I usually start by adding an optional parameter to the constructor to avoid breaking every other file in the project that instantiates this class.

// BEFORE: Hardcoded dependency
class OrderManager {
    public function __construct() {
        $this->db = new DatabaseConnection(); 
    }
}

// AFTER: Introducing a seam
class OrderManager {
    private $db;
    public function __construct(DatabaseConnection $db = null) {
        // If no DB is passed (production), use the old way.
        // If a DB is passed (test), use the mock.
        $this->db = $db ?: new DatabaseConnection();
    }
}

Now, in your test, you can pass in a mock object. You've effectively decoupled the business logic from the infrastructure without having to rewrite the entire application architecture in one afternoon.

Shouldn't I just rewrite the messy parts first so they're actually testable?

Absolutely not. This is the most dangerous trap in software engineering. I've seen countless developers spend two weeks "cleaning up" a legacy module, only to realize they've accidentally deleted a critical edge-case fix that was implemented five years ago by someone who is no longer with the company.

The rule is: Never rewrite without tests. If the code is too ugly to test, use the "Extract Method" refactor. Take a chunk of the logic that doesn't touch the database—like a tax calculation—and move it into its own small, public method. You can test that small method in isolation immediately. Once that's covered, move to the next chunk.

You aren't aiming for perfection right now; you're aiming for coverage. Once the legacy code is wrapped in a blanket of tests, you can rewrite it with the confidence that you aren't breaking the business.




📋 Practical Task

Refactoring the Legacy InvoiceCalculator

You have been handed a legacy class called InvoiceCalculator. It currently calculates a final price but is impossible to unit test because it instantiates a CurrencyConverter class directly inside its method, which makes an external API call to get live exchange rates.

class InvoiceCalculator {
    public function calculateTotal($amount, $currency) {
        $converter = new CurrencyConverter(); // The problematic dependency
        $rate = $converter->getRate($currency);
        return $amount * $rate;
    }
}

Your Task:

  • Modify the InvoiceCalculator class to introduce a "seam" using the constructor pattern discussed in the lesson.
  • Ensure that the class still works for existing production code (it should still create a CurrencyConverter if none is provided).
  • Write a PHPUnit test case that passes a mocked CurrencyConverter into the InvoiceCalculator to verify that the calculateTotal method correctly multiplies the amount by the rate returned by the mock.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.