TP de Programmation autour du développement dirigé par les tests

1 - Principe général

Le développement dirigé par les tests est une technique de développement logiciel proposée par Kent Beck dans les années 2000 et dont, modestement, il attribue lui-même la paternité à un ancien guide de programmation qui décrit un cycle de développement démarrant par la description des sorties que le programme est censé produire. Elle est présentée en détails dans son livre :
"Test Driven Development by example" de Kent Beck,
édité chez Pearson Education,
ISBN: 978-0321146533,
ISBN 10: 0321146530

Cette technique propose d'articuler le développement logiciel autour d'un cycle dont l'élément central est le test, elle est constituée par l'enchaînement des étapes suivantes :

  1. écrire un petit test qui ne marche pas, voire même qui ne compile pas
  2. exécuter l'ensemble des tests et s'assurer du non fonctionnement du nouveau test
  3. modifier le code testé de manière à faire fonctionner le test rapidement, même si c'est en utilisant de mauvaises techniques de programmation
  4. exécuter l'ensemble des tests et s'assurer de leur bon fonctionnement
  5. refactoriser le code afin d'éliminer toute duplication qui aurait été introduite dans le but de faire fonctionner le nouveau test
La motivation principale de cette technique est de permettre au programmeur de progresser dans sa tâche de manière incrémentale. D'après Kent Beck, lorsqu'un programmeur ne voit pas clairement comment résoudre son problème dans son ensemble, cette technique lui donne le moyen d'avancer, à pas aussi petits qu'il le souhaite, vers une solution. Le programmeur avance de manière constante et peut constater ses progrés au fil des nouveaux tests validés, préservant ainsi sa motivation et sa confiance. Outre cette motivation principale, le développement dirigé par les tests présente les avantages suivants:

2 - Mise en place

Par nature, en raison du cycle court entre l'écriture d'un test et sa validation, le développement dirigé par les tests requiert un environnement permettant d'exécuter simplement une série de tests et de repérer facilement les diverses erreurs produites à la compilation ou à l'exécution. C'est une bonne motivation pour passer à un environnement de développement intégré (IDE) muni d'une bibliothèque d'automatisation du lancement et de la validation des tests. Nous vous donnons ici un point de départ pour les environnements Netbeans et Eclipse en utilisant JUnit, mais, si vous avez une préférence pour un autre IDE, vous devriez pouvoir suivre le TP sans trop de difficulté.

Pour démarrer, créez un nouveau projet avec support pour les tests :

Votre projet est maintenant créé. Dans un IDE, un projet correspond à l'ensemble des ressources faisant partie d'une application (code source, système de compilation, images, sons, ...). Vous pouvez retrouver votre projet dans la vue Package explorer ou Projects de l'environnement où vous pouvez voir sa structure et son contenu.

3 - Un premier exemple

Ce premier exemple se déroulera comme un tutoriel. Histoire de faire dans l'originalité, nous prendrons comme premier exemple la réalisation d'une file d'attente. Nous souhaitons donc créer une classe File, mais comme nous sommes en développement dirigé par les tests (TDD), nous devons commencer par écrire un test. Pour cela :

Votre IDE crée alors pour vous un squelette de classe permettant de tester, avec un style un peu différent selon l'IDE. Vous pouvez remarquer, sur le code produit par défaut ou suggéré en commentaires, que les méthodes de test sont précédée d'une directive @Test.

Que pouvons nous mettre dans notre premier test ? Il faut commencer simple, en implémentant, par étapes, un test, le plus complet possible, de la spécification d'une file. La toute première chose que nous ferons avec une file sera de la créer, et une file nouvellement créée devra être vide. Ecrivons donc cela :

	@Test
	public void test() {
		File f = new File();
		assertEquals(f.size, 0);
	}
Cet exemple ne compile pas : il n'y a pas de classe File et encore moins d'attributs dans cette classe. Toutefois vous pouvez déjà voir la structure que prendrons nos futurs tests : une utilisation du code destiné à être testé suivie d'assertions sur le résultat (assertions fournies par JUnit) qui serviront à décider du resultat du test. En outre, il est déjà possible d'exécuter notre test : L'IDE nous avertit qu'il reste des erreurs mais, si nous continuons, le test s'exécute et nous affiche une barre rouge, signifiant son echec, ainsi que le détail de ce qui n'a pas fonctionné.

