Unit testing and test-driven development are practices that makes it easy and efficient to create well-structured and well-working code. However, many software projects didn't create unit tests from the beginning.
In this presentation I will show a test automation strategy that works well for legacy code, and how to implement such a strategy on a project. The strategy focuses on characterization tests and refactoring, and the slides contain a detailed example of how to carry through a major refactoring in many tiny steps
2. Who is Lars Thorup?
● Software developer/architect
● JavaScript, C#
● Test Driven Development
● Continuous Integration
● Coach: Teaching TDD and
continuous integration
● Founder of ZeaLake
● @larsthorup
3. The problems with legacy code
● No tests
● The code probably works...
● Hard to refactor
● Will the code still work?
● Hard to extend
● Need to change the code...
● The code owns us :(
● Did our investment turn sour?
4. How do tests bring us back in control?
● A refactoring improves the design without changing
behavior
● Tests ensure that behavior is not
accidentally changed
● Without tests, refactoring is scary
● and with no refactoring, the design decays over time
● With tests, we have the courage to refactor
● so we continually keep our design healthy
6. How do we get to sustainable legacy code?
● Make it easy to add characterization tests
● Have good unit test coverage for important areas
● Don't worry about code you don't need to change
● Test-drive all new code
● Now we own the code :)
7. Making legacy code sustainable
● Select an important area
● Driven by change requests
● Add characterization tests
● Make code testable
● Refactor the code
● Add unit tests
● Remove characterization tests
● Small steps
8. Characterization tests
● Characterize current
behavior
● Integration tests
● Either high level unit tests
● Or end-to-end tests
● Don't change existing code
● Faulty behavior = current
behavior: don't change it!
● Make a note to fix later
● Test at a level that makes it
easy
● The characterization tests
are throw-aways
● Demo:
● Web service test: VoteMedia
● End-to-end browser test:
entrylist.demo.test.js
9. Make code testable
● Avoid large methods
● They require a ton of setup
● They require lots of scenarios to cover all variations
● Avoid outer scope dependencies
● They require you to test at a higher level
● Avoid external dependencies
● ... a ton of setup
● They slow you down
10. Refactor the code
● Add interface
● Inject a mock instead of the
real thing
● Easier setup
● Infinitely faster
Notifier
EmailSvc
IEmailSvc
EmailSvcStub
NotifierTest
● Extract method
● Split up large methods
● To simplify unit testing single
behaviors
● Demo:
VoteWithVideo_Vimas
● Add parameter
● Pass in outer-scope
dependencies
● The tests can pass in their
own dummy values
● Demo:
Entry.renderResponse
11. Add unit tests
● Now that the code is testable...
● Write unit tests for you small methods
● Pass in dummy values for parameters
● Mock dependencies
● Rinse and repeat...
12. Remove the characterization tests
● When unit test code coverage is good enough
● To speed up feedback
● To avoid test duplication
13. Small steps - elephant carpaccio
● Any big refactoring...
● ...can be done in small steps
● Demo: Security system (see slide 19 through 31)
14. Test-drive all new code
● Easy, now that unit testing tools are in place
Failing
test
Succeeding
test
Good
design Refactor
Test
Intention
Think, talk
Code
15. Making legacy code sustainable
● Select an important area
● Driven by change requests
● Add characterization tests
● Make code testable
● Refactor the code
● Add unit tests
● Remove characterization tests
● Small steps
16. It's not hard - now go do it!
● This is hard
● SQL query efficiency
● Cache invalidation
● Scalability
● Pixel perfect rendering
● Cross-browser compatibility
● Indexing strategies
● Security
● Real time media streaming
● 60fps gaming with HTML5
● ... and robust Selenium tests!
● This is not hard
● Refactoring
● Unit testing
● Dependency injection
● Automated build and test
● Continuous Integration
● Fast feedback will make
you more productive
● ... and more happy
18. Avoid feature branches
● For features as well as large refactorings
● Delayed integration
● Increases risk
● Increases cost
19. Use feature toggles
● Any big refactoring...
● ...can be done in small
steps
● Allows us to keep
development on
trunk/master
● Drastically lowering the risk
● Commit after every step
● At most a couple of hours
20. Security example
● Change the code from the
old security system
● To our new extended
security model
interface IPrivilege
{
bool HasRole(Role);
}
class Permission
{
bool IsAdmin();
}
21. Step 0: existing implementation
● Code instantiates
Legacy.Permission
● and calls methods like
permission.IsAdmin()
● ...all over the place
● We want to replace this
with a new security system
void SomeController()
{
var p = new Permission();
if (p.IsAdmin())
{
...
}
}
22. Step 1: New security implementation
● Implements an interface
● This can be committed
gradually
interface IPrivilege
{
bool HasRole(Role);
}
class Privilege : IPrivilege
{
bool HasRole(Role r)
{
...
}
}
24. Step 3: Factory
● Create a factory
● Have it return the new
implementation
● Unless directed to return
the wrapped old one
class PrivilegeFactory
{
IPrivilege Create(bool old=true)
{
if(!old)
{
return new Privilege();
}
return new LegacyPermission();
}
}
25. Step 4: Test compatibility
● Write tests
● Run all tests against both
implementations
● Iterate until the new
implementation has a
satisfactory level of
backwards compatibility
● This can be committed
gradually
[TestCase(true)]
[TestCase(false)]
void HasRole(bool old)
{
// given
var f = new PrivilegeFactory();
var p = f.Create(old);
// when
var b = p.HasRole(Role.Admin);
// then
Assert.That(b, Is.True);
}
26. Step 5: Dumb migration
● Replace all uses of the old
implementation with the
new wrapper
● Immediately use the
exposed old
implementation
● This can be committed
gradually
void SomeController()
{
var priv = f.Create(true)
as LegacyPermission;
var p = priv.Permission;
if (p.IsAdmin())
{
...
}
}
27. Step 6: Actual migration
● Rewrite code to use the
new implementation
instead of the exposed old
implementation
● This can be committed
gradually
void SomeController()
{
var p = f.Create(true);
if (p.HasRole(Role.Admin)
{
...
}
}
28. Step 7: Verify migration is code complete
● Delete the property
exposing the old
implementation
● Go back to previous step if
the code does not compile
● Note: at this point the code
is still using the old
implementation
everywhere!
class LegacyPermission : IPrivilege
{
...
// Permission Permission
// {
// get: { return p; }
// }
private Permission p;
}
29. Step 8: Verify migration works
● Allow QA to explicitly switch
to the new implementation
● We now have a Feature
Toggle
● Do thorough exploratory
testing with the new
implementation
● If unintented behavior is
found, go back to step 4
and add a new test that
fails for this reason, fix the
issue and repeat
class PrivilegeFactory
{
IPrivilege Create(bool old=true)
{
var UseNew = %UseNew%;
if(!old || UseNew)
{
return new Privilege();
}
return new LegacyPermission();
}
}
30. Step 9: Complete migration
● Always use the new
implementation
● Mark the old
implementation as
Obsolete to prevent new
usages
class PrivilegeFactory
{
IPrivilege Create()
{
return new Privilege();
}
}
[Obsolete]
class Permission
{
...
}
31. Step 10: Clean up
● After proper validation in
production, delete the old
implementation