Testy dla wielu danych w TestNG

Sytuacja hipotetyczna. Mamy sobie jakiś interfejs i chcemy przetestować do dość dokładnie. Rzecz w tym, że dane wejściowe muszą spełniać dużo warunków względnie mogą zostać dostarczone w różnych konfiguracjach. Jeżeli chcielibyśmy napisać oddzielny test dla każdego zestawu danych to mówiąc obrazowo byśmy się zajebali. Oczywiści da się to zrobić jak zestawów jest niewiele np. metoda z dwoma parametrami, każdy może być null i w zależności od tego co przekażemy to dostajemy inne wyniki. Przy dwóch parametrach potrzeba czterech zestawów danych. Jeżeli w klasie mamy kilka takich metod, to ilość testów rośnie. Ilość danych do utrzymania rośnie, ilość problemów też rośnie. Chlebik poruszył delikatnie ten temat. Ja postanowiłem szybko opisać co i jak.

Narzędzia

Na początek wystarczy TestNG i tyle. Przykład będzie na tyle ogólny, że dodatkowe narzędzia i rozszerzenie będziecie mogli napisać sami.

Co testujemy

Testujemy bardzo skomplikowany interfejs od którego zależy cały nasz system. Interfejs może być implementowany na wiele różnych sposobów w zależności od konkretnego modułu, ale niezależnie od implementacji powinien zawsze zwracać takie same wyniki. Oto i nasz interfejs:

Listing 1. Interfejs Math

/**
 * In all methods if param is null then it is 0.
 */
public interface Math {

	double add(Double a, Double b);

	double sub(Double a, Double b);

	double mul(Double a, Double b);

	double div(Double a, Double b) throws IdiotUserException;

}

Jak widać jest tu trochę metod do przetestowania. Jeżeli dołożymy do tego różne implementacje okaże się, że ilość kodu testującego znacznie przewyższa ilość kodu biznesowego. Nie jest to złe, ale głupio wygląda.

Obiektowa droga do bogactwa – Dziedziczenie

Na początek zajmiemy się prostszym problemem, czyli mnogością implementacji. By rozwiązać problem wielu implementacji, dla których musimy przeprowadzić takie same testy wystarczy odpowiednie użycie dziedziczenia. W pierwszym kroku tworzymy bazową klasę testową:

Listing 2. Klasa MathTest

public abstract class MathTest {
	
	private Math math;

	@BeforeClass
	protected void setUp(){
		math = getMath();
	}

	protected abstract Math getMath() ;

// testy na razie nieistotne
}

Jak widać tworząc nową implementację jej testowanie w podstawowym zakresie ograniczy się tylko do rozszerzenia klasy MathTest. Przykładowo dla SimpleMath

Listing 3. Klasa SimpleMathTest

@Test
public class SimpleMathTest extends MathTest {

	@Override
	protected Math getMath() {
		return new SimpleMath();
	}

}

Jak nas podkusi zrobić implementacje w oparciu o JNI czy WS to dodatkowo będziemy jeszcze wykonywać konfigurację.

Przygotowanie danych testowych

TestNG dostarcza nam piękną adnotację DataProvider, która służy do oznaczania metod dostarczających dane. Metoda oznaczona tą adnotacją musi zwracać Object[][] i jeżeli jest w klasie poza testem to musi być statyczna. Jeżeli chcemy zatem przygotować dane do testu dodawania to:

Listing 4. Klasa MathDataProvider

public class MathDataProvider {

	@DataProvider(name="addData")
	public static Object[][] addData(){
		return new Object[][]{
				new Object[]{2., 2., 4.},
				new Object[]{2., null, 2.},
				new Object[]{null, 2., 2.},
				new Object[]{null, null, 0.}
		};
	}
	@DataProvider(name="subData")
	public static Object[][] subData(){
		return new Object[][]{
				new Object[]{2., 2., 0.},
				new Object[]{2., null, 2.},
				new Object[]{null, 2., -2.},
				new Object[]{null, null, 0.}
		};
	}
	
