Kurs TDD cz. 8: Testy parametryzowane

Temat testów parametryzowanych pojawił się przy okazji pisania naszego “drugiego” testu jednostkowego. Wtedy to skorzystaliśmy z atrybutu [TestCase] pozwalającym na zdefiniowanie zestawów wartości, które są przekazywane do metody testowej. Stosując atrybuty do testów parametryzowanych (m. in. TestCase, Values, Range, Random) ograniczamy liczbę naszych metod testowych i redukujemy ilość redundantnego kodu. Ponadto, zyskujemy na czytelności naszych testów.

Kod źródłowy do kursu TDD pojawił się na GitHubie i jest dostępny pod adresem: https://github.com/dariusz-wozniak/TddCourse. Zapraszam do ściągania i dalszej lektury…

Testy parametryzowane

Celem testów parametryzowanych jest przekazanie zestawu wartości danych do metody testowej. Każdy zestaw danych jest traktowany jako osobny test. W NUnit ideę parametryzacji testów realizuje się przy pomocy atrybutów:

  • [TestCase] (zestaw wartości dla wszystkich parametrów metody)
  • [Values] (zestaw wartości dla parametru metody)
  • [Range] (zakres od-do z zadanym krokiem dla parametru metody)
  • [Random] (zakres od-do oraz ilość wartości do wygenerowania dla parametru metody)

Oraz:

  • [TestCaseSource] (zestaw wartości odczytany ze zmiennej dla wszystkich parametrów metody)
  • [ValueSource] (zestaw wartości odczytany ze zmiennej dla parametru metody)

Zobrazujmy te przypadki kodem źródłowym na przykładzie funkcji Divide pochodzącej z naszego kalkulatora, o którym była mowa w części piątej kursu. Niektóre z testów (przykładowo te z atrybutem Random) mają jedynie na celu pokazanie w jaki sposób realizowana jest idea parametryzacji testów. Nie zawsze istnieje sens użycia danej techniki w przypadku metody Divide, dlatego też skupimy się tutaj nie na ich zasadności, a na tym jaki uzyskujemy rezultat.

TestCase

Przepis jest następujący – Atrybutowi metody TestCase przypisujemy kolejno wartości, które są odwzorowaniem parametrów naszego testu.† Przykład pochodzi z części drugiej kursu.

† NUnit konwertuje typy za pomocą metody Convert.ChangeType.

[TestCase(4, 2, 2.0f)]
[TestCase(-4, 2, -2.0f)]
[TestCase(4, -2, -2.0f)]
[TestCase(0, 3, 0.0f)]
[TestCase(5, 2, 2.5f)]
[TestCase(1, 3, 0.333333343f)]
public void Divide_ReturnsProperValue(int dividend, int divisor, float expectedQuotient)
{
    var calc = new Calculator();
    var quotient = calc.Divide(dividend, divisor);
    Assert.AreEqual(expectedQuotient, quotient);
}

Spójrzmy na sygnaturę metody oraz jeden z zestawu testów:

[TestCase(-4, 2, -2.0f)]
public void Divide_ReturnsProperValue(int dividend, int divisor, float expectedQuotient)

Wartości z atrybutu TestCase przekazywane są kolejno do parametrów metody:

  • dividend (dzielna) = -4
  • divisor (dzielnik) = 2
  • expectedQuotient (oczekiwany iloraz) = -2

W ostatnich dwóch liniach testu sprawdzamy czy nasz kalkulator radzi sobie z zadanym zestawem danych:

var quotient = calc.Divide(dividend, divisor);
Assert.AreEqual(expectedQuotient, quotient);

Values

W atrybucie Values przekazujemy konkretne wartości do parametru metody. Przykład:

[Test]
public void Divide_DividendIsZero_ReturnsQuotientEqualToZero(
    [Values(-2, -1, 1, 2)] double divisor)
{
    var calc = new Calculator();
    float quotient = calc.Divide(0, divisor);

    Assert.AreEqual(0, quotient);
}

W powyższym przykładzie chcemy dowieść, że gdy dzielna jest równa zeru, to niezależnie od wartości dzielnika, iloraz ma być równy również zeru. Wartości dzielnika: {-2, -1, 1, 2}. W tym teście mamy więc cztery zestawy danych, wszystkie zdefiniowane w atrybucie Values.

Range

W atrybucie Range przekazujemy wartości graniczne zakresu oraz krok (domyślnie = 1). Przykład:

[Test]
public void Divide_DividendIsZero_ReturnsQuotientEqualToZero(
    [Range(from: 1, to: 5, step: 1)] double divisor)