Faisons marcher notre test avec une classe File :

Après avoir cliqué sur Finish, votre classe est créée, vous pouvez alors la compléter de la manière suivante :
public class File {
	int size;
	
	public File() {
		size = 0;
	}
}
Vous pouvez alors relancer le test : la barre devient verte, le test passe !

Il nous reste à éliminer la duplication. Celle-ci n'apparaît pas forcément de manière évidente dans ce petit exemple, mais elle est néanmoins présente : l'attribut size et la constante 0 sont tous les deux utilisés directement dans le code de nos deux méthodes. Or, il s'agit dans les deux cas du même détail d'implémentation de la partie de la spécification disant qu'une nouvelle file doit être vide. Dans ce cas, cette (petite) duplication est symptomatique d'une dépendance entre le code utilisateur (notre test) et le code de la file : impossible de changer l'un sans l'autre. Eliminons cette duplication, en cacheant le test de vacuité derrière une nouvelle méthode :

	public boolean estVide() {
		return size == 0;
	}
et le test devient :
	@Test
	public void test() {
		File f = new File();
		assertTrue(f.estVide());
	}
La barre reste verte, tout va bien, nous pouvons continuer. Au passage, vous pouvez remarquer que nous appelons duplication la répétition d'une partie du code faisant partie de l'implémentation, par opposition à une répétition fonctionnelle (un algorithme qui effectuerait deux fois la même tâche pour obtenir son résultat par exemple).

Pour l'instant nous n'avons n'avons testé qu'un seul cas pour notre méthode estVide, le cas où la file est effectivement vide. Pour tester complètement son bon fonctionnement, il nous faut compléter le test avec le cas où la file n'est pas vide :

	@Test
	public void test() {
		File f = new File();
		assertTrue(f.estVide());
		f.enfiler(1);
		assertFalse(f.estVide());
	}