	@DataProvider(name="mulData")
	public static Object[][] mulData(){
		return new Object[][]{
				new Object[]{2., 2., 4.},
				new Object[]{2., null, 0.},
				new Object[]{null, 2., 0.},
				new Object[]{null, null, 0.}
		};
	}
	
	@DataProvider(name="divData1")
	public static Object[][] divData1(){
		return new Object[][]{
				new Object[]{2., 2., 1.},
				new Object[]{null, 2., 0.},
		};
	}
	
	@DataProvider(name="divData2")
	public static Object[][] divData2(){
		return new Object[][]{
				new Object[]{2., null, 0.},
				new Object[]{null, null, 0.},
		};
	}
}

Każdy „rekord” odzwierciedla jeden „wsad” parametrów. Zatem wystarczy zaimplementować testy.

Implementacja testów

Jeżeli do metody mają trafiać dane to musi ona przyjmować jakieś parametry. Są różne szkoły „parametrowania”. Najprościej jest podać parametry takie jakie chcemy przekazać do metody plus parametr z wynikiem. Inna zaleca przygotowanie specjalnego obiektu, który będzie zawierał dane wejściowe i wynik i w takim wypadku metoda testowa ma tylko jeden parametr. Pierwsza szkoła jest dobra jeżeli parametry są proste. Druga jeżeli chcemy parametryzować testy o bardziej złożonych warunkach. Zaimplementujmy zatem testy…

Listing 5. Klasa MathTest kompletem testów

public abstract class MathTest {

	private Math math;

	@BeforeClass
	protected void setUp() {
		math = getMath();
	}

	protected abstract Math getMath();

	@Test(dataProvider = "addData", dataProviderClass = MathDataProvider.class)
	public void testAdd(Double a, Double b, Double expected) {
		assertEquals(math.add(a, b), expected);
	}

	@Test(dataProvider = "subData", dataProviderClass = MathDataProvider.class)
	public void testSub(Double a, Double b, Double expected) {
		assertEquals(math.sub(a, b), expected);
	}

	@Test(dataProvider = "mulData", dataProviderClass = MathDataProvider.class)
	public void testMul(Double a, Double b, Double expected) {
		assertEquals(math.mul(a, b), expected);
	}

	@Test(dataProvider = "divData1", dataProviderClass = MathDataProvider.class)
	public void testDiv1(Double a, Double b, Double expected)
			throws IdiotUserException {
		assertEquals(math.div(a, b), expected);
	}

	@Test(dataProvider = "divData2", dataProviderClass = MathDataProvider.class, expectedExceptions = IdiotUserException.class)
	public void testDiv2(Double a, Double b, Double expected)
			throws IdiotUserException {
		assertEquals(math.div(a, b), expected);
	}
}

Adnotacja Test pozwala na dopasowanie do metody testowej źródła danych zarówno wewnętrznego jak i z zewnętrznej klasy. Oczywiście klasę dostawcy można zdefiniować na poziomie klasy wtedy jest ona dopasowywana wszędzie tam gdzie nie pasuje nic innego. Niestety nie można zdefiniować dostawcy na poziomie klasy dziedziczącej.

Podsumowanie

Możliwość parametryzacji testów daje nam nowe narzędzie w walce z błędami. Dodatkowo duża elastyczność tego rozwiązania pozwala na wprowadzenie zarządzania parametrami z poza testów np. za pomocą bazy danych czy arkusza kalkulacyjnego. To znowuż otwiera drogę do całkowicie nowych możliwości związanych z testowaniem. Przeprowadzenie testów z różnymi zestawami parametrów będzie ograniczało się do wyboru odpowiedniego źródła danych. Czy nie jest to piękne?

