Les tests unitaires

Introduction

Un des éléments les plus importants dans le développement d’un logiciel est le test des différentes fonctionnalités de ce dernier. Les limites trop serrées de date limite de développement et l’aspect un peu répétitif et peu agréable du développement des tests font en sorte que, malheureusement, le développement de tests est souvent laissé pour compte. Il s’agit néanmoins d’une étape capitale au développement d’un logiciel fiable et robuste.

On a souvent une version très limitée de l’utilisation des tests. Tout programmeur comprendra que les tests doivent:

    • Valider ce que l’application fait;
    • Permets de détecter des « bugs » dans le programme

Par contre, les tests permettent également de:

    • Documenter les classes
      • Indiquer les limites de ces classes
    • Prévoir l’écriture des modules du programme
      • En effet, le « Test Driven Developpement » consiste à écrire les tests des modules avant que ces modules soient créés.
    • L’application ne fait pas ce qu’elle n’est pas censée faire
      • En d’autres mots, que l’application fait ce qu’elle est créée pour faire, et rien d’autre

Les différents types de tests

Il existe plusieurs types de tests qui permettent de valider différents aspects du logiciel. Les plus courants sont les tests suivants:

    • Tests unitaires (parfois appelé « White Box Testing »):
      • Permet de tester individuellement les modules de programmation. En programmation orientée objet par classes, les modules de programmation sont les classes en tant que telles.
      • Dans les tests unitaires, le test connaît la structure (et non le code précis des méthodes) de la classe et l’utilise afin de valider chacune de ses fonctionnalités.
    • Tests de non-régression (parfois appelé « Black Box Testing »):
      • Ce type de tests permet de valider l’expérience utilisateur;
      • Valide l’interface et les valeurs visibles par l’utilisateur;
      • Le test doit prendre en compte ce que le logiciel doit faire, et non la structure des classes;
      • Ce type de tests ne nécessite pas l’utilisation de la programmation. Par exemple, un adjoint administratif pourrait effectuer ces tests.

On appelle Tests système l’ensemble de tous les tests (unitaire et de non-régression) du système.

On appelle une suite de tests l’ensemble des tests s’appliquant à une partie de logiciel. Par exemple, on pourrait avoir une suite de tests pour une méthode, pour une classe ou pour un logiciel.

Dans le cadre du cours, nous allons voir l’utilisation des tests unitaires.

Les tests unitaires

Dans un contexte objet, une suite de tests d’un test unitaire doit permettre de tester le maximum de fonctionnalités d’une classe. En général, on ne teste pas les procédures effectuant des entrées/sorties (lecture au clavier, écriture à l’écran, sauvegarde de fichier, etc.), mais ce n’est pas une règle absolue. S’il est possible de tester une méthode sans trop de difficulté, il est préférable de le faire. Les tests d’une série de tests doivent comprendre les types de cas suivants:

    • Cas normaux:
      • Représentent des cas qui permettent de valider que la méthode fait le travail qu’elle est sensée faire;
      • Généralement les cas les plus simples à tester;
      • Souvent, il s’agit du seul type de cas utilisés par les programmeurs plus « hatif ».
    • Cas erronés:
      • Représente des cas qui n’est pas sensé être traité par la méthode;
      • Permet de valider la validation d’exceptions dans la méthode:
        • Dans la plupart des langages, test l’utilisation des exceptions style throw.
        • En Eiffel, ça permet de vérifier que les préconditions sont bien construits.
    • Cas limites:
      • Représente les premières valeurs représentées comme normales ou erronées.
      • Généralement les cas les plus difficiles à trouver;
      • Ce n’est pas toutes les méthodes qui ont des cas limites
        • Par exemple, pour indexer une liste en Java, nous aurions quatre (4) cas limites:
          • 0 qui représente le premier cas normal;
          • -1 qui représente le dernier cas erroné (avant 0)
          • « Taille de la liste -1 » qui représente le dernier cas normal
          • « Taille de la liste » qui représente le premier cas erroné après la « Taille de la liste -1 »

