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 <b>null</b> 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?