//...

Powyższy kod wygeneruje zestaw wartości od 1 do 5 z krokiem 1: {1, 2, 3, 4, 5}.

Random

W atrybucie Random podajemy wartości graniczne zakresu oraz liczbę wartości, które mają się wygenerować losowo. Przykład:

[Test]
public void Divide_DividendAndDivisorAreRandomPositiveNumbers_ReturnsPositiveQuotient(
    [Random(min: 1, max: 100, count: 10)] double dividend,
    [Random(min: 1, max: 100, count: 10)] double divisor)
{
    var calc = new Calculator();
    float quotient = calc.Divide(dividend, divisor);
    Assert.That(quotient > 0);
}

W tym przykładzie generujemy po 10 wartości losowych dla każdego parametru z zakresu od 1 do 100. Tym razem użyliśmy atrybutu dla dwóch parametrów, w związku z czym ilość zestawów testowych jest równa 10 × 10 = 100.

Podsumowanie

  • Testy parametryzowane znacznie ułatwiają testowanie kodu w przypadkach, kiedy mamy zdefiniowane zestawy wartości. Dzięki temu uzyskamy wiele testów bez niepotrzebnej duplikacji kodu oraz poprawimy ich czytelność.
  • Zestaw wartości może być dostarczany przez nie-programistę, np. przez klienta, analityka biznesowego lub testera.

Trzy grosze

  • NUnit posiada także atrybuty [TestCaseSource] oraz [ValueSource], które nie zostały ukazane wyżej, ale są bajecznie proste. Argumenty te odczytują wartości z podanej zmiennej — W argumencie atrybutu podajemy nazwę zmiennej, z której mają być odczytane wartości. Ma to następujące korzyści:
    • Jeden zestaw danych możemy przekazywać do wielu metod testowych.
    • Dane mogą być odczytane z zewnętrznego źródła (np. plik, baza danych). Pamiętajmy tylko, że test jednostkowy powinien wykonywać się w pamięci, a więc czytanie danych z zewnętrznego źródła jest niezgodne z jego definicją. Nic nie stoi jednak na przeszkodzie, aby tę funkcjonalność wykorzystać w innego rodzaju testów, np. w testach integracyjnych lub akceptacyjnych.
  • Pokazane sposoby realizacji testów parametryzowanych dotyczą tylko jednego frameworka — NUnit. W innych frameworkach możemy tych koncepcji nie znaleźć lub występują one pod inną nazwą.
  • Nie należy używać atrybutu Random w kontekście testu jednostkowego. Jest to niezgodne z zasadą mówiącą, że test jednostkowy powinien być powtarzalny. W przypadku zestawu danych który za każdym razem jest generowany losowo, takiej powtarzalności nie ma.

Zobacz też

Źródła

9 thoughts on “Kurs TDD cz. 8: Testy parametryzowane

  1. Pingback: dotnetomaniak.pl
    • Jakie są korzyści z przerzucenia się na xUnit? Jakie są różnice pomiędzy xUnit a nUnit (oczywiście poza składnią)?

      • Bardzo dobre pytanie, Łukasz.

        Na stronie xUnita wymienione są główne powody, dla których ten framework powstał: http://xunit.github.io/docs/why-did-we-build-xunit-1.0.html. Przykładowo, xUnit nie posiada metod Setup i TearDown, które mogą przysporzyć nieco kłopotów. Należy jednak zaznaczyć, że artykuł ten powstał w czasach NUnita w wersji drugiej. NUnit 3, na przykład, nie wspiera już atrybutu ExpectedException, który w tym artykule jest (i słusznie!) krytykowany.

        Różnice funkcjonalne m. xUnitem a NUnitem (+MSTest) opisane są też na stronie: http://damsteen.nl/blog/2016/06/04/comparing-dotnet-testing-frameworks.

        Czy istnieją jakieś korzyści z przerzucenia się na jeden lub drugi framework? Tak, jeśli chcemy wykorzystać funkcjonalność, której nie ma drugi framework. W przeciwnym razie, jest to kwestia różnicy w semantyce i podejściu do pisania testów (np. wspomniany już wcześniej Setup i TearDown).

  2. Pingback: Kurs TDD cz. 9: Testy kombinatoryczne i sekwencyjne | DARIUSZ WOZNIAK.NET
  3. Pingback: Kurs TDD cz. 10: Teorie | DARIUSZ WOZNIAK.NET
  4. Pingback: Assert.Multiple | DariuszWozniak .NET

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s