I. Identifier des patterns courants dans les cas de tests, et les utiliser pour créer de nouveaux tests

En 1995, la fameuse équipe des quatre Erich Gamma, Richard Helm, Ralph Johnson, et John Vlissides présentaient le concept des design patterns pour des logiciels en développement. Les Design patterns augmentent l'efficacité du développement en aidant les développeurs à identifier des problèmes courants et à leurs appliquer des solutions standards. La plupart des développeurs utilisent les design patterns pour écrire le code de l'application, mais peu de développeurs se rendent compte que les design patterns sont aussi utiles pour écrire des cas de tests. Allons explorer comment identifier les patterns courants dans les cas de test et comment utiliser ces patterns pour créer des nouveaux tests.

II. Patterns pour les tests Junit

Aujourd'hui, écrire des tests unitaires est une partie obligatoire du traitement des développements des logiciels pour la plupart des développeurs. Pour les développeurs java, les tests unitaires signifient la production d'une classe de test Junit pour toutes les classes. Si vous n'êtes pas assez chanceux pour avoir accès aux logiciels sophistiqués de générations de tests, l'écriture de tests unitaires fait probablement partie de votre quotidien.
Si vous pensez que l'écriture de tests unitaires est souvent ennuyeuse, ingrate, et même inutile, vous n'êtes pas le seul. La plupart des développeurs préfèrent écrire du code « qui fait quelque chose ». La conception sérieuse des tests unitaires après l'écriture d'une classe peut prendre du temps considérable (à moins que vous utilisiez un logiciel de conduite de test (TDD - Test-Driven Design)). De plus, l'écriture de tests unitaires est souvent un traitement de routine qui conduit lui-même à la mécanisation. Si vous vous regardez vous-même minutieusement pendant que vous écrivez des tests unitaires, vous remarquerez que vous appliquez très souvent un pattern récurrent pour créer le test unitaire pour une classe particulière. Par exemple, un des plus simples et un des plus familiers patterns de test consiste en une invocation d'une méthode de test sur des tests d'entrée, suivi par une déclaration de la sortie attendue correspondante :

 
Sélectionnez
 
import junit.framework.*;
public class StringBufferTest extends TestCase {
	public void testAppend() throws Throwable {
		StringBuffer testObject = new StringBuffer("a");
		testObject.append("b");
		assertEquals("ab", testObject.toString());
	}
}

Notez que la classe StringBuffer est seulement utilisée comme exemple ; dans la vie réelle vous n'écrirez probablement pas des tests pour des classes de bibliothèques.

Les tests qui suivent cette structure basique peuvent déjà être considérés comme des applications de tests de simples patterns de tests. Ce pattern d'assertion de base améliore seulement le test isolé d'une seule méthode. C'est un pattern utile pour la vérification des algorithmes de traitements de données avec des entrées et des sorties connues, mais cela ne fournit pas d'aide pour tester la solidité d'une classe entière.

Pour vérifier l'exactitude de l'ensemble d'une classe, vous avez souvent besoin d'un pattern de test qui implique de multiples méthodes. Un exemple de tel pattern de test de méthodes multiples est le test combiné de méthodes de getters / setters. Dans ce pattern, un attribut d'un objet testé est configuré à une certaine valeur ; quand cet attribut est sollicité après, cette même valeur devra être retournée. Ce pattern de test trivial peut vous aider à révéler un nombre d'erreurs fréquentes, telle que l'alias d'un champ et des noms de paramètres ou des champs faux étant configurés (le fait d'affecter un champ à une valeur) ou retournés :

 
Sélectionnez
 
import junit.framework.*;
import java.util.*;
class DateTest extends TestCase {
	public void testSetTime() throws Throwable {
		Date testObject = new Date();
		testObject.setTime(100000L);
		assertEquals(100000L, testObject.getTime());
	}
}

Pour des simples méthodes getters et setters, il y a souvent seulement une voie d'exécution possible. Pourtant, vous devriez toujours écrire un nombre de tests qui utilise différentes valeurs ou objets. Un seul test qui utilise zéro ou une référence NULL comme une valeur de test peut vous donner un faux sens de la sécurité.Il existe un certain nombre d'autres paires de méthodes que vous pouvez utiliser comme une base pour tester des patterns similaires. Les classes de collections et les classes qui supportent des «écouteurs événements» (event listeners) ont souvent des méthodes pour ajouter et supprimer des objets. Un pattern de test courant est d'ajouter un objet puis de l'enlever et de déclarer que cet objet de test soit dans le même état dans lequel il était avant l'invocation des deux dernières méthodes (si c'est vraiment les sémantiques attendues de la classe testée).

Vous pouvez étendre ces patterns de tests basiques pour impliquer plus que juste une ou deux méthodes. Pour tester des classes plus compliquées, il est souvent nécessaire de créer un objet, d'invoquer un certain nombre de méthodes pour manipuler cet objet, et de comparer alors l'état final de l'objet avec le résultat attendu.

III. Patterns connus