10 myśli na temat “Testy dla wielu danych w TestNG

  1. 1. Czy zawsze trzeba specyfikować parametr name adnotacji DataProvider?

    2. Ja bym wywalił te powtórzone new Object[], na przykład tak:

    @DataProvider(name=”addData”)
    public static Object[][] addData(){
    Object[][] data = {
    {2., 2., 4.},
    {2., null, 2.},
    {null, 2., 2.},
    {null, null, 0.}
    };
    return data;
    }

    3. W Clojure jest takie makro „are” przydatne do
    testowania, trochę podobne do tych dataproviderów. Przykład:

    ;; zwykły test
    (deftest test-dodawania
    (is (= (+ 3 4) 7))
    (is (= (+ 42 0) 42))
    (is (= (+ 1 -1) 0)))

    ;; ten sam test z are
    (deftest test-dodawania
    (are [x y z] (= (+ x y) z)
    3 4 7
    42 0 42
    1 -1 0))

  2. 1. tak ponieważ po tym parametrze wiązane są metody testowe i metody dostarczające danych.
    2. Można i tak 😀
    3. Podobne konstrukcje można też uzyskać w scali. Dlatego też biorę od ciebie te książki do programowania funkcyjnego 😀

  3. Się wczoraj bawiłem trochę DataProviderem, rewelacyjny wynalazek. Jako że Spocka nie używamy to cieszę się, że udało mi się TestNg przeforsować, sprawdzanie nastu scenariuszy inaczej to męka.

    Ad 1 Daniela: chyba nie trzeba. To ma domyślną wartość bodaj na pusty string, ale wtedy kończymy z 1 DataProviderem. Aczkolwiek nie jestem pewien tego co piszę bo nie testowałem.
    Ad 2: popieram, będzie czytelniej.

    Koziołku: rozszerzasz klasy testowe? Zastanawiałem się ostatnio sam co ma większy sens i póki co skończyłem z public static mock/stub factory classes do testów jeno. Znacząco ułatwia to inicjalizację i parametryzację testów. Nie jestem fanem słowa static, ale też nie przepadam za dzieciczeniem, nie wiem więc co jest mniejszym złem.
    Teoretycznie od 6.x w TestNg można wstrzykiwać, ale tego nie testowałem i nie przyglądałem się nawet bliżej. Wiem jedynie że Cedric wsadził Guice gdzieś w projekt.
    Miał ktoś z tym jakieś doświadczenie?

  4. A i o najważniejszym zapomniałem 😛
    Co to kozia stopa za nulle? 0 się wsadza.
    Przyjdzie młodzież i pomyśli, że przekazywanie nulla jako parametru to jedno z best practice, bo wyczesany Koziołek, co ma popularnego bloga i wiedzę, tak robi. A później ja będę wojować by mi nulli nie przysyłali :/

  5. Wstrzykiwania w TestNG 6.x jeszcze też nie testowałem, bo mi mój projekt Guiceowy uległ przesunięciu w bliżej nie sprecyzowaną przyszłość.
    Co do rozszerzania klas testowych to jak tu pokazałem jest to dobra rzecz jak masz np. kilka różnych implementacji mających jakieś wspólne API. Bardziej przydaje się to przy testach integracyjno-jednostkowych kiedy korzystasz z zewnętrznych zasobów np. plików w różnym formacie.

    Co do nulli to rzeczywiście nie jest to „best practice”, ale często spotykamy się z takim podejściem. Tu chodziło raczej o pokazanie, że może występować duża granulacja warunków brzegowych, która prowadzi do namnożenia testów. Wiadomo zresztą, że najlepsze metody to takie z maks jednym parametrem.
    Co do wojowania to polecam IdiotUserException. Odkąd użyłem wobec niektórych biznesowych to nawet się nie odzywają na korytarzu. Zatem skuteczne.

  6. @DataProvider(name=”mulData”)
    public static Object[][] mulData(){

    w takiej sytuacji możesz odpuścić name – defaultowo name jest takie jak nazwa metody


    pozdrawiam
    Tomek Kaczanowski

  7. „Testujemy bardzo skomplikowany interfejs od którego zależy cały nasz system.”

    Hmm, zawsze wydawało mi się, że testuje się implementacje.

  8. @Krzysiek, interfejs jest pojęciem względnym . Tu pokazuję przypadek kiedy testy piszemy „pod interfejs”, a dostawcy implementacji „certyfikują się” względem takiego testu.

Napisz odpowiedź

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax