dotNed

Welkom bij dotNed Inloggen | Aanmelden | Help
in Zoeken

Dennis' avonturen in .net

Op zoek naar de grenzen (in unit testing dan...)

Unit testing blijft voor veel mensen een moeilijk te begrijpen concept. Ok, de beginselen zijn simpel maar de daadwerkelijke toepassing ervan levert nog veel problemen op. Gelukkig helpen de tools ons. Zo is er een feature in Visual Studio die niet veel mensen gebruiken maar die het leven een stuk eenvoudiger kunnen maken. Ik heb het over het gebruik van een database als bron voor de input van de unit tests.

Een voorbeeld:

Stel, we hebben een class library nodig met daarin een aantal business objects. Voordat we deze gaan schrijven, zullen we eerst een paar test cases maken (even een opfrisser: de basis van TDD (test driven development) is: schrijf test, zorg dat het compileert, zorg dat de test slaagt, refactor, herhaal).

We werken in dit voorbeeld met een class die postcodes bevat. De tests zien er dan in eerste instantie als volgt uit:

[TestMethod]

[Owner("Dennis Vroegop")]

[Description("Constructor should only accept valid values. The values will be in the properties")]

public void ConstructorShouldOnlyAcceptValidValues()

{

    Postcode pc = new Postcode("1234 AB");

    Assert.AreEqual(1234, pc.NumericPart);

    Assert.AreEqual("AB", pc.AlfaPart);

 

}

 

[TestMethod]

[Owner("Dennis Vroegop")]

[Description("The constructor will raise an exception when an invalid value is given")]

[ExpectedException(typeof(InvalidPostcodeException))]

public void ConstructorShouldRaiseExceptionOnInvalidValues()

{

    Postcode pc = new Postcode("1234");

    // Nothing to do here: an exception will be raised

}

To zover niets spannends. We hebben 2 test methods. De eerste, ConstructorShouldOnlyAcceptValidValues, instantieert een nieuwe postcode en in de constructor geven we een geldige postcode mee. Daarna controleren we de 2 properties van de Postcode class die de onderdelen van de postcode teruggeven. De tweede, ConstructorShouldRaiseExceptionOnInvalidValues, krijgt een niet-geldige postcode mee en dus krijgen we een InvalidPostCodeException. Even een opmerking: ik maak de namen van mijn tests altijd zo beschrijven mogelijk: op die manier weet ik precies wat er in de tests zou moeten gebeuren.

Goed. Stap 2. Zorgt dat het compileert. Dat levert de volgende code op:

public class InvalidPostcodeException : Exception

{

 

}

 

public class Postcode

{

    public int NumericPart { get; set; }

    public string AlfaPart { get; set; }

 

    /// <summary>

    /// Initializes a new instance of the Postcode class.

    /// </summary>

    public Postcode(string value)

    {

 

    }

}

Deze code compileert (mits we de using statements goed hebben staan). Nu nog zorgen dat de test slagen (want dat doen ze nu niet).

We vullen de constructor van onze Postcode class aan als volgt:

public Postcode(string value)

{

    Regex regex = new Regex(@"^^(?<numeric>[1-9]{1}[0-9]{3})[\s]*(?<alfa>[a-zA-Z]{2})$");

    if(!regex.IsMatch(value))

        throw new InvalidPostcodeException(value);

    MatchCollection matches = regex.Matches(value);

    if (matches.Count > 1)

        throw new InvalidPostcodeException(value);

 

    GroupCollection group = matches[0].Groups;

    NumericPart = Int32.Parse(group["numeric"].Value);

    AlfaPart = group["alfa"].Value.ToUpper();

}

En voor de volledigheid: dit is onze InvalidPostcodeException:

public class InvalidPostcodeException : Exception

{

    public InvalidPostcodeException(string postcode)

        : base(String.Format("Invalid postcode: {0}", postcode))

    {

    }

 

    protected InvalidPostcodeException(SerializationInfo info, StreamingContext context)

        : base(info, context)

    {

 

    }

 

    public InvalidPostcodeException()

        : base()

    {

 

    }

 

    public InvalidPostcodeException(string message, Exception innerException)

        : base(message, innerException)

    {

 

    }

}

Dit is al een stuk beter. We kunnen nu testen of we een geldige postcode invoeren of niet. Maar... hoe zeker zijn we van de volledigheid van onze testen? Is 1234 AB een geldige postcode? Volgens onze test wel. Maar hoe zit het met 1234 ab? En 1234[tab]Ab? En 1234 a^? Wordt die geaccepteerd?

Om daar zeker van te zijn moeten we een hele reeks met unittests gaan schrijven. We kunnen voor al deze voorbeelden een stuk code maken die deze waardes in de constructor plaatst. Uiteraard doen we dat slimmer: we maken een array met mogelijke waardes en plaatsen die in een lus. Maar wat als we nou later nog meer mogelijkheden bedenken? Of andere combinaties? Dan moeten we onze code gaan herschrijven, iets wat ik altijd zo veel mogelijk probeer te voorkomen. Code die je aanpast is gevoelig voor de introductie van nieuwe fouten. Immers: als debuggen het verwijderen van fouten uit onze code is, dan is logischerwijs programmeren het proces van het toevoegen van bugs.... Denk daar maar eens over na.

Unit testers zeggen dat je de grenzen op moet zoeken van je invoer variabelen. De ondergrens, de toegestane waardes en de ondergrens (vandaar ook de titel van dit stukje). Nu is het in het geval van de postcode niet zo moeilijk maar je zult in je eigen werk vast wel voorbeelden kunnen vinden van wat ingewikkelder situaties. We blijven echter even bij onze postcode.

In SQL Server maak ik een nieuwe database. Deze bevat een tabel, die er als volgt uitziet:

ID Postcode IsValid
1 1234 AB True
2 1234AB True
3 1234ab True
4 1234 ab True
5 123456 False
6 NULL False
7 abcd ef False
8 1234 A* False
9 0123 AB False

Deze tabel staat in de database UnitTestData, en heeft als naam dbo.Postcodes.

Ik heb ook 2 views gemaakt, deze zijn als volgt gedefinieerd:
SELECT PostCode FROM dbo.Postcodes WHERE (IsValid = 1) (genaamd: vwValidPostcode) en
SELECT PostCode FROM dbo.Postcodes WHERE (IsValid = 0) (genaamd: vwInvalidPostcode).

Terug naar Visual Studio. We passen de twee unit-tests aan als volgt:

[TestMethod]

[DataSource("System.Data.SqlClient",

    "Data Source=KENNY2;Initial Catalog=UnitTestData;Integrated Security=True",

    "vwValidPostcode",

    DataAccessMethod.Sequential)]

[Owner("Dennis Vroegop")]

[Description("Constructor should only accept valid values. The values will be in the properties")]

public void ConstructorShouldOnlyAcceptValidValues()

{

    Postcode pc = new Postcode(TestContext.DataRow["PostCode"].ToString());

    Assert.AreEqual(1234, pc.NumericPart);

    Assert.AreEqual("AB", pc.AlfaPart);

 

}

 

[TestMethod]

[DataSource("System.Data.SqlClient",

    "Data Source=KENNY2;Initial Catalog=UnitTestData;Integrated Security=True",

    "vwInvalidPostcode",

    DataAccessMethod.Sequential)]

[Owner("Dennis Vroegop")]

[Description("The constructor will raise an exception when an invalid value is given")]

[ExpectedException(typeof(InvalidPostcodeException))]

public void ConstructorShouldRaiseExceptionOnInvalidValues()

{

    Postcode pc = new Postcode(TestContext.DataRow["PostCode"].ToString());

    // Nothing to do here: an exception will be raised

}

Er is een nieuw attribuut toegevoegd: DataSource. Deze zorgt ervoor dat er data uit een database gehaald wordt (uit een XML file of een CSV bestand kan uiteraard ook), welke vervolgens in de TestContext.DataRow["veldnaam"] gelezen kan worden, waarna dit als input voor je parameters gebruikt kan worden. Als je nu de test draait zal je zien dat de tests slagen. Niets spannends. Echter: de tests worden niet 1 maal uitgevoerd, maar voor ieder record in je tabel (of in ons geval: ieder record uit de view). Als er 1 mislukt, kun je dat in de resultaten terug zien: onze exception geeft keurig de falende postcode weer.

Ik kan deze code nu inchecken en de gebruikers van mijn class, of sterker nog: de business analisten kunnen nu voorbeelden van postcodes verzinnen waar ik als ontwikkelaar niet aan gedacht heb. Ze hoeven alleen maar nieuwe waardes in de tabel in te voegen, daar aan te geven of ze geldig zijn of niet en de unit tests draaien (dat doet de built omgeving al voor me, nog makkelijker dus...).

Je ziet: op die manier haal je een stukje test gegevens uit je unit test code en hou je schonere unittests over. Ook is de bruikbaarheid van de unittests vergroot: anderen kunnen nu testcases verzinnen en zien wat het resultaat is. Het nadeel? De kans is groot dat er nu extra bugs gevonden worden wat er voor zorgt dat ik het nog drukker krijgt. Maar laat mijn baas die laatste zin maar niet lezen ;-)

Published Thursday, December 20, 2007 3:07 PM door dvroegop
Filed Under: ,

Comments

 

hansvd said:

Mijn ervaring is anders. Namelijk dat het gebruik van een database in unit tests voornamelijk nadelen heeft. De unit tests worden afhankelijk van configuratie (database connectie), database beschikbaarheid en database inhoud. Bovendien worden ze traag: je hebt al snel honderden testen in een beetje project.

Dit betekent dat de testen niet zo vaak gedraaid worden als wel zou moeten (in ieder geval elke keer voor het inchecken). En dat kan weer mede oorzaak zijn dat de testen gaan verwateren, niet meer onderhouden worden en uiteindelijk helemaal niet meer uitgevoerd worden.

Mijn advies is om unit tests vrij te houden van database en zelfs file access. Zet geautomatiseerde testen waarin database toegang onvermijdelijk is (persistency testen, integratie testen , functionele testen) apart  en laat die uitvoeren door de buildmachine.
December 22, 2007 11:24 PM
 

dvroegop said:

Diegene die wel eens een praatje van mij over Unit Testing hebben gehoord, weten dat ik een vrij puristische houding t.o.v. TDD heb. Een set met unit tests moet binnen een seconde te draaien zijn, anders is het geen unit test meer. Bij voorkeur moet een unit test uit te voeren zijn bij iedere compile....

Ik ben het dan ook helemaal eens met wat Hans schreef: gebruik de techniek die ik beschreef niet in een normale unit test omgeving.

Maar.... in het geval van integratie testen (zo gauw je dus meerdere lagen aan het testen bent, bij voorkeur op de build machine zoals Hans terecht opmerkte) kan het gebruik van de database/file/xml wel degelijk nut hebben. Dan is de bovenstaande techniek een enorme aanwinst op je toolset.

Kortom: unit tests moeten snel, klein en vooral toegespitsts zijn op de unit. Vandaar ook de naam :-)

Ik had hier wat duidelijker over moeten zijn
Dennis
December 23, 2007 6:36 PM
Anonymous comments are disabled

About dvroegop

Programmeert al sinds 1982. Microsoft Surface MVP.
Powered by Community Server, by Telligent Systems