I. Introduction

Aujourd'hui, tous les acteurs du développement sont d'accord sur l'importance des tests. La réalisation d'une application passe nécessairement par des phases de tests. Une application non soumise aux tests est une application qui par nature sera beaucoup plus instable et donc aura toutes les chances d'être vouée à l'échec, l'abandon, ou au délaissement. Pourtant, encore trop souvent, les projets font l'impasse, espérant gagner un peu de temps. Les tests sont accusés, bien à tort, de consommer trop de charges, d'être difficiles à maintenir, etc. Or une stratégie dans laquelle les tests sont au cœur des développements peut faire gagner non seulement en fiabilité ou en crédibilité mais aussi en temps.

Une des méthodes à la mode est la fameuse TDD (Test Driven Development) ou "Développement Guidé par les Tests" qui ne sera pas expliquée ici. À la place je propose "3T", pour "Test en Trois Temps". Cette solution personnelle s'inspire des TDD. J'en propose une version allégée qui convient pour la plupart des besoins simples et, surtout, qui ne nécessite que très peu de temps pour la mettre en œuvre.

Pour découvrir les TDD, je conseille le très bon tutoriel de Bruno Irsier sur developpez.com à l'adresse http://bruno-orsier.developpez.com/tutoriels/TDD/pentaminos

II. Le cahier des charges

À défaut de spécifications claires, bien rédigées, avec des règles métier correctement identifiées, il n'est pas rare qu'on ne dispose que de simples post-it. Illustrons donc la suite de cet article à l'aide de post-it.

Dans la suite, et à titre d'illustration pour cet article, disons que le client demande le développement de la fonction mathématique "Factoriel" dans la couche "Service" du projet. Il fournit la série de post-it suivante :

Regle #RG1.0
f(n) = 1 si n == 0
ie. 0! = 1

Regle #RG1.1
f(n) = 1 si n == 1
ie. 1! = 1

Image non disponible

Regle #RG1.2
f(n) = n! = n * (n - 1)!
ex. 5! = 5 * 4 * 3 * 2 * 1 = 120

Image non disponible

Regle #RG1.3
f(n) => Erreur (IllegalArgumentException) si n < 0

Image non disponible

Si, en tant que développeur, on découvre une incohérence dans le cahier des charges, on doit en avertir le client. Il ne faut pas prendre l'initiative de modifier la demande sans l'accord du client. Dans un cas plus complexe, il se peut qu'on ait simplement mal compris les spécifications.

III. Mise en place

Il faut avoir un projet sur lequel travailler. Pour illustrer cet article, on crée donc un miniprojet, avec Maven, nommé "my-calculette" et dans lequel on programmera des fonctions mathématiques simples en guise d'exemple.

La doc d'installation de Maven est disponible à l'adresse suivante : http://maven.apache.org/download.html#Installation

Sur le disque, on ajoute le dossier "my-calculette" (à l'emplacement que l'on souhaite) puis on y crée le fichier "pom.xml" avec le contenu suivant :

pom.xml
Sélectionnez

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.thi</groupId>
  <artifactId>calculette</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>

  <name>calculette</name>
  <url>http://maven.apache.org</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.8.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

Ensuite, en ligne de commande, dans le répertoire "my-calculette", on fabrique la structure du projet Eclipse à l'aide de la commande Maven suivante.

Création du projet
Sélectionnez

mvn clean install eclipse:eclipse

Si on vient tout juste d'installer Maven, celui-ci va commencer par rapatrier un nombre assez important de bibliothèques, dont JUnit, nécessaires à son fonctionnement. Donc, il est important d'avoir une connexion Internet active à ce moment-là.

La commande crée en particulier le dossier "target" et les fichiers Eclipse ".classpath" et ".project". Il ne reste plus qu'à importer le projet dans Eclipse (via le menu "File>Import>General>Existing Projects into Workspace"). Dans le projet importé, on crée un répertoire source, via un clic droit sur le projet, puis "New>Source Folder". Maintenant on peut travailler.

Eclipse et Maven ne sont utilisés ici qu'à titre pratique. Ils n'ont rien à voir avec "3T".

IV. 3T en action

Maintenant qu'on a mis en place un projet Eclipse (my-calculette) et qu'on possède un cahier des charges, on peut s'intéresser à "3T", en trois étapes :

  1. écriture des interfaces ;
  2. rédaction des tests ;
  3. développement assisté par les tests.

