Sind Unit Tests wirtschaftlich untragbar?

Immer wieder höre ich die Aussagen “Unit Tests sind schön und gut, wir haben nur keine Zeit dafür.” Oder “Klar, man kann Tests machen, hauptsache, es nimmt nicht zu viel Zeit von der Arbeit weg” ??!!

 

Nur um Missverständnisse zu vermeiden: Tests macht man nicht zum Spaß oder aus Langeweile, die Tests stellen die Essenz, die abstraktester Form der Lösung dar!

 

Ich habe bis heute Schwierigkeiten damit, meinem Gegenüber zu erklären, dass er sich irrt. Ich WEIß es einfach aus Erfahrung, dass dies eine Milchmädchenrechnung ist. Keine ernsthafte Argumente gegen Tests würden bei einer tieferen Überprüfung standhalten. Die Pros übertreffen klar die Kontras. Aber wie soll ich etwas – für mich – Offensichtliches in Worte fassen? Wie soll ich etwas in ein paar Sätzen erklären, was ich in einem andauernden Prozess durch jeden NICHT (oder nicht richtig) geschriebenen Test gelernt habe? Oder durch jeden Aha-Effekt oder durch jeden stressfreien Release (kein Stress entsteht, wo kein Platz für Bugs existiert 😉 )

 

Unit Tests sind für den Open Mind “selbsterklärend”: wenn der Bug in einem ungetesteten Code steckt, dann wird das zu einer “blinden” Fehlersuche führen, die Tage dauern kann und auf jedem Fall Geld und Ruf kostet. Wie lange dauert es, den Fehler in einem getesteten Codebasis zu finden, wo die Eingrenzung innerhalb von Sekunden erfolgt? Wie oft kommt es überhaupt vor, dass dieses Problem entsteht? Für mich schaut die Rechnung so aus:


Zeit_für_fehlersuche = Unproduktive_Zeit;
f(Unproduktive_Zeit) = Verschwendetes_Geld;

0->Zeit_für_fehlersuche(getesteter_Code)----------------------->Zeit_für_fehlersuche(ungetesteter_Code)---.....oo

Ok, ich glaube, ihr kennt jetzt meinen Standpunkt 😉 Aber ich bin ja nicht die ultimative Maßstab dafür, wie man arbeiten sollte. Deshalb habe ich ein paar Artikel und Statistiken von klügeren Leuten zusammengesucht, bitte liest die auch.

 

Diese Infos habe ich bei stackoverflow gefunden:

Realizing quality improvement through test driven development: results and experiences of four industrial teams und hier eine Diskussion darüber.

The study and its results were published in a paper entitled Realizing quality improvement through test driven development: results and experiences of four industrial teams, by Nagappan and research colleagues E. Michael Maximilien of the IBM Almaden Research Center; Thirumalesh Bhat, principal software-development lead at Microsoft; and Laurie Williams of North Carolina State University. What the research team found was that the TDD teams produced code that was 60 to 90 percent better in terms of defect density than non-TDD teams. They also discovered that TDD teams took longer to complete their projects—15 to 35 percent longer.

“Over a development cycle of 12 months, 35 percent is another four months, which is huge,” Nagappan says. “However, the tradeoff is that you reduce post-release maintenance costs significantly, since code quality is so much better. Again, these are decisions that managers have to make—where should they take the hit? But now, they actually have quantified data for making those decisions.”

Es gab auch kleinere Experimente dazu wie z.B. Code Lab – TDD vs. Non-TDD

Over 3 iterations, average time taken to complete the kata without TDD was 28m 40s. Average time with TDD was 25m 27s. Without TDD, on average I made 5.7 passes (delivering into acceptance testing). With TDD, on average I made 1.3 passes (in two attempts, they passed first time, in one it took 2 passes.)

Now, this was a baby experiment, of course. And not exactly laboratory conditions. But I note a couple of interesting things, all the same.

Und weil ein Bild mehr als tausend Worte spricht: Die Kostenverteilung bei getesteten Code schaut ungefähr so aus
Testing Benefits

Tests schreiben ist einfach, der Ertrag ist riesig. Warum soll man also keine Tests schreiben? Sind wir wirklich unfehlbar, schreiben wir immer den perfekten Code? Seien wir mal ehrlich…Ich bin es sicher nicht und ihr auch nicht.

 

