Laatst kreeg ik de vraag, “Hoe maak je in je testdata onderscheid tussen een regular expression en een gewone tekst”. Oftewel: Hoe beheer je verschillende soorten steps als ze alleen verschillen in de manier waarop data vergeleken wordt. Je hebt een veld en je wilt controleren of er een bepaalde waarde in staat, maar soms ook of het matcht aan een pattern, of het een waarde bevat of misschien dat het een bepaalde minimum lengte heeft:
# exact value
Then the name field is equal to "Douglas Adams"
# contains a value
Then the name field contains "Adams"
# matching a pattern
Then the name field is matching the regex "+ +"
# minimum field length
Then the name field contains at least 2 characters
Je zal nu geneigd zijn om 1 van de volgende oplossingen te kiezen:
1. Voor elk check-type maak je een aparte stepdefinitie aan. Dit levert in dit geval 4 verschillende steps op die marginaal verschillen in de Assert sectie. 2. Je maakt één step die alle verschillende gevallen aankan door middel van een switch of een if/else if boom.
Beide zijn niet echt ideaal te noemen: Je zal het waarschijnlijk voor veel meer velden willen gaan inbouwen wat óf een wildgroei aan steps op gaat leveren, óf heel veel herhalende code in de vorm van switch statements.
Ik ben al een tijd fan van Fluent Assertions, een library die je assertions in menselijke taal laat noteren. Wat nu als we dat gedrag kunnen nabootsen en daar een generiek component voor gaan schrijven:
# exact value
Then the name field should be "Douglas Adams"
# contains a value
Then the name field should contain "Adams"
# matching a pattern
Then the name field should match regex "+ +"
# minimum field length
Then the name field length should be greater or equal to "2"
De gegenereerde code ziet er als volgt uit:
[Then(@"the name field should be ""(.*)""")]
public void ThenTheNameFieldShouldBe(string expectedValue)
{
ScenarioContext.Current.Pending();
}
We kunnen de daadwerkelijk compare actie er nu uit halen en die voeden in een switch statement:
[Then(@"the name field (.+) ""(.*)""")]
public void ThenTheNameFieldShouldBe(string compareAction, string expectedValue)
{
// normally the actual value is retrieved from your application
var actualValue = "Douglas Adams";
switch (compareAction)
{
case "should be":
actualValue.Should().Be(expectedValue);
break;
case "should contain":
actualValue.Should().Contain(expectedValue);
break;
case "should match regex":
actualValue.Should().MatchRegex(expectedValue);
break;
case "length should be greater or equal to":
actualValue.Length.Should().BeGreaterOrEqualTo(Convert.ToInt32(expectedValue));
break;
default:
throw new ArgumentException("Compare action is not a valid action");
}
}
Dit levert nog geen reusable code op, maar je ziet wellicht waar dit heen gaat: we kunnen dit deel ook weer generiek maken. Dat doen we door een step argument transformatie. Hiervoor zetten we een class in de argumentenlijst, SpecFlow zal nu proberen dit te converteren naar dit object met behulp van een zogenaamde StepArgumentTransfomation:
[Then(@"the name field (.+) ""(.*)""")]
public void ThenTheNameFieldShouldBeComparable(ShouldComparer<string> compareAction, string expectedValue)
{
// normally the actual value is retrieved from your application
var actualValue = "Douglas Adams";
// ShouldComparer action here...
}
De ShouldComparer moet gevoed worden met de actie die het moet uitvoeren en bij een call naar een Execute method de daadwerkelijke actie ook uitvoeren:
public class ShouldComparer<T1>
{
private readonly Action<T1, T1> _action;
public ShouldComparer(Action<T1, T1> action)
{
this._action = action;
}
public void Execute(T1 actual, T1 expected) => _action(actual, expected);
}
Als het T1 type je wat verward: In dit geval maken we gebruik van Generics. De T1 staat voor het type waarmee de class wordt aangemaakt, zodat we niet alleen de actie op string types uit kunnen voeren, maar op elk type dat wordt ondersteund door Fluent Assertions. In ons geval maken we een ShouldComparer aan voor strings (dat wordt gedaan door definitie ShouldComparer<string> compareAction in de stepmethod parameters), maar zo kunnen we de ShouldComparer later ook voor andere object typen gebruiken.
Nu volgt de implementatie: we moeten SpecFlow vertellen hoe het een string zoals “should be” of “should match regex” moet ombouwen naar de juiste actie. Hiervoor maken we een nieuwe StepArgumentTransformation methode aan die in een public class met een binding attribute moet staan. De methode moet een parameter ontvangen van het type waarvan we willen converteren en een object teruggeven van het type waarnaar we willen converteren.
[Binding]
public class ShouldTransformations
{
public ShouldComparer<string> shouldTransformationWithTypedComparer(string term)
{
switch (term)
{
case "should be":
return new ShouldComparer<string>((actual, expected)
=> actual.Should().Be(expected));
case "should contain":
return new ShouldComparer<string>((actual, expected)
=> actual.Should().Contain(expected));
case "should match regex":
return new ShouldComparer<string>((actual, expected)
=> actual.Should().MatchRegex(expected));
case "should be greater or equal to":
return new ShouldComparer<string>((actual, expected)
=> actual.Length.Should().BeGreaterOrEqualTo(Convert.ToInt32(expected)));
default:
throw new ArgumentException("the term was not recognized");
}
}
}
In dit geval staan hier alleen de vier gevallen die in het scenario staat aangegeven, maar in de praktijk zal je hier alle gevallen zetten die je (potentieel) wilt gebruiken zoals Should().NotBe() of Should().Match().
Nu hoeven we alleen nog de Execute method aan te roepen in de stepdefinitie:
[Then(@"the name field (.+) ""(.*)""")]
public void ThenTheNameFieldShouldBeComparable(ShouldComparer<string> compareAction, string expectedValue)
{
var actualValue = "Douglas Adams";
compareAction.Execute(actualValue, expectedValue);
}
Bij nieuwe steps hoef je in je stepdefinitie alleen nog maar een ShouldComparer toe te voegen en je hebt alle functionaliteit die je al eerder hebt geïmplementeerd voor een compare. Als je andere en/of eigen objecten wilt comparen, dan kan dat ook. Je kan je eigen StepArgumentTransformation schrijven voor elk type dat je wilt comparen, of je gebruikt de generieke implementatie:
[StepArgumentTransformation]
public ShouldComparer<T1> ShouldTransformationWithTypedComparer<T1>(string term)
{
switch (term)
{
case "should be":
return new ShouldComparer<T1>((actual, expected) => actual.Should().Be(expected));
case "should not be":
return new ShouldComparer<T1>((actual, expected) => actual.Should().NotBe(expected));
default:
throw new ArgumentException("the term was not recognized");
}
}
Deze comparer kan je feeden met elke willekeurige class en de Fluent Assertions library zal automatisch een compare maken.
De CompareAction class kan je eventueel ook nog fluent maken zodat er een nog beter leesbaar statement uit voortkomt:
compare.Actual(actualValue).With().Expected(expectedValue);
Conclusie:
In bepaalde SpecFlow steps wil je vaak meer testen dan alleen gelijkheid van data. Het inbouwen van een mechanisme om ook te vergelijken op bijvoorbeeld ongelijkheid, het matchen op een pattern of een minimum veldlengte kan resulteren in een wildgroei aan steps of het opblazen van de step methodes met veel herhaling. Door gebruik te maken van Fluent Assertions en Step Argument Transformations hebben we een generiek bruikbaar patroon die de vergelijkingsactie lostrekt van de overige acties in de glue code. Hiermee maken we onze code meer DRY en verkrijgen we een hogere mate van Separation of Concerns.
Comments