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 ;-)