Und hier noch die obligatorische Buchempfehlung: The Art Of Unit Testing Das Buch ist wunderbar verständlich geschrieben mit echten Beispielen und guten Argumenten, warum und wie man testen soll.

Tausende Codezeilen verstehen – aber wie?

Nach 7 Wochen Zwangsurlaub (siehe unten) bin ich wieder back to life: mit einem halbwegs neuen Bein (mit Titaneinlagen und Schlitzschrauben 😉 ), in einer neuen Stadt mit 100%-er Snowcoverage, in einem neuen Job – nach einem Monat Verspätung.

Das letzte Mal, als ich bei einer Firma den Code verstehen musste, ging es um ASP-Classic. Die Abhängigkeiten waren überschaubar, das Debugen ging mit Response.Write-Zeilen ;). Aber wie macht man das heute, wie versteht man den bestehende Code, der in vielen Jahren aus den fleißigen Fingern der Programmierern herausgeflossen ist? Und das im Web, in einer unglaublich flexiblen E-Commerce-Anwendung…

Die Lösung war einfach: mit Unit-Tests ! Ich musste nicht erklärt bekommen haben, WAS der Code tut, nur welche Aufrufe zu welchen Ergebnissen führen. Immer, wenn ich ein Szenario verstanden habe, wurden dafür Tests geschrieben und das Ergebnis besprochen. Der Begriff unit wurde teilweise natürlich ausgedehnt, aber das hat nichts an der Tatsachen geändert, dass am Ende

  1. ich den Code verstanden habe
  2. die meisten analysierten Zeilen durch einen Test abgedeckt wurden
  3. die Diskussionen über den Testnamen dazugeführt haben, dass manch unnötige Zeilen (lese “Szenarien”) entfernt wurden, also der Code besser geworden ist

Jeder Code ist testbar

Immer wieder steht man vor der Herausforderung, zu einem bestehenden Legacy-Code neue Funktionalitäten hinzu zufügen oder vorhandene Bugs beheben zu müssen.
Ich möchte hier eine Möglichkeit dazu beschreiben, einen Weg den ich u.a. von Michael Feathers gelernt habe.

Nehmen wir an, der Code schaut so aus:

using System.Net.Mail;

namespace LegacyCode
{
public class KompletterWorkflow
{
public void SpeichereDatenUndVersendeMail( int id, string emailaddress, string text, string cc )
{
if( !string.IsNullOrEmpty( text ) )
{
Daten user;
DatenRepository repository = new DatenRepository();
user = new Daten { Id = id, Email = emailaddress };
repository.Save( user );

if( !SmtpRepository.IsValidEmailAddress( emailaddress ) ) return;
IEmailRepository emailrep = new SmtpRepository();
MailMessage message = new MailMessage( "myAddress@xy.com", emailaddress, "Testmail", text );
if( !string.IsNullOrEmpty( cc ) ) message.CC.Add( cc );
emailrep.SendMail( message );
}
}
}
}

Die referenzierten Klassen wären dann sowas wie:

public class DatenRepository
{
public void Save( Daten daten )
{
//Speichert die Daten ab
}
}

public interface IEmailRepository
{
void SendMail( MailMessage message );
}

public class SmtpRepository : IEmailRepository
{
public static bool IsValidEmailAddress( string address )
{
try
{
MailAddress mailAddress = new MailAddress( address );
return true;
}
catch
{
return false;
}
}

public void SendMail( MailMessage message )
{
//Versende die Email
}
}

public class Daten
{
public int Id { get; set; }
public string Email { get; set; }
}

Diese Klasse, so wie sie ist, verletzt jede Menge Prinzipien.
Die wichtigsten sind: The Single Responsibility Principle (SRP) und The Open-Closed Principle (OCP). Außerdem ist die Klasse in diesem Zustand nicht testbar, da man mittendrin Objekte erstellt, deren Verhalten auch mitgetestet werden müssten.
Wenn man das weißt, dann ist die Aufgabe einfach: die Implementierungen DatenRepository und SmtpRepository dürfen nicht in der Methode instanziert werden, sondern sie müssen nach der Regeln der Inversion of Control (IoC) der Klasse übergeben werden.
Zusätzlich werden auch die zusammenhängenden Parameter der Methode SpeichereDatenUndVersendeMail zum userDaten zusammengefasst.

