NUnit – TestCaseSourceAttribute

Chciałem pokazać działanie kilku ciekawych atrybutów w NUnit. Długo się zastawiałem które są najciekawsze, w jakiej formie je sprzedać, jakich przykładów użyć i na co zwrócić uwagę. Doszedłem do jednego wniosku: za długo się zastanawiam. Poniżej pierwszy atrybut, który często mi się przydaje, a może nie każdy jest świadom jego istnienia. Wiedza ze standardowej dokumentacji NUnit 2.6.4 (najnowszy NUnit ostatnio nie współpracował z resharperem, lub odwrotnie) okraszona kilkoma przykładami i przemyśleniami. Przykład może jest odrobinę naciągany, ale powinien fajnie obrazować co chcę osiągnąć.

Punkt wyjścia

Będziemy sobie testować implementacje interfejsu ISelectSourceService, która to ma dostarczać różne słowniki do select listy.

    public interface ISelectSourceService
    {
        Dictionary<int, string> GetCountries(Lang lang, bool withEmpty0 = false);
    }

Zacznijmy od testowania domyślnego wywołania GetCountries. Chcemy wiedzieć, że dla każdego języka dostajemy nie pusty słownik, niepustych wartości. Jakie mamy opcje.

Możemy sobie napisać test dla każdej wartości lang, ale podejście trochę słabe. Mnożymy kod, a poza tym możemy przegapić pojawienie się nowego języka. W takim razie napiszmy bazową metodę testującą i wywołajmy w teście dla wszystkich wartości enum’a Lang. Coś w ten deseń:

    [Test]
    public void GetCountriesShouldReturnNotEmptyDictionary()
    {
        ISelectSourceService service = new SelectSourceService();
        TestForLanguage(service, Lang.Polish);
        TestForLanguage(service, Lang.English);
        TestForLanguage(service, Lang.German);
    }

Fajnie, kodu mniej ale jak się wysypie nie sprawdzi pozostałych wartości, a jak nie przypilnujemy to nie wiemy, która wartość psuje test. Nadal przegapiamy pojawienie się nowej wartości enum. Możemy być sprytni, wyciągnąć wszystkie elementy enuma i uruchomić TestForLanguage dla każdego elementu. Podejście dobre i złe:). Dzięki temu mamy jeden test, nie przegapimy nowego enum’a,  ale gdy któraś wartość powoduje błąd to przerywamy test i nie sprawdzamy reszty.
Innym podejściem byłby test z paroma testcase’ami.

    [TestCase(Lang.Polish)]
    [TestCase(Lang.English)]
    [TestCase(Lang.German)]
    public void GetCountriesShouldReturnNotEmptyDictionary(Lang lang)

Wtedy rezultaty wyglądają lepiej, ale nadal boli mnie to że mogę przegapić pojawienie się nowego języka i mam już tego dosyć.

TestCaseSourceAttribute

Z pomocą przychodzi nam TestCaseSourceAttribute, który pozwala podać źródło naszych testcase’ów i przetestować każdy case nawet gdy któryś się poczerwieni.

Właśnie to jest rozwiązanie problemu, który kiedyś strasznie mnie dotykał. W TestCaseSourceAttribute możemy sobie, utworzyć kolekcję do przetestwania. Innym problemem w którym mi się to przydało jest ładowanie test case’ów z pliku json lub innego źródła. Korzytając z tego atrybutu testowanie nasze może zaprezentować się następująco:

        [TestCaseSource("LanguagesTestCases")]
        public void GetCountriesShouldReturnNotEmptyDictionaryForAnyLanguage(Lang lang)
        {
            ISelectSourceService service = new SelectSourceService();
            var received = service.GetCoutries(lang);
            Assert.NotNull(received);
            Assert.IsNotEmpty(received);
        }

        public static IEnumerable LanguagesTestCases()
        {
            var enums = Enum.GetValues(typeof(Lang));
            return enums;
        }

Tym sposobem jeżeli w samej implementacji pojawi się nowy element, który powinien być przetestowany, TestCase zostanie utworzony automatycznie.
Trochę mnie boli jeszcze prezentacja pojedynczych test casesów, ale jest akceptowalna. Rozwiązanie tej bolączki zaprezentuje w następnym przykładzie.

Sam parametr sourceName musi spełniać kilka warunków.

  • Musi to być nazwa pola, property lub metoda
  • Musi to być instancja lub element statyczny
  • Musi zwracać IEnumerable lub obiekt implementujący IEnumerable
  • Pojedyncze itemy muszą być kompatybilne z sygnaturą metody testującej.

TestCaseData

Ciekawostką i rozwiązaniem wcześniej opisywanego problemu jest zwracanie elementów typu TestCaseData. Obiekt ten pozwala zdefiniować oczekiwany rezultat, wyjątek czy nazwę i opis test case’a. Dla przykładu mając property informujący czy trójkąt jest prostokątny możemy to testować w ten sposób.

        [TestCaseSource(nameof(TestCases))]
        public void IsRectangularShouldReturnProperValue(Triangle triangle, bool expectedResult)
        {
            var isRectangular = triangle.IsRectangular;
            Assert.AreEqual(expectedResult, isRectangular);
        }

        public static IEnumerable TestCases()
        {
            yield return new object[] {new Triangle(5, 4, 3), true};
            yield return new object[] {new Triangle(5, 4, 4), false};
            yield return new object[] {new Triangle(5, 2, 2), false}; //this one throws exception

        }

Podejście to sprawia jednak parę problemów.

  1. Nie możemy oczekiwanego rezultatu ustawić jako rezultat metody
  2. Nie możemy w jednym TestCaseSource sprawdzić czy dla złych danych (nie tworzących trójkąta) rzucany jest odpowiedni wyjątek.
  3. Test explorer nie pokazuje nic przyjemnego jako pojedyncze case’y. Jeżeli klasa TriangleEdges nie przeładowała ToString() wrzuca domyślny ToString()[screen1].
  4. Nawet jeśli przeładowaliśmy ToString() to:
    • nadal może nie być nasze oczekiwane rozwiązanie [screen2],
    • wymusza implemetacje ToString() pod testy.
screen1

Screen1

screen2

Screen2

Wszystkie te bolączki rozwiązują obiekty TestCaseData, przy pomocy których test mógłby wyglądać następująco:

        [TestCaseSource(nameof(TestCaseDataCases))]
        public bool IsRectangularShouldReturnProperValue2(Triangle triangle)
        {
            return triangle.IsRectangular;
        }

        public static IEnumerable TestCaseDataCases()
        {
            yield return new TestCaseData(new Triangle(5, 4, 3))
                .Returns(true)
                .SetName("Case for true");
            yield return new TestCaseData(new Triangle(5, 4, 4))
                .Returns(false)
                .SetName("Case for false");
            yield return new TestCaseData(new Triangle(5, 2, 2))
                .Throws(typeof(ThisIsNotTriangleException))
                .SetName("Case for exception");
        }

Test explorer też prezentuje się dużo lepiej.

screen3

Screen3

Oczywiście sam przykład mocno uproszczony i naciągany, ale pogląd na sprawę daje.
Mam nadzieję, że jest to zalążek wiedzy, która okaże się przydana i zainspiruje do przejrzenia dokumentacji NUnit.
Warto.

Napisano w .net, Nuget Tagi: , , ,