Die neue Vorgehensweise beim Validieren von Daten über View Models und Attribute erfordert auch eine kleine Umstellung bei der test-getriebenen Entwicklung (TDD).
So kann man zwar weiterhin die Controller-Action auf die Anforderungen hin testen, etwa ob der ModelState ungültig ist, wenn eine per Attribut deklarierte Bedingung an ein Element des View Models nicht erfüllt ist (z.B. "required"), aber eigentlich ist das der falsche Ort - viel mehr sollte man das View Model selbst testen.
Um das tun zu können, muss man während des Testens an die Attribute herankommen. Das geht natürlich per Reflection, aber Microsoft hat mitgedacht und im Namespace System.ComponentModel.DataAnnotations eine Helfer-Klasse namens Validator mitgeliefert (.NET 4.0).
Mit der darin befindlichen Methode TryValidateObject lässt sich das SUT (System under Test) überprüfen.
1: public class SignUpViewModel
2: {
3:
4: [Required(ErrorMessage = "Bitte geben Sie Ihren Nachnamen an")]
5: public string UserLastName { get; set; }
6:
7: [BooleanRequiredToBeTrue(ErrorMessage = "Bitte lesen und akzeptieren Sie die AGB.")]
8: public bool AgreesWithTerms { get; set; }
9:
10: }
1: [TestMethod]
2: public void Der_Nachname_darf_kein_Leerstring_sein()
3: {
4: var sut = new SignUpViewModel { UserLastName = " " };
5: var validationResults = new List<ValidationResult>();
6: var result = Validator.TryValidateObject(sut,
7: new ValidationContext(sut, null, null), validationResults);
8: Assert.IsFalse(result);
9: }
Allerdings ist das noch nicht optimal. Das SUT ist zwar in diesem Fall nicht valide, aber ob das daran liegt, dass "AgreeWithTerms" false ist oder ob der Nachname ein Leerstring ist, ist unklar.
Die von der Prüfmethode TryValidateObject zurückgelieferte Liste an Fehlern enthält aber zum Glück auch die dazugehörigen Informationen, die nötig sind um zu prüfen, ob nun genau "UserLastName" invalide ist.
Hierzu habe ich mir eine kleine Erweiterung geschrieben, die die Prüfung übernimmt:
1: public class ValidationResults : List<ValidationResult>
2: {
3: }
4:
5: public static class TestHelper
6: {
7:
8: public static bool ContainsField<T>(this ValidationResults results,
9: Expression<Func<T, object>> action)
10: {
11: string name = null;
12:
13: if (action.Body is MemberExpression)
14: name = ((MemberExpression) action.Body).Member.Name;
15:
16: if (action.Body is UnaryExpression)
17: name = ((UnaryExpression) action.Body).Operand.ToString().Split('.')[1];
18:
19: if(name == null)
20: throw new ArgumentException("Unbekannter Expression-Typ, bitte ergänzen");
21:
22: return results.Any(names => names.MemberNames.Contains(name));
23: }
24:
25: }
Die etwas aufwendige und nach Handarbeit aussehende Vorgehensweise scheint nötig, da "action.Body" unterschiedlichen Typs sein kann (üblicherweise MemberExpression, bei Bools aber bsp. UnaryExpression) und so kein einheitlicher Zugriff auf den gesuchten "Namen" möglich ist (falls jemand eine Alternative weiß ... :-)). Der Aufruf erfolgt wie folgt:
1: [TestMethod]
2: public void Der_Nachname_darf_kein_Leerstring_sein()
3: {
4: var sut = new SignUpViewModel { UserLastName = " " };
5: var validationResults = new ValidationResults();
6: Validator.TryValidateObject(sut, new ValidationContext(sut, null, null),
7: validationResults, true);
8: Assert.IsTrue(validationResults.
9: ContainsField<SignUpViewModel>(m => m.UserLastName));
10: }
Nett daran ist vor allem die gegebene Typsicherheit, so dass auf "Magic Strings" verzichtet werden kann und auch im Fall einer Refaktorisierung keine Probleme entstehen und Tests von Hand nachgezogen werden müssen.