Najlepsze rozwiązania dotyczące testowania jednostkowego za pomocą platform .NET Core i .NET Standard

Istnieje wiele zalet pisania testów jednostkowych; pomagają one w regresji, udostępniają dokumentację i ułatwiają dobre projektowanie. Jednak trudne do odczytania i kruche testy jednostkowe mogą żałosić spustoszenie w bazie kodu. W tym artykule opisano niektóre najlepsze rozwiązania dotyczące projektowania testów jednostkowych dla projektów .NET Core i .NET Standard.

W tym przewodniku poznasz pewne najlepsze rozwiązania podczas pisania testów jednostkowych, aby zapewnić odporność testów i łatwo je zrozumieć.

John Reese ze specjalnym podziękowaniami RoyA Osherove

Dlaczego test jednostkowy?

Istnieje kilka powodów, dla których należy używać testów jednostkowych.

Krótszy czas wykonywania testów funkcjonalnych

Testy funkcjonalne są kosztowne. Zazwyczaj obejmują one otwarcie aplikacji i wykonanie szeregu kroków, które użytkownik (lub ktoś inny) musi wykonać w celu zweryfikowania oczekiwanego zachowania. Te kroki mogą nie zawsze być znane testerowi. Będą musieli skontaktować się z kimś bardziej kompetentnym w tym obszarze, aby przeprowadzić test. Testowanie samo w sobie może potrwać kilka sekund w przypadku trywialnych zmian lub minut w przypadku większych zmian. Na koniec ten proces musi być powtarzany dla każdej zmiany, która jest wprowadzana w systemie.

Testy jednostkowe, z drugiej strony, wziąć milisekundy, mogą być uruchamiane przy naciśnięciu przycisku i niekoniecznie wymagają żadnej wiedzy na temat systemu. Niezależnie od tego, czy test kończy się pomyślnie, czy kończy się niepowodzeniem, nie do modułu uruchamiającego testy, a nie do osoby.

Ochrona przed regresją

Wady regresji to wady wprowadzane po wprowadzeniu zmiany w aplikacji. Testerzy często testują nie tylko swoją nową funkcję, ale także funkcje testowe, które istniały wcześniej, aby sprawdzić, czy wcześniej zaimplementowane funkcje nadal działają zgodnie z oczekiwaniami.

W przypadku testowania jednostkowego można ponownie uruchomić cały zestaw testów po każdej kompilacji, a nawet po zmianie wiersza kodu. Daje pewność, że nowy kod nie przerywa istniejącej funkcjonalności.

Dokumentacja pliku wykonywalnego

Nie zawsze może być oczywiste, co robi dana metoda lub jak zachowuje się, biorąc pod uwagę określone dane wejściowe. Możesz zadać sobie pytanie: Jak działa ta metoda, jeśli przekażę go pusty ciąg? Null?

Jeśli masz zestaw dobrze nazwanych testów jednostkowych, każdy test powinien być w stanie jasno wyjaśnić oczekiwane dane wyjściowe dla danego danych wejściowych. Ponadto powinno być możliwe sprawdzenie, czy faktycznie działa.

Mniej powiązany kod

Gdy kod jest ściśle powiązany, może być trudny do testowania jednostkowego. Bez tworzenia testów jednostkowych dla pisania kodu sprzężenie może być mniej widoczne.

Pisanie testów dla kodu będzie naturalnie rozdzielać kod, ponieważ byłoby trudniejsze do testowania w przeciwnym razie.

Cechy dobrego testu jednostkowego

  • Szybko: nie jest rzadkością dla dojrzałych projektów, aby mieć tysiące testów jednostkowych. Uruchomienie testów jednostkowych powinno zająć trochę czasu. Milisekund.
  • Izolowane: Testy jednostkowe są autonomiczne, mogą być uruchamiane w izolacji i nie mają zależności od żadnych czynników zewnętrznych, takich jak system plików lub baza danych.
  • Powtarzalne: uruchomienie testu jednostkowego powinno być zgodne z wynikami, czyli zawsze zwraca ten sam wynik, jeśli nie zmieniasz niczego między przebiegami.
  • Samodzielne sprawdzanie: test powinien być w stanie automatycznie wykryć, czy zakończył się powodzeniem lub niepowodzeniem bez żadnej interakcji z człowiekiem.
  • Czas: Test jednostkowy nie powinien trwać nieproporcjonalnie długo, aby napisać w porównaniu z testowanym kodem. Jeśli okaże się, że testowanie kodu zajmuje dużo czasu w porównaniu z pisaniem kodu, rozważ użycie projektu, który jest bardziej testowalny.

Pokrycie kodu