Exemples

Pour vous permettre de bien comprendre la mécanique, je vais donner un exemple de test unitaire pour un cas normal, erroné et limite en Java et Eiffel.

Les tests suivants s’appliquent à la méthode permettant de lire un élément dans une liste (en Java et en Eiffel).

Cas normaux

Voici un exemple de cas normal où nous utilisons une liste de 5 éléments et que nous utilisons l’index du second élément.

En Eiffel:

test_at_normal
		-- Test normal pour {LIST}.`at'
	note
		testing:  "covers/{LIST}.at"
	local
		l_liste:LIST[INTEGER]
	do
		create {LINKED_LIST[INTEGER]} l_liste.make
		l_liste.extend (1)
		l_liste.extend (2)
		l_liste.extend (3)
		l_liste.extend (4)
		l_liste.extend (5)
		assert("{LIST}.at(2) valide", l_liste.at (2) = 2)
	end

En Java:

@Test
public void testGetNormal() {
    List<Integer> lListe = new LinkedList<Integer>();
    lListe.add(new Integer(1));
    lListe.add(new Integer(2));
    lListe.add(new Integer(3));
    lListe.add(new Integer(4));
    lListe.add(new Integer(5));
    Assert.assertEquals(lListe.get(1).intValue(), 2);
}

Cas erronés

Voici un exemple de cas erroné où nous utilisons la même liste de 5 éléments et que nous utilisons l’index 10, qui est à l’extérieur de cette liste.

En Eiffel:

test_at_errone
		-- Test erroné pour {LIST}.`at'
	note
		testing:  "covers/{LIST}.at"
	local
		l_retry:BOOLEAN
		l_liste:LIST[INTEGER]
		l_valeur:INTEGER
	do
		if not l_retry then
			create {LINKED_LIST[INTEGER]} l_liste.make
			l_liste.extend (1)
			l_liste.extend (2)
			l_liste.extend (3)
			l_liste.extend (4)
			l_liste.extend (5)
			l_valeur := l_liste.at (10)
			assert("{LIST}.at erroné valide", False) -- N'est jamais sensé se rendre ici
		end
	rescue
		if attached {PRECONDITION_VIOLATION} {EXCEPTIONS}.exception_manager.last_exception as
			la_exception and then la_exception.type_name ~ Current.generating_type.name
		then
			l_retry := True
			retry
		end
	end

La clause « rescue » sert en gros à la même chose qu’un « try…catch » en Java. En gros, si un appel de méthode effectue une exception lors de l’exécution, le code branche directement dans la clause « rescue ». Si le code se termine dans la clause « rescue », le test est considéré comme ayant échoué. C’est la raison pour laquelle on effectue un « retry », qui relance la méthode (sans modifier la variable « l_retry ») de manière à ce que la méthode termine correctement. Ce « retry » est lancé seulement si la méthode échoue avec une précondition, ce qui indique que la méthode gère correctement l’utilisation d’un mauvais index.

Sachez que le code suivant fonctionne tout le temps, pour valider les cas erronés. Vous pouvez donc utiliser directement ce code, en changeant seulement ce qui est à l’intérieur du « if not l_retry then ».

En Java:

@Test
public void testGetErrone() {
    List<Integer> lListe = new LinkedList<Integer>();
    lListe.add(new Integer(1));
    lListe.add(new Integer(2));
    lListe.add(new Integer(3));
    lListe.add(new Integer(4));
    lListe.add(new Integer(5));
    try{
        lListe.get(10);
        Assert.assertTrue(false);
    }catch(IndexOutOfBoundsException exception) {
    }catch(Exception exception) {
        Assert.assertTrue(false);
    }
}

Cas limites normaux

Voici un exemple de cas limite normal où nous utilisons une liste de 5 éléments et que nous utilisons l’index du premier élément. Le premier élément est le premier index valide des cas normaux. C’est donc un cas limite.

En Eiffel:

test_at_limite_normal
		-- Test limite normal pour {LIST}.`at'
	note
		testing:  "covers/{LIST}.at"
	local
		l_liste:LIST[INTEGER]
	do
		create {LINKED_LIST[INTEGER]} l_liste.make
		l_liste.extend (1)
		l_liste.extend (2)
		l_liste.extend (3)
		l_liste.extend (4)
		l_liste.extend (5)
		assert("{LIST}.at(2) valide", l_liste.at (1) = 1)
	end

En Java:

@Test
public void testGetNormalLimite() {
    List<Integer> lListe = new LinkedList<Integer>();
    lListe.add(new Integer(1));
    lListe.add(new Integer(2));
    lListe.add(new Integer(3));
    lListe.add(new Integer(4));
    lListe.add(new Integer(5));
    Assert.assertEquals(lListe.get(0).intValue(), 1);
}

Cas limites erronés

Voici un exemple de cas limite erroné où nous utilisons une liste de 5 éléments et que nous utilisons l’index du premier élément. Le premier élément est le premier index valide des cas normaux. C’est donc un cas limite.

En Eiffel:

test_at_limite_errone
		-- Test limite erroné pour {LIST}.`at'
	note
		testing:  "covers/{LIST}.at"
	local
		l_retry:BOOLEAN
		l_liste:LIST[INTEGER]
		l_valeur:INTEGER
	do
		if not l_retry then
			create {LINKED_LIST[INTEGER]} l_liste.make
			l_liste.extend (1)
			l_liste.extend (2)
			l_liste.extend (3)
			l_liste.extend (4)
			l_liste.extend (5)
			l_valeur := l_liste.at (0)
			assert("{LIST}.at limite erroné valide", False) -- N'est jamais sensé se rendre ici
		end
	rescue
		if attached {PRECONDITION_VIOLATION} {EXCEPTIONS}.exception_manager.last_exception as
			la_exception and then la_exception.type_name ~ Current.generating_type.name
		then
			l_retry := True
			retry
		end
	end

En Java:

@Test
public void testGetErroneLimite() {
    List<Integer> lListe = new LinkedList<Integer>();
    lListe.add(new Integer(1));
    lListe.add(new Integer(2));
    lListe.add(new Integer(3));
    lListe.add(new Integer(4));
    lListe.add(new Integer(5));
    try{
        lListe.get(-1);
        Assert.assertTrue(false);
    }catch(IndexOutOfBoundsException exception) {
    }catch(Exception exception) {
        Assert.assertTrue(false);
    }
}

Outils de tests unitaires

Les outils de tests unitaires permettent d’automatiser l’exécution des tests. Ce type d’outil est très intéressant, car ils permettent d’exécuter rapidement et efficacement les tests unitaires du système entier, n’importe quand. Ce type d’outil permet généralement de:

    • Écrire des tests;
    • Lancer des tests;
    • Avoir rapidement accès au rapport d’exécution des tests;
    • Planifier l’exécution automatique des tests

La majorité des langages ont des outils de tests unitaires automatisés (par exemple: Junit, PHPunit, Auto test d’EiffelStudio, etc.)

Généralement, on aime exécuter l’entièreté des tests lorsque:

    • On a terminé une fonctionnalité importante et on s’apprête à faire une soumission sur notre dépôt de gestion de version (par exemple, git);
    • On a effectué un « merge » de plusieurs branches sur notre dépôt de gestion de version et on doit le soumettre officiellement;
    • On doit créer une « release » finale (envoyer l’exécutable sur le site web de la compagnie par exemple).

Références

Voici la référence de Eiffel AutoTest: https://www.eiffel.org/doc/eiffelstudio/AutoTest

Explication d’utilisation de Eiffel AutoTest: https://www.eiffel.org/doc/eiffelstudio/Using_AutoTest

Retour


Auteur: Louis Marchand
Creative Commons License
Sauf pour les sections spécifiées autrement, ce travail est sous licence Creative Commons Attribution 4.0 International.