Ce n'est pas facile d'établir des patterns généraux pour tester des classes arbitraires, mais le code lui même peut fournir des indications sur l'utilité des patterns de tests connus. Pour ce qui est de l'exemple du pattern de test des getters et setters, l'indice est simplement dans le nom des méthodes. Les paires de méthodes dont les noms commencent avec un set et un get (où le type d'un seul paramètre d'une méthode set est le même que le type de retour de la méthode get) sont souvent des méthodes d'accesseurs pour un champ particulier d'un objet. Votre code est susceptible de contenir des paires standards de get/set ou des add/remove, mais vous devriez aussi regarder pour des noms de méthodes à domaines spécifiques qui pointeraient les méthodes multiples qui pourrait être testées avec le même pattern (par exemple, débuter avec le même préfixe le nom des méthodes auxquelles le même type de pattern s'appliquerait).
Si vous êtes en train de développer les pré conditions et les post conditions des méthodes de votre document de façon systématique et méthodologique suivant le Design-by-Contract (DbC), alors vos annotations DbC sont une autre bonne source d'indices (voir Listing 1). Basé sur l'information du contrat, vous pouvez construire des patterns simples de test qui invoquent une méthode et déclarent les post conditions :

 
Sélectionnez
 
import junit.framework.*;
public class SimpleSetTest extends TestCase {
	public void testConstructor() throws Throwable {
		SimpleSet testObject = new SimpleSet();
		assertTrue(testObject.isEmpty());
  	}
}

Cependant, si vous êtes vraiment en train d'utiliser DbC, vous avez probablement un contrôleur DbC ou un environnement d'exécution DbC qui améliorent déjà ces simples vérifications. Le réel avantage de savoir les pré conditions et les post conditions de méthodes est que vous pouvez créer des chaînes de tests qui impliquent des séquences de méthodes testées plus longues, auxquelles vous pouvez relier les post conditions de la méthode testée avec les pré conditions de la méthode testée suivante dans la séquence. Les méthodes qui n'ont pas de pré conditions peuvent être utilisées à n'importe quel endroit dans la chaîne :

 
Sélectionnez
 
import junit.framework.*;
public class SimpleSetTest2 extends TestCase {
	public void testSequence() throws Throwable {
		Object testItem = "test item";
		SimpleSet testObject = new SimpleSet();
		assertTrue(testObject.isEmpty());
		testObject.add(testItem);
		assertTrue(testObject.contains(testItem));
		testObject.remove(testItem);
		assertTrue(!testObject.contains(testItem));
	}
}

La qualité de vos annotations DbC a une grande influence sur comment ces annotations peuvent être facilement utilisées pour la construction de séquences de tests. Le fait de juste déclarer des arguments et de retourner des valeurs qui ne peuvent pas être nulles n'est pas vraiment très utile pour la création de séquences de tests significatives.

En plus de tirer des conseils d'après les conventions de nommage et des annotations de DbC,il serait sans doute aussi intéressant de jeter un oeil sur des super classes de classes testées et des interfaces implémentées. Très souvent, vous pouvez utiliser le même pattern de test pour tester toutes les classes qui implémentent une certaine interface ou qui étendent une certaine super classe. Par exemple, un simple jeu de patterns de test peut être utilisé pour fournir une couverture de tests basiques pour des classes qui implémentent l'interface java.util.Iterator. Après avoir déterminé un bon pattern de test pour l'interface de l'Itérator, vous pouvez appliquer ce pattern à toutes les classes qui implémentent l'interface de l'Itérator.

Pour commencer, considérez le contrat de l'interface de l'Iterator. Ce contrat est facile quand vous avez déjà des annotations DbC. Cependant, certains aspects sémantiques des classes ne peuvent pas être exprimés facilement comme étant des conditions DbC. Dans de tels cas, vous pourriez commencer avec ce jeu de règles que chaque Iterator devra satisfaire :

  1. Si hasNext() retourne vrai, à ce moment là une invocation ultérieure de next() ne doit pas lancer une NoSuchElementException.
  2. Si hasNext() retourne faux, à ce moment là une invocation ultérieure de next() doit lancer une NoSuchElementException.
  3. De multiples invocations de hasNext() doivent retourner la même valeur si next() n'a pas été appelé entre temps.
  4. Dès que hasNext() a retourné faux, elle ne doit jamais retourner vrai après.
  5. Dès que next() a lancé une NoSuchElementsException, les invocations ultérieures doivent aussi lancer une exception.

Ce jeu de conditions couvre déjà beaucoup de problèmes communs avec l'implémentation de l'interface de l'Iterator. Vous pouvez traduire ces conditions à l'intérieur des patterns de tests. Le seul défi restant est la génération des objets Iterator dans différents états qui vous permettent d'exercer toutes les façons d'exécutions du code (voir Listing 2 ).
Vous pouvez librement choisir la valeur de la constante REPEAT_COUNT. Dépendant de la logique du programme qui est derrière votre itérateur, il peut-être possible que -à cause d'obscures b- l'Iterator revienne retourner plus d'éléments après hasNext() qui a déjà retourné faux. Une reprise de compteur de deux peut être suffisant pour détecter l'engorgement entre deux états. Si vous suspectez qu'un mauvais état de modification pourrait se passer après un grand nombre de répétitions, choisissez une plus grande valeur au niveau du compteur. Le même pattern, avec différentes valeurs de répétitions, peut-être utilisé aussi pour d'autres classes.

