TestNG et ses DataProvider

J’aime beaucoup TestNG, et je l’utilise plutôt que JUnit depuis des années. D’abord, il s’agit d’un outil développé par un français, Cédric Beust, après tout, on a le droit d’être un peu chauvin ! Ensuite, j’ai toujours trouvé que les fonctionnalités offertes par ce framework étaient supérieures à JUnit, pourtant largement plus utilisé.

Cher et précieux lecteur, je te propose, dans cet article d’éviter de démarrer une nouvelle polémique dont le net est si friand, mais plutôt de te présenter une fonctionnalité particulièrement puissante de TestNG : les DataProvider.

Utiliser des données de test en nombre

Tester un algorithme complètement n’est pas toujours une question triviale. Faisons un inventaire rapide de ce qu’il faut faire.

  • Tester son bon fonctionnement sur les cas nominaux : toutes les variables d’entrée non nulles, les entiers positifs et pas trop grands, les adresses mail sont effectivement des adresses mail, les chaînes de caractères sont non vides, bref, tous ces paramètres se composent d’éléments honnêtes et fréquentables, comme on en rêve.
  • Tester les cas dégradés : certaines variables ont des valeurs « qu’elles ne devraient pas avoir », nulles, des chaînes de caractères sont vides, les entiers ont pour valeur Integer.MAX_VALUE, les flottants valent NaN, bref, toutes choses dont on ne rêve que dans des cauchemars.
  • Tester les cas pathologiques : ce sont les pires, les cas que l’on ne prévoit pas, parce qu’on n’y pense pas, mais que l’on rencontre tout de même en production. Ceux-la, on ne les a pas mis dans la batterie de tests, mais on les ajoute une fois qu’ils ont été observés. Seuls les naïfs (dont tu ne fais pas partie, précieux lecteur) prétendent que ces cas n’existent pas.

Pouvoir spécifier ces cas de tests, et notamment les enrichir, indépendamment des méthodes tests qui les utilisent s’avère particulièrement utile, pour ne  pas dire quasiment indispensable. Le fameux principe de la separation of concerns s’applique ici à plein.

TestNG intègre directement cette fonctionnalité, de façon très élégante, c’est l’objet de cet article.

Notion de DataProvider

Un DataProvider, dans le jargon TestNG, est une source de données, que l’on connecte à une méthode de test. Une méthode de test peut prendre en entrée des arguments, fournis à l’exécution du test par TestNG, à partir du DataProvider.

Techniquement, un DataProvider est une méthode, appelée par TestNG, et dont l’objet retourné est fourni en paramètre à une méthode de test. Un même DataProvider peut être connecté à autant de méthodes de test que l’on veut. Les deux sont donc complètement découplés : d’un côté l’on se préoccupe de créer des données de test qui recouvrent au maximum les cas d’utilisation, et de l’autre, on se préoccupe de tester les fonctionnalités de notre système. On peut donc enrichir l’un et l’autre indépendamment, ce qui est bien notre objectif.

Montrons tout ceci sur un exemple et prenons une classe modèle simple, Message :

public class Message {

	private String subject ;
	private String recipient ;
	private String content ;

	public Message() {
	}

	public Message(String subject, String recipient, String content) {
		this.subject = subject ;
		this.recipient = recipient ;
		this.content = content ;
	}

	// suivent les getters / setters et une méthode toString()
}

La classe à tester est la classe MessageConsumer, un service JMS, qui, comme tu peux t’en rendre compte, cher et précieux lecteur a été légèrement simplifié.

public class MessageConsumer {

	public boolean consume(Message message) {
		System.out.println("Consuming " + message)  ;
		return true ;
	}
}

Ce consommateur de messages est censé consommer des messages, comme son nom peut le laisser supposer. On peut écrire une première classe de test à la sauce TestNG, de la même façon que l’on écrirait un test JUnit. Ici @Test est une annotation TestNG, qui a la même sémantique que celle de JUnit.

public class MessageConsumerTest {

	@Test
	public void testMessageConsumer() {

		MessageConsumer consumer =
			new MessageConsumer("subject", "recipient", "content")  ;
		Message message = new Message(subject, recipient, content)  ;
		Assert.assertTrue(consumer.consume(message))  ;
	}
}

Cette classe n’utilise pas de DataProvider, et si l’on veut multiplier les configurations de messages testés, il faut dupliquer la génération du message pour tous les cas que l’on veut tester.

C’est là que les DataProvider nous viennent en aide. TestNG nous permet d’annoter une méthode de génération de messages de test (dans notre exemple).

public class MessageConsumerTest2 {

	@DataProvider(name="LocalMessageProvider")
	public Object [][] messageProvider() {
		return new Object[][] {
				{"subject-00", "recipient-00", "content-00"},
				{"subject-01", "recipient-01", "content-01"}
		} ;
	}

	@Test(dataProvider="LocalMessageProvider")
	public void testMessageConsumer(
		String subject, String recipient, String content) {

		MessageConsumer consumer = new MessageConsumer() ;
		Message message = new Message(subject, recipient, content) ;
		Assert.assertTrue(consumer.consume(message)) ;
	}
}

Le DataProvider est annoté par @DataProvider, annotation qui prend en attribut le nom de cette source de données.

Cette méthode retourne un tableau d’objets, à deux dimensions. Chaque ligne de ce tableau est un jeu de paramètres passé à la méthode de test. TestNG appelle cette méthode de test autant de fois qu’il y a des lignes au tableau. Il ne t’aura pas échappé, précieux lecteur, que si les lignes de notre tableau comportent des objets qui ne correspondent aux paramètres de la méthode appelée, les choses risquent ne pas se passer si bien que ça…

Une méthode peut recevoir des données de ce DataProvider, en déclarant un attribut dataProvider dans son annotation @Test. Cet attribut doit avoir pour valeur le nom du DataProvider qui va fournir les données.

Cette première version est très simplifiée, on voit tout de même déjà que :

  • la génération de nos vecteurs de test est centralisée : une unique méthode peut fournir des données à autant de méthodes de test que l’on veut ;
  • ajouter des vecteurs de test consiste à ajouter des lignes dans un tableau, ce qui est une bonne simplification.

Dans la pratique, notre classe de test pourra ressembler à ceci.

public class MessageConsumerTest2Bis {

	@DataProvider(name="LocalMessageProvider")
	public Object [][] messageProvider() {
		return new Object[][] {
				{new Message("subject-00", "recipient-00", "content-00")},
				{new Message("subject-01", "recipient-01", "content-01")}
		} ;
	}

	@Test(dataProvider="LocalMessageProvider")
	public void testMessageConsumer(Message message) {

		MessageConsumer consumer = new MessageConsumer() ;
		Assert.assertTrue(consumer.consume(message)) ;
	}
}

DataProvider déclarés dans une classe propre

Les sources de données des exemples précédents ne peuvent être utilisées que dans la classe dans laquelle elles sont déclarées. On peut aussi les ranger dans une classe séparée. Dans ce cas, la méthode de génération de données doit être statique.

public class MessageProvider {

	public static final String MESSAGE_PROVIDER = "MessageProvider" ;

	@DataProvider(name=MESSAGE_PROVIDER)
	public static Object [][] createMessage() {
		return new Object[][] {
				{new Message("subject-00", "recipient-00", "content-00")},
				{new Message("subject-01", "recipient-01", "content-01")}
		} ;
	}
}

Pour y faire référence, on doit en plus donner le nom de la classe qui contient la méthode source de données.

import static org.paumard.MessageProvider.MESSAGE_PROVIDER;

public class MessageConsumerTest2Bis {

	@Test(
		dataProviderClass=MessageProvider.class,
		dataProvider=MESSAGE_PROVIDER)
	public void testConsumer(Message message) {

		MessageConsumer consumer = new MessageConsumer() ;
		Assert.assertTrue(consumer.consume(message)) ;
	}
}

Je trouve plus sûr dans ce cas d’utiliser une constante importée statiquement pour nommer la source de données. De cette manière, on se protège des refactoring sauvages, dont on sait bien qu’ils n’arrivent jamais, mais tout de même.

La classe statique utilisée peut bien sûr comporter autant de méthodes statiques de fourniture de vecteurs de test que l’on veut.

Cela dit, l’écriture manuelle de tableaux comporte deux inconvénients.

  • Elle est éventuellement fastidieuse, on ne s’imagine pas générer des milliers de vecteurs de test de cette manière !
  • Elle nécessite de créer l’ensemble des vecteurs de test avant d’exécuter le premier test, ce qui ne va pas être très efficace. On préférerait un mode de fonctionnement de type lazy.

TestNG prévoit le cas, et plutôt que de retourner un tableau bidirectionnel d’objets, notre méthode peut aussi renvoyer un itérateur. Chaque élément itéré est un tableau d’objets, qui représente le jeu de paramètres envoyé aux méthodes de test. Le pattern devient alors le suivant.

public class MessageProviderBis {

	public static final String MESSAGE_PROVIDER = "MessageProvider" ;

	private static int index = 0 ;

	private static String [][] data = {
		{"subject-00", "recipient-00", "content-00"},
		{"subject-01", "recipient-01", "content-01"}
	} ;

	@DataProvider(name=MESSAGE_PROVIDER)
	public static Iterator<object> provideMessage() {

		return new Iterator() {
			public boolean hasNext() {
				return index < data.length ;
			}

			public Object[] next() {
				Message message =
					new Message(data[index][0], data[index][1], data[index][2]) ;
				index++ ;
				return new Object [] {message} ;
			}

			public void remove() {
				// operation non supportée
				throw new UnsupportedOperationException() ;
			}
		} ;
	}
}

Connecter cet itérateur à une lecture de tableau n’est pas forcément le meilleur usage de ce pattern, on peut imaginer toutes sortes de générations de données différentes, calculatoires, lecture dans des documents XML ou autres, pourquoi pas en base de données, etc… En utilisant ce pattern, la génération de milliers de vecteurs de test devient parfaitement gérable.

Tests en multithread

TestNG propose une fonctionnalité très puissante, qui consiste à lancer une même méthode de test plusieurs fois, dans une réserve de threads de taille fixée. Cela permet de tester l’invocation de notre consommateur de messages en multithread simplement.Le pattern d’utilisation est le suivant.

import static org.paumard.MessageProvider.MESSAGE_PROVIDER;

public class MessageConsumerTest3 {

	private MessageConsumer consumer ;

	@BeforeClass
	public void prepareConsumer() {
		consumer = new MessageConsumer() ;
	}

	@Test(
		dataProviderClass=MessageProvider.class,
		dataProvider=MESSAGE_PROVIDER,
		threadPoolSize=4, invocationCount=10)
	public void testConsume(Message message) {
		Assert.assertTrue(consumer.consume(message)) ;
	}
}

Cela signifie que chaque paquet de vecteurs de test (nous avons ici écrit deux vecteurs de test) sera passé 10 fois, dans 4 threads différents.

Tout se passerait bien, sauf que TestNG utilise une unique instance de MessageProvider pour générer les vecteurs de test, et celle que nous avons écrite n’est pas thread safe. Il faut donc la modifier pour qu’elle le devienne. Elle pourra ressembler au dernier exemple suivant.

public class MessageProvider {

	public static final String MESSAGE_PROVIDER = "MessageProvider" ;

	// utiliser un AtomicInteger permet d'incrémenter l'index en
	// un seul appel de méthode
	private static ThreadLocal<AtomicInteger> index =
		new ThreadLocal<AtomicInteger>() ;

	private static String [][] data = {
		{"subject-00", "recipient-00", "content-00"},
		{"subject-01", "recipient-01", "content-01"}
	} ;

	@DataProvider(name=MESSAGE_PROVIDER)
	public static Iterator<object> provideMessage() {

		return new Iterator<object> () {

			{
				// je me rappelle avoir lu que les blocs non statiques
				// étaient inutiles...
				index.set(new AtomicInteger(0)) ;
			}

			public boolean hasNext() {
				return index.get().get() < data.length ;
			}

			public Object[] next() {
				Message message =
					new Message(
						data[index.get().get()][0],
						data[index.get().get()][1],
						data[index.get().get()][2]) ;
				index.get().incrementAndGet() ;
				return new Object [] {message} ;
			}

			public void remove() {
				// operation non supportée
				throw new UnsupportedOperationException() ;
			}
		} ;
	}
}

Ce dernier exemple constitue un squelette de data provider réutilisable dans toutes les classes de test, complètement découplé du code d’utilisation, thread-safe et extensible à loisir.

Références

Documentation de TestNG : http://testng.org/doc/documentation-main.html

Blog de son créateur, Cédric Beust : http://beust.com/

Artéfact Maven pour TestNG : http://mvnrepository.com/artifact/org.testng/testng

Une réflexion au sujet de « TestNG et ses DataProvider »

Les commentaires sont fermés.