Adding Tests to
Untestable Legacy Code
HEISENBUG MOSCOW | OCT 5, 2021 @afilina
Slide 2
Slide 2 text
"We need tests to refactor,
but we need to refactor the code
to make it testable."
Only a problem if we write the wrong tests at
the wrong time of the refactoring process.
Slide 3
Slide 3 text
Anna Filina
• Coding since 1997
• VB, PHP, Java, Ruby, C#, etc.
• Legacy archaeology
• Test automation
• Public speaking
• Mentorship
• Twitter legacy woes
• YouTube videos
Slide 4
Slide 4 text
No Tests
Hard to justify a
testing spree.
Slide 5
Slide 5 text
Always write tests
with other work.
Don't make it a
standalone task.
Slide 6
Slide 6 text
• Bug report.
• New feature.
• Refactoring.
• Build coverage over time.
Slide 7
Slide 7 text
Not Unit-Testable
Slide 8
Slide 8 text
• Bootstraps the framework.
• Hits the database.
• Call external APIs.
• Requires extensive mocking.
• Can't mock.
The code is not untestable,
just not unit-testable.
Slide 9
Slide 9 text
Unit tests end up being
convoluted and not useful.
Natural response: refactor to
make code more testable.
Slide 10
Slide 10 text
Unit tests are sensitive to structure
changes. Say you test a class.
Slide 11
Slide 11 text
That class might not exist after refactoring, so
you throw the test away. What was the point?
Slide 12
Slide 12 text
• Break up class.
• Extract method.
• Change method signature.
• Reorganize dependencies.
Such changes will invalidate unit tests. This
defies the purpose of regression tests.
Slide 13
Slide 13 text
You need to keep the same test for the "before" and
"after" code, so don't write unit tests yet.
Slide 14
Slide 14 text
Start with acceptance tests. Interact with the
whole system using request/response.
Slide 15
Slide 15 text
ASP
Classic
Tests like that don't even care in which language
the application is written.
Slide 16
Slide 16 text
PHP
I used this approach to migrate an application
from ASP Classic to PHP.
Slide 17
Slide 17 text
Scenario: User can subscribe with a credit card
Given I selected a subscription level
When I enter valid credit card details
Then I should see a payment receipt
For acceptance tests, I use any framework
that supports Gherkin.
Slide 18
Slide 18 text
• PHP: Behat
• C#: SpecFlow
• Java: Cucumber
There are even frameworks for testing
mobile and desktop applications.
Slide 19
Slide 19 text
Slower but fewer
You'll still use unit tests for the fine-grained
logic and implementation details.
Slide 20
Slide 20 text
Unit Tests
Slide 21
Slide 21 text
Acceptance tests will pass with both the
original and refactored code.
Slide 22
Slide 22 text
Any time you refactor, write new unit
tests for the new code.
Slide 23
Slide 23 text
class ProductController extends AbstractController
{
public function search()
{
//...
return $this->render("products/search.html.twig", $products);
}
}
To test this controller, you need to bring in
the entire framework.
Slide 24
Slide 24 text
class ProductController
{
//...
public function __construct(Templating $templating)
{
$this->templating = $templating;
}
public function search()
{
//...
return $this->templating->render("products/search.html.twig", $products);
}
}
Don't extend. Inject dependencies
so you can mock them.
Slide 25
Slide 25 text
• Write high-level tests that survive refactoring.
• Write unit tests for new or refactored code.
This is the plan for an application that has
no tests.
Slide 26
Slide 26 text
Broken Tests
Some applications already have tests, but
they might be broken or of poor quality.
Slide 27
Slide 27 text
• Didn't compile.
• Most failed due to DB changes.
• After fixes, many kept failing.
• After review, most were wrong or redundant.
This was an application when one small feature had
300 acceptance tests, but we couldn't use them.
Slide 28
Slide 28 text
• Checking that something did not happen.
• Comparing large DB dump after execution.
• Many unnecessary permutations.
• Many scenarios still untested.
Example: e-mail not sent, but this
can happen due to a crash.
Slide 29
Slide 29 text
Afraid to throw
code away
The alternative can be even more costly.
Slide 30
Slide 30 text
• Fixing estimated at 6 months.
• The tests would still be unmaintainable.
• Many scenarios would still require new tests.
Slide 31
Slide 31 text
• 46 new tests.
• All scenarios covered.
• No redundancies.
• Easy to read and to maintain.
Scenario: Add warning for invalid city
Given city exists for RU-MSK
And file contains entry for RU-MSK / 2021-01-01
And file contains entry for CA-MTL / 2021-01-01
When I execute Import Daily Weather
Then 1 weather entry should have been imported
And warning Invalid city code "CA-MTL" should be logged
New tests were readable and maintainable.
Only 46 of them, and they covered all use cases.
Slide 34
Slide 34 text
• Will the tests survive refactoring?
• Effort of fixing vs rewriting tests.
• Do the tests give you confidence?
• Can you maintain these tests?
Based on those answers, you'll know
whether to salvage or rewrite the tests.