IV. Isoler les patterns de test

Si vous avez conçu vos classes basées sur des cas d'utilisation -soit documentés dans les diagrammes UML (Unified Modeling Language) ou sous une autre forme- vous avez également une bonne ressource pour les patterns de tests : les cas d'utilisation eux-mêmes.

Les cas d'utilisations décrivent comment les classes interagissent avec les autres classes quand un certain scénario est exécuté. Souvent, vous pouvez extraire directement une séquence d'invocations de méthode depuis une description de cas d'utilisations. Ces séquences peuvent être utilisées comme des patterns de tests. Ces patterns de tests que vous extrayez des cas d'utilisations peuvent ne pas être spécifiques à une classe particulière. A la place, l'ensemble du jeu des classes interactives peut être testé, et la séquence des méthodes testées vérifie l'interaction globale entre les classes plutôt qu'à l'intérieur de classes individuelles.
Vous pouvez identifier des patterns de test basés sur des conventions de nommage, des annotations DbC, des superclasses/interfaces et des cas d'utilisations. Comme vous avez vu, une fois que le pattern adéquat est identifié, il peut être appliqué aux classes testées mécaniquement. Quelquefois, même le traitement de l'identification d'un pattern de test est plutôt mécanique. En effet, les techniques explorées dans cet article se prêtent idéalement eux-mêmes à l'automatisation.
Les outils de tests de générations automatisés actuellement disponibles peuvent identifier automatiquement des patterns de tests convenables et créer des cas de tests basés sur ces patterns. De tels outils peuvent fournir des couvertures de tests basiques pour votre code et vous dégager plus de temps pour l'écriture du code de l'application et peut-être plus de temps pour des cas de tests plus difficiles qui ne peuvent pas être générés automatiquement.

Cependant, toutes ces informations supposent qu'il y a déjà du code testé, et que les cas de test sont crées sur du code existant. Si vous êtes en train de suivre une méthodologie comme TDD, les patterns de tests sont problématiques parce que vous allez écrire des tests avant que le code testé existe.

V. Codes

V-A. Code 1

Vos annotations DbC peuvent être une bonne source d'indications si vous êtes en train de développer sur de la méthodologie Design-by-Contract et vous allez fournir des pré conditions et des post conditions pour les méthodes :

 
Sélectionnez
 
public class SimpleSet { 
	/** @post this.isEmpty() */ 
	public SimpleSet() {
		//...
  	} 

	/** @post this.contains(item) 
	* @post !this.isEmpty() 
	*/ 
	public void add(Object item) {
		//...
  	} 

	/** @pre !this.isEmpty() 
	* @pre this.contains(item) 
	* @post !this.contains(item) 
	*/ 
  	public void remove(Object item) {
		//...
  	} 

  	public boolean contains(Object item) {
		//...
		return false;
  	}

	public boolean isEmpty() {
		return false;
  	}
}

V-B. Code 2

En relevant le défi de générer les objets Iterator de génération dans différents états, vous pouvez vous exercer à exécuter le code de toutes les façons possibles, vous pouvez librement choisir la valeur de la constante REPEAT_COUNT.

 
Sélectionnez
 
import java.util.*;
import junit.framework.*;
public class SimpleIteratorTest extends TestCase {
	private final static int REPEAT_COUNT = 10;

	public void testSimpleIterator() throws Throwable {
		Iterator testObject = new SimpleIterator();

		while (testObject.hasNext()) {
			// Déclare la condition #3 pour hasNext() == true:        	
			for (int repeat = 0; repeat < REPEAT_COUNT; repeat++) {
				assertTrue(testObject.hasNext());
			}
         
			// une violation de la condition #1 sera 
			// automatiquement reportée par JUnit: 
			//
			testObject.next();
		}

		// Déclare la condition #2:
		String caught = null;
		try {
			testObject.next();
		} catch (Throwable throwable) {
			caught = throwable.getClass().getName();
		}
		assertEquals(NoSuchElementException.class.getName(), caught);

		// Déclare la condition #4:
		for (int repeat = 0; repeat < REPEAT_COUNT; repeat++) {
			assertFalse(testObject.hasNext());
		}

		// Déclare la condition #5:
		for (int repeat = 0; repeat < REPEAT_COUNT; repeat++) {
			caught = null;
			try {
				testObject.next();
			} catch (Throwable throwable) {
				caught = throwable.getClass().getName();
			}
			assertEquals(NoSuchElementException.class.getName(), caught);
		}
	}
}

VI. Au sujet de l'auteur

Mirko Raner est un ingénieur système de la Parasoft Corporation, où il fait partie de l'équipe de développement qui est responsable pour Jtest, un outil automatisé de préventions d'erreurs pour Java. Mirko écrit de façon occasionnelle pour les magazines d'industries et participe à des conférences, notamment celles de JavaOne, la conférence internationale Unicode, la conférence ACM ItiCSE, et celle du Symposium USENIX Java Virtual Machine.