Edmonds Commerce - Legacy Code Testing
Overview
Add characterisation tests and approval tests to untested code. Enable safe refactoring with comprehensive test coverage.
The Testing Challenge for Legacy Code
Problem: No Tests = No Refactoring
Legacy code without tests is expensive and risky to modify.
Risks Without Tests:
- Unintended side effects
- Regression bugs introduced
- Developers hesitant to change code
- Technical debt accumulates
- Performance degrades silently
Solution: Characterisation Tests
Capture current behavior as a safety net for refactoring.
Characterisation Testing
What Are Characterisation Tests?
Tests that document the current behavior of code, right or wrong. They establish a baseline before refactoring begins.
Purpose:
- Document current behavior
- Prevent regression
- Enable confident refactoring
- Create safety net
How Characterisation Tests Work
// Characterisation test - documents current behavior
public function testUserPasswordHash() {
$user = new User('test@example.com', 'password123');
// What does the code actually do? Document it.
$this->assertNotNull($user->password_hash);
$this->assertTrue(strlen($user->password_hash) > 10);
// Maybe it uses md5 (bad), but document current behavior
}
Writing Characterisation Tests
- Run Legacy Code: Execute the behavior you're testing
- Observe Results: What actually happens?
- Document Behavior: Write test asserting observed behavior
- Validate Test: Verify test catches changes to behavior
The Process
1. Write Test (fails - code isn't tested yet)
2. Execute Legacy Code (observe results)
3. Assert Observed Behavior (test passes)
4. Now you have safety net for refactoring
Approval Testing
What Is Approval Testing?
Compare actual output against a previously-approved output file. Great for complex systems with many outputs.
Use Cases:
- Multi-step workflows producing files/reports
- Complex query results
- Generated documents
- System configuration outputs
Approval Testing Example
public function testOrderProcessing() {
$order = $this->getComplexOrder();
$result = $processor->process($order);
// Approve output against file
\Approvals::verify($result);
// First run: creates result.approved.txt
// Later runs: compares against approved
}
First Run:
Generated: order-result.received.txt
Action: Review and rename to order-result.approved.txt
Later Runs:
Compare: order-result.received.txt vs order-result.approved.txt
Result: If different, test fails (you changed the behavior)
Legacy Testing Strategies
Strategy 1: Seam Model
Identify "seams" in code where you can inject test doubles.
Seams Enable:
- Dependency injection without refactoring
- Isolation of components
- Testing without external dependencies
class LegacyService {
private $database;
public function __construct($database = null) {
// Seam: can pass test double
$this->database = $database ?? Database::getInstance();
}
}
// In test:
$testDB = new MockDatabase();
$service = new LegacyService($testDB);
Strategy 2: Characterisation Tests First
Before refactoring:
- Write characterisation tests
- Refactor incrementally
- Keep tests passing
- Gradually improve tests
// Phase 1: Document current behavior
public function testComplexLogic() {
$result = $legacyFunction($input);
$this->assertEquals($expectedOutput, $result);
}
// Phase 2: Refactor, tests stay passing
// Phase 3: Improve test quality
Strategy 3: Approval Tests for Workflows
Complex multi-step processes benefit from approval tests.
public function testCompleteCheckoutWorkflow() {
$order = $this->createOrder();
$order->addItem($this->product, 2);
$order->applyDiscount('SAVE10');
$order->process();
$result = json_encode($order->toArray());
\Approvals::verify($result);
}
Strategy 4: Sprout Method Pattern
Extract untested code into new, testable method.
// Before: untestable mixed code
function processData($data) {
// ... 100 lines of legacy code ...
$result = complexCalculation($data); // hard to test
// ... more code ...
}
// After: extract "sprout"
function complexCalculation($data) {
// Extracted, now testable in isolation
return $result;
}
function processData($data) {
// ... legacy code ...
$result = complexCalculation($data); // can now test
// ... more code ...
}
Testing Tools for Legacy Code
PHPUnit/Pest
Standard PHP testing frameworks.
- Unit test untestable components
- Integration test through facades
- Mock external dependencies
Mockery
Create test doubles (mocks, stubs, spies).
$database = Mockery::mock(Database::class);
$database->shouldReceive('query')
->with('SELECT ...')
->andReturn($testData);
Prophecy
Doubles and spies for testing.
$prophet = new Prophecy\Prophet();
$db = $prophet->prophesize(Database::class);
$db->query('SELECT ...')->willReturn($data);
Approval Tests
Compare outputs against approved baselines.
\Approvals::verify($actualOutput, $approvalName);
Our Legacy Testing Approach
1. Baseline Coverage Analysis
Understand current test situation.
Analysis:
- Test coverage percentage
- Untested modules
- Critical paths without tests
- Risk areas
2. Test Addition Strategy
Plan test addition systematically.
Priority:
- Critical business logic (highest risk)
- Complex algorithms (hard to understand)
- Frequently modified code
- Performance-sensitive code
3. Characterisation Tests
Document current behavior before refactoring.
Process:
- Run legacy code
- Capture output
- Write test asserting current behavior
- Creates safety net
4. Targeted Test Addition
Add unit tests incrementally.
Approach:
- Extract methods that are testable
- Inject dependencies
- Write unit tests
- Gradually improve
5. Integration Testing
Test workflows through facades.
Coverage:
- Happy path workflows
- Error conditions
- Edge cases
- Boundary conditions
Test Maintenance
Keeping Tests Current
Tests must stay aligned with behavior.
Discipline:
- Update tests when behavior changes intentionally
- Investigate test failures (not always code bugs)
- Remove obsolete tests
- Refactor tests alongside code
Test Coverage Targets
Legacy Code:
- Minimum 70-80% coverage
- 100% on critical paths
- Focus on behavior, not line coverage
Continuous Testing
Run tests continuously.
Strategy:
- Unit tests on every commit
- Integration tests on pull requests
- Nightly full test suite
- Production smoke tests
Timeline & Effort
Adding Tests to 50k LOC Legacy Codebase:
- 4-8 weeks for comprehensive coverage
- 2-4 weeks for critical path testing
- Ongoing maintenance
Cost:
- Initial effort: 5-15% of refactoring budget
- Return: Enables all refactoring work
- Worth the investment
Testing Metrics
Before Testing Program:
- Coverage: 10-20%
- Test-driven changes: impossible
- Refactoring risk: very high
After Testing Program:
- Coverage: 80%+
- Test-driven changes: standard
- Refactoring risk: low
Common Testing Patterns
Mocking External Dependencies:
$service = Mockery::mock(PaymentGateway::class);
$service->shouldReceive('charge')->andReturn(true);
Characterisation Test:
// Document what the code actually does
$result = $legacyFunction();
$this->assertEquals($observedResult, $result);
Approval Test:
// Compare against approved output
Approvals::verify($output);
Target Audiences
Legacy System Teams: Add tests before refactoring.
Risk-Averse Teams: Tests reduce refactoring risk significantly.
Scaling Teams: Tests enable confident growth.
Quality-Focused Teams: Invest in test foundation.
Related Services
Legacy Refactoring: Refactor with confidence when tests in place.
PHP 8 Migration: Tests validate migration safety.
Code Audit: Identify highest-priority areas for testing.
Contact
Based in the UK, serving global clients. Discuss your testing strategy, coverage goals, or legacy system testing needs.