IV-A. Interface

Puisqu'il est demandé de programmer le calcul de la fonction Factoriel dans la couche Service, on commence par déclarer cette méthode, de manière classique (et propre), dans l'interface dédiée.

L'interface
Sélectionnez

public interface CalculetteService {

	/**
	 * Calcule le factoriel de n. <br/>
	 * 
	 * ex. f(5) = 5! = 5 x 4 x 3 x 2 x 1 = 120.
	 * 
	 * @param n
	 * @return le factoriel de n.
	 * @throws IllegalArgumentException
	 *             Si la param n est négatif.
	 */
	int factoriel(int n);

	...	
}

IV-B. Tests

Avant même de coder l'implémentation du calcul, on écrit la classe de test. Cette seconde étape illustre ce qu'est le TDD, en créant une rupture avec les processus classiques de développement. On change ainsi d'approche, en mettant en avant le résultat à obtenir, plutôt que de commencer à s'y intéresser une fois les développements terminés ou bien avancés. Dans 3T cette seconde étape est relativement simple. En effet, il suffit de "recopier" les spécifications.

Afin d'assurer une bonne lisibilité et une bonne maintenabilité des tests, il y a deux principes qu'il convient de suivre :

  1. renseigner la Javadoc de chaque méthode de test avec le résultat attendu ;
  2. avoir une structure homogène du corps de chaque méthode de test, afin de faire apparaître les paramètres, les résultats attendus et les méthodes testées.

Voici un exemple de méthode de test :

Avec tous les tests
Sélectionnez

/**
 * Test de la regle RG1.0 <br/>
 * f(0) = 0 <br/>
 * PARAM n = 0 <br/>
 * RESULT = 1
 */
@Test
public void testCalcRG1_0() {
	// Param
	final int n = 0;

	// Resultat attendu
	final int result = 1;

	// Appel
	final int resultatCalcule = calc.factoriel(n);

	// Test
	assertEquals(result, resultatCalcule);
}

Pour l'écriture d'une méthode de test, on se souvient de la règle des 3 A(s) : Arrange (préparation des objets et mocks pour le test) puis Act (appel de la méthode à tester) et enfin Assert (lignes d'assertions). Ici le premier "A" est composé des blocs "param" et "résultat attendu".

On reproduit ce schéma pour toutes les règles du cahier des charges. Comme les appels et les tests sont globalement tous les mêmes, on peut en profiter pour les factoriser.

Format
Sélectionnez

public class CalculetteServiceTest {
	/**
	 * Le service de calcul
	 */
	protected CalculetteService calc;
	
	/**
	 * Test de la regle RG1.0 <br/>
	 * f(0) = 0 <br/>
	 * PARAM n = 0 <br/>
	 * RESULT = 1
	 */
	@Test
	public void testCalcRG1_0() {
		// Param
		final int n = 0;

		// Resultat attendu
		final int result = 1;

		// Appel et test
		doTestRG1_x(n, result);
	}

	/**
	 * Test de la regle RG1.1 <br/>
	 * f(1) = 0 <br/>
	 * PARAM n = 1 <br/>
	 * RESULT = 1
	 */
	@Test
	public void testCalcRG1_1() {
		// Param
		final int n = 1;

		// Resultat attendu
		final int result = 1;

		// Appel et test
		doTestRG1_x(n, result);
	}

	/**
	 * Test de la regle RG1.2 <br/>
	 * f(5) = 120 <br/>
	 * PARAM n = 5 <br/>
	 * RESULT = 120
	 */
	@Test
	public void testCalcRG1_2() {
		// Param
		final int n = 5;

		// Resultat attendu
		final int result = 120;

		// Appel et test
		doTestRG1_x(n, result);
	}

	/**
	 * Test de la regle RG1.3 <br/>
	 * f(-3) =- ERROR <br/>
	 * PARAM n = -3 <br/>
	 * RESULT = IllegalArgumentException
	 */
	@Test(expected = IllegalArgumentException.class)
	public void testCalcRG1_3() {
		// Param
		final int n = -3;

		// Resultat attendu
		final int result = 1;

		// Appel et test
		doTestRG1_x(n, result);
	}

	/**
	 * Fait les appels et les tests pour les regles RG 1.x <br/>
	 * PARAM n <br/>
	 * PARAM result
	 */
	private void doTestRG1_x(final int n, final int result) {
		// Appel
		final int resultatCalcule = calc.factoriel(n);

		// Test
		assertEquals(result, resultatCalcule);
	}

}


public class RecursiveCalculetteServiceTest extends CalculetteServiceTest {

	/**
	 * Set up
	 */
	@Before
	public void doBefore() {
		// calc = ...
	}

}

La classe CalculetteServiceTest doit être "abstraite" et permettre de faire des tests génériques quelle que soit l'implémentation du service.

Dans le cas de la règle RG1.2, on peut écrire plusieurs tests avec différentes valeurs de n, prises au hasard. Quand on souhaite tester plusieurs valeurs, on peut soit regrouper les tests dans une seule méthode soit écrire une méthode pour chaque valeur. C'est ce second choix que je propose dans le cadre de 3T. On aura ainsi un code ressemblant au suivant.

Test de plusieurs valeurs
Sélectionnez

	/**
	 * Test de la regle RG1.2-A <br/>
	 * f(5) = 120 <br/>
	 * PARAM n = 5 <br/>
	 * RESULT = 120
	 */
	@Test
	public void testCalcRG1_2_120() {
		// Param
		final int n = 5;

		// Resultat attendu
		final int result = 120;

		// Appel et test
		doTestRG1_x(n, result);
	}

	/**
	 * Test de la regle RG1.2-24 <br/>
	 * f(4) = 24 <br/>
	 * PARAM n = 4 <br/>
	 * RESULT = 24
	 */
	@Test
	public void testCalcRG1_2_24() {
		// Param
		final int n = 4;

		// Resultat attendu
		final int result = 24;

		// Appel et test
		doTestRG1_x(n, result);
	}

	/**
	 * Test de la regle RG1.2-720 <br/>
	 * f(6) = 720 <br/>
	 * PARAM n = 6 <br/>
	 * RESULT = 720
	 */
	@Test
	public void testCalcRG1_2_C() {
		// Param
		final int n = 6;

		// Resultat attendu
		final int result = 720;

		// Appel et test
		doTestRG1_x(n, result);
	}
	
	...

On lance la série de tests (bouton droit sur le nom de la classe, puis menu Run As > JUnit test) et on constate que tous les tests sont rouges. C'est normal puisque la méthode "doBefore()" ne définit pas encore d'implémentation. On n'obtient que des NullPointerException.

On va donc maintenant créer une première implémentation, qui ne fait que compiler.

Implémentation minimale
Sélectionnez

public class RecursiveCalculetteService implements CalculetteService {

	@Override
	public int factoriel(int n) {
		throw new UnsupportedOperationException("Cette méthode n'a pas encore été écrite.");
	}

}

Puis on finit la méthode "doBefore()" en utilisant cette implémentation.

Set up
Sélectionnez

public class RecursiveCalculetteServiceTest {

	/**
	 * Set up
	 */
	@Before
	public void doBefore() {
		calc = new RecursiveCalculetteService();
	}

	...
}

On relance les tests, qui restent rouges. Toutefois, la classe de tests est désormais finie et on peut la mettre de côté.

IV-C. Développement

Dans cette troisième et dernière étape, il s'agit de programmer la fonctionnalité demandée à proprement parler. Il faut procéder par itération, en s'aidant de la classe de tests qu'on a laissée ouverte dans un coin d'Eclipse. L'idée est de n'écrire que du code pour faire passer les tests au vert, l'un après l'autre, un seul à la fois. Le mieux est de le faire dans l'ordre des tests quand c'est possible.

On commence donc par la première règle : RG1.0

RG1.0
Sélectionnez

@Override
public int factoriel(int n) {
	
	// RG1.0 f(0) = 1
	if(n == 0) {
		return 1;
	}
	
	throw new UnsupportedOperationException("Cette partie de la méthode n'a pas encore été écrite.");
}

On relance donc la série de tests et on constate que le test "testCalcRG1_0" devient vert, tandis que les trois autres restent rouges.

On continue avec la règle suivante : RG1.1.

RG1.1
Sélectionnez

@Override
public int factoriel(int n) {
	
	// RG1.0 f(0) = 1
	// RG1.1 f(1) = 1
	if(n == 0 || n == 1) {
		return 1;
	}
	
	throw new UnsupportedOperationException("Cette partie de la méthode n'a pas encore été écrite.");
}

On relance donc la série de tests et on constate que le test "testCalcRG1_1" devient vert, que le test "testCalcRG1_0" reste vert, et que les 2 autres restent rouges.

On continue avec la règle suivante : RG1.2.

RG1.2
Sélectionnez

@Override
public int factoriel(int n) {

	// RG1.0 f(0) = 1
	// RG1.1 f(1) = 1
	if (n == 0 || n == 1) {
		return 1;
	}

	// RG1.2 f(n) = n * f(n - 1)
	return n * factoriel(n - 1);
}

On relance donc la série de tests et on constate que le test "testCalcRG1_2" devient vert, que les tests "testCalcRG1_1" et "testCalcRG1_0" restent verts, et que le dernier test reste rouge.

On continue avec la dernière règle : RG1.3.

RG1.3
Sélectionnez

@Override
public int factoriel(int n) {

	// RG1.3 Si n < 0 => ERREUR
	if(n < 0) {
		throw new IllegalArgumentException("Le param n ne peut pas être négatif.");
	}
	
	// RG1.0 f(0) = 1
	// RG1.1 f(1) = 1
	if (n == 0 || n == 1) {
		return 1;
	}

	// RG1.2 f(n) = n * f(n - 1)
	return n * factoriel(n - 1);
}

On relance donc la série de tests et on constate que tous les tests sont désormais verts. À ce stade, on est en droit de penser que (sauf hasard malheureux) le développement de la fonctionnalité est terminé.

Pour finir on lance l'ensemble des tests de l'application, et non pas seulement de la classe (sous réserve que l'application contienne d'autres tests, ce qui devrait être le cas si le calcul du Factoriel n'est pas la première fonctionnalité de la calculette, cf. Chapitre "V Les évolutions"), pour vérifier que le développement n'a pas introduit de régression. Si tous les tests sont verts, c'est qu'il n'y a rien à faire et on peut raisonnablement se dire que le développement est terminé. Par contre, si un ancien test devient rouge, c'est qu'on a créé une régression, qu'il faut corriger.

Si on dispose d'un serveur d'intégration, on peut se contenter de ne lancer les tests que sur un sous-ensemble des tests de l'application pour vérifier la non-régression. En effet, le serveur d'intégration se chargera alors de lancer l'intégralité des tests. L'idée est qu'il peut y avoir des milliers de tests dans l'application et les lancer peut prendre beaucoup de temps.

V. Les évolutions, bogues et mises à jour

C'est inévitable, à un moment ou un autre, le client va commander des évolutions de l'application. Voici un exemple d'évolution évidemment présenté sous forme de post-it.

Regle #RG1.2.1
Le calcul prend moins de 1 seconde.

Image non disponible

À partir de là, on repart directement sur "3T".

Pour l'étape 1 (Interface) il n'y a rien à faire dans l'exemple.

Pour l'étape 2, il faut ajouter un nouveau test.

RG1.2.1
Sélectionnez
			
/**
 * Test de la regle RG1.2.1 <br/>
 * f(5) = 120 <br/>
 * PARAM n = 5 <br/>
 * RESULT = 120
 * DUREE = 1 seconde max
 */
@Test(timeout=1000)
public void testCalcRG1_2_1() {
	testCalcRG1_2();
}		

Au départ, on s'intéresse uniquement au(x) nouveau(x) test(s) qu'on doit faire passer au vert. S'ils sont verts, c'est qu'il n'y a rien à faire. Ici on ajoute une contrainte de performance donc c'est possible, mais pour une nouvelle fonctionnalité, ça n'arrive jamais. Si un nouveau test est rouge, on passe à la troisième étape.

Pour l'étape 3 (nouveau dév), on programme la nouvelle fonctionnalité et on relance les nouveaux tests. Quand les nouveaux tests sont verts, on peut considérer que les nouveaux développements (pris seuls) sont OK. Une fois que les nouveaux tests sont verts, on relance l'ensemble des tests pour vérifier la non-régression.

Quand un bogue est découvert, il donne généralement lieu à la création d'un ticket dans le système de gestion des anomalies (Mantis, Jira, etc.) Le ticket est alors référencé par un numéro unique et peut faire office de nouveau post-it. La correction d'un bogue peut se dérouler comme le développement d'une nouvelle fonctionnalité, c'est-à-dire comme indiqué plus haut.

Quand le cahier des charges évolue, et plus particulièrement quand une règle (déjà développée) change, je conseille de ne pas versionner ladite règle mais d'en créer une nouvelle (avec un nouveau numéro). Si la nouvelle règle est incompatible avec l'ancienne, on supprime simplement l'ancienne règle, devenue fausse.

VI. Suivi

Un point très intéressant de "3T" est le suivi (reporting) qu'on peut/doit mettre en place. En effet "3T" propose de recopier les spécifications pour écrire les tests. Or les spécifications définissent très précisément le contenu de l'application. Par association, lorsque N % des tests passent au vert, cela "signifie" que N % du cahier des charges est satisfait, c'est-à-dire que l'application est développée à hauteur de N %.

Des logiciels, liés au serveur d'intégration, permettent de suivre l'avancement des développements et de donner de la visibilité au chef de projet. En effet, le serveur d'intégration récupère la dernière version du code de manière régulière (par exemple tous les soirs, ou après chaque commit), puis le compile et lance les tests. Le chef de projet peut alors consulter les résultats.

VII. Conclusion

Sans suivre tous les principes du TDD, la "3T" permet de développer une application rapidement, avec un taux de confiance élevé et en forçant les membres de l'équipe à documenter. La "3T" permet de couvrir l'ensemble des spécifications à moindres frais et de façon relativement automatisée ; il suffit de recopier. Cela évite notamment aux développeurs de se poser mille questions, dans tous les sens, et de produire exactement ce qui est demandé, sans rien oublier ni ajouter en trop.

VIII. Remerciements

Je tiens à remercier, en tant qu'auteur de cet article de présentation de la "3T", toutes les personnes qui m'ont aidé et soutenu. Je pense tout d'abord à mes collègues qui subissent mes questions au quotidien mais aussi à mes contacts et amis du web, dans le domaine de l'informatique ou non, qui m'ont fait part de leurs remarques et critiques. Bien entendu, je n'oublie pas l'équipe de developpez.com qui m'a guidé dans la rédaction de cet article et m'a aidé à le corriger et le faire évoluer, principalement sur le forum.

Plus particulièrement j'adresse mes remerciements, par ordre alphabétique, à Aldian, ClaudeLELOUP, Estelle, Hing, keulkeul, Laurent.B, Nemek, et Yann

Thierry

IX. Annexes

IX-A. Quelques réponses

IX-A-1. "3T" diverge pas mal du TDD.

Oui c'est exact et c'est assumé. Faire du Test Driven Dev, c'est se donner les moyens de réussir ses projets. Néanmoins, peu de CP (Chef de Projet) sont prêts à se lancer dans l'aventure. La mise en œuvre de la "3T" ne nécessite que très peu d'investissements. Le choix et l'écriture des cas de test est quasi systématique puisqu'il suffit de recopier les spécifications. Certains outils (non présentés dans ce document) permettent même de générer du code de test directement à partir du cahier des charges, par exemple depuis un Wiki. En outre, certains concepts des TDD, notamment en ce qui concerne la manière de faire initialement échouer des tests, ne sont pas évidents à comprendre. "3T" est alors un compromis qui semble cohérent dans de nombreux cas.

IX-A-2. La méthode de calcul du factoriel proposée renvoie 0 quand N vaut 50.

Oui et c'est normal. En Java, les "int" sont codés sur 32 bits. Les chiffres positifs vont de 0 à 2^31-1. Quand on calcule "50!" on dépasse la capacité des "int". D'ailleurs on dépasse la capacité max dès que N vaut 28 ou plus. On pourrait utiliser des "long", codés sur 64 bits mais ça ne ferait que reculer le problème. On pourrait aussi utiliser des BigInteger, déjà mieux adaptés. Mais l'idéal est encore d'utiliser des techniques spécifiques aux grands nombres. Toutefois ce n'est pas l'objet de ce document. La méthode de calcul du factoriel est ici proposée à titre d'illustration.

IX-A-3. Tâche finie

Une des questions qu'on se pose souvent (ou qu'on devrait se poser) est de savoir sur quel(s) critère(s) tâche/fonctionnalité peut être considéré comme terminée. Dans le cadre de 3t, c'est relativement simple ; une tâche est terminée lorsqu'elle a franchi les trois phases (Interface, Test, Développement) décrites par la méthode.