public class KompletterWorkflow
{
private DatenRepository m_repository;
private IEmailRepository m_emailrep;

public KompletterWorkflow(DatenRepository repository, IEmailRepository emailrep)
{
m_repository = repository;
m_emailrep = emailrep;
}

public void SpeichereDatenUndVersendeMail( Daten userDaten, string text, string cc )
{
if( !string.IsNullOrEmpty( text ) )
{
m_repository.Save( userDaten);

if( !SmtpRepository.IsValidEmailAddress( userDaten.Email ) ) return;
MailMessage message = new MailMessage( "myAddress@xy.com", userDaten.Email, "Testmail", text );
if( SmtpRepository.IsValidEmailAddress( cc ) ) message.CC.Add( cc );
m_emailrep.SendMail( message );
}
}
}

Um SRP gerecht zu werden, müsste man auch die Methode in 2 teilen: DatenSpeichern und VersendeMail. Das ist allerdings aus der Sicht der Testbarkeit unwichtig und ich will den Artikel nicht unnötig in die Länge ziehen 😉

Jetzt müssen wir nur noch die Tests schreiben.
Da wir Unittests schreiben, also nur das Verhalten dieser eine Methode testen, müssen wir die fremden Objekte mocken: Das hbedeutet, wir werden ihr Verhalten nachspielen, so tun als ob.

Wir müssen 3 verschiedene Fälle behandeln: ein Interface IEmailRepository, eine konkrete Implementierung DatenRepository und eine statische Methode SmtpRepository.IsValidEmailAddress(string). Bei den ersten zwei empfehlt es sich ein Mocking-Framework wie z.B. RhinoMock zu nutzen.

using NUnit.Framework;
using Rhino.Mocks;
using System.Net.Mail;
namespace LegacyCode.Tests
{
[TestFixture]
public class DatenSpeichernUndVersendeMailTests
{
private IEmailRepository m_emailrep;
private DatenRepository m_repository;

[SetUp]
public void Init()
{
m_emailrep = MockRepository.GenerateStub<IEmailRepository>();
m_repository = MockRepository.GenerateStub<DatenRepository>();
}

[Test]
public void Daten_werden_gespeichert_wenn_Text_nicht_leer()
{
//Arrange
KompletterWorkflow workflow = new KompletterWorkflow( m_repository, m_emailrep );
Daten userDaten = new Daten{Id=1,Email="test@test.de"};

//Act
workflow.SpeichereDatenUndVersendeMail( userDaten , "Emailtext", "cc" );

//Assert
m_repository.AssertWasCalled( a => a.Save( userDaten ) );
m_emailrep.AssertWasCalled( a => a.SendMail( Arg<MailMessage>.Matches( b => b.To[0].Address == userDaten.Email && b.CC.Count == 0 ) ) );
}
}
}

Wenn wir jetzt die Tests mit NUnit ausführen, bekommen wir folgende Fehlermeldung:

LegacyCode.Tests.DatenSpeichernUndVersendeMailTests.Daten_werden_gespeichert_wenn_Text_nicht_leer:
System.InvalidOperationException : No expectations were setup to be verified, ensure that the method call in the action is a virtual (C#) / overridable (VB.Net) method call

Das heißt, die Methoden, worüber wir Annahmen treffen, müssen entweder in einem Interface veröffentlicht worden oder überschreibbar also virtual sein.

public virtual void Save( Daten daten )
{
//Speichert die Daten ab
}

Wenn wir jetzt die Tests durchführen, dann passt alles.

Für die statische Methode erstellt man am besten eine separate Testklasse. Da hier keine andere Komponenten wie Datenbank oder Filesystem angesprochen werden, wird die Methode ganz einfach zu testen sein:

[TestFixture]
public class IsValidEmailTests
{
[Test]
public void Leere_Adresse_ist_nicht_valide()
{
Assert.That( SmtpRepository.IsValidEmailAddress( string.Empty ), Is.False );
}
}

Das war’s !
Mit weiteren Tests kann das Verhalten unserer Klasse komplett abgedeckt und danach mit TDD die Klasse erweitert oder verändert werden.
Und das alles ohne die Befürchtung, dass diese Änderungen zu unvorhersehbaren Ergebnissen führen.