I. Écrire du code imbitable dans vos classes de test▲
Voici l'histoire de la recette du poulet au whisky, à la sauce XML… Laquelle de ces 2 versions allez-vous préférer ?
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
class
RecetteTest {
@Test
public
void
testToXml
(
) {
Recette r =
new
Recette
(
'poulet au whisky'
);
assertNotNull
(
r);
String s =
r.toXml
(
);
assertNotNull
(
s);
String[] splits =
s.split
(
'
\n
'
);
assertTrue
(
splits.length ==
8
);
for
(
String split : splits) {
if
(
split ==
null
||
''
.equals
(
split)) {
fail
(
'error'
);
}
}
assertEquals
(
'<!--?xml version=
\'
1.0
\'
encoding=
\'
UTF-8
\'
?-->'
).trim
(
), splits[0
].trim
(
));
assertEquals
(
''
.trim
(
), splits[1
].trim
(
));
assertEquals
(
'
\t
'
.trim
(
), splits[2
].trim
(
));
assertEquals
(
'
\t\t
poulet'
.trim
(
), splits[3
].trim
(
));
assertEquals
(
'
\t\t
olives'
.trim
(
), splits[4
].trim
(
));
assertEquals
(
'
\t\t
whisky'
.trim
(
), splits[5
].trim
(
));
assertEquals
(
'
\t
'
.trim
(
), splits[6
].trim
(
));
assertEquals
(
''
.trim
(
), splits[7
].trim
(
));
}
}
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
public
class
RecetteTest {
@Test
public
void
testToXml
(
) {
// execute XML marshalling
Recette recette =
new
Recette
(
'poulet au whisky'
);
String actualXml =
recette.toXml
(
);
// read exepected result
String expectedXml =
readFileFromClasspath
(
'expected_poulet-au-whisky.xml'
);
// check result
assertEquals
(
formatXml
(
expectedXml), formatXml
(
actualXml));
}
/**
* read into a String a text file from classpath
*
*
@param
filePath
*
@return
the String content
*/
protected
static
String readFileFromClasspath
(
String filePath) {
// ...
}
/**
*
@param
unformatedXml
*
@return
a formated and indented XML
*/
protected
static
String formatXml
(
String unformatedXml) {
// ...
}
}
Écrire des méthodes trop longues, exploser la complexité cyclomatique, appliquer des non-conventions de nommage, éviter soigneusement les commentaires dans le code, écrire en « ASCII art », pratiquer la duplication de code… Toutes ces pratiques malheureusement trop répandues sont autant de raisons de ne pas avoir envie de maintenir un code ou un test legacy… Un test qui ne marche plus et que personne ne veut réparer est à l'agonie. Ce n'est qu'une question de temps avant que plus personne ne puisse le remettre en état. Soyez donc « fourmi » plutôt que «cigale » quand vous écrivez vos tests ! Pensez à leur avenir, à la personne qui devrait les relire, les comprendre, les mettre à jour, les corriger. Toutes les bonnes pratiques de qualité de code valables sur votre application le sont également sur les tests.
- Pensez à la lisibilité et à la clarté de votre test. Évitez toute ambiguïté ! Un test est une histoire qu'on doit pouvoir rapidement comprendre.
- Le plus simple est toujours le mieux, Keep It Simple and Stupid ! Évitez d'alourdir le code du test avec des assertions inutiles, ou des bouts de code morts.
II. Avoir des tests dépendants du temps▲
Un bon test doit être au maximum isolé du monde extérieur, ne pas dépendre de paramètres que l'on ne maîtrise pas dans son test. Écrire un test dont le comportement dépend de la date à laquelle il s'exécute peut avoir de fâcheuses conséquences ; ça n'ira pas jusqu'à une rupture du continuum espace-temps provoquant la destruction totale de l'univers, mais cela mettra bel et bien vos tests en porte à faux ! Pour illustrer cette idée, dans l'exemple suivant, que se passera-t-il si l’on exécute le test le 22 octobre 2015 ?
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
public
class
Dolorean {
public
String startTemporalConvector
(
Date destinationDate) {
if
(
destinationDate.after
(
new
Date
(
))) {
return
'future'
;
}
else
{
return
'past'
;
}
}
}
public
class
DoloreanTest {
@Test
public
void
testTravelInPast
(
) throws
ParseException {
Date psteDate =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'12/11/1955'
);
String destination =
new
Dolorean
(
).startTemporalConvector
(
psteDate);
assertEquals
(
'past'
, destination);
}
@Test
public
void
testTravelInFuture
(
) throws
ParseException {
Date futureDate =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'21/10/2015'
);
String destination =
new
Dolorean
(
).startTemporalConvector
(
futureDate);
assertEquals
(
'future'
, destination);
}
}
… le test testTravelInFuture() qui était vert jusqu'à présent passera rouge ! Pour rendre ce test insensible au temps qui passe, il faut casser cette dépendance à la date système. Il existe plusieurs façons de faire, mais le principe reste le même.
II-A. Solution 1 (à la main)▲
Notre code métier doit s'abstraire de la récupération de la date système. Nous devons être capables d'utiliser la date système dans le cas par défaut, et dans le contexte d'un test, d'utiliser une date fixe. On pourra implémenter ce comportement en passant par exemple par une Factory :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
public
class
Dolorean {
public
String startTemporalConvector
(
Date destinationDate) {
// pivot date is now configurable !
if
(
destinationDate.after
(
DateFactory.getNow
(
))) {
return
'future'
;
}
else
{
return
'past'
;
}
}
}
public
interface
DateProvider {
Date create
(
);
}
public
class
DateFactory {
public
static
DateProvider impl =
new
DateProvider
(
) {
@Override
public
Date create
(
) {
return
new
Date
(
);
}
}
;
public
static
Date getNow
(
) {
return
impl.create
(
);
}
}
public
class
DoloreanTest {
@BeforeClass
public
static
void
setupNow
(
) {
// set now = 1/1/2000
DateFactory.impl =
new
DateProvider
(
) {
@Override
public
Date create
(
) {
try
{
return
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'1/1/2000'
);
}
catch
(
ParseException e) {
return
null
;
}
}
}
;
}
@Test
public
void
testTravelInPast
(
) throws
ParseException {
Date psteDate =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'12/11/1955'
);
String destination =
new
Dolorean
(
).startTemporalConvector
(
psteDate);
assertEquals
(
'past'
, destination);
}
@Test
public
void
testTravelInFuture
(
) throws
ParseException {
Date futureDate =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'21/10/2015'
);
String destination =
new
Dolorean
(
).startTemporalConvector
(
futureDate);
assertEquals
(
'future'
, destination);
}
}
Dans le contexte de l'application, la date retournée par getNow() sera la date système, dans le test, ce sera le 1/1/2000 à chaque fois…
On pourrait par ailleurs utiliser un framework pour gérer les dates, tel que Joda time qui propose également de mocker la date système !
II-B. Solution 2 (avec un framework de mock)▲
Une autre solution élégante, en mockant avec Mockito.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
public
class
Dolorean {
public
String startTemporalConvector
(
Date destinationDate) {
// pivot date is now configurable !
if
(
destinationDate.after
(
now
(
))) {
return
'future'
;
}
else
{
return
'past'
;
}
}
Date now
(
) {
return
new
Date
(
);
}
}
@RunWith
(
MockitoJUnitRunner.class
)
public
class
DoloreanTest {
private
static
Dolorean dolorean;
@BeforeClass
public
static
void
setupNow
(
) throws
ParseException {
// setup mocked now() method on a dolorean
dolorean =
Mockito.mock
(
Dolorean.class
);
Date firstJanuary2000 =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'1/1/2000'
);
Mockito.when
(
dolorean.now
(
)).thenReturn
(
firstJanuary2000);
// do not mock startTemporalConvector()
Mockito.when
(
dolorean.startTemporalConvector
(
Mockito.any
(
Date.class
))).thenCallRealMethod
(
);
}
@Test
public
void
testTravelInPast
(
) throws
ParseException {
Date psteDate =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'12/11/1955'
);
String destination =
dolorean.startTemporalConvector
(
psteDate);
assertEquals
(
'past'
, destination);
}
@Test
public
void
testTravelInFuture
(
) throws
ParseException {
Date futureDate =
new
SimpleDateFormat
(
'dd/mm/yyyy'
).parse
(
'21/10/2015'
);
String destination =
dolorean.startTemporalConvector
(
futureDate);
assertEquals
(
'future'
, destination);
}
}
II-C. La cerise sur le gâteau▲
Nous venons de voir que mocker la date système dans vos tests vous permet de les rendre indépendants de la date de lancement (ce qui les rend pérennes). Avantage supplémentaire de ce type de mock : nous pouvons à présent tester un 3e cas de test intéressant : le cas aux limites destinationDate = today ! (Et constater ainsi qu'il reste un bug sur ce cas, où on souhaiterait ne pas démarrer inutilement le convecteur temporel ! Au prix où est le plutonium cette année…).
III. Négliger la vraie sémantique des types utilisés▲
Quand on est concentré sur l'écriture du code d'une fonctionnalité, on a parfois tendance à négliger l'implémentation de son test et à s'autoriser quelques libertés sur du code qui n'est « que » du code de test… Certains raccourcis pourraient pourtant bien s'avérer fatals pour vos tests, dans un futur plus ou moins proche ! On aura, par exemple, tendance à oublier la sémantique des types que l'on manipule. Prenons le cas de test suivant :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
public
class
Recette {
private
String nom;
public
Recette
(
String nom) {
super
(
);
this
.nom =
nom;
}
public
Set loadIngredients
(
) {
...
}
}
public
class
RecetteTest {
@Test
public
void
testLoadIngredients
(
) {
Recette recette =
new
Recette
(
'poulet au whisky'
);
Set ingredients =
recette.loadIngredients
(
);
ArrayList ingredientsList =
new
ArrayList
(
ingredients);
assertEquals
(
'olives'
, ingredientsList.get
(
0
));
assertEquals
(
'whisky'
, ingredientsList.get
(
1
));
assertEquals
(
'poulet'
, ingredientsList.get
(
2
));
}
}
Le test fonctionne, tout est vert… Jusqu'au jour où arrive un projet de migration de l'application : il est temps d'abandonner Java 5 au profit de Java 7 ! Seulement voilà, avec la version 7 du JRE, les tests échouent avec l'erreur.
2.
3.
org.junit.ComparisonFailure: expected:<
[olives]>
but was:<
[whisky]>
at org.junit.Assert.assertEquals
(
Assert.java:123
)
at jre.RecetteTest.testLoadIngredients
(
RecetteTest.java:23
)
Que s'est-il passé ? L'implémentation des assertions est depuis le début fausse ! En effet, les ingrédients chargés par loadIngredients() sont retournés dans un java.util.Set, un type représentant un ensemble au sens mathématique, dans lequel il n'existe pas de doublon, et surtout, pas d'ordre sur les éléments ! (C'est ici notre erreur puisque notre test se base sur un supposé ordre pour vérifier le contenu du java.util.Set). Ainsi, le java.util.Set ne garantissant aucun ordre, les nouvelles implémentations Java 7 pour java.util.Set (telles que java.util.HashSet) ne retournent plus les éléments dans le même ordre que la version 5, ce qui reste sémantiquement correct, mais a pour effet indésirable l'échec cuisant de notre test… Une implémentation correcte serait tout simplement :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
public
class
RecetteTest {
@Test
public
void
testLoadIngredients
(
) {
Recette recette =
new
Recette
(
'poulet au whisky'
);
Set ingredients =
recette.loadIngredients
(
);
Set expectedIngredients =
new
HashSet
(
) {{
add
(
'olives'
);
add
(
'whisky'
);
add
(
'poulet'
);
}}
;
assertEquals
(
expectedIngredients, ingredients);
}
}
Le problème rencontré ici n'a pas de lien direct avec les tests unitaires. La même faute d'inattention peut être commise dans le code applicatif, qui pourra être la cause d'un bug difficile à détecter !
IV. Jouer les gros (disques) durs▲
Parfois, il nous faut accéder au disque de la machine dans un test unitaire.
Soit dans l'implémentation même du test, pour en améliorer la lisibilité (en listant par exemple depuis un fichier plat, un jeu de données d'entrée ; ou de la même façon, un résultat de méthode qui sert de référence (XML, json, CSV…)).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
public
class
ECommerceServiceTest {
ECommerceService service =
new
ECommerceService
(
);
@Test
public
void
testFacturation
(
) throws
Exception {
// input dataset read from file system
String order =
readFile
(
'ma_commande1.csv'
);
// run code
String actualBill =
service.createBill
(
order);
// expected output read from file system
String expectedBill =
readFile
(
'ma_facture1.csv'
);
assertEquals
(
expectedBill, actualBill);
}
}
Soit parce que le code testé génère lui-même un fichier d'output sur disque (ex. : tester une classe qui produit un fichier de reporting sur disque).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
public
class
ECommerceService {
List orderList =
...
public
void
createOrdersReport
(
String outputFile) {
FileWriter w =
new
FileWriter
(
new
File
(
outputFile));
w.write
(
toCsv
(
orderList));
w.close
(
);
}
}
public
class
ECommerceServiceTest {
ECommerceService service =
new
ECommerceService
(
);
@Test
public
void
testCreateOrdersReport
(
) throws
Exception {
// invoke service dumping output on file system
String outputReport =
'orders_report.csv'
;
// run code
service.createOrdersReport
(
outputReport);
// check result from file system
String actualReport =
readFile
(
outputReport);
String expectedReport =
...
assertEquals
(
expectedReport, actualReport);
}
}
Dans les deux cas, on sera amené à résoudre les problèmes habituels liés aux fichiers sur disque…
IV-A. Les problèmes classiques liés aux systèmes de fichiers▲
Qui dit tests unitaires, dit intégration continue et forge logicielle, et donc probablement hétérogénéité des environnements et accès au système de fichiers. Mieux vaut donc être compatible si l'on ne veut pas voir ses tests virer au rouge sur un environnement autre que son poste de développement !
IV-A-1. Types de systèmes de fichiers▲
Les chemins Windows et chemins Unix étant différents, évitez au maximum tout code potentiellement dépendant de la plate-forme.
2.
3.
4.
5.
File outputFile1 =
new
File
(
'./work/out.txt'
);
// pointe vers ./work/out.txt sous Unix, et .\work\out.txt sous Windows
File outputFile2 =
new
File
(
'.
\\
work
\\
out.txt'
);
// sous Unix, pointe vers le ficher nommé 'work\out.txt' du répertoire courant !!
IV-A-2. Encodage des fichiers▲
De la même façon, l'encodage d'un fichier texte varie d'un système à un autre. Le grand classique qu'on ne présente plus est le caractère “retour chariot” (‘\n' sous Unix et ‘\r\n' sous Windows). Pour éviter toute mauvaise surprise, préférez le format Unix, car il est également correctement interprété par Windows.
IV-B. Système de fichiers et bonnes pratiques dans les tests▲
IV-B-1. Les chemins de l'enfer▲
Les chemins (path) des fichiers utilisés dans vos tests sont également source d'erreur. Assurez-vous donc que les chemins de vos fichiers sont bien valides, sous n'importe quel environnement. Évitez autant que possible les chemins absolus, qui ne sont jamais les mêmes d'une machine à une autre. Utilisez toutes les ruses possibles et imaginables pour vous abstraire des chemins absolus.
- Chemins relatifs (simples, et efficaces… KISS !) :
File inputFile =
new
File
(
'./src/test/resources/montest1.txt'
);
- À défaut, chemins absolus construits depuis une racine variabilisée, par exemple :
2.
// pointe vers /tmp/out.txt sous Unix, et c:\temp\out.txt sous Windows (ou le temp\ de l'utilisateur)
File outputFile =
new
File
(
System.getProperty
(
'java.io.tmpdir'
) +
'/out.txt'
);
Assurez-vous que les chemins utilisés existent ! Le plus prudent est encore de créer les chemins à la volée quand ils n'existent pas…
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
public
class
TestReportService {
private
static
String tmpDir =
System.getProperty
(
'java.io.tmpdir'
);
private
static
String reportPath =
'/report/'
;
private
static
File reportDir =
new
File
(
tmpDir +
reportPath);
private
ReportService reportService =
new
ReportService
(
);
@BeforeClass
public
static
void
setupPaths
(
) {
// create output dir if not exists
if
(!
reportDir.exists
(
)) {
reportDir.mkdirs
(
);
}
}
@Test
public
void
testMyReport
(
) {
String reportName =
'my_report'
;
File generatedCsv =
new
File
(
tmpDir +
reportPath +
'my_report.csv'
);
reportService.report
(
reportName, generatedCsv);
File expectedCsv =
readFile
(
'src/test/resources/expected_my_report.csv'
);
assertCsvEquals
(
expectedCsv, generatedCsv);
}
private
void
assertCsvEquals
(
File expectedCsv, File generatedCsv) {
// ...
}
private
File readFile
(
String string) {
//...
}
}
IV-B-2. Soyez propre et bien élevé▲
Par ailleurs, qui dit fichiers sur disque, dit quota… L'espace disque n'est pas illimité !!! D'où la nécessité de faire le ménage derrière soi… Ne pas contrôler l'utilisation disque faite par ses tests, c'est l'assurance d'avoir un build continu qui échouera à un moment ou à un autre, ce n'est qu'une question de temps avant de saturer l'espace disponible ! On pourra avoir plusieurs approches.
IV-B-2-a. Faire le ménage avant de partir▲
Merci de laisser l'endroit dans l'état dans lequel vous l'avez trouvé !
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
class
TestReportService {
private
static
String tmpDir =
System.getProperty
(
'java.io.tmpdir'
);
private
static
String reportPath =
'/report/'
;
private
static
File reportDir =
new
File
(
tmpDir +
reportPath);
private
ReportService reportService =
new
ReportService
(
);
@BeforeClass
public
static
void
setupPaths
(
) {
// create output dir if not exists
// ...
}
@Test
public
void
testMyReport
(
) {
// ...
}
@AfterClass
public
static
void
cleanUpFiles
(
) throws
IOException {
// remove all contained generated files and directory
FileUtils.deleteQuietly
(
reportDir);
}
}
Il est préférable d'utiliser une méthode @AfterClass (ou @After) plutôt que de faire le ménage à la fin des méthodes de tests… En effet, quelle que soit l'issue d'un test, cette méthode sera invoquée par JUnit, garantissant la propreté des lieux à votre sortie.
IV-B-2-b. Profiter d'un endroit régulièrement nettoyé par quelqu'un d'autre▲
On peut par exemple écrire ses fichiers temporaires dans le target/ de Maven, automatiquement vidé lors du clean !
IV-B-2-c. Utiliser les fichiers autodestructibles▲
Le JDK fournit une API File outillée (c'est la même que celle utilisée sur les tournages des épisodes de Mission Impossible).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
public
class
TestReportService {
private
static
String tmpDir =
System.getProperty
(
'java.io.tmpdir'
);
private
static
String reportPath =
'/report/'
;
private
static
File reportDir =
new
File
(
tmpDir +
reportPath);
private
ReportService reportService =
new
ReportService
(
);
@BeforeClass
public
static
void
setupPaths
(
) throws
IOException {
// create output dir
if
(!
reportDir.exists
(
)) {
reportDir.mkdirs
(
);
}
// auto delete folder on exit
reportDir.deleteOnExit
(
);
}
@Test
public
void
testMyReport
(
) {
String reportName =
'my_report'
;
File generatedCsv =
new
File
(
tmpDir +
reportPath +
'my_report.csv'
);
generatedCsv.deleteOnExit
(
); // auto delete generated CSV on exit
reportService.report
(
reportName, generatedCsv);
File expectedCsv =
readFile
(
'src/test/resources/expected_my_report.csv'
);
assertCsvEquals
(
expectedCsv, generatedCsv);
}
}
IV-B-2-d. Ne faites confiance à personne▲
Une dernière chose à propos des fichiers et des tests : il serait dommage de passer à côté d'une régression à cause d'un excès de confiance… Imaginons par exemple que le code de la classe ReportService ait été mal écrit (qui n'a jamais été débutant dans sa vie ?) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
public
class
ReportService {
public
void
report
(
String reportName, File outputFile) {
try
{
// ...
}
catch
(
Exception e) {
e.printStackTrace
(
);
}
return
;
}
}
L'appel de report() pourra échouer sans pour autant faire passer en rouge son test unitaire.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
public
class
TestReportService {
private
static
String tmpDir =
System.getProperty
(
'java.io.tmpdir'
);
private
static
String reportPath =
'/report/'
;
private
static
File reportDir =
new
File
(
tmpDir +
reportPath);
private
ReportService reportService =
new
ReportService
(
);
@BeforeClass
public
static
void
setupPaths
(
) throws
IOException {
// create output dir
if
(!
reportDir.exists
(
)) {
reportDir.mkdirs
(
);
}
}
@Test
public
void
testMyReport
(
) {
String reportName =
'my_report'
;
File generatedCsv =
new
File
(
tmpDir +
reportPath +
'my_report.csv'
);
reportService.report
(
reportName, generatedCsv);
// OOps !!! The report() invokation has thrown an exception and catched it silently before returning...
// ... And the report file has not been dumped !!!
File expectedCsv =
readFile
(
'src/test/resources/expected_my_report.csv'
);
assertCsvEquals
(
expectedCsv, generatedCsv);
// ... But the test is green because a previously generated my_report.Csv exists !
}
}
Pour s'éviter ce genre de piège, mieux vaut être prudent et passer un petit coup de balai avant de commencer à s'installer :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
public
class
TestReportService {
private
static
String tmpDir =
System.getProperty
(
'java.io.tmpdir'
);
private
static
String reportPath =
'/report/'
;
private
static
File reportDir =
new
File
(
tmpDir +
reportPath);
private
ReportService reportService =
new
ReportService
(
);
@BeforeClass
public
static
void
setupPaths
(
) throws
IOException {
// first, clean old generated files
FileUtils.deleteQuietly
(
reportDir);
// then recreate empty dir
reportDir.mkdirs
(
);
}
@AfterClass
public
static
void
cleanUpFiles
(
) throws
IOException {
// clean up before leaving place
FileUtils.forceDelete
(
reportDir);
}
@Test
public
void
testMyReport
(
) {
// ...
}
}
V. Décorréler l'écriture du code de celle du test▲
Si vous êtes déjà familiarisés à l'écriture des tests unitaires, vous l'aurez compris : écrire une fonctionnalité et les tests associés sont 2 choses intimement liées :
- le test unitaire dépend évidement du code testé ;
- mais la fonctionnalité doit également être écrite de manière à pouvoir être testée par une classe de test ! Qui ne s'est jamais retrouvé obligé de refactoriser du code pour pouvoir « brancher » son test unitaire ?
Ce constat est probablement la raison d'exister des pratiques dites « Test First » ou « Test Driven Development » (écrire les tests avant/pendant la fonctionnalité). Même les détracteurs de ces pratiques s'accorderont à dire qu'il est assez exceptionnel d'écrire a posteriori un test sur un code legacy sans avoir besoin de refactoriser celui-ci un minimum… Alors que se passe-t-il si j'organise mon planning de travail comme suit : la première semaine, j'écris du code, la semaine suivante, j'écris les tests ?
- Je risque de commiter du code non testé (donc buggé), qui pourra être utilisé par mes camarades, et leur faire perdre un temps précieux à cause de bugs dont je suis l'auteur.
- Je vais devoir me remémorer le contexte de chaque fonctionnalité au moment où je me décide à écrire son test. Ce travail est un coût supplémentaire que j'ai pourtant déjà payé une première fois pour développer la fonctionnalité. Je risque, de plus, d'être moins pertinent dans mes cas de tests, en oubliant par exemple les cas aux limites, qui me sont sortis de l'esprit.
- Par ailleurs, le refactoring nécessaire pour écrire mon test sera beaucoup plus lourd, car mon code est probablement déjà utilisé un peu partout.
Un dernier cas de figure sujet à polémique, dans lequel tout le monde se reconnaîtra : le cas de la « deadline » intenable. La stratégie habituellement suivie est la suivante :
- Je code la fonctionnalité ;
- Je teste rapidement « à la main » ;
- Je livre en UAT/Prod ;
- Après le rush de la mise en production, je peux prendre le temps pour finalement écrire mes tests unitaires.
Les objectifs seront en apparence remplis, mais il ne faudra pas perdre de vue le coût du compromis.
- Comme dit précédemment, le coût d'écriture des tests a posteriori sera plus important, et la qualité de la couverture risque d'être moins bonne.
- Il va probablement falloir refactoriser du code déjà validé et parti en production, pour pouvoir écrire les tests. Cela impliquera de devoir valider à nouveau le code refactorisé, pour s'assurer de la non-régression.
VI. Sortez couverts !▲
Alors voilà, ça y est, après pas mal d'efforts, vous disposez dans votre projet d'un bon paquet de tests unitaires : 95% de vos classes « intelligentes» ont leur classe de test associée ; de quoi être serein face aux évolutions à venir ! Pas convaincu ?! Et pour cause, il s'agit ici d'un indicateur assez naïf de couverture de tests.
Pour être réellement « couvert », il vous faudra bien entendu enrichir chacune de vos classes de tests de cas de tests judicieux, afin de couvrir la plus grande partie des chemins d'exécution possibles…
Prenons un exemple : voici un programme de simulation d'attaque « zombie » ; il y a d'un côté les zombies (à l'humour assez mordant), et de l'autre, une population saine, que ça ne fait pas rire du tout : chaque personne contaminée passe à son tour dans la catégorie zombie. On considère qu'en un jour, un seul zombie peut contaminer 10 personnes saines.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
public
class
ZombieWarSimulator {
/**
*
@param
initialPeople
initial number of healthy people
*
@param
initialZombies
initial number of zombies
*
@param
days
defining the simulation duration
*
@return
new number of zombies after the number of days
*/
public
static
int
getZombieNumberAfterDays
(
int
initialPeople, int
initialZombies, int
days) {
int
currentPeople =
initialPeople;
int
currentZombies =
initialZombies;
for
(
int
day =
0
; day <
days; day++
) {
// each day a zombie contaminate 10 people
int
newZombies =
Math.min
(
currentZombies *
10
, currentPeople);
currentZombies =
currentZombies +
newZombies;
currentPeople =
currentPeople -
newZombies;
}
return
currentZombies;
}
}
public
class
ZombieWarSimulatorTest {
@Test
public
void
simulateZDay
(
) {
// 3 days after 1 initial zombie
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
10000
, 1
, 3
);
assertThat
(
newZombies).isEqualTo
(
1331
);
}
}
Notre test est correct et valide le cas général : 3 jours après l'apparition du patient « 0 », on compte 1331 personnes infectées. Mais le code fonctionne-t-il dans les cas suivants :
- apparition simultanée de plusieurs patients « 0 » ;
- pas de patient « 0 » ;
- population entièrement contaminée ;
- cas du jour « Z » ;
- etc.
Pour avoir une bonne couverture de code, il nous faudra ajouter les cas aux limites à ce cas général.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
public
class
ZombieWarSimulatorTest {
@Test
public
void
simulateZDay
(
) {
// 3 days after 1 initial zombie
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
10000
, 1
, 3
);
assertThat
(
newZombies).isEqualTo
(
1331
);
}
@Test
public
void
simulateZDay2Zombies
(
) {
// 3 days after 2 initial zombies
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
10000
, 2
, 3
);
assertThat
(
newZombies).isEqualTo
(
2662
);
}
@Test
public
void
simulateNoZombie
(
) {
// no zombie
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
10000
, 0
, 3
);
assertThat
(
newZombies).isEqualTo
(
0
);
}
@Test
public
void
simulate28DaysLater
(
) {
// 28 days after 1 initial zombie
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
10000
, 1
, 28
);
assertThat
(
newZombies).isEqualTo
(
10001
);
}
@Test
public
void
simulateBeforeZDay
(
) {
// 1 zombie and no contamination days
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
10000
, 1
, 0
);
assertThat
(
newZombies).isEqualTo
(
1
);
}
}
Voilà qui est beaucoup mieux ! Mieux, mais pas ultime. En effet, notre test en l'état manque d'élégance : nous avons beaucoup de duplication de code.
Heureusement, les frameworks de test fournissent aujourd'hui des solutions de tests paramétrables, telles que JUnitParams que j'utiliserai ici (ou encore le runner JUnit Parameterized, ou les Parameters de TestNG) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
@RunWith
(
JUnitParamsRunner.class
)
public
class
ZombieWarSimulatorTest {
@Test
@Parameters
(
value =
{
"10000, 1, 3, 1331"
, // 3 days after 1 initial zombie in 10000 people
"10000, 2, 3, 2662"
, // 3 days after 2 initial zombies in 10000 people
"10000, 0, 3, 0"
, // no initial zombies in 10000 people
"10000, 1, 28, 10001"
, // 28 days after 1 initial zombie in 10000 people
"10000, 1, 0, 1"
// 1 zombie and no contamination days in 10000 people
}
)
public
void
simulateZDay
(
int
initialPeople, int
initialZombies, int
days, int
finalZombies) {
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
initialPeople, initialZombies, days);
assertThat
(
newZombies).isEqualTo
(
finalZombies);
}
}
Convaincu ? C'en est tellement simple que l'on peut même « blinder » notre code, en étendant encore davantage notre couverture avec des cas de tests supplémentaires, pour pas plus cher !
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
@RunWith
(
JUnitParamsRunner.class
)
public
class
ZombieWarSimulatorTest {
@Test
@Parameters
(
value =
{
"10000, 1, 3, 1331"
, // 3 days after 1 initial zombie in 10000 people
"100000, 1, 4, 14641"
, // 4 days after 1 initial zombie in 100000 people
"1000000, 1, 5, 161051"
, // 5 days after 1 initial zombie in 1000000 people
"10000, 2, 3, 2662"
, // 3 days after 2 initial zombies in 10000 people
"100000, 2, 4, 29282"
, // 4 days after 2 initial zombies in 100000 people
"1000000, 2, 5, 322102"
, // 5 days after 2 initial zombies in 1000000 people
"10000, 3, 3, 3993"
, // 3 days after 3 initial zombies in 10000 people
"100000, 3, 4, 43923"
, // 4 days after 3 initial zombies in 100000 people
"1000000, 3, 5, 483153"
, // 5 days after 3 initial zombies in 1000000 people
"10000, 0, 3, 0"
, // no initial zombies, 3 days later
"10000, 0, 4, 0"
, // no initial zombies, 4 days later
"10000, 0, 5, 0"
, // no initial zombies, 5 days later
"10000, 1, 28, 10001"
, // 28 days after 1 initial zombie in 10000 people
"10000, 1, 280, 10001"
, // 280 days after 1 initial zombie in 10000 people
"10000, 1, 2800, 10001"
, // 2800 days after 1 initial zombie in 10000 people
"10000, 1, 0, 1"
, // 1 zombie and no contamination days
"10000, 2, 0, 2"
, // 2 zombies and no contamination days
"10000, 3, 0, 3"
// 3 zombies and no contamination days
}
)
public
void
simulateZDay
(
int
initialPeople, int
initialZombies, int
days, int
finalZombies) {
int
newZombies =
ZombieWarSimulator.getZombieNumberAfterDays
(
initialPeople, initialZombies, days);
assertThat
(
newZombies).isEqualTo
(
finalZombies);
}
}
Formidable non ? Pas si sûr… En effet, les TU c'est bien, en abuser ça craint. Nous venons ici d'ajouter des cas de tests supplémentaires qui n'ont pas augmenté notre couverture de code : en effet, ils sont redondants avec les cas précédents, ils n'apportent donc rien de plus. La situation est même pire que ça.
- Le coût récurrent de maintenance de ce test a été augmenté (si je change l'implémentation de getZombieNumberAfterDays(), je dois mettre à jour d'autant plus de valeurs de référence de « newZombies »).
- Le test a perdu en clarté : les cas de test intéressants qu'il serait judicieux de mettre en lumière sont noyés dans un brouhaha de tests identiques.
- Le temps d'exécution de mes tests est rallongé.
VII. Plus c'est long, plus c'est bon…▲
Comme nous venons de le voir, avoir une armada de tests unitaires sur son application, c'est très rassurant et sécurisant ; ça laisse supposer que l'on bénéficie d'une bonne couverture de tests qui en cas de régression s'avérera salvatrice. « C'est pas faux ! » diront certains… Mais si ça implique d'avoir des builds qui tournent pendant des plombes, cela risque fort d'être très mauvais pour la productivité et la réactivité de l'équipe de développement…
VII-A. Conséquences▲
Premièrement, les développeurs impatients ne prennent plus le temps de lancer tous les tests avant de commiter leur code : augmentation de la fréquence des régressions sur le repository des sources, pouvant impacter toute l'équipe, et bloquer jusqu'à l'ensemble des développements en cours.
Par ailleurs, un commit « toxique» ne sera pointé du doigt par le build continu que trop tardivement ! La correction va nécessiter un effort de remise dans le contexte de la part du développeur, qui a eu largement le temps de commencer un autre développement (qu'il va devoir mettre en pause, voire peut-être détricoter pour s'atteler à la correction du build).
Enfin, livrer un patch de production en urgence prend beaucoup plus de temps : pour construire le livrable, il faudra choisir entre :
- faire un « skip » des tests (et livrer une version packagée potentiellement buggée) ;
- ou bien faire un build complet avec les tests, mais par conséquent, déployer le patch avec d'autant plus de délais.
Un temps global d'exécution trop long de vos tests nuira donc à leur bonne santé : il découragera les développeurs de les entretenir, et peut aboutir à terme à leur désactivation.
VII-B. Alors que faire ?▲
VII-B-1. La question de la couverture▲
Déjà, posez-vous la question : la couverture de tests est-elle pertinente ?
- N'y a-t-il pas des doublons dans les classes/méthodes de tests ? Peut-on en supprimer ? Ou refactorisé ?
- N'y a-t-il pas des morceaux de code surtestés (faut-il vraiment tester « apache-commons » et « log4j », parce que vous les utilisez dans votre application) ?
- N'y a-t-il pas même des tests coûteux et apportant peu de valeur ajoutée dont on peut se passer sans grand dommage ?
- Et surtout, peut-on alléger les jeux de données d'entrée ? Ne contiennent-ils pas des redondances ? (ce qui diminuera le volume de données à traiter et donc les temps d'exécution)
VII-B-2. Revue de l'architecture▲
Un build trop long n'est-il pas le signe d'un mauvais découpage applicatif (projet monolithique ?) À défaut de raccourcir les temps d'exécution des tests, on doit pouvoir faire des builds partiels, afin d'éviter de lancer systématiquement tous les tests (en particulier ceux qui n'ont pas de rapport avec le code modifié).
On peut donc tenter de redécouper le projet en plusieurs artefacts, ou sous-modules Maven. On dissocie ainsi en plusieurs builds (en définissant des interfaces claires entre modules, on doit pouvoir travailler sur un seul module à la fois).
VII-B-3. Raffiner autrement les builds▲
Une solution alternative, qui nous évitera ce travail de découpage, consistera à ségréguer les tests par catégories pour n'en exécuter qu'une partie. On pourra ensuite utiliser les profils Maven pour définir des groupes de tests à exécuter.
VII-B-4. Optimisations▲
Enfin, on pourra éventuellement chercher à optimiser les tests comme on optimise une application (optimisation d'algorithmes, factorisation, parallélisme…).
VIII. Accélérer les tests en parallélisant▲
Comme nous venons de le voir, une des actions possibles pour lutter contre des tests unitaires trop longs, c'est de les optimiser, notamment en profitant des architectures multicœurs des machines modernes afin de les exécuter en parallèle.
VIII-A. Le geste technique▲
Il existe désormais des Testrunners capables d'exécuter les tests unitaires en parallèle. Je prendrai, pour illustrer mes propos, l'exemple du plugin Maven Surefire qui offre des options de parallélisation :
- en exécutant les tests en parallèle sur un pool de threads ;
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
<plugins>
[...]
<plugin>
<groupId>
org.apache.maven.plugins</groupId>
<artifactId>
maven-surefire-plugin</artifactId>
<version>
2.16</version>
<configuration>
<!-- make tests classes & methods runnable in parallel -->
<parallel>
both</parallel>
<!-- use a 10 thread thread pool -->
<threadCount>
10</threadCount>
</configuration>
</plugin>
[...]
</plugins>
- ou en forkant l'exécution des tests sur plusieurs process en parallèle.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
<plugins>
[...]
<plugin>
<groupId>
org.apache.maven.plugins</groupId>
<artifactId>
maven-surefire-plugin</artifactId>
<version>
2.16</version>
<configuration>
<!-- run tests on a pool of 3 concurrent process -->
<forkCount>
3</forkCount>
<!-- use a pool of JVMs -->
<reuseForks>
true</reuseForks>
</configuration>
</plugin>
[...]
</plugins>
VIII-B. Le 2e effet Kiss Cool▲
Ces optimisations (par multithreading ou forking) sont en apparence séduisantes, car non intrusives par rapport au code (les seules modifications se situent dans la configuration de Surefire dans le pom.xml). Dans la pratique, les choses ne sont pas si simples. Eh oui, qui dit parallélisme dit problèmes de race conditions, deadlocks, et autres joyeusetés concurrentes ! Voici pêle-mêle, quelques exemples de problèmes que vous serez susceptibles de rencontrer…
VIII-B-1. Quand les tests couvrent du code non thread-safe▲
Le problème n°1, comment peut-on tester du code non thread-safe dans un contexte d'exécution de tests que l'on souhaite multithreadé ? Prenons l'exemple suivant d'un cache (certes primitif et naïf je vous l'accorde, mais néanmoins parfait pour cet exemple) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
public
interface
Cache {
Object get
(
int
id);
int
size
(
);
void
flush
(
);
}
//not thread safe impl
public
class
CacheImpl implements
Cache {
Map<
Integer, Object>
cache =
new
HashMap<
Integer, Object>(
);
int
size =
0
;
public
Object get
(
int
id) {
if
(
cache.get
(
id) ==
null
) {
cache.put
(
id, load
(
id));
size++
;
}
return
cache.get
(
id);
}
protected
Object load
(
int
id) {
// load from DB...
}
void
flush
(
) {
cache =
new
HashMap<
Integer, Object>(
);
size =
0
;
}
public
int
size
(
) {
return
size;
}
}
public
class
CacheTest {
private
static
Cache cache =
new
CacheImpl
(
);
@Test
public
void
testLoadFromCache
(
) throws
Exception {
cache.flush
(
);
cache.get
(
1
);
assertEquals
(
"1 object should be in cache"
, 1
, cache.size
(
));
}
@Test
public
void
testLoad2FromCache
(
) throws
Exception {
cache.flush
(
);
cache.get
(
1
);
cache.get
(
2
);
assertEquals
(
"2 objects should be in cache"
, 2
, cache.size
(
);
}
}
Les tests fonctionnent correctement en mode séquentiel. Néanmoins, lorsque l'on active le mode « parallel » de Surefire, nos tests échouent :
2.
3.
Failed tests:
CacheTest.testLoad2FromCache:25
2
objects should be in cache expected:<
2
>
but was:<
3
>
CacheTest.testLoadFromCache:18
1
object should be in cache expected:<
1
>
but was:<
3
>
On ne peut cependant pas en vouloir au code de CacheImpl.java(cette implémentation n'est pas supposée thread-safe). C'est ici le test runner qui ne respecte plus le contrat d'utilisation de notre cache.
VIII-B-1-a. Option n°1 : Rendre le code thread-safe ?▲
Pourquoi ne pas modifier CacheImpl pour le rendre thread-safe ? Ce sera fait sans grande difficulté. Mais est-ce malgré tout bien raisonnable ? Changer du code métier, juste pour accélérer les tests, avec toutes les conséquences que cela peut entraîner sur la « prod » (risque de deadlocks, impact sur les performances) ?
VIII-B-1-b. Option n°2 : Rendre le test thread-safe ?▲
Et sinon, ne peut-on pas tout simplement rendre le test CacheTest lui-même thread-safe ?
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
public
class
CacheTest {
private
static
Cache cache =
new
CacheImpl
(
);
@Test
public
void
testLoadFromCache
(
) throws
Exception {
int
size =
0
;
synchronized
(
cache) {
cache.flush
(
);
cache.get
(
1
);
size =
cache.size
(
);
}
assertEquals
(
"1 object should be in cache"
, 1
, size);
}
@Test
public
void
testLoad2FromCache
(
) throws
Exception {
int
size =
0
;
synchronized
(
cache) {
cache.flush
(
);
cache.get
(
1
);
cache.get
(
2
);
size =
cache.size
(
);
}
assertEquals
(
"2 objects should be in cache"
, 2
, cache.size
(
));
}
}
« Super ! » me direz-vous, on a corrigé notre problème de concurrence, sans modifier le code du cache (le test repasse au vert) !
Mais encore une fois, est-ce bien raisonnable ? Nous venons de « réséquentialiser » les tests qu'on essayait de « paralléliser » ; on a rendu le code du test moins maintenable, pour un gain de performance nul : retour à la case départ.
VIII-B-1-c. Option n°3 : forker les JVM pour passer les tests ?▲
Enfin, on peut paralléliser autrement que par multithreading : en forkant l'exécution des tests sur plusieurs JVM, et ainsi éviter l'accès concurrent sur la même instance du cache. C'est probablement ici la solution à privilégier.
VIII-B-2. Quand les tests eux-mêmes sont non thread-safe▲
Ce cas de figure est, pour ainsi dire, le « cas général ». En effet, le code des tests unitaires a en général été écrit (et c'est bien légitime) sans préoccupation concurrentielle… Il sera donc souvent exposé à des problèmes liés à l'exécution en parallèle.
VIII-B-2-a. État partagé▲
Pour illustrer ce propos, prenons une implémentation thread-safe de notre cache (celle-ci est peut-être naïve et peu performante, mais suffisante pour notre exemple ;-)) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
public
interface
Cache {
Object get
(
int
id);
void
flush
(
);
int
size
(
);
}
// thread-safe cache impl
public
class
CacheImpl implements
Cache {
Map<
Integer, Object>
cache =
new
HashMap<
Integer, Object>(
);
int
size =
0
;
public
synchronized
Object get
(
int
id) {
if
(
cache.get
(
id) ==
null
) {
cache.put
(
id, load
(
id));
size++
;
}
return
cache.get
(
id);
}
protected
Object load
(
int
id) {
// load from DB...
}
public
synchronized
void
flush
(
) {
cache =
new
HashMap<
Integer, Object>(
);
size =
0
;
}
public
synchronized
int
size
(
) {
return
size;
}
}
Voici un test unitaire legacy associé, qui compte les appels à la base :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
public
class
CacheSpyImpl extends
CacheImpl implements
Cache {
public
static
int
loadNumber =
0
;
@Override
protected
Object load
(
int
id) {
loadNumber++
;
return
super
.load
(
id);
}
}
public
class
CacheTest {
private
Cache cache =
new
CacheSpyImpl
(
);
@Before
public
void
initCache
(
) {
CacheSpyImpl.loadNumber =
0
;
cache.flush
(
);
}
@Test
public
void
testLoadFromCache
(
) throws
Exception {
cache.get
(
1
);
assertEquals
(
"The object should have been loaded from DB"
, 1
, CacheSpyImpl.loadNumber);
}
@Test
public
void
testReloadFromCache
(
) throws
Exception {
cache.get
(
1
);
assertEquals
(
"The cache should have been loaded from DB"
, 1
, C acheSpyImpl.loadNumber);
// get from cache
cache.get
(
1
);
assertEquals
(
"The object should be in cache"
, 1
, CacheSpyImpl.loadNumber);
}
}
Ce test, qui fonctionnait parfaitement en séquentiel, passe à présent en rouge lorsque l'on active le mode parallel de Surefire :
2.
3.
Failed tests:
CacheTest.testReloadFromCache:29
The cache should have been loaded from DB expected:<
1
>
but was:<
2
>
CacheTest.testLoadFromCache:23
The object should have been loaded from DB expected:<
1
>
but was:<
2
>
On comprend aisément la difficulté qui apparaît ici (malgré une implémentation de cache thread-safe) : quand les méthodes du test (lui, non thread-safe !) s'exécutent en parallèle, le compteur d'appels loadNumber static de la classe CacheSpyImpl est incrémenté en « même temps » par les 2 threads de tests ; sa valeur devient donc totalement « incohérente » (sans parler des possibles problèmes d'inconsistance mémoire). Le code de test s'appuyant sur un état static (ce serait pareil avec un singleton) est donc à bannir dans un environnement multithreadé.
Il est intéressant de noter une alternative salvatrice dans notre cas : le mode fork de Surefire devrait nous éviter ce genre de « collision » : on aura une classe CacheSpyImpl chargée par JVM, et donc plus de partage d'état. Attention cependant à ne pas « pooler » les JVM ! (reuseFork=true)
VIII-B-2-b. Race conditions : le retour de la vengeance▲
Nous venons de voir que partager un registre mémoire commun peut aboutir à des race conditions aboutissant à l'échec de nos tests. Plus globalement, ce genre de situation peut dériver de n'importe quel partage de ressource. Prenons cet autre exemple :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
public
class
FnocServiceTest {
String outputReport =
"output.csv"
;
FnocService service =
new
FnocService
(
);
@Test
public
void
testCreateOrdersReport
(
) throws
Exception {
// create orders report
dumpReeport
(
new
Order
(
"Best of Star Acadebide"
), outputReport);
// check result from file system
String actualReport =
readFile
(
outputReport);
String expectedReport =
readFile
(
"src/test/resources/order_report_cd.csv"
);
assertEquals
(
expectedReport, actualReport);
}
@Test
public
void
testCreateOrdersReport
(
) throws
Exception {
// create orders report
service.dumpReeport
(
new
Order
(
"Walking Beer"
, "World Bar Z"
), outputReport);
// check result from file system
String actualReport =
readFile
(
outputReport);
String expectedReport =
readFile
(
"src/test/resources/order_report_bluray.csv"
);
assertEquals
(
expectedReport, actualReport);
}
}
Ce test qui fonctionne parfaitement en exécution séquentielle, vire au rouge dès que l'on active le mode parallel de Surefire ; pire même : le mode fork cette fois-ci ne nous sauve pas la mise ! En effet, la ressource partagée est ici le fichier output.csv, accédé en écriture de manière concurrente par les deux tests (pas de « collision mémoire », exit la solution du fork).
Et pourtant, la situation n'est pas si dramatique : il suffit en effet d'éviter les « collisions » de noms de fichiers pour que tout rentre dans l'ordre :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
public
class
FnocServiceTest {
FnocService service =
new
FnocService
(
);
@Test
public
void
testOrdersReportCD
(
) throws
Exception {
// generate unique file name e.g /tmp/out~7549426430997112982.csv
String outFile =
File.createTempFile
(
"out~"
, ".csv"
).getAbsolutePath
(
);
// create orders report
service.dumpReeport
(
new
Order
(
"Best of Star Acadebide"
), outFile);
// check result from file system
String actualReport =
readFile
(
outFile);
String expectedReport =
readFile
(
"src/test/resources/order_report_cd.csv"
);
assertEquals
(
expectedReport, actualReport);
}
@Test
public
void
testOrdersReportBluray
(
) throws
Exception {
//generate unique file name e.g /tmp/out~54659864654654.csv
String outFile =
File.createTempFile
(
"out~"
, ".csv"
).getAbsolutePath
(
);
// create orders report
service.dumpReeport
(
new
Order
(
"Walking Beer"
, "World Bar Z"
), outFile);
// check result from file system
String actualReport =
readFile
(
outFile);
String expectedReport =
readFile
(
"src/test/resources/order_report_bluray.csv"
);
assertEquals
(
expectedReport, actualReport);
}
}
On rencontrera la même problématique avec d'autres types de ressources partagées :
- les répertoires (que se passe-t-il si on crée/supprime dans une méthode @Before/@After un répertoire de dump utilisé dans testOrdersReportCD() et testOrdersReportBluray() ?) ;
- les sockets et les numéros de port ;
- ou encore, avec des données en base.
VIII-B-3. Pour conclure sur la parallélisation▲
Comme nous venons de voir à travers ces quelques exemples, multithreader les tests crée bien souvent de nouvelles « dépendances » entre les cas de tests, qui n'existaient pas en exécution séquentielle :
- partage d'une zone mémoire commune (état d'une classe, d'un singleton) ;
- partage d'une ressource extérieure commune (fichier, répertoire, socket).
Ces « collisions » engendrées par la parallélisation nécessitent de mettre en place quelques adaptations. Plusieurs options s'offrent à nous :
- corriger le test, mais cela n'est pas toujours possible, et revient parfois à reséquentialiser les tests parallélisés ;
- corriger le code, mais rendre du code thread-safe (pour pouvoir paralléliser les tests), c'est le complexifier. Attention donc à la maintenance, qui en sera ensuite plus coûteuse. Attention également aux incidents de production, en cas de race conditions !
- forker les JVM. Certains problèmes d'accès concurrents ne seront toutefois pas résolus par cette solution. Par ailleurs, cela peut engendrer d'autres problèmes (utilisation mémoire).
Quelle que soit la stratégie choisie, ces adaptations ne sont ni anodines, ni gratuites :
- rendre thread-safe le code ou les tests nécessite du temps de mise en place ;
- ce code sera plus complexe, donc plus coûteux à maintenir ;
- les tests rendus parallèles seront moins robustes (il est toujours difficile de comprendre, reproduire, et diagnostiquer un problème d'accès concurrent) ;
- les futurs tests seront également un peu plus coûteux à écrire, car ils devront eux aussi être thread-safe.
En conclusion, paralléliser les tests raccourcira bien le temps de build, mais le prix à payer sera un surcoût sur l'écriture et la maintenance des tests ! Alors, êtes-vous prêt à payer ce prix ?
VIII-B-4. Derniers conseils pour la route▲
Même si on réussira globalement à paralléliser les tests sur un projet, il sera intéressant, voire impératif, de se garder un mode de fonctionnement dégradé (exécution classique non parallèle des tests) :
- afin de diagnostiquer les tests en échec à cause de la parallélisation ;
- pour ne pas rester bloqué sur un build, dans les périodes critiques (build de release, patch en urgence) ;
- enfin, il restera bien souvent quelques cas de tests particuliers qui seront trop coûteux ou difficiles à rendre thread-safe. Il serait dommage de devoir renoncer à la parallélisation de l'ensemble des tests, à cause d'un ou deux cas pathologiques isolés !
VIII-B-4-a. Implémentation▲
On pourra utiliser les Categories JUnit pour ségréguer nos tests unitaires thread-safe et non thread-safe. Il suffit pour cela de déclarer une category spéciale pour les tests non thread-safe :
2.
3.
4.
/**
* A JUnit4 Category for test that need to be executed in a monothread environnemnet (disable parallelisation of tests execution)
*/
public
interface
SequentialTestCategory {
}
On décore ensuite les tests non thread-safe pathologiques :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
@Category
(
SequentialTestCategory.class
)
public
class
TestComplicatedAndNotThreadSafeCode {
public
void
testUnthreadsafeMethod1
(
) {
...
}
public
void
testUnthreadsafeMethod2
(
) {
...
}
}
Enfin, on déclare deux configurations de Surefire dans le pom.xml du projet :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
<properties>
<test.parallel>
both</test.parallel>
</properties>
...
<plugin>
<groupId>
org.apache.maven.plugins</groupId>
<artifactId>
maven-surefire-plugin</artifactId>
<executions>
<execution>
<!-- thread safe tests -->
<id>
default-test</id>
<phase>
test</phase>
<goals>
<goal>
test</goal>
</goals>
<configuration>
<excludedGroups>
SequentialTestCategory</excludedGroups>
<parallel>
${test.parallel}</parallel>
<perCoreThreadCount>
true</perCoreThreadCount>
<threadCount>
10</threadCount>
</configuration>
</execution>
<execution>
<!-- thread unsafe tests -->
<id>
sequential-tests</id>
<phase>
test</phase>
<goals>
<goal>
test</goal>
</goals>
<configuration>
<groups>
SequentialTestCategory</groups>
<parallel>
none</parallel>
</configuration>
</execution>
</executions>
</plugin>
...
Les tests non thread-safe seront alors exécutés à part sur un seul thread, évitant les problèmes de concurrence pour ceux-ci !
Enfin, en cas de besoin, on pourra globalement débrayer le mode multithreadé pour tous les tests en surchargeant la variable « test.parallel » :
/
$ mvn test -
Dtest.parallel=
none
VIII-B-5. Le coût de la simplicité▲
Une dernière solution d'optimisation s'offre à nous aujourd'hui, en cadeau dans chaque boite de Maven 3 ; eh oui, cette dernière version propose nativement des builds en parallèle pour un projet modulaire : chaque module indépendant peut en effet être buildé en même temps, puisqu'ils ne dépendent pas les uns des autres. Cette solution offre par ailleurs les avantages suivants :
- mise en œuvre gratuite, car native, on profite juste du découpage modulaire ;
- beaucoup moins de risques de race conditions, puisqu’on teste en parallèle des modules indépendants et non des classes/méthodes ;
- une migration de Maven 2 à Maven 3 est peu coûteuse (rien à voir avec une migration Maven 1 vers Maven 2 !).
Cette solution est probablement celle qui offre le meilleur « ROI », même si le gain à la clé est moindre que la parallélisation par Surefire, son coût de mise en place et de maintenance est très intéressant !
IX. La grenouille qui se prenait pour un bœuf▲
À présent, voici l'histoire de Bob. Qui est Bob ? Un développeur lambda plus vraiment junior, pas vraiment senior, Bob c'est vous, c'est moi. À travers ses expériences passées, Bob a fini par comprendre (certes dans la douleur), pourquoi tester son code. Il est aujourd'hui parfaitement convaincu de l'utilité des tests, la difficulté résidant à présent dans « l'art et la manière »
Bob travaille actuellement sur le projet« Pwetter », un système de microblogage très prometteur. Il vient de développer une nouvelle fonctionnalité, permettant de rechercher les « Pwets » postés contenant certains mots-clés. Bien décidé à mettre en pratique les bons principes, il ajoute dans le projet la classe de test unitaire suivante :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
public
class
PwetterSearchTest {
@BeforeClass
public
void
static
startup
(
) {
PwetServer.connect
(
"mongodb://dbdev1.pwetter.net,dbdev2.pwetter.net:2500/?replicaSet=test"
);
}
@AfterClass
public
void
static
shutdown
(
) {
PwetServer.disconnect
(
);
}
@Test
public
void
testSearch
(
) throws
Exception {
// Arrange
PwetService api =
PwetServer.getServiceInterface
(
);
api.post
(
"Hey people ! So@t rocks !!!"
);
api.post
(
"Be smart, enjoy coding"
);
api.post
(
"So@t, what else ?"
);
// Act
List<
Pwet>
pwets =
api.search
(
new
KeywordFilter
(
"So@t"
));
// Assert
assertThat
(
pwets).containsExactly
(
"Hey people ! So@t rocks !!"
, "So@t, what else ?"
);
}
}
Bob exécute son test, tout fonctionne. Et pourtant, ce n'était peut-être pas une bonne idée. Mais il est où le problème, me direz-vous ?
Même s'il faut accorder à Bob que :
- le test unitaire fonctionne ;
- il couvre une fonctionnalité importante ;
- il valide l'algorithme de recherche, la persistance, et l'intégration réussie dans le reste du projet.
… Il n'a d'unitaire que le nom, et ressemble furieusement à un « test d'intégration » :
- il teste une fonctionnalité entière, qui s'appuie sur plusieurs « briques logicielles », qui couvre une grande partie de code, et dans un contexte « intégré » avec le reste du code ;
- il dépend de la disponibilité de la « base » ;
- il peut être assez long à s'exécuter.
En outre, un test d'intégration ne remplace pas les tests unitaires du code sous-jacent, mais il en est complémentaire. Un test unitaire trop gourmand (qui couvre beaucoup de code dans un seul test) :
- sera fragile (la moindre évolution du code le fera virer au rouge) ;
- sera très difficile à maintenir (il ne sera pas aisé de trouver l'origine du problème dans un périmètre aussi vaste !) ;
- nous donnera finalement une faible couverture de code (dans un use case fonctionnel, on ne teste pas les n cas aux limites des briques logicielles sous-jacentes).
Ce test a malgré tout une certaine valeur, mais il n'a pas sa place parmi les tests unitaires. Il doit en être isolé (avec les autres « tests d'intégration »), afin de pouvoir être exécuté de manière dissociée des vrais tests unitaires. Ainsi, les vrais tests unitaires ne seront pas pénalisés par sa fragilité !
X. Faux-positifs▲
Qui dans sa vie de développeur n'a jamais été confronté au fameux test, qui des fois passe, des fois échoue ? Nous venons de le voir, les raisons possibles sont multiples :
- bugs d'accès concurrents ;
- logique métier testée intrinsèquement non déterministe ;
- problèmes aléatoires de dépassement de mémoire (OutOfMemory) ;
- indisponibilité temporaire du réseau ;
- indisponibilité temporaire de la base ;
- espace disque insuffisant ;
- partage d'une ressource commune non stable (telle qu'un composant tiers extérieur) ;
- utilisation d'une dépendance SNAPSHOT.
Quelle qu'en soit la cause, ces tests en échec sur le build continu, qui mystérieusement se réparent tout seuls viennent briser votre concentration, et parasiter votre tâche en cours, et émoussent irrémédiablement votre vigilance : c'est l'histoire du jeune garçon qui criait « au loup », ces faux positifs vous amènent à ne plus prêter attention aux builds en échec sur la forge logicielle… Ce « bruit » implique des retards sur la correction des véritables régressions, noyées dans la masse, ce qui aura pour conséquences :
- l'augmentation du coût de maintenance des tests (plus une correction est faite tardivement, plus elle est difficile, plus elle coûte cher) ;
- la généralisation du « bypass » des tests de la part les développeurs.
Cela peut aboutir à la désactivation partielle, voire globale des tests unitaires par un développeur exaspéré (voilà, les TU sont morts, « game over ») !
Ces faux positifs sont une plaie, parfois trop souvent pris à la légère, qu'il faut à tout prix régler au plus tôt :
- en corrigeant le problème, si c'est corrigeable ;
- à défaut, en isolant en quarantaine les tests pathologiques (en les catégorisant par exemple), et ainsi éviter les faux positifs à répétition sur le continuous build (en attendant une réelle correction, les tests pathologiques seront lancés avec une fréquence moindre, et uniquement dans un contexte dédié, sous contrôle d'un développeur averti) ;
- dans le pire des cas et faute de mieux, en désactivant/supprimant le test incriminé, qui finalement fait plus de mal que de bien (la question à se poser à ce moment-là est bel est bien : « le soldat Ryan mérite-t-il d'être sauvé ? »).
XI. Le mot de la fin▲
Nous avons passé en revue des problématiques que l'on rencontre classiquement quand on écrit et maintient des tests unitaires automatisés… Même si certains problèmes ont des solutions, et ne demandent qu'un peu d'expérience ou d'astuce pour être résolus, d'autres en revanche n'ont pas de solution “miracle”, et appellent au compromis, et nous amèneront à nous poser la question : quel est le coût de mise en place, le coût de maintenance, et le gain à la clé ?
XI-A. Actif▲
Quel est l'apport des tests unitaires ?
- la qualité de l'application : une production qui va mieux (moins de bugs, et moins de régressions, donc moins de support et de bug fixing !) ;
-
la qualité du code (une diminution de la dette technique et du coût de maintenance) :
- un meilleur design de code induit par sa testabilité, un contrat d'utilisation plus clair des composants, une granularité plus juste (plus fine ou plus grossière), une meilleure gestion des dépendances entre composants de l'application,
- avoir des tests unitaires en filet de sécurité, c'est la possibilité de faire sereinement du refactoring de code qui nous permet plus d'agilité ;
- une documentation (un exemple d'utilisation), qui améliore la productivité des développeurs.
XI-B. Passif▲
Quel est le coût des tests unitaires ?
- le coût supplémentaire d'écriture des tests ;
-
le coût de maintenance des tests existants :
- analyser les tests en échec (problèmes des faux positifs, problèmes des « qui s'en occupe »),
- correction des tests suite aux évolutions,
- maintenance de l'environnement nécessaire aux tests (forge logicielle, base, espace disque, ressources extérieures) ;
- un time-to-market qui peut paraître plus long : en effet, peut-on se permettre de livrer en urgence un patch de production lorsque ses tests sont rouges ? Et doit-on attendre (parfois plusieurs heures) la fin de l'exécution de tous les tests avant de pouvoir le livrer ?
-
les risques de dérive : trop de tests tuent les tests :
- lorsque les coûts de maintenance code vs. tests sont déséquilibrés, que le plus gros de l'effort correctif se situe dans les tests et non dans le code, c'est symptomatique d'une perversion des bons principes de tests (couverture redondante, inadaptée, majoritairement non pertinente ?),
- lorsque le code devient compliqué à cause des tests (par exemple, rendre une classe thread-safe uniquement pour les tests).
XI-C. To test or not to test ?▲
Pas de réponse absolue, chaque contexte a ses particularités, chaque solution ses avantages et ses inconvénients… Peser le pour et le contre, ne pas négliger les contraintes supplémentaires qu'une réponse peut apporter, arbitrer en toute connaissance de cause, ne pas ignorer les conséquences de nos décisions sur le moyen et long terme : c'est là que se situe notre valeur ajoutée !
XII. Remerciements▲
Cet article a été publié avec l'aimable autorisation de la société SoatSoat. Les articles originaux (10 trucs infaillibles pour rater ses tests unitaires en toutes circonstances partie 1 et partie 2) peuvent être vus sur le blog/site de Soat.
Nous tenons à remercier ced pour sa relecture attentive de cet article et Mickaël Baron pour la mise au gabarit.