Testowanie jednostkowe platformy .NET: wydaj z góry, aby zaoszczędzić później
Opublikowany: 2022-03-11Często pojawia się wiele nieporozumień i wątpliwości dotyczących testów jednostkowych podczas omawiania ich z interesariuszami i klientami. Testy jednostkowe czasami brzmią jak nitkowanie zębów dla dziecka: „Już myję zęby, dlaczego muszę to robić?”
Sugerowanie testów jednostkowych często brzmi jak niepotrzebny wydatek dla osób, które uważają, że ich metody testowania i testy akceptacji użytkownika są wystarczająco mocne.
Ale testy jednostkowe są bardzo potężnym narzędziem i są prostsze niż myślisz. W tym artykule przyjrzymy się testom jednostkowym i jakie narzędzia są dostępne w DotNet, takie jak Microsoft.VisualStudio.TestTools i Moq .
Spróbujemy zbudować prostą bibliotekę klas, która obliczy n-ty wyraz w ciągu Fibonacciego. Aby to zrobić, będziemy chcieli stworzyć klasę do obliczania ciągów Fibonacciego, która zależy od niestandardowej klasy matematycznej, która sumuje liczby. Następnie możemy użyć .NET Testing Framework, aby upewnić się, że nasz program działa zgodnie z oczekiwaniami.
Co to jest testowanie jednostkowe?
Testy jednostkowe rozkładają program na najmniejszy fragment kodu, zwykle na poziomie funkcji, i zapewniają, że funkcja zwraca oczekiwaną wartość. Korzystając z platformy testów jednostkowych, testy jednostkowe stają się oddzielną jednostką, która może następnie uruchamiać automatyczne testy programu w trakcie jego tworzenia.
[TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }
Prosty test jednostkowy przy użyciu metodologii Arrange, Act, Assert testujący, że nasza biblioteka matematyczna może poprawnie dodać 2 + 2.
Po skonfigurowaniu testów jednostkowych, jeśli zostanie dokonana zmiana w kodzie, aby uwzględnić dodatkowy warunek, który nie był znany, gdy program został po raz pierwszy opracowany, na przykład testy jednostkowe pokażą, czy wszystkie przypadki pasują do oczekiwanych wartości wyjście przez funkcję.
Testowanie jednostkowe nie jest testowaniem integracyjnym. To nie jest testowanie od końca do końca. Chociaż obie są potężnymi metodologiami, powinny działać w połączeniu z testami jednostkowymi, a nie jako zamiennik.
Korzyści i cel testów jednostkowych
Najtrudniejszą do zrozumienia zaletą testów jednostkowych, ale najważniejszą, jest możliwość ponownego testowania zmienionego kodu w locie. Powodem, dla którego może to być tak trudne do zrozumienia, jest to, że tak wielu programistów myśli sobie: „Nigdy więcej nie dotknę tej funkcji” lub „Po prostu przetestuję ją ponownie, kiedy skończę”. A interesariusze myślą w kategoriach: „Jeśli ten fragment jest już napisany, dlaczego muszę go ponownie przetestować?”
Jako ktoś, kto był po obu stronach spektrum rozwoju, powiedziałem obie te rzeczy. Deweloper we mnie wie, dlaczego musimy to ponownie przetestować.
Zmiany, które wprowadzamy na co dzień, mogą mieć ogromny wpływ. Na przykład:
- Czy Twój przełącznik prawidłowo uwzględnia nową wartość, którą włożyłeś?
- Czy wiesz, ile razy użyłeś tego przełącznika?
- Czy poprawnie uwzględniłeś porównania ciągów bez rozróżniania wielkości liter?
- Czy odpowiednio sprawdzasz wartości null?
- Czy wyjątek rzutu jest obsługiwany zgodnie z oczekiwaniami?
Testy jednostkowe zajmują się tymi pytaniami i zapamiętują je w kodzie oraz w procesie zapewniającym, że na te pytania zawsze będą udzielane odpowiedzi. Testy jednostkowe można uruchomić przed kompilacją, aby upewnić się, że nie wprowadzono nowych błędów. Ponieważ testy jednostkowe są zaprojektowane tak, aby były atomowe, są uruchamiane bardzo szybko, zwykle mniej niż 10 milisekund na test. Nawet w bardzo dużej aplikacji pełny zestaw testów można przeprowadzić w niecałą godzinę. Czy Twój proces UAT może się z tym równać?
Fibonacci_GetNthTerm_Input2_AssertResult1
, który jest pierwszym uruchomieniem i obejmuje czas konfiguracji, wszystkie testy jednostkowe są wykonywane w czasie poniżej 5 ms. Moja konwencja nazewnictwa jest ustawiona tak, aby łatwo wyszukiwać klasę lub metodę w klasie, którą chcę przetestować
Jako programista może to po prostu brzmi dla ciebie jak więcej pracy. Tak, masz pewność, że kod, który wypuszczasz, jest dobry. Ale testowanie jednostkowe daje również możliwość sprawdzenia, gdzie Twój projekt jest słaby. Czy piszesz te same testy jednostkowe dla dwóch kawałków kodu? Czy zamiast tego powinny znajdować się na jednym kawałku kodu?
Sprawienie, by kod był testowalny jednostkowo, jest sposobem na ulepszenie projektu. A dla większości programistów, którzy nigdy nie testowali jednostkowo lub nie poświęcają tyle czasu na rozważenie projektu przed kodowaniem, możesz zdać sobie sprawę, jak bardzo twój projekt się poprawia, przygotowując go do testów jednostkowych.
Czy Twój kod można przetestować?
Oprócz DRY mamy też inne względy.
Czy twoje metody lub funkcje próbują zrobić za dużo?
Jeśli musisz napisać zbyt złożone testy jednostkowe, które działają dłużej, niż się spodziewasz, Twoja metoda może być zbyt skomplikowana i lepiej pasować do wielu metod.
Czy właściwie wykorzystujesz wstrzykiwanie zależności?
Jeśli testowana metoda wymaga innej klasy lub funkcji, nazywamy to zależnością. W testowaniu jednostkowym nie obchodzi nas, co ta zależność robi pod maską; do celów badanej metody jest to czarna skrzynka. Zależność ma własny zestaw testów jednostkowych, które określą, czy jej zachowanie działa poprawnie.
Jako tester chcesz zasymulować tę zależność i powiedzieć mu, jakie wartości zwrócić w określonych przypadkach. Zapewni to większą kontrolę nad przypadkami testowymi. Aby to zrobić, będziesz musiał wstrzyknąć fikcyjną (lub, jak zobaczymy później, wyśmiewaną) wersję tej zależności.
Czy Twoje komponenty współdziałają ze sobą tak, jak tego oczekujesz?
Po opracowaniu zależności i iniekcji zależności może się okazać, że w kodzie zostały wprowadzone zależności cykliczne. Jeśli Klasa A zależy od Klasy B, która z kolei zależy od Klasy A, powinieneś ponownie rozważyć swój projekt.
Piękno wstrzykiwania zależności
Rozważmy nasz przykład Fibonacciego. Twój szef mówi, że ma nową klasę, która jest bardziej wydajna i dokładna niż obecny operator add dostępny w C#.
Chociaż ten konkretny przykład nie jest bardzo prawdopodobny w prawdziwym świecie, widzimy analogiczne przykłady w innych komponentach, takich jak uwierzytelnianie, mapowanie obiektów i prawie każdy proces algorytmiczny. Na potrzeby tego artykułu załóżmy, że nowa funkcja dodawania Twojego klienta jest najnowsza i najlepsza od czasu wynalezienia komputerów.
W związku z tym szef wręcza ci bibliotekę czarnoskrzynkową z pojedynczą klasą Math
, a w tej klasie pojedynczą funkcją Add
. Twoja praca polegająca na wdrożeniu kalkulatora Fibonacciego może wyglądać mniej więcej tak:
public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }
To nie jest przerażające. Tworzysz wystąpienie nowej klasy Math
i używasz jej, aby dodać poprzednie dwa terminy, aby uzyskać następny. Przeprowadzasz tę metodę przez normalną baterię testów, obliczając do 100 terminów, obliczając 1000. semestr, 10.000 semestr i tak dalej, aż poczujesz się usatysfakcjonowany, że twoja metodologia działa dobrze. W przyszłości użytkownik skarży się, że 501. termin nie działa zgodnie z oczekiwaniami. Spędzasz wieczór przeglądając swój kod i próbując dowiedzieć się, dlaczego ta narożna sprawa nie działa. Zaczynasz podejrzewać, że najnowsza i najlepsza lekcja Math
nie jest tak dobra, jak myśli twój szef. Ale to czarna skrzynka i tak naprawdę nie możesz tego udowodnić – wewnętrznie wpadasz w impas.
Problem polega na tym, że zależność Math
nie jest wstrzykiwana do twojego kalkulatora Fibonacciego. Dlatego w swoich testach zawsze polegasz na istniejących, nieprzetestowanych i nieznanych wynikach z Math
, aby przetestować Fibonacciego. Jeśli wystąpi problem z Math
, Fibonacci zawsze będzie się mylił (bez kodowania specjalnego przypadku dla 501. terminu).
Pomysł na rozwiązanie tego problemu polega na wstrzyknięciu klasy Math
do kalkulatora Fibonacciego. Ale jeszcze lepiej jest utworzyć interfejs dla klasy Math
, który definiuje metody publiczne (w naszym przypadku Add
) i zaimplementować interfejs w naszej klasie Math
.
public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }
Zamiast wstrzykiwać klasę Math
do Fibonacciego, możemy wstrzyknąć interfejs IMath
do Fibonacciego. Zaletą jest to, że możemy zdefiniować naszą własną klasę OurMath
, o której wiemy, że jest dokładna i przetestować nasz kalkulator pod kątem tego. Co więcej, używając Moq możemy po prostu zdefiniować, co zwraca Math.Add
. Możemy zdefiniować liczbę sum lub po prostu powiedzieć Math.Add
, aby zwrócił x + y.
private IMath _math; public Fibonacci(IMath math) { _math = math; }
Wstrzyknij interfejs IMath do klasy Fibonacciego

//setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);
Używanie Moq do definiowania, co zwraca Math.Add
.
Teraz mamy wypróbowaną i prawdziwą (no cóż, jeśli operator + jest błędny w C#, mamy większe problemy) metodę dodawania dwóch liczb. Używając naszego nowego Mocked IMath
, możemy zakodować test jednostkowy dla naszego 501. semestru i sprawdzić, czy nie pomyliliśmy naszej implementacji lub czy niestandardowa klasa Math
wymaga trochę więcej pracy.
Nie pozwól, aby metoda próbowała zrobić zbyt wiele
Ten przykład wskazuje również na ideę metody, która robi za dużo. Oczywiście, dodawanie jest dość prostą operacją, która nie wymaga dużej abstrakcji jego funkcjonalności od naszej metody GetNthTerm
. Ale co by było, gdyby operacja była trochę bardziej skomplikowana? Zamiast dodatkowo, być może była to walidacja modelu, wywołanie fabryki w celu uzyskania obiektu do działania lub zebranie dodatkowych potrzebnych danych z repozytorium.
Większość programistów będzie próbowała trzymać się idei, że jedna metoda ma jeden cel. W testowaniu jednostkowym staramy się trzymać zasady, że testy jednostkowe należy stosować do metod atomowych i wprowadzając zbyt wiele operacji do metody, czynimy ją nietestowalną. Często możemy stworzyć problem, w którym musimy napisać tyle testów, aby poprawnie przetestować naszą funkcję.
Każdy parametr, który dodajemy do metody, zwiększa liczbę testów, które musimy napisać wykładniczo, zgodnie ze złożonością parametru. Jeśli dodasz wartość logiczną do swojej logiki, musisz podwoić liczbę testów do zapisania, ponieważ teraz musisz sprawdzić prawdziwe i fałszywe przypadki wraz z bieżącymi testami. W przypadku walidacji modelu złożoność naszych testów jednostkowych może bardzo szybko wzrosnąć.
Wszyscy jesteśmy winni, że dodajemy trochę więcej do metody. Ale te większe, bardziej złożone metody stwarzają potrzebę zbyt wielu testów jednostkowych. I szybko staje się jasne, kiedy piszesz testy jednostkowe, że metoda próbuje zrobić zbyt wiele. Jeśli masz wrażenie, że próbujesz przetestować zbyt wiele możliwych wyników na podstawie parametrów wejściowych, rozważ fakt, że Twoja metoda musi zostać podzielona na szereg mniejszych.
Nie powtarzaj się
Jeden z naszych ulubionych najemców programowania. Ten powinien być dość prosty. Jeśli zdarzy Ci się pisać te same testy więcej niż raz, oznacza to, że wprowadziłeś kod więcej niż raz. Może ci się przydać refaktoryzacja, która działa we wspólnej klasie, która jest dostępna dla obu instancji, w których próbujesz jej użyć.
Jakie narzędzia do testowania jednostek są dostępne?
DotNet oferuje nam bardzo potężną platformę testów jednostkowych po wyjęciu z pudełka. Korzystając z tego, możesz wdrożyć tak zwaną metodologię Arrange, Act, Assert. Ustalasz swoje wstępne rozważania, działasz zgodnie z tymi warunkami za pomocą testowanej metody, a następnie stwierdzasz, że coś się stało. Możesz potwierdzić wszystko, czyniąc to narzędzie jeszcze potężniejszym. Możesz stwierdzić, że metoda została wywołana określoną liczbę razy, że metoda zwróciła określoną wartość, że zgłoszono określony typ wyjątku lub cokolwiek innego, co możesz wymyślić. Dla tych, którzy szukają bardziej zaawansowanego frameworka, NUnit i jego odpowiednik w Javie JUnit są dobrymi opcjami.
[TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }
Sprawdzenie, czy nasza Metoda Fibonacciego obsługuje liczby ujemne, zgłaszając wyjątek. Testy jednostkowe mogą sprawdzić, czy został zgłoszony wyjątek.
Aby obsłużyć wstrzykiwanie zależności, zarówno Ninject, jak i Unity istnieją na platformie DotNet. Różnica między nimi jest bardzo niewielka i staje się kwestią, czy chcesz zarządzać konfiguracjami za pomocą Fluent Syntax lub XML Configuration.
Do symulacji zależności polecam Moq. Moq może być trudne do opanowania, ale sedno polega na tym, że tworzysz wyśmiewaną wersję swoich zależności. Następnie mówisz zależności, co ma zwrócić w określonych warunkach. Na przykład, jeśli masz metodę o nazwie Square(int x)
, która podniosła liczbę całkowitą do kwadratu, możesz ją powiedzieć, gdy x = 2, zwróć 4. Możesz również powiedzieć, aby zwróciła x^2 dla dowolnej liczby całkowitej. Lub możesz powiedzieć, aby zwrócił 5, gdy x = 2. Dlaczego miałbyś wykonać ostatni przypadek? W przypadku, gdy metodą w ramach testu jest sprawdzenie poprawności odpowiedzi z zależności, możesz chcieć wymusić zwrócenie nieprawidłowych odpowiedzi, aby upewnić się, że prawidłowo wyłapujesz błąd.
[TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }
Użycie Moq do poinformowania wykpiwanego interfejsu IMath
, jak obsłużyć testowany Add
. Możesz ustawić jawne przypadki za pomocą It.Is
lub zakres za pomocą It.IsInRange
.
Ramy testów jednostkowych dla DotNet
Platforma testów jednostkowych firmy Microsoft
Microsoft Unit Testing Framework to gotowe rozwiązanie do testowania jednostkowego firmy Microsoft i dołączone do programu Visual Studio. Ponieważ jest dostarczany z VS, ładnie się z nim integruje. Po rozpoczęciu projektu program Visual Studio zapyta, czy chcesz utworzyć bibliotekę testów jednostkowych obok aplikacji.
Microsoft Unit Testing Framework zawiera również szereg narzędzi, które pomogą Ci lepiej analizować procedury testowe. Ponadto, ponieważ jest własnością i jest napisany przez Microsoft, istnieje pewne poczucie stabilności w jego istnieniu w przyszłości.
Ale pracując z narzędziami firmy Microsoft, otrzymujesz to, co ci dają. Integracja Microsoft Unit Testing Framework może być kłopotliwa.
NUnit
Największą zaletą korzystania z NUnit są testy sparametryzowane. W powyższym przykładzie Fibonacciego możemy wprowadzić kilka przypadków testowych i upewnić się, że wyniki są prawdziwe. A w przypadku naszego 501. problemu, zawsze możemy dodać nowy zestaw parametrów, aby zapewnić, że test będzie zawsze wykonywany bez potrzeby stosowania nowej metody testowej.
Główną wadą NUnit jest integracja z Visual Studio. Brakuje w nim dzwonków i gwizdków, które są dostarczane z wersją Microsoft i oznacza, że będziesz musiał pobrać własny zestaw narzędzi.
xUnit.Net
xUnit jest bardzo popularny w C#, ponieważ świetnie integruje się z istniejącym ekosystemem .NET. Nuget ma wiele dostępnych rozszerzeń xUnit. Dobrze integruje się również z Team Foundation Server, chociaż nie jestem pewien, ilu programistów .NET nadal używa TFS w różnych implementacjach Git.
Z drugiej strony, wielu użytkowników narzeka, że brakuje dokumentacji xUnit. Dla nowych użytkowników do testowania jednostkowego może to spowodować ogromny ból głowy. Ponadto rozszerzalność i zdolność adaptacji xUnit sprawiają, że krzywa uczenia się jest nieco bardziej stroma niż NUnit lub Microsoft Unit Testing Framework.
Projektowanie/rozwój oparty na testach
Test drive design/development (TDD) to nieco bardziej zaawansowany temat, który zasługuje na osobny post. Chciałem jednak przedstawić wprowadzenie.
Chodzi o to, aby zacząć od testów jednostkowych i powiedzieć testom jednostkowym, co jest poprawne. Następnie możesz napisać swój kod wokół tych testów. Teoretycznie koncepcja brzmi prosto, ale w praktyce bardzo trudno jest wytrenować mózg, aby myślał wstecz o aplikacji. Ale podejście to ma wbudowaną zaletę polegającą na tym, że nie jest wymagane pisanie testów jednostkowych po fakcie. Prowadzi to do mniejszej refaktoryzacji, przepisywania i zamieszania klasowego.
TDD było trochę modnym hasłem w ostatnich latach, ale adopcja jest powolna. Jego konceptualny charakter jest mylący dla interesariuszy, co utrudnia uzyskanie aprobaty. Ale jako programista zachęcam do napisania nawet małej aplikacji z wykorzystaniem podejścia TDD, aby przyzwyczaić się do tego procesu.
Dlaczego nie możesz mieć zbyt wielu testów jednostkowych
Testy jednostkowe to jedno z najpotężniejszych narzędzi testowych, jakimi dysponują twórcy. W żaden sposób nie jest wystarczająca do pełnego testu aplikacji, ale jej zalety w testowaniu regresji, projektowaniu kodu i dokumentacji celu są niezrównane.
Nie ma czegoś takiego jak pisanie zbyt wielu testów jednostkowych. Każdy przypadek brzegowy może sugerować duże problemy w Twoim oprogramowaniu. Zapamiętywanie znalezionych błędów podczas testów jednostkowych może zapewnić, że błędy te nie znajdą sposobów na powrót do oprogramowania podczas późniejszych zmian w kodzie. Chociaż możesz dodać 10-20% do początkowego budżetu projektu, możesz zaoszczędzić znacznie więcej niż na szkoleniach, poprawkach błędów i dokumentacji.
Repozytorium Bitbucket użyte w tym artykule znajdziesz tutaj.