Introduktion till testdriven utveckling
Förord
Testdriven utveckling är en metod för att genom korta iterationer utveckla sin kod. Varje iteration börjar med att skapa ett test. Därefter modifierar man sin kod för att testet skall vara uppfyllt. Här kommer en introduktion till hur det går till.
Tid, kostnad, kvalitet – välj två.
Det är en vanlig åsikt att ett mjukvaruprojekt kan optimeras efter högst två av de tre parametrarna tid/kostnad/kvalitet samtidigt, den tredje blir vad den blir. Till exempel kan man optimera på tid och kvalité och då sticker kostnaden iväg. Detta anses av många vara något som närmast kan beskrivas som en naturlag. Jag håller med, men med vissa invändningar.
Jämför med en triangel med konstant omkrets. Den har en tyngdpunkt som flyttar sig om man ändrar längden på sidorna. Förlänger man en sida så måste en eller två sidor bli kortare för att omkretsen inte skall förändras. Låt nu sidornas längd representerar tid, kostnad och kvalité.
Min invändning är att om man förändrar metod/arbetssätt/verktyg så kan man förändra omkretsen på triangeln och därmed förbättra/försämra alla tre parametrar. Själv anser jag att testdriven utveckling är en metod för att med oförändrad arbetsinsats (=tid och pengar) höja kvalitén. Därefter kan man alltid dra och slita i ”triangelns hörn” beroende på vad man vill optimera på.
Vad är testdriven utveckling?
Det finns många förklaringar till vad testdriven utveckling är. - Ett sätt att testa 100 procent av koden.
- Ett sätt att bryta ner problemet i små hanterbara bitar.
- Ett sätt att översätta mer abstrakta krav till konkreta krav.
- Testdriven utveckling är besläktad med men inte samma sak som Extreme Programming. Extreme Programming använder sig av testdriven utveckling som ett av flera medel.
- Ett sätt att garantera att tillägg och andra förändringar (buggrättningar) inte får oönskade effekter.
- Att med samma arbetsinsats lägga grunden för bättre kod
Principen för testdriven utveckling
Arbete i korta iterationer där varje iteration har som mål endera, men aldrig bägge, av två saker: Lägga till funktionalitet eller förfina redan existerande funktionalitet.Utbyggnad av funktionalitet görs genom att utvecklaren först skapar ett test för att sedan köra testet och ser att det felar (ibland kan det hända att testet går bra direkt). Därefter skriver utvecklaren kod för att uppfylla testet och kör testet för att verifiera att det går igenom. När man har gått igenom detta kan man gå igenom det ett varv till eller övergå till förfining.
Förfining är den process där koden struktureras om för att undvika dubbel kod eller för att få en överskådligare struktur. Dock skall ingen funktionalitet läggas till! Som en del av detta verifieras även att de existerande testerna fortfarande går igenom. När de gör det kan man återgå till utbyggnad av funktion igen.
Ett exempel:
Säg att vi skall implementera en FIFO-kö. Visst kan vi sätta oss och koda den direkt men hur bra är det? Jag skall försöka visa hur man gör detta med testdriven utveckling. För ändamålet använder jag C# och Nunit. Nunit kan laddas hem från http://www.nunit.org/ .Det krav vi har är att göra en FIFO-kö.
Börja med att lista rimliga tester som kan göras på en kö.
1. Skapa en kö och se att den är tom.
2. Skapa en kö, försök hämta något från den och få ett exception eftersom den är tom.
3. Skapa en kö och lägg något i kön, se att kön inte är tom.
4. Skapa en kö, lägg något i kön, hämta från kön och verifiera att det hämtade är det som lades på kön.
5. Skapa en kö, lägg två saker på kön, hämta en sak på kön och se att den inte är tom.
6. Skapa en kö, lägg något i kön, hämta från kön och verifiera att kön är tom.
7. Skapa en kö, lägg tre saker i kön, läs tre saker från kön och verifiera att de kommer i rätt ordning.
8. Skapa en kö, lägg tjugo saker på kön, läs tjugo saker och verifiera att den är tom.
9. …
Listan ovan kan naturligtvis göras längre.
Skapa nu en test, vi kan börja med 1.
Koden kommer att se ut någonting i stil med följande:
using System;
using NUnit.Framework;
namespace Queue.Test
{
[TestFixture]
public class QueueTest
{
[Test]
public void NewQueueIsEmpty()
{
// 1. Create a new queue and ensure that it is empty.
Queue.Logic.Queue q = new Queue.Logic.Queue();
Assert.IsTrue(q.IsEmpty());
}
}
}
Denna kommer inte ens att kompilera eftersom q.IsEmpty inte är implementerad. Så låt oss göra det.
using System;
namespace Queue.Logic
{
public class Queue
{
public bool IsEmpty()
{
return false;
}
}
}
Vi kan nu bygga vår solution och köra testet med Nunit.
Vi kan se att NewQueueIsEmpty är röd vilket indikerar att testet misslyckas. Det är nu dags att ändra implementationen av vår kö så att den på enklast möjliga sätt uppfyller vårt test.
using System;
namespace Queue.Logic
{
public class Queue
{
public bool IsEmpty()
{
return true;
}
}
}
Vi kompilerar och kör testen och nu får vi grönt ljus.
En del kan invänder att vår implementation av IsEmpty är en aning simpel men det är också nyckeln till det hela. Gör inte mer än vad som skall göras. Det håller koden enkel och lättöverskådlig. Att lägga in funktionalitet i kod för att den ”kan behövas” i framtiden är en dålig vana som bara kostar tid.
Vi skapar nu en test för vår tredje test ovan (man behöver inte ta dem i den ordning de står).
[Test]
public void NewQueueWithOneElementIsntEmpty()
{
// 3. Create a new queue, put one item on the queue
// and check that the queue isn't empty
Queue.Logic.Queue q = new Queue.Logic.Queue();
q.Put(1);
Assert.IsFalse(q.IsEmpty());
}
och ett skelett för funktionen Put som vi behöver.
public void Put(object o)
{
}
När vi nu kompilerar och testar får vi ett test som felar och ett som lyckas.
Dags att få testet att lyckas genom att skriva om koden.
using System;
namespace Queue.Logic
{
public class Queue
{
private bool isEmpty = true;
public bool IsEmpty()
{
return isEmpty;
}
public void Put(object o)
{
isEmpty = false;
}
}
}
Ovanstående kod uppfyller båda våra tester även om det inte är mycket till kö ännu. Men fortsätter man att arbeta på detta sätt så får man en riktigt bra kö med fullständiga tester. Det senare gör att man kan våga ändra rätt kraftigt i koden eftersom testerna verifierar att funktionen fortfarande är riktig.
Här tänkte jag göra som TV-kockarna och visa hur koden blev när jag gick igenom proceduren för de åtta testerna ovan.
Först för testerna.
using System;
using NUnit.Framework;
namespace Queue.Test
{
[TestFixture]
public class QueueTest
{
Queue.Logic.Queue q;
[Test]
public void NewQueueIsEmpty()
{
// 1. Skapa en kö och se att den är tom.
Queue.Logic.Queue q = new Queue.Logic.Queue();
Assert.IsTrue(q.IsEmpty());
}
[Test]
public void NewQueueWithOneElementIsntEmpty()
{
// 3. Skapa en kö och lägg något i kön, se att kön inte är tom.
Queue.Logic.Queue q = new Queue.Logic.Queue();
q.Put(1);
Assert.IsFalse(q.IsEmpty());
}
[Test]
[ExpectedException(typeof(Queue.Logic.EmptyQueueException))]
public void ExceptionFromEmptyQueue()
{
// 2. Skapa en kö, försök hämta något från den och få ett exception eftersom den är tom.
Queue.Logic.Queue q = new Queue.Logic.Queue();
object o = q.Get();
}
[Test]
public void PutGetIsSame()
{
// 4. Skapa en kö, lägg något i kön, hämta från kön och verifiera att det hämtade är det som lades på kön.
Queue.Logic.Queue q = new Queue.Logic.Queue();
Object o = 1;
q.Put(o);
Assert.AreSame(o, q.Get());
}
[Test]
public void PutTwoGetOneIsNotEmpty()
{
// 5. Skapa en kö, lägg två saker på kön, hämta en sak på kön och se att den inte är tom.
Queue.Logic.Queue q = new Queue.Logic.Queue();
q.Put(1);
q.Put(2);
object o = q.Get();
Assert.IsFalse(q.IsEmpty());
}
[Test]
public void PutGetIsEmpty()
{
// 6. Skapa en kö, lägg något i kön, hämta från kön och verifiera att kön är tom.
Queue.Logic.Queue q = new Queue.Logic.Queue();
q.Put(1);
object o = q.Get();
Assert.IsTrue(q.IsEmpty());
}
[Test]
public void PutThreeGetThreeVerifyOrder()
{
// 7. Skapa en kö, lägg tre saker i kön, läs tre saker från kön och verifiera att de kommer i rätt ordning.
Queue.Logic.Queue q = new Queue.Logic.Queue();
object o1 = 23;
object o2 = "just a string";
object o3 = new Queue.Logic.Queue();
q.Put(o1);
q.Put(o2);
q.Put(o3);
Assert.AreSame(o1, q.Get(), "First object is not correct");
Assert.AreSame(o2, q.Get(), "Second object is not correct");
Assert.AreSame(o3, q.Get(), "Third object is not correct");
}
[Test]
public void PutTwentyGetTwentyIsEmpty()
{
// 8. Skapa en kö, lägg tjugo saker på kön, läs tjugo saker och verifiera att den är tom.
Queue.Logic.Queue q = new Queue.Logic.Queue();
for (int i = 1; i <= 20 ; i++)
q.Put(i);
for (int i = 1; i <= 20; i++)
{
object o = q.Get();
}
}
}
}
Och sedan för kön.
using System;
namespace Queue.Logic
{
///
/// Exception som kan slängas vid otillåtna operationer på en tom kö.
///
public class EmptyQueueException : System.ApplicationException
{
public EmptyQueueException() : base() {}
public EmptyQueueException(string message) : base(message) {}
public EmptyQueueException(string message, Exception innerException) : base(message, innerException) {}
}
///
/// Klass som representerar en kö av typen först in först ut. Objekten som läggs i
/// kön kan vara av valfri typ.
///
public class Queue
{
///
/// Klass som representerar en nod i en dubbellänkad cirkulär lista. Noden
/// knyts till ett objekt när den skapas och har funktioner för att länka in
/// sig i en lista av noder, länka ur sig ur en lista av noder, för att
/// avgöra om listan den ingår i är tom sånär som på den själv och för att få
/// tag i det lagrade objectet.
///
private class Item
{
///
/// Föregående nod i listan
///
public Item next;
///
/// Nästa nod i listan
///
public Item prev;
///
/// Objektet som lagras av noden
///
private object itemInQueue;
///
/// Konstruktor
///
/// Objektet som skall lagras av noden
public Item(object toBeStored)
{
itemInQueue = toBeStored;
next = this;
prev = this;
}
///
/// Länka in denna nod efter den andra noden. Förutsätter att denna nod
/// inte redan är inlänkad i en annan lista.
///
/// Noden som den skall hamna efter.
/// Sig själv
public Item LinkAfter(Item nextItem)
{
if (!this.IsEmpty()) throw new EmptyQueueException("Denna funktion stöder inte att sätta in en lista i en annan");
this.next = nextItem;
this.prev = nextItem.prev;
nextItem.prev = this;
this.prev.next = this;
return this;
}
///
/// Länka ur denna nod ur den lista den ingår i.
///
/// Sig själv
public Item Unlink()
{
this.next.prev = this.prev;
this.prev.next = this.next;
this.next = this;
this.prev = this;
return this;
}
///
/// Funktion som tar reda på om listan är tom så när som på noden själv.
///
/// true om tom, annars false
public bool IsEmpty()
{
return (this.next == this) && (this.prev == this);
}
///
/// Det objekt som lagras av noden.
///
public object StoredObject
{
get { return itemInQueue; }
}
}
private Item head = new Item(null); // Skapa ett huvud för den dubbellänkade cirkulära listan.
///
/// Ta reda på om kön är tom eller ej
///
/// True om tom, annars false
public bool IsEmpty()
{
return head.IsEmpty();
}
///
/// Lägg in ett objekt i kön.
///
/// Objektet som skall in i kön
public void Put(object o)
{
Item newObject = new Item(o);
newObject.LinkAfter(head.next);
}
///
/// Hämta det första objektet från kön.
///
/// Det hämtade objektet
public object Get()
{
if (this.IsEmpty()) throw new EmptyQueueException("The queue is empty");
Item i = head.prev.Unlink();
return i.StoredObject;
}
}
}
Att tänka på:
Det finns mängder att verktyg för enhetstest. För olika plattformar och språk. I mitt exempel har jag valt att använda NUnit som är väl etablerat. Nämnas kan att Microsoft Visual Studio 2005 Team System kommer att innehålla stöd för enhetstestning som är mycket likt NUnit. Dock verkar detta inte komma i de mindre paketeringarna av Visual Studio 2005.Verktyg för enhetstest är mest tillämpbara på icke grafiska användargränssnitt.
Test av moduler där man arbetar mot en databas, mot ännu ej existerande system/moduler eller en extern källa som man inte har kontroll över är problematiska men inte omöjliga. Exempel på extern källa kan vara avläsningar från en utomhustermometer – temperaturen varierar över dagen. En annan kan vara en webservice som ger dig dagens bibelord – det ser inte likadant ut från dag till dag. En tredje kan vara ett datorsystem som inte alltid är tillgängligt. Alla dessa fall går att lösa men kräver både tankemöda och arbete. När det gäller databas kan man tänka sig att man arbetar mot en testdatabas och att man skapar rutiner för att få den i ett känt tillstånd innan testerna körs. För alla fall kan det vara möjligt att skapa objekt som imiterar det verkliga objektet. Det finns många tankar om hur man gör detta på bästa sätt och jag rekommenderar att googla på test driven development och mock objects. Nämnas bör också att det finns verktyg som underlättar detta.
Enhetstest är test på modulnivå. Det ersätter inte system och acceptanstester men kan låta de senare fokusera på kravuppfyllnad istället för att leta småfel. Däremot är enhetstest bra för regressiontest, det vill säga att testa att gamla fel inte smyger sig in igen.
Pelle Johansson
Det här är något man helt klart bör studera mer. Tack för din artikel.
Mattias Vartiainen
En bra inblick i testdriven utveckling. Så snart jag påbörjar något nytt projekt så blir det nog till att testa NUnit. Vore intressant att se testdriven utveckling i projekt med databaser också. Hur folk löst det på bästa sätt.
Johan Segolsson
Minska bredden på bilderna, samt raderna i koden. Sidan blir alldeles för bred annars.
Magnus Olofsson
Det här var precis vad jag alltid har letat efter utan att veta om att jag letade. =) Jag ska definitivt försöka tillämpa detta i nästa utvecklingsprojekt. Tack!