Ici nous avons plusieurs utilisations de la même méthode dans des situations différentes (arguments différents ou état de l'objet différent) devant produire un résultat différent, on parle alors de triangulation : plusieurs tests permettant de cerner un problème qu'un seul test pourrait ne pas voir. Complétons notre classe File de manière à faire passer le test :
	public void enfiler(int e) {
		size = 1;
	}
Le fait que cette implémentation ne soit pas la bonne n'est pas bien grave, au contraire. Si nous sommes tentés d'écrire une meilleure solution que celle simplement requise par les tests c'est que la couverture assurée par les tests n'est pas assez bonne, une partie de la spécification n'est pas testée. Il suffit alors d'avancer par triangulation, écrire un autre test pour avancer. C'est le principe de base du TDD : avancer rapidement, par petits pas, mais de manière constante et assurée, en augmentant la couverture fournie par les tests à chaque étape. La seule variable est la taille des pas, qu'il est possible de faire varier selon l'état du développement : plus la tâche semble difficile, plus ils doivent être petits. Dans notre exemple, la tâche n'est pas très difficile, mais nous conserverons nos petits pas pour illustrer notre propos.

Continuons dans le même esprit : l'état d'une file peut varier de vide à non vide au cours du temps, vidons donc notre file :

	@Test
	public void test() {
		File f = new File();
		assertTrue(f.estVide());
		f.enfiler(1);
		assertFalse(f.estVide());
		f.défiler();
		assertTrue(f.estVide());
	}
On complète notre file et le tour est joué :
	public void défiler() {
		size = 0;
	}

Nous arrivons maintenant à ce qui nous préoccupait dans notre implémentation : la mémoire de l'état de la file ne semble pas être à très long terme. Ecrivons donc un test pour évaluer ça : l'idée est d'enfiler plusieurs éléments est de vérifier que la file n'est pas vide tant que la dernier élément enfilé n'a pas été défilé.

	@Test
	public void test() {
		final int n = 5;
		int i;
		File f = new File();
		assertTrue(f.estVide());
		f.enfiler(1);
		assertFalse(f.estVide());
		f.défiler();
		assertTrue(f.estVide());
		for (i=0; i<n; i++) {
			f.enfiler(i);
			assertFalse(f.estVide());
		}
		while (i > 0) {
			assertFalse(f.estVide());
			f.défiler();
			i--;
		}
		assertTrue(f.estVide());
	}
Ce qui nous amène à modifier enfiler et défiler pour retrouver la barre verte :
	public void enfiler(int e) {
		size++;
	}
	
	public void défiler() {
		size--;
	}
Une partie de notre test est alors devenue redondante, nous pouvons le simplifier. Nous en profitons aussi pour le renommer, il semble maintenant tester complètement la spécification de estVide :
        @Test
        public void testVide() {
                final int n = 5;
                int i;
                File f = new File();
                assertTrue(f.estVide()); 
                for (i=0; i<n; i++) {
                        f.enfiler(i);
                        assertFalse(f.estVide());
                }
                while (i > 0) {
                        assertFalse(f.estVide());
                        f.défiler();
                        i--;
                }
                assertTrue(f.estVide());
        }

4 - Un enfilage correct

Nous pouvons maintenant nous attaquer aux éléments contenus dans la file, leur valeur et leur ordre. Nous commençons avec un premier test basique :

	@Test
	public void testValeurs() {
		File f = new File();
		f.enfiler(1);
		assertEquals(f.tête(), 1);
	}
Il nous faut alors une méthode tête pour passer le test :
	public int tête() {
		return 1;
	}
Ici, l'implémentation est pour ainsi dire inexistante ou fausse : elle ne répond pas à la question mais renvoie uniquement la valeur attendue par le test. Cela permet de mettre en place une méthode, donc d'avancer, et se corrige facilement par triangulation. Nous modifions donc notre test :
	@Test
	public void testValeurs() {
		File f = new File();
		f.enfiler(1);
		assertEquals(f.tête(), 1);
		f.défiler();
		f.enfiler(2);
		assertEquals(f.tête(), 2);
	}
Que nous corrigeons de manière immédiate en ajoutant un état à la file :
public class File {
	int size;
	int élément;
	
	public File() {
		size = 0;
	}
	
	public boolean estVide() {
		return size == 0;
	}
	
	public void enfiler(int e) {
		size++;
		élément  = e;
	}
	
	public void défiler() {
		size--;
	}
	
	public int tête() {
		return élément;
	}

}

Cela fait un moment que nous n'avons pas eu d'occasion particulière de factoriser notre code. Or, nous pouvons remarquer que la première ligne de nos deux tests est la même. Nous pourrions écrire une méthode pour initialiser la file, mais JUnit nous offre un mécanisme pour appliquer la même initialisation à tous les tests, l'annotation @Before :

import static org.junit.Assert.*;

import org.junit.Test;
import org.junit.Before;

public class TestFile {
	File f;
	
	@Before
	public void init() {
		f = new File();
	}
	
	@Test
	public void testVide() {
		final int n = 5;
		int i;
		assertTrue(f.estVide());
		for (i=0; i<n; i++) {
			f.enfiler(i);
			assertFalse(f.estVide());
		}
		while (i > 0) {
			assertFalse(f.estVide());
			f.défiler();
			i--;
		}
		assertTrue(f.estVide());
	}

	@Test
	public void testValeurs() {
		f.enfiler(1);
		assertEquals(f.tête(), 1);
		f.défiler();
		f.enfiler(2);
		assertEquals(f.tête(), 2);
	}
}

Revenons à nos valeurs, nous nous doutons bien qu'une mémoire à un seul élément est insuffisante, triangulons :

	@Test
	public void testEtat() {
		f.enfiler(1);
		f.enfiler(2);
		assertEquals(f.tête(), 1);
		f.défiler();
		assertEquals(f.tête(), 2);		
	}
Ce qui nous force à nous souvenir de plusieur valeurs. Cela donne beaucoup de modifications, mais il nous faut un tableau et un indice... :
public class File {
	int size;
	int [] élément;
	int tête;
	
	public File() {
		size = 0;
		élément = new int[10];
		tête = 0;
	}
	
	public boolean estVide() {
		return size == 0;
	}
	
	public void enfiler(int e) {
		élément[tête+size]  = e;
		size++;
	}
	
	public void défiler() {
		size--;
		tête++;
	}
	
	public int tête() {
		return élément[tête];
	}
}

Notre file n'est pas encore circulaire, il va encore falloir trianguler. Ici le test change l'état de la file, en le répettant on finit par obtenir un résultat différent :

	@Test
	public void testEtat() {
		int i;
		
		for (i=0; i<10; i++) {
			f.enfiler(1);
			f.enfiler(2);
			assertEquals(f.tête(), 1);
			f.défiler();
			assertEquals(f.tête(), 2);		
			f.défiler();			
		}
	}
Pour retrouver la barre verte, nous devons passer à du circulaire :
	public void enfiler(int e) {
		élément[(tête+size)%10]  = e;
		size++;
	}
	
	public void défiler() {
		size--;
		tête = (tête + 1)%10;
	}
Et nous pouvons factoriser ce 10 qui se retrouve partout :
public class File {
	final int capacity=10;
	int size;
	int [] élément;
	int tête;
	
	public File() {
		size = 0;
		élément = new int[capacity];
		tête = 0;
	}
	
	public boolean estVide() {
		return size == 0;
	}
	
	public void enfiler(int e) {
		élément[(tête+size)%capacity]  = e;
		size++;
	}
	
	public void défiler() {
		size--;
		tête = (tête + 1)%capacity;
	}
	
	public int tête() {
		return élément[tête];
	}
}

5 - Et les erreurs correctes ?

Il nous reste à tester les cas d'erreurs légitimes produits par notre file : le cas de la file vide et celui de la file pleine. JUnit permet de tester une levée attendue d'exception :

import org.junit.Rule;
import org.junit.rules.ExpectedException;
...
	@Rule
	public ExpectedException thrown = ExpectedException.none();

	@Test
	public void testExceptionVide() throws FileVideException {
		thrown.expect(FileVideException.class);
		f.défiler();
	}
Ce qui nous demande l'ajout d'une classe pour compiler :
public class FileVideException extends Exception {
	public FileVideException(String message) {
		super(message);
	}
}
Et l'ajout d'une levée d'exception pour passer le test :
	public void défiler() throws FileVideException {
		if (size == 0)
			throw new FileVideException("Impossible de défiler : file vide");
		size--;
		tête = (tête + 1)%capacity;
	}
Plus aucun test ne passe alors, c'est normal, nous venons de changer l'interface. Cela fait partie du travail de développement : une remise en cause du design en fonction des besoins apparaîssant lors du développement. Nous pouvons corriger nos tests pour tenir compte de cette nouvelle interface :
public class TestFile {
	File f;
	
	@Before
	public void init() {
		f = new File();
	}
	
	@Test
	public void testVide() {
		final int n = 5;
		int i;
		
		try {
			assertTrue(f.estVide());
			for (i=0; i<n; i++) {
				f.enfiler(i);
				assertFalse(f.estVide());
			}
			while (i > 0) {
				assertFalse(f.estVide());
				f.défiler();
				i--;
			}
			assertTrue(f.estVide());
		} catch (Exception e) {
			fail();
		}
	}

	@Test
	public void testValeurs() {
		try {
			f.enfiler(1);
			assertEquals(f.tête(), 1);
			f.défiler();
			f.enfiler(2);
			assertEquals(f.tête(), 2);
		} catch (Exception e) {
			fail();
		}
	}
	
	@Test
	public void testEtat() {
		int i;
		try {
			for (i=0; i<10; i++) {
				f.enfiler(1);
				f.enfiler(2);
				assertEquals(f.tête(), 1);
				f.défiler();
				assertEquals(f.tête(), 2);		
				f.défiler();			
			}
		} catch (Exception e) {
			fail();
		}
	}

	@Rule
	public ExpectedException thrown = ExpectedException.none();

	@Test
	public void testExceptionVide() throws FileVideException {
		thrown.expect(FileVideException.class);
		f.défiler();
	}
}

Pour terminer, nous pouvons faire pareil pour le cas de la file pleine :

	@Test
	public void testExceptionPleine() throws FilePleineException {
		int i;
		
		for (i=0; i<f.capacity(); i++) {
			f.enfiler(i);
		}
		thrown.expect(FilePleineException.class);
		f.enfiler(f.capacity());
	}
Il ne vous reste plus qu'à compléter le code pour faire passer le test...

6 - Pour continuer...

Continuez le développement de votre file en y intégrant une priorité, puis un itérateur, et, bien sur, en suivant les principes du TDD. Ensuite, nous ne saurions trop vous recommender la lecture du livre de Kent Beck. La place aidant, les exemples qui y sont développés sont plus riches et intéressants que celui développé ici. En particulier, la refactorisation y tient une place nettement plus importante. Enfin, sachez conserver et appliquer les bonnes idées du TDD dans vos développements futurs :