Wysoki procent pokrycia kodu jest często skojarzony z wyższą jakością kodu. Jednak sama miara nie może określić jakości kodu. Ustawienie zbyt ambitnego celu procentowego pokrycia kodu może być sprzeczne z produktem. Wyobraź sobie złożony projekt z tysiącami gałęzi warunkowych i wyobraź sobie, że ustawisz cel 95% pokrycia kodu. Obecnie projekt utrzymuje 90% pokrycia kodu. Czas potrzebny na uwzględnienie wszystkich przypadków brzegowych w pozostałych 5% może być ogromnym przedsięwzięciem, a propozycja wartości szybko się zmniejsza.

Wysoki procent pokrycia kodu nie jest wskaźnikiem sukcesu ani nie oznacza wysokiej jakości kodu. Reprezentuje on tylko ilość kodu objętego testami jednostkowym. Aby uzyskać więcej informacji, zobacz Pokrycie kodu testowania jednostkowego.

Porozmawiajmy z tym samym językiem

Termin makiety jest niestety często nadużywany podczas mówienia o testowaniu. W poniższych punktach zdefiniowano najczęstsze typy fałszywych danych podczas pisania testów jednostkowych:

Fake - fałszywy jest rodzajowym terminem, który może służyć do opisywania wycinku lub pozornego obiektu. Niezależnie od tego, czy jest to wycink, czy pozorny, zależy od kontekstu, w którym jest używany. Innymi słowy, fałszywe może być stub lub makiety.

Makiety - pozorny obiekt jest fałszywym obiektem w systemie, który decyduje, czy test jednostkowy przeszedł, czy nie. Pozorowanie zaczyna się jako Fake, dopóki nie zostanie twierdzone przeciwko.

Stub — wycinkę można kontrolować dla istniejącej zależności (lub współpracownika) w systemie. Korzystając z wycinku, możesz przetestować kod bez bezpośredniego radzenia sobie z zależnością. Domyślnie stub zaczyna się jako fałszywy.

Rozważmy następujący fragment kodu:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Poprzedni przykład byłby wycinkiem nazywanym makiety. W tym przypadku jest to wycink. Po prostu przekazujesz kolejność jako środek umożliwiający utworzenie wystąpienia Purchase (test systemu). Nazwa MockOrder jest również myląca, ponieważ ponownie kolejność nie jest pozorna.

Lepszym podejściem byłoby:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Zmiana nazwy klasy na FakeOrder, sprawiła, że klasa była o wiele bardziej ogólna. Klasę można używać jako makiety lub wycinku, w zależności od tego, co jest lepsze dla przypadku testowego. W poprzednim przykładzie FakeOrder jest używany jako wycink. Podczas asercji nie używasz FakeOrder żadnego kształtu ani formularza. FakeOrder został przekazany do Purchase klasy, aby spełnić wymagania konstruktora.

Aby użyć go jako makiety, możesz zrobić coś podobnego do następującego kodu:

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

W tym przypadku sprawdzasz właściwość na Fake (twierdząc przeciwko niemu), więc w poprzednim fragmencie kodu jest mockOrder mock.

Ważne

Ważne jest, aby ta terminologia była poprawna. Jeśli wywołasz wycinki "makiety", inni deweloperzy będą wprowadzać fałszywe założenia dotyczące twojej intencji.

Główną rzeczą, aby pamiętać o makiety i wycinki jest to, że makiety są jak wycinki, ale twierdzisz przeciwko makiety obiektu, podczas gdy nie dochodzisz do wycinków.

Najlepsze rozwiązania

Poniżej przedstawiono niektóre z najważniejszych najlepszych rozwiązań dotyczących pisania testów jednostkowych.

Unikanie zależności infrastruktury

Spróbuj nie wprowadzać zależności od infrastruktury podczas pisania testów jednostkowych. Zależności sprawiają, że testy są powolne i kruche i powinny być zarezerwowane do testów integracji. Można uniknąć tych zależności w aplikacji, postępując zgodnie z regułą jawnych zależności i przy użyciu wstrzykiwania zależności. Testy jednostkowe można również zachować w osobnym projekcie niż testy integracji. Takie podejście zapewnia, że projekt testu jednostkowego nie zawiera odwołań do pakietów infrastruktury ani zależności.

Nazywanie testów

Nazwa testu powinna składać się z trzech części:

  • Nazwa testowanej metody.
  • Scenariusz, w którym jest testowany.
  • Oczekiwane zachowanie podczas wywoływanego scenariusza.

Dlaczego?

Standardy nazewnictwa są ważne, ponieważ jawnie wyrażają intencję testu. Testy to nie tylko upewnienie się, że kod działa, ale także udostępniają dokumentację. Wystarczy przyjrzeć się zestawowi testów jednostkowych, aby wywnioskować zachowanie kodu, nawet nie patrząc na sam kod. Ponadto, gdy testy kończą się niepowodzeniem, możesz zobaczyć dokładnie, które scenariusze nie spełniają Twoich oczekiwań.

Źle:

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Lepsze:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Rozmieszczanie testów

Uporządkuj, Act, Assert jest typowym wzorcem podczas testowania jednostkowego. Jak wskazuje nazwa, składa się z trzech głównych akcji:

  • Rozmieść obiekty, utwórz i skonfiguruj je w razie potrzeby.
  • Działanie na obiekcie.
  • Twierdzenie , że coś jest zgodnie z oczekiwaniami.

Dlaczego?

  • Wyraźnie oddziela testowane elementy od kroków rozmieszczania i aserowania .
  • Mniejsze szanse na przeplatanie asercji z kodem "Act".

Czytelność jest jednym z najważniejszych aspektów podczas pisania testu. Oddzielenie każdej z tych akcji w teście wyraźnie wyróżnia zależności wymagane do wywołania kodu, sposobu wywoływania kodu i tego, co próbujesz potwierdzić. Chociaż może być możliwe połączenie niektórych kroków i zmniejszenie rozmiaru testu, głównym celem jest zapewnienie, że test jest tak czytelny, jak to możliwe.

Źle:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Lepsze:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Pisanie testów z minimalnym przekazywaniem

Dane wejściowe, które mają być używane w teście jednostkowym, powinny być najprostsze, aby sprawdzić zachowanie, które jest obecnie testowane.

Dlaczego?

  • Testy stają się bardziej odporne na przyszłe zmiany w bazie kodu.
  • Bliżej testowania zachowania w porównaniu z implementacją.

Testy zawierające więcej informacji niż wymagane do przeprowadzenia testu mają większe szanse na wprowadzenie błędów do testu i mogą sprawić, że intencja testu będzie mniej jasna. Podczas pisania testów chcesz skupić się na zachowaniu. Ustawianie dodatkowych właściwości modeli lub używanie wartości innych niż zero, jeśli nie są wymagane, tylko odejmuje od tego, co próbujesz udowodnić.

Źle:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Lepsze:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Unikaj ciągów magicznych

Nazewnictwo zmiennych w testach jednostkowych jest ważne, jeśli nie ważniejsze, niż nazewnictwo zmiennych w kodzie produkcyjnym. Testy jednostkowe nie powinny zawierać ciągów magicznych.

Dlaczego?

  • Zapobiega konieczności sprawdzania kodu produkcyjnego przez czytelnika testu w celu określenia, co sprawia, że wartość jest wyjątkowa.
  • Jawnie pokazuje, co próbujesz udowodnić, zamiast próbować osiągnąć.

Ciągi magiczne mogą powodować zamieszanie w czytniku testów. Jeśli ciąg wygląda na zwykły, może się zastanawiać, dlaczego określona wartość została wybrana dla parametru lub wartości zwracanej. Ten typ wartości ciągu może prowadzić do bliższego przyjrzenia się szczegółom implementacji, a nie skupieniu się na teście.

Napiwek

Podczas pisania testów należy dążyć do wyrażania jak największej ilości intencji. W przypadku ciągów magicznych dobrym rozwiązaniem jest przypisanie tych wartości do stałych.

Źle:

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Lepsze:

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Unikanie logiki w testach

Podczas pisania testów jednostkowych unikaj ręcznego łączenia ciągów, warunków logicznych, takich jak if, while, fori , i switchinnych warunków.

Dlaczego?

  • Mniej szans na wprowadzenie usterki wewnątrz testów.
  • Skoncentruj się na wyniku końcowym, a nie na szczegółach implementacji.

Po wprowadzeniu logiki do zestawu testów prawdopodobieństwo wprowadzenia do niego usterki znacznie się zwiększa. Ostatnim miejscem, w którym chcesz znaleźć usterkę, jest pakiet testowy. Należy mieć wysoki poziom pewności, że testy działają, w przeciwnym razie nie będziesz im ufać. Testy, którym nie ufasz, nie udostępniają żadnej wartości. Gdy test zakończy się niepowodzeniem, chcesz mieć poczucie, że coś jest nie tak z kodem i że nie można go zignorować.

Napiwek

Jeśli logika w teście wydaje się nieunikniona, rozważ podzielenie testu na co najmniej dwa różne testy.

Źle:

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

Lepsze:

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

Preferuj metody pomocnika do konfigurowania i usuwania

Jeśli potrzebujesz podobnego obiektu lub stanu dla testów, preferuj metodę pomocnika niż użycie Setup atrybutów i Teardown , jeśli istnieją.

Dlaczego?

  • Mniej nieporozumień podczas odczytywania testów, ponieważ cały kod jest widoczny z poziomu każdego testu.
  • Mniejsze prawdopodobieństwo skonfigurowania zbyt dużej lub zbyt małej ilości dla danego testu.
  • Mniejsze prawdopodobieństwo udostępniania stanu między testami, co powoduje utworzenie niechcianych zależności między nimi.

W strukturach testów jednostkowych Setup jest wywoływana przed każdym i każdym testem jednostkowym w zestawie testów. Chociaż niektórzy mogą postrzegać to jako przydatne narzędzie, zazwyczaj kończy się na skutek wzdętych i trudnych do odczytania testów. Każdy test będzie zazwyczaj miał inne wymagania, aby uzyskać test i uruchomić. Niestety wymusza Setup użycie dokładnie tych samych wymagań dla każdego testu.

Uwaga

Element xUnit usunął zarówno polecenie SetUp, jak i TearDown w wersji 2.x

Źle:

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

Lepsze:

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Unikaj wielu działań

Podczas pisania testów spróbuj uwzględnić tylko jeden akt na test. Typowe podejścia do używania tylko jednego aktu obejmują:

  • Utwórz oddzielny test dla każdego działania.
  • Użyj testów sparametryzowanych.

Dlaczego?

  • Gdy test zakończy się niepowodzeniem, jest jasne, który akt kończy się niepowodzeniem.
  • Gwarantuje, że test koncentruje się tylko na jednym przypadku.
  • Przedstawia cały obraz, dlaczego testy kończą się niepowodzeniem.

Wiele aktów musi być indywidualnie aserowanych i nie ma gwarancji, że wszystkie asercyjnie zostaną wykonane. W większości struktur testowania jednostkowego po niepowodzeniu asercji w teście jednostkowym testy kontynuowane są automatycznie uznawane za zakończone niepowodzeniem. Ten rodzaj procesu może być mylący, ponieważ funkcja, która rzeczywiście działa, będzie wyświetlana jako niepowodzenie.

Źle:

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Lepsze:

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

Weryfikowanie metod prywatnych za pomocą metod publicznych testowania jednostkowego

W większości przypadków nie powinno być konieczne przetestowanie metody prywatnej. Metody prywatne są szczegółami implementacji i nigdy nie istnieją w izolacji. W pewnym momencie będzie publiczna metoda, która wywołuje metodę prywatną w ramach implementacji. Należy zadbać o końcowy wynik metody publicznej, która wywołuje metodę prywatną.

Rozważmy następujący przypadek:

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

Pierwszą reakcją może być rozpoczęcie pisania testu, TrimInput ponieważ chcesz upewnić się, że metoda działa zgodnie z oczekiwaniami. Jednak jest całkowicie możliwe, że ParseLogLine manipulowanie sanitizedInput w taki sposób, że nie oczekujesz, renderowanie testu przeciwko TrimInput bezużytecznemu.

Prawdziwy test należy wykonać względem publicznej metody ParseLogLine , ponieważ jest to, co należy ostatecznie obchodzić.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

W tym punkcie widzenia, jeśli widzisz metodę prywatną, znajdź metodę publiczną i napisz testy względem tej metody. Tylko dlatego, że metoda prywatna zwraca oczekiwany wynik, nie oznacza systemu, który ostatecznie wywołuje metodę prywatną, prawidłowo używa wyniku.

Odwołania statyczne wycinków

Jedną z zasad testu jednostkowego jest to, że musi mieć pełną kontrolę nad testem systemu. Ta zasada może być problematyczna, gdy kod produkcyjny zawiera wywołania do odwołań statycznych (na przykład DateTime.Now). Spójrzmy na poniższy kod:

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Jak ten kod może być testowany jednostką? Możesz wypróbować takie podejście, jak:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

Niestety, szybko zdasz sobie sprawę, że istnieje kilka problemów z testami.

  • Jeśli zestaw testów zostanie uruchomiony we wtorek, drugi test zakończy się powodzeniem, ale pierwszy test zakończy się niepowodzeniem.
  • Jeśli zestaw testów zostanie uruchomiony na inny dzień, pierwszy test zakończy się powodzeniem, ale drugi test zakończy się niepowodzeniem.

Aby rozwiązać te problemy, należy wprowadzić szew do kodu produkcyjnego. Jednym z podejść jest zawijanie kodu potrzebnego do sterowania w interfejsie i posiadanie kodu produkcyjnego zależy od tego interfejsu.

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Pakiet testów staje się teraz następujący:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

Teraz zestaw testów ma pełną kontrolę DateTime.Now nad dowolną wartością i może wyprzeć dowolną wartość podczas wywoływania metody.