Effectief Werken met Legacy Code & TypeMock.NET
Het toevoegen van functionaliteit, oplossen van een probleem, verbeteren van het ontwerp en optimaliseren zijn de meest voorkomende redenen voor het aanpassen van bestaande software. De grootste uitdaging hierbij is het behouden van het originele gedrag. Het maken van een impactanalyse, reviewen van code en het uitvoeren van regressietests zijn manieren om het risico dat het originele gedrag verandert, zo veel mogelijk te beperken. Dit artikel geeft een, voor sommige radicale, visie op legacy code. Vanuit deze visie worden technieken en tips geïntroduceerd voor het aanpassen van legacy code.
Wat is Legacy Code?
Cambridge Dictionary - Legacy : "Something that is a part of your history or which stays from an earlier time".
Onder legacy code verstaan de meeste ontwikkelaars verwarde, ongestructureerde code waar we aanpassing in aan moeten brengen maar die we nog niet echt begrijpen. Code zonder tests is legacy code. Het maakt niet uit hoe goed geschreven, hoe objectgeoriënteerd of hoe ingekapseld de code is. Met tests is het relatief eenvoudig om snel, controleerbaar en met vertrouwen aanpassingen te maken. Zonder tests hebben we eigenlijk geen idee of de code is verbeterd of verslechterd.
Inventariseren van Legacy Code
Als voorbeeld nemen we een fictieve (sterk vereenvoudigde) betaalmodule die voor medewerkers van een verkooporganisatie salarissen berekent. De verkooporganisatie wil voor iedere geslaagde verkoop aan de betreffende medewerker een bonus toekennen. Er is gevraagd deze functionaliteit toe te voegen aan de betaalmodule. De betaalmodule bestaat uit drie klassen:

Fig. 1: Klassediagram van de betaalmodule
- EmployeePayment: Haalt medewerker op, berekent salaris
- Employee: Vertegenwoordigt medewerker, persisteert medewerker
- EmployeeDao: Persisteert medewerkergegevens
Het werken in onbekende code, zeker legacy code, kan beangstigend zijn. Meestal proberen we een beter begrip te krijgen door het maken van notities en schetsen. Een andere methode is het printen van code waarin we de verantwoordelijkheden markeren en code omcirkelen om deze vervolgens op te breken in aparte methodes.
Refactoring: Is het herstructureren van bestaande code, zodat de complexiteit afneemt en de code zich minder star gedraagt bij aanpassingen zonder bestaand gedrag te veranderen.
De meest efficiënte methode is Scratch Refactoring [Feathers]. Hierbij haal je de code uit het source control systeem en begin je te refactoren. Langzaam wordt duidelijk hoe de code precies werkt en gooi vervolgens de aangepaste code weg. Belangrijk is ook de vertragingstijden onder controle te houden. Hoe vaak verdrinken we niet in een zee van aanpassingen als we genoodzaakt zijn kleine aanpassingen te bundelen door de enorme build en deploy tijden. Door de feedbackloop op de aangepaste code kort te houden, verkleinen we de kans om op een dood spoor te belanden.
Afhankelijkheden spelen een belangrijke rol bij het aanpassen van legacy code. De EmployeePayment klasse (zie listing 1) bevat de businesslogica voor berekenen van het salaris van een medewerker. De EmployeePayment klasse een afhankelijkheid met de EmployeeDao implementatie (zie listing 4) wat het testen van de businesslogica bemoeilijkt.
Afhankelijkheden zijn een groot probleem in legacy code
Aan de slechte testbaarheid van deze klasse valt af te lezen dat deze niet gebouwd is op basis van Test Driven Development [Beck TDD]. Test Driven Development is een methode waarbij eerst een geautomatiseerde test wordt gedefinieerd waarna precies genoeg productiecode wordt geschreven om de test te doen slagen. Deze methode werkt een testbaar ontwerp in de hand, waarbij de nadruk ligt op afhankelijkheden. Voor dit soort situaties passen we vaak het Dependency Inversion Principle [Martin DIP] toe. In plaats van afhankelijkheid van de EmployeeDao implementatie verleg je de afhankelijkheid naar iets abstracters, in dit geval een interface (IEmployeeDao). Het is nu tevens mogelijk met het Dependency Injection [Fowler DI] pattern fake- of stub-implementaties van de IEmployeesDao interface te maken, wat de testbaarheid van de businesslogica enorm verbetert.
public class EmployeePayment
{
private Employee toPay;
public EmployeePayment(string employeeId)
{
EmployeeDao dao = new EmployeeDao();
toPay = dao.GetEmployee(employeeId);
}
public decimal CalculatePay()
{
return toPay.BasePay;
}
}
Listing 1: Implementatie van de betaalmodule
Helaas is deze refactoring geen optie omdat tal van andere externe systemen ook gebruik maken van de betaalmodule. De signatuur wijzigen van de constructor of een extra property om de afhankelijkheid te verminderen vereist ook aanpassingen aan de externe systemen wat enorme kosten met zich meebrengt.
De Program klasse (zie listing 2) maakt een instantie aan van de EmployeePayment klasse, waarna het salaris wordt berekend en getoond.
class Program
{
static void Main(string[] args)
{
EmployeePayment payment = new EmployeePayment("PGIE");
decimal pay = payment.CalculatePay();
Console.WriteLine(string.Format("Payment: {0}", pay));
}
}
Listing 2: Implementatie salarissysteem
De Employee klasse (zie listing 3) bevat al de benodigde kennis uit het domein, in de vorm van het wel of niet toekennen van een bonus en registreren van het totale aantal verkopen. Dit vergemakkelijkt de aansluiting van de betaalmodule.
public class Employee
{
public string Id;
public decimal BasePay;
public bool ReceiveSalesBonus;
public int SalesTotal;
public Employee(string id,
decimal basePay,
bool salesBonus,
int salesTotal)
{
this.Id = id;
this.BasePay = basePay;
this.ReceiveSalesBonus = salesBonus;
this.SalesTotal = salesTotal;
}
public void RegisterSuccessfulSale()
{
SalesTotal++;
}
public void Save()
{
EmployeeDao dao = new EmployeeDao();
dao.SaveEmployee(Id,
BasePay,
ReceiveSalesBonus,
SalesTotal);
}
}
Listing 3: Implementatie verkooporganisatie medewerker
De Employee klasse is een voorbeeld van een veelvoorkomende overtreding van het Single Responsibility Principe. Volgens het Single Responsibility Principe [Martin SRP] mag een klasse maar één reden hebben voor verandering. De EmployeePayment klasse bevat businesslogica en persistentielogica. Het mixen van deze twee verantwoordelijkheden is gevaarlijk, omdat businesslogica doorgaans vaker en om heel verschillende redenen verandert dan de persistentielogica.
public class EmployeeDao
{
public Employee GetEmployee(string id)
{
// A stub which returns canned data
return new Employee("FOO", 300, false, 10);
}
public void SaveEmployee(string id,
decimal basePay,
bool salesBonus,
decimal salesTotal)
{
// ..
}
}
Listing 4: Implementatie medewerker persistentie
Aanpassen van Legacy Code
Na bestuderen van de betaalmodule is het tijd de strategie te bepalen voor het toevoegen van de nieuwe bonusfunctionaliteit. De signatuur behouden van de EmployeePayment klasse is een randvoorwaarde, maar hoe krijgen we de CalculatePay methode in een testharnas? Type Mocks (ook wel bekend als Vituele Mocks) is een krachtige tool die de noodzaak wegneemt om bestaande code te refactoren en te herstructureren alleen om deze testbaar te maken. TypeMock.NET [TypeMock] is gebaseerd op Aspect Oriented Programming [AOP] technieken. De nieuwe code wordt ingekapseld in een Mock aspect. Vervolgens wordt de applicatie-executie in de gaten gehouden door de .NET Framework profiler API. Als de CLR een methode laadt, wordt de IL vervangen door de geïnstrumenteerde IL code. De geïnjecteerde code roept het TypeMock.NET framework aan welke de mocked waarden retourneert.
Type Mocks (ook wel bekend als Vituele Mocks) is een krachtige tool die de noodzaak wegneemt om bestaande code te refactoren en te herstructureren alleen om deze testbaar te maken.
Voorafgaand aan het toevoegen van nieuwe functionaliteit schrijven we een test voor de huidige salarisberekening (zie listing 5). De MockManager klasse is de ingang voor het framework. Eerst initialiseren we type mocking door de Init methode aan te roepen op de MockManager. Daarna maken we een mock instantie aan van de EmployeeDao klasse. Nu is het zaak om de GetEmployee te onderscheppen en ervoor te zorgen dat de orginele code niet wordt uitgevoerd. In plaats daarvan retourneren we een mocked waarde om het gedrag van de CalculatePay methode te controleren. Hiervoor instrueren we de employeeDaoMock instantie middels de ReturnAlways methode om bij iedere aanroep van de GetEmployee methode een mocked Employee instantie te retourneren. Vervolgens maken we een nieuwe instantie aan van de EmployeePayment klasse en laten deze het salaris berekenen door het aanroepen van de CalculatePay methode. Het enige wat rest, is controleren of het salaris correct is berekend.
[TestFixture]
public class EmployeePaymentTests
{
[SetUp]
public void SetUp()
{
MockManager.Init();
}
[Test]
public void Pay_SalesBonusIgnored()
{
Mock employeeDaoMock =
MockManager.Mock(typeof (EmployeeDao));
employeeDaoMock.AlwaysReturn(
"GetEmployee",
new Employee("FOO", 300, false, 10));
EmployeePayment payment =
new EmployeePayment("FOO");
decimal pay = payment.CalculatePay();
Assert.AreEqual(300, pay);
}
}
Listing 5: Unit-test implementatie zonder verkoopbonus (orgineel gedrag)
Merk hierbij op dat we het gedrag van de legacy code zonder aanpassingen testbaar hebben weten te krijgen. Ook de interactie tussen de EmployeePayment- en EmployeeDao-klasse is hierbij bewaard gebleven. Met deze test als vangnet hebben we genoeg vertrouwen om de bonusfunctionaliteit toe te voegen. De fundering is gelegd!
Om niet in dezelfde valkuil te trappen passen we nu de Test Driven Development methode toe. Eerst coderen we een test die faalt (zie listing 6). De test succesvol door de compiler krijgen is de volgende stap.
[Test]
public void Pay_IncludingSalesBonus()
{
Mock employeeDaoMock =
MockManager.Mock(typeof (EmployeeDao));
employeeDaoMock.AlwaysReturn(
"GetEmployee",
new Employee("FOO", 300, true, 10));
EmployeePayment payment =
new EmployeePayment("FOO", 2);
decimal pay = payment.CalculatePay();
Assert.AreEqual(320, pay);
}
Listing 6: Unit-test implementatie met verkoopbonus
Er is een belangrijke ontwerpbeslissing genomen in deze test. De constructor van de EmployeePayment klasse heeft naast het al bestaande employeeId argument een extra bonusPerSale argument. Vooralsnog is niet bekend of de nieuwe versie van de betaalmodule ook verantwoordelijk is voor het vaststellen van het bonusbedrag. Het feit dat dit implementatiedetail de kop op steekt in deze eenvoudige test geeft stof tot nadenken over het EmployeePayment ontwerp. Door alle logica, benodigd voor de salarisberekening, in te kapselen in een EmployeePaymentStrategy object [Gang of Four Strategy], wordt de EmployeePayment klasse afgeschermd van details en transformeert deze in een Payment klasse. Deze abstractere Payment klasse is beter herbruikbaar door het uitwisselen van algoritmen (EmployeeBonusPaymentStrategy, CustomerPaymentStrategy, etc). Dit is een grote refactoringstap met een brede scope en belandt dus op de refactoringlijst voor de volgende iteratie.
Tijd om de test te doen slagen. Eerder is al genoemd dat de signatuur van de EmployeePayment klasse behouden moet blijven. Er zit niets anders op dan een nieuwe constructor toe te voegen en de orginele te koppelen (constructor chaining). Vervolgens passen we de CalculatePay methode aan (zie listing 7).
public class EmployeePayment
{
private Employee toPay;
private decimal bonusPerSale;
public EmployeePayment(string employeeId)
: this(employeeId, 0)
{
}
public EmployeePayment(string employeeId,
decimal bonusPerSale)
{
EmployeeDao dao = new EmployeeDao();
this.toPay = dao.GetEmployee(employeeId);
this.bonusPerSale = bonusPerSale;
}
public decimal CalculatePay()
{
if (toPay.ReceiveSalesBonus)
{
return toPay.BasePay +
(bonusPerSale*toPay.SalesTotal);
}
else
{
return toPay.BasePay;
}
}
}
Listing 7: Implementatie betaalmodule met verkoopbonus
Als laatste verifiëren we de correcte werking door het uitvoeren van de tests (zie listing 8).
----- Test started: Assembly:
SdnEffectiefMetLegacyCodeEnTypeMock.exe -----
2 passed, 0 failed, 0 skipped, took 2.92 seconds.
Listing 8: Uitvoer TestDriven.NET
Conclusie
Het toevoegen van functionaliteit, oplossen van een probleem, verbeteren van het ontwerp en optimaliseren zijn de meest voorkomende redenen voor het aanpassen van bestaande software. De grootste uitdaging hierbij is het behouden van het originele gedrag. Dit is mogelijk, maar zoals in het gebruikte voorbeeld te zien is, spelen tests hierbij een hele belangrijke rol. Zo kan met behulp van TypeMock.NET de legacy code uit het genoemde voorbeeld testbaar worden gemaakt. Door de voorbeelden uit dit artikel effectief toe te passen kan nieuwe functionaliteit aan bestaande software worden toegevoegd met behoud van het originele gedrag.
Literatuurverwijzingen
[TypeMock]: http://www.typemock.com
[Feathers]: Feathers, Working Effectively with Legacy Code, Prentice Hall, 2004
[Beck TDD]: Beck, Test-Driven Development By Example, Addison-Wesley, 2003.
[Fowler Refactoring]: Fowler, Refactoring, Addison-Wesley, 1999.
[Martin DIP]: Martin, The Dependency Inversion Principle, http://www.objectmentor.com/resources/articles/dip.pdf
[Fowler DI]: http://www.martinfowler.com/articles/injection.html
[Martin SRP]: Martin, The Single Responsibility Principle, http://www.objectmentor.com/resources/articles/srp
[Gang of Four Strategy]: Gamma, Helm, Johnson, and Vlissides, Design Patterns, Addison-Wesley, 1995
[AOP]: http://en.wikipedia.org/wiki/Aspect-oriented_programming
Sources
De sources die bij dit artikel horen kunt u downloaden via Gielens_WerkenMetLegacyCodeTypeMock.NET.zip.