Wykorzystywanie mocków w testach jednostkowych z użyciem Moq
Ostatnim razem pisałem o początkach z testami w NUnit. Wspomniałem tam, że dobrym kolejnym krokiem będzie zapewne mockowanie danych. Jest to technika, która często się przydaje, dlatego kolejny post postanowiłem poświęcić temu tematowi.
Czym są mocki i do czego służą?
Mock jest to zamiennik obiektu, który wygląda tak samo jak prawdziwy obiekt, ale my sterujemy jego zachowaniem, np. definiujemy jakie wartości mają zwracać funkcje. Dlaczego w ogóle chcielibyśmy wykorzystywać takie obiekty? Wyobraźmy sobie, że chcemy przetestować klasę MediaPlayer. Klasa ta zajmuje się obsługą i odtwarzaniem plików muzycznych i wideo. W niej będziemy mieli więc zapewne odniesienie do czegoś w stylu AudioManager, czy VideoManager. Będą to klasy, które zajmują się obsługą dźwięku i wideo. Są to zewnętrzne zależności, które w pewnym momencie musimy zainicjować w klasie MediaPlayer. Klasa AudioManager może zajmować się otwieraniem i odczytywaniem danych z pliku muzycznego oraz przekazywaniem ich do urządzenia wyjściowego (np. głośników). Została ona stworzona przez kogoś innego i zakładamy, że działa poprawnie. Nie chcemy testować jej zachowania. Dlatego testując klasę MediaPlayer powinniśmy użyć zamiennika za klasę AudioManager – takiego, który zachowuje się tak samo z perspektywy klasy MediaPlayer, ale tak naprawdę nie odczytuje danych z pliku i nie przekazuje ich na wyjście, a jedynie raportuje, że to zrobił, czy też wykonuje inną akcję, przez nas ustaloną.
Ktoś może sobie pomyśleć “Ale zaraz, czemu nie przetesotwać też klasy AudioManager razem z MediaPlayer. Wtedy wiedzielibyśmy, że wszystko działa poprawnie!”. To prawda, jednak są z tym podejściem 2 problemy:
- Tutaj skupiamy się na testach jednostkowych, a to byłby już test integracyjny, który sprawdza interakcje między oddzielnymi częściami systemu.
- Gdy testujemy MediaPlayer z mockiem i test nie przejdzie, to wiemy, że problem jest w jakimś konkretnym miejscu klasy MediaPlayer, a tak istnieje szansa, że coś nie zadziałało gdzie indziej i szukanie źródła problemu trwa dłużej.
Przykładowy kod do testowania
Chciałbym pokazać wam jak najprostszy (ale jednak realny) przykład, w którym przydatne będą mocki. Musimy więc użyć przykładu, gdzie będziemy mieli pewne zależności. Stwórzmy sobie klasę do walidowania wartości pól:
public class Validator { Dictionary<string, List<IValidatorRule>> _rules = new Dictionary<string, List<IValidatorRule>>(); public void AddRule(string fieldName, IValidatorRule rule) { if (_rules.ContainsKey(fieldName)) { _rules[fieldName].Add(rule); } else { _rules.Add(fieldName, new List<IValidatorRule> { rule }); } } public bool IsValid(string fieldName, object value) { if(_rules.ContainsKey(fieldName)) { var rules = _rules[fieldName]; foreach (var rule in rules) { var isValid = rule.IsValid(value); if(!isValid) { return false; } } } return true; } } public interface IValidatorRule { bool IsValid(object obj); }
Metoda AddRule służy do dodawania nowych reguł walidacji dla danego pola (np. czy zawiera wartość, czy email jest poprawny, czy liczba jest większa od zera, itp.). Jako parametry podajemy nazwę pola, które chcemy walidować oraz obiekt implementujący nasz interfejs IValidatorRule, w którym to zawarta będzie reguła walidacyjna (przykłady za moment). Obiekty z regułami walidacji zapisujemy w słowniku, gdzie kluczem jest nazwa pola. Druga metoda, IsValid, służy do sprawdzania, czy pole zawiera poprawną wartość, np. gdy chcemy zatwierdzić formularz. Tutaj argumenty to nazwa pola (żebyśmy mogli znaleźć reguły w słowniku) oraz wartość pola. Metoda zwraca false, jeśli chociaż jedna reguła nie jest spełniona (inaczej true).
Interfejs IValidatorRule zawiera jedną metodę – IsValid. Poniżej 2 przykłady implementacji tego interfejsu:
public class RequiredRule : IValidatorRule { public bool IsValid(object obj) { if(obj is string s) { return !string.IsNullOrEmpty(s); } return obj != null; } } public class PositiveNumberRule : IValidatorRule { public bool IsValid(object obj) { if(long.TryParse(obj?.ToString(), out var l)) { return l >= 0; } if (double.TryParse(obj?.ToString(), out var d)) { return d >= 0; } return false; } }
W klasie RequiredRule sprawdzamy czy obiekt nie jest nullem lub czy string nie jest pusty. W PositiveNumberRule sprawdzamy czy obiekt jest liczbą nieujemną.
Naszą zewnętrzną zależnością w klasie Validator są obiekty IValidatorRule. Gdy mamy już właściwy kod, możemy przejść do testów.
Prosty test
Zacznijmy od pierwszego, najprostszego przypadku. Chcemy przetestować czy metoda IsValid zwraca true, jeśli nie mamy żadnych reguł:
[Test] public void IsValidShouldReturnTrueIfThereAreNoRules() { var myField = -1; var v = new Validator(); var actual = v.IsValid(nameof(myField), myField); Assert.That(actual, Is.True); }
Oczywiście, test się powiedzie. Nie było reguł do sprawdzenia, więc jakakolwiek wartość pola jest poprawna. Tutaj sytuacja jest jasna. Sprawdzamy też tylko implementację metody IsValid.
Test z mockami tworzonymi ręcznie
A co się stanie, jeśli na początku dorzucimy do naszego pola jakąś regułę? Zobaczmy:
[Test] public void IsValidShouldReturnFalseIfAtLeastOneRuleReturnsFalse() { var myField = -1; var v = new Validator(); v.AddRule(nameof(myField), new PositiveNumberRule()); var actual = v.IsValid(nameof(myField), myField); Assert.That(actual, Is.False); }
Do pola myField przypisaliśmy regułę PositiveNumberRule. Sprawdzamy, czy wartość wynikowa to false, a więc test także się powiedzie. Jednak, jak wskazuje nazwa metody testowej, chcieliśmy sprawdzić tu implementację IsValid, a sprawdzamy też PositiveNumberRule. Jeśli zmienilibyśmy implementację PositiveNumberRule to test mógłby przestać działać, a do takiej sytuacji nie chcemy doprowadzać. Wtedy utrzymanie testów staje się bardziej uciążliwe. Test może przestać działać, jak zmienimy coś w IsValid, ale powinien być wolny od innych zależności.
Jak zaradzić tej sytuacji? Zastąpimy PositiveNumberRule inną regułą. Będzie to klasa, która także implementuje IValidatorRule, ale będzie ona stworzona konkretnie do tego testu. Tak więc jej implementacja będzie taka, jakiej potrzebujemy akurat tutaj. Nie będzie ona też dołączona do projektu aplikacji/biblioteki, a tylko do projektu testowego.
public class FakeRule : IValidatorRule { public bool IsValid(object obj) { return false; } }
A następnie zmieniamy regułę w teście z PositiveNumberRule na FakeRule:
[Test] public void IsValidShouldReturnFalseIfAtLeastOneRuleReturnsFalse() { var myField = -1; var v = new Validator(); v.AddRule(nameof(myField), new FakeRule()); var actual = v.IsValid(nameof(myField), myField); Assert.That(actual, Is.False); }
Świetnie! Usunęliśmy naszą zależność.
Czy do każdego testu powinniśmy więc tworzyć oddzielną klasę z regułą, jaką potrzebujemy w tym teście? Możemy tak robić. Ale jeśli da się łatwo, bez skomplikowanej logiki, uogólnić jedną klasę, to możemy też pójść tą drogą. Przykładowo, nasza klasa FakeRule zawsze zwraca false. Możemy jednak przekazywać do niej parametr i mówić jej co powinna zwrócić.
public class FakeRule : IValidatorRule { bool _whatToReturn; public FakeRule(bool whatToReturn) { _whatToReturn = whatToReturn; } public bool IsValid(object obj) { return _whatToReturn; } }
Dzięki temu klasa ta może być też wykorzystana w innych testach, które wymagają reguł zwracających true, jak np. ten:
[Test] public void IsValidShouldReturnTrueIfAllRulesReturnTrue() { var myField = -1; var v = new Validator(); v.AddRule(nameof(myField), new FakeRule(true)); var actual = v.IsValid(nameof(myField), myField); Assert.That(actual, Is.True); }
Test z Moq
Możemy tworzyć mocki ręcznie i czasami może to być wygodniejsze rozwiązanie, ale zazwyczaj tego nie potrzebujemy. Możemy wtedy użyć szybszego rozwiązania, jak Moq. Zacznijmy od ściągnięcia do projektu z testami paczki nugetowej o nazwie Moq. Jeśli już ją mamy, to pierwszy z powyższych testów możemy przepisać następująco:
[Test] public void IsValidShouldReturnFalseIfAtLeastOneRuleReturnsFalse() { var myField = -1; var v = new Validator(); var rule = new Mock<IValidatorRule>(); rule.Setup(s => s.IsValid(myField)).Returns(false); v.AddRule(nameof(myField), rule.Object); var actual = v.IsValid(nameof(myField), myField); Assert.That(actual, Is.False); }
Zamieniliśmy FakeRule na obiekt z Moq. Działa on w ten sposób, że podajemy mu interfejs, który ma mockować – i w zasadzie tyle, gotowe. Nie musimy nic implementować, jeśli nie chcemy. Mówimy mu tylko, co ma robić z metodą, która nas interesuje. Służy do tego metoda Setup. Podajemy w niej interesującą nas metodę z interfejsu wraz z parametrami. Gdy nastąpi dokładnie takie jej wywołanie, to metoda ta (z naszego interfejsu) zwróci to, co wpiszemy w metodzie Returns, którą widać dalej w tej samej linijce.
Jak użyć takiego moqowego obiektu w teście? Sam obiekt rule to opakowanie naszego właściwego obiektu , a ten znajduje się we właściwości Object – i to tę właściwość przekazujemy do AddRule.
Podobnie z naszym kolejnym testem. Tam musimy tylko zamienić Returns(false) na Returns(true) (oraz Assert):
[Test] public void IsValidShouldReturnTrueIfAllRulesReturnTrue() { var myField = -1; var v = new Validator(); var rule = new Mock<IValidatorRule>(); rule.Setup(s => s.IsValid(myField)).Returns(true); v.AddRule(nameof(myField), rule.Object); var actual = v.IsValid(nameof(myField), myField); Assert.That(actual, Is.True); }
Jest to szybsze niż pisanie mocków ręcznie. Moq jest całkiem rozbudowany i przejdziemy teraz przez kilka jego głównych funkcjonalności.
Sprawdzanie wyjątków
Załóżmy scenariusz, w którym reguła walidacyjna rzuci z jakiegoś powodu wyjątek, np. sprawdzamy, czy wpisana liczba należy do ciągu fibonacciego i przy dużej liczbie dostaniemy StackOverflowException. Jeśli używamy do reguły Moqa, to możemy skorzystać do tego celu z metody Throws:
[Test] public void AddRuleShouldThrowExceptionIfRuleThrewException() { var myField = 40000000; var v = new Validator(); var rule = new Mock<IValidatorRule>(); rule.Setup(s => s.IsValid(myField)).Throws<StackOverflowException>(); v.AddRule(nameof(myField), rule.Object); TestDelegate del = () => v.IsValid(nameof(myField), myField); Assert.Throws<StackOverflowException>(del); }
Używamy tutaj Setup(…).Throws, gdzie mówimy Moqowi, że metoda z takimi argumentami powinna rzucić dany wyjątek. Następnie z Assert.Throws możemy to potwierdzić. Sama testowana metoda jest przekazywana do asserta jako TestDelegate – żeby nie wywołała się od razu, bo wtedy dostalibyśmy wyjątek.
Weryfikowanie liczby wywołań
Metoda IsValid powinna wywołać po kolei wszystkie walidatory danego pola, które też posiadają metodę IsValid. Załóżmy, że chcemy się upewnić, że ta wewnętrzna metoda wywołuje się tylko raz. Oczywiście możemy użyć do tego Moq:
[Test] public void IsValidShouldCheckRuleOneTime() { var myField = -1; var v = new Validator(); var rule = new Mock<IValidatorRule>(); v.AddRule(nameof(myField), rule.Object); v.IsValid(nameof(myField), myField); rule.Verify(s => s.IsValid(myField), Times.Once); }
Moq posiada metodę Verify – podajemy do niej wywołanie metody, którą chcemy sprawdzić (razem z konkretnymi argumentami) oraz liczbę razy, ile metoda powinna się wywołać (Times.Once). Ta linijka kodu służy nam za assert, nic więcej do weryfikacji testu nie musimy już dopisywać.
Istnieje jeszcze kilka innych, podobnych metod, jak VerifySet, czy VerifyGet, które to z kolei służą do sprawdzania właściwości. Dodatkowo, zamiast konkretnego parametru możemy podać np. It.IsAny<object>() – wtedy sprawdzamy wywołanie dla jakiegokolwiek parametru typu object. Jak nietrudno zgadnąć, również dla klasy Times istnieją pomocnicze metody, jak Exactly, Between, czy AtMost.
Sekwencje
Czasami zdarza się sytuacja, że chcemy by mockowy obiekt zwrócił raz taką, a raz taką wartość. W naszym przykładzie moglibyśmy sprawdzać, czy IsValid na pewno na nowo wywołuje reguły walidacyjne pola po jego aktualizacji. Jak widzieliśmy w pierwszym przykładzie z Moq, mamy funkcje Setup oraz Returns, które ustalają jedną konkretną zwracaną wartość dla danej funkcji. Zamiast tego, możemy użyć SetupSequence, która pozwoli nam na stworzenie sekwencji zwracanych wartości. Z nią, możemy wywołać Returns kilka razy. Pierwsze Returns określa, jaką wartość zwróci funkcja przy pierwszym wywołaniu, drugie Returns – wartość przy drugim wywołaniu, itd.
[Test] public void IsValidShouldReturnUpdatedValueAfterChangeInFieldValue() { var myField = 1; var v = new Validator(); var rule = new Mock<IValidatorRule>(); rule.SetupSequence(s => s.IsValid(myField)) .Returns(true) .Returns(false); v.AddRule(nameof(myField), rule.Object); var actual1 = v.IsValid(nameof(myField), myField); // Przyjmijmy, że wartość w polu się zmieniła // Nie musimy robić tego naprawdę, ponieważ zmieniamy wartość zwracaną przez mock dla drugiego wywołania. var actual2 = v.IsValid(nameof(myField), myField); Assert.That(actual1, Is.True); Assert.That(actual2, Is.False); }
Zdarzenia
Są również sytuacje, w których niezbędne jest przetestowanie zdarzeń. Załóżmy scenariusz, gdzie nasze reguły walidacyjne mogą wywołać zdarzenie IsValidating. Zazwyczaj nie jest to potrzebne, bo mamy prostą, lokalną logikę i feedback dla użytkownika jest natychmiastowy. Ale być może niektóre reguły muszą zrobić zapytanie do jakiegoś api, żeby sprawdzić poprawność danych. Wtedy wypadałoby pokazać progress bar. Dodajemy więc zdarzenie do interfejsu:
public interface IValidatorRule { bool IsValid(object obj); event EventHandler<bool> IsValidating; }
I implementujemy je w klasach reguł. W klasie Validator, w metodzie AddRule subskrybujemy do tego zdarzenia i na razie, dla prostoty przykładu, będziemy po prostu ustawiać tam jakąś właściwość.
public class Validator { ... public bool IsValidating { get; set; } public void AddRule(string fieldName, IValidatorRule rule) { ... rule.IsValidating += Rule_IsValidating; } private void Rule_IsValidating(object sender, bool isValidating) { IsValidating = isValidating; } ... }
Teraz pytanie, jak możemy przetestować, czy zdarzenie to zostało wywołane? Bardzo prosto – sprawdzając wartość pola IsValidating. A jak wywołać samo zdarzenie? W końcu znajduje się w klasie, którą mockujemy. Na szczęście Moq przychodzi tu z pomocą.
[Test] public void IsValidatingShouldBeSetWhenRuleRaisesIsValidatingEvent() { var myField = 1; var v = new Validator(); var rule = new Mock<IValidatorRule>(); rule.Setup(s => s.IsValid(myField)).Returns(true).Raises(e => e.IsValidating += null, rule.Object, true); v.AddRule(nameof(myField), rule.Object); v.IsValid(nameof(myField), myField); Assert.That(v.IsValidating, Is.True); }
Moq udostępnia nam metodę Raises, która wywołuje zdarzenie z podanymi parametrami, gdy używamy metody ustawianej w Setup. Na koniec sprawdzamy wartość pola IsValidating i widzimy, że zdarzenie rzeczywiście zostało wywołane.
Jest tylko jeden problem. To zdarzenie, IsValidating, powinno zostać wywołane 2 razy – na początku metody z parametrem true (gdy zaczynamy walidację) i na końcu metody z parametrem false (gdy kończymy walidację). Jednak taka składnia Moq pozwala nam tylko na jedno wywołanie zdarzenia. Jest to pewne ograniczenie. Możemy jednak trochę zmienić podejście i użyć innej metody – Raise. Metoda ta jest wywoływana bezpośrednio na mocku, a nie podczas używania metody ustawianej w Setup:
rule.Raise(e => e.IsValidating += null, rule.Object, true);
Wtedy możemy jednak wywołać zdarzenie dowolną ilość razy. Nie jest to co prawda idealne rozwiązanie, ale jest to pewna alternatywa.
Podsumowanie
W powyższym wpisie zapoznaliśmy się z mockowaniem. Wiemy już czym są mocki, do czego służą, jak tworzyć je ręcznie oraz jak wykorzystywać bibliotekę Moq. Upraszcza ona wiele rzeczy i pozwala na szybsze pisanie testów. Ale, jak do wszystkiego, trzeba podchodzić do tej biblioteki z dystansem i zastanowić się, jakie będzie dla nas najlepsze podejście. Być może natkniemy się na ograniczenia i lepiej będzie napisać własne mocki – niekoniecznie dla wszystkich testów, ale tylko tam gdzie to konieczne. Jeśli jednak interesuje cię temat testów, to zachęcam do dalszego poznawania biblioteki Moq, ponieważ to nie są jej wszystkie możliwości.
Czy biblioteka Moq ma też inne funkcje, które często wykorzystujesz? Zachęcam do podzielenia się nimi w komentarzach.
Kod z posta dostępny jest jako pełny projekt na githubie: https://github.com/Ermlab/how-to-start-mocking-with-moq