Upload
lars-thorup
View
410
Download
4
Embed Size (px)
DESCRIPTION
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
Citation preview
Unit testinglegacy codeLars ThorupZeaLake Software Consulting
May, 2014
Who is Lars Thorup?
● Software developer/architect● JavaScript, C#● Test Driven Development● Continuous Integration
● Coach: Teaching TDD and continuous integration
● Founder of ZeaLake
● @larsthorup
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?
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
What comes first: the test or the refactoring?
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 :)
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
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
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
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
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...
Remove the characterization tests● When unit test code coverage is good enough
● To speed up feedback
● To avoid test duplication
Small steps - elephant carpaccio● Any big refactoring...
● ...can be done in small steps
● Demo: Security system (see slide 19 through 31)
Test-drive all new code● Easy, now that unit testing tools are in place
Failingtest
Succeedingtest
Gooddesign Refactor
Test
IntentionThink, talk
Code
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
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
A big refactoring is needed...
Avoid feature branches● For features as well as large refactorings
● Delayed integration● Increases risk● Increases cost
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
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();
}
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())
{
...
}
}
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)
{
...
}
}
Step 2: Wrap the old implementation● Create
Security.LegacyPermission
● Implement new interface
● Wrap existing implementation
● Expose existing implementation
class LegacyPermission : IPrivilege
{
LegacyPermission(Permission p)
{
this.p = p;
}
bool HasRole(Role r)
{
if (r == Role.Admin)
return p.IsAdmin();
return false;
}
Permission Permission
{
get: { return p; }
}
private Permission p;
}
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();
}
}
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);
}
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())
{
...
}
}
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)
{
...
}
}
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;
}
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();
}
}
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
{
...
}
Step 10: Clean up● After proper validation in
production, delete the old implementation