Si vous codez en procédurale (et non en objets) vous employez probablement un anti-pattern qui s'appelle getter/setter, votre code est globalement séparé en de bonnes grosses couches techniques et ajouter une fonctionnalité consiste à ajouter sempiternellement le même type de code sur toutes les couches. #Boring
Subséquemment, vous avez probablement d'énormes grappes de structures de données que vous avez besoin de comparer récursivement durant vos tests.
Heureusement pour vous AssertJ est là et vous évitera d'avoir à coder proprement :
assertThat(actualObject)
.usingRecursiveComparison()
.isEqualTo(expectedObject);
Désolé pour le ton, mais c'est lourdingue de voir des ingénieurs proclamés "experts" qui au bout de 15/20 ans de métier ne font toujours pas la différence entre les paradigmes procédurale et objets. #Affligeant
Je viens de percuter qu'en Rust, les TU sont dans le même fichier que celui du source à tester à ceci près qu'ils sont dans un module appelé tests
... J'ai failli vomir sur le coup !
On a quand même bientôt 20 années de Maven et Gradle, ou de Composer et Symfony, ou encore presque une décennie de npm/yarn et Karma, de rake et Ruby, de pyb et Python ; et des gens n'ont toujours pas compris qu'il y avait une différence entre les sources qu'on livre et celles qui servent à fabriquer les sources qui vont être livrées. De la même manière qu'il ne faut pas générer les binaires dans le répertoire ./src
il ne faut pas mélanger le code de ses TU avec celui de son programme, idem pour les ressources des tests et du programme.
Il n'y a pas à dire, l'univers des langages bas niveau (C/C++/Rust/Go/etc) est d'une pauvreté en terme de méthodologie, c'est incroyable ! D'autant qu'une bonne partie des personnes y œuvrant se prends systématiquement pour des cadors, j'ai de la peine pour eux et encore plus pour ceux qui doivent faire avec...
Bref, pour des raisons évidentes d'hygiène je vais détourner le répertoire des TI en TU si c'est possible de le faire...
EDIT : l'arborescence standard d'un projet Rust.
.
├── Cargo.lock
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── main.rs
│ └── bin/
│ ├── named-executable.rs
│ ├── another-executable.rs
│ └── multi-file-executable/
│ ├── main.rs
│ └── some_module.rs
├── benches/
│ ├── large-input.rs
│ └── multi-file-bench/
│ ├── main.rs
│ └── bench_module.rs
├── examples/
│ ├── simple.rs
│ └── multi-file-example/
│ ├── main.rs
│ └── ex_module.rs
└── tests/
├── some-integration-tests.rs
└── multi-file-test/
├── main.rs
└── test_module.rs
Tu peux aussi ajouter cette property directement dans le pom.xml de Maven :
<properties>
<maven.test.skip>true</maven.test.skip>
</properties>
Et ça supprimera les tests de façon permanente.
Je résume, si vos tests unitaires sont deux à deux distincts, s'ils n'engendre pas de conflits lors de leur exécution en parallèle (donc il faut dissocier les TU des TI qui quant à eux s'appuient sur l'injection de dépendances) alors vous pouvez dire à Surefire d'exécuter en parallèle autant de TEST que vous avez de Thread CPU de disponibles.
Après un benchmark succinct, j'ai divisé par 5 le temps d'exécution de mes tests sur un 4 cœurs physiques et 8 cœurs logiques via la technologique HT (Hyper-Threading chez Intel / Hyper-Transport chez AMD).
Voici comment faire :
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
<configuration>
<forkCount>1C</forkCount> <!-- C'est la même commande que pour Maven, 1 thread / CPU -->
<reuseForks>true</reuseForks>
</plugin>
Aparté
Après plusieurs jours de recherche et quelques semaines de remise en question, je peux enfin mettre à plat ce que j'ai compris du clean code et surtout de ce que devrait être des TU écrits en TDD pour un service RESTful. Pour vous faire saisir l'idée dernière cela, je vais considérer que nous écrivons un serveur RESTful en Sparkjava (je n'ai pas encore migré totalement vers Javalin).
Principe de base
Selon Martin Fowler, Robert Martin et Kent Bent, nous n'avons pas forcément le lien MaClasse => MaClasseTest. En réalité, nous avons une problématique à laquelle répond une interface derrière laquelle se trouve une implémentation pouvant avoir un groupe de classes. Notre classe de test va donc tester ce petit groupe de classes à travers des TU.
Pourquoi ne pas conserver le pattern UneClasse <=> UneClasseTest ?
Simplement parce qu'il corrèle nos tests à la structure et l'organisation de notre code (ie. noms et packages). De manière formelle, on dit que le code des classes des tests et le code des sources sont covariants ; c'est-à-dire que changer l'architecture de l'un, induit inévitablement un changement chez l'autre.
Alors qu'en réalité, les TU écrits en TDD s'attachent à ce qui doit être fait et non comment cela est fait. De facto, tester l'implémentation à travers une interface décroit le couplage et rend le code de nos tests contra-variant avec le code de nos sources.
Débutons par un exemple
Considérons un micro-service RESTful qui gère un carnet d'adresse. La première question à se poser est : quelle est la liste des fonctionalités qu'expose ce service ?
Nous allons en considérer que deux :
- Ajouter un contact
- Supprimer un contact
=> Pas de recherche ni modification pour cet exemple.
En BDD, nous testerions ce service REST en lui envoyant du JSON ou carrément depuis l'interface graphique via du Selenium ou du Protractor où nos uses cases auraient été rédigés en Gherkin. Dans notre exemple en TDD, nous allons tester directement les méthodes des routes Sparkjava en mockant les paramètres spark.Request et spark.Response pour qu'ils retournent les valeurs qui nous arrangent bien.
Architecture applicative
L'achitecture demeure en couche, même si une encapsulation forte (au sens Yegor Bugayenko du terme) est omniprésente.
__________________
| |
| ROUTES |
| ^ |
|--------|---------
| v |
| PERSISTENCE |
| ^ |
|--------|--------|
| v |
| BASE DE DONNÉES |
|_________________|
Tests unitaires & TDD
Avant d'écrire la route AddPerson, nous allons commencer par écrire un test qui échoue :
/*
* COPYRIGHT © ITAMETIS - TOUS DROITS RÉSERVÉS
* Pour plus d'information veuillez contacter : copyright@itametis.com
*
* -------------------------------------------------------------------
*
* Cet extrait de code est tiré des dojos de nos amis chez ITAMETIS.
* Merci de m'avoir permise de m'en service ici pour étayer mon
* propos.
*/
import org.testng.Test
import org.assertj.Assertions.assertThat
import org.mockito.Mock.mock
import org.mockito.Mock.when
import spark.Request
import spark.Response
class AddPersonTest {
private val route:Route = AddPerson()
@Test
fun `AddPerson route should be able to create a regular user in base`() {
// Given
val json = "{'name':'Wayne', 'firstName':'Bruce', 'mobile':'+33012345678' }"
val request = mock(Request::class.java)
val response = mock(Response::class.java)
when(request.body()).thenReturn(json)
// When
val result = route.handle(request, response)
// Then
assertThat(result).contains("id")
.contains("'name':'Wayne'")
.contains("'firstName':'Bruce'")
.contains("'mobile':'+33012345678'")
}
}
Implémentation de la route AddPerson
Les routes sont des objets dédiés à une préoccupation. Typiquement la route AddPerson s'écrirait de la façon suivante :
/*
* COPYRIGHT © ITAMETIS - TOUS DROITS RÉSERVÉS
* Pour plus d'information veuillez contacter : copyright@itametis.com
*
* -------------------------------------------------------------------
*
* Cet extrait de code est tiré des dojos de nos amis chez ITAMETIS.
* Merci de m'avoir permise de m'en service ici pour étayer mon
* propos.
*/
import com.jsoniter.JsonIterator
import com.jsoniter.JsonStream
import spark.Request
import spark.Response
import com.itametis.sample.dto.PersonDto
import com.itametis.sample.entity.People
import com.itametis.sample.entity.People.Person
class AddPerson:Route {
companion object {
private const val CONTENT_TYPE = "Content-Type"
private const val RESPONSE_TYPE = "application/json"
}
data class PersonDto @JvmOverload construtor(
val id:Long? = null,
val name:String = "",
val firstName:String = "",
val mobile:String = "",
)
override fun handle(request:Request, response:Response):Any {
// Récupération du DTO
val dto:PersonDto = JsonIterator.deserialize(request.body(), Person::class.java)
// Enregistrement de la personne en base
val person:Person = People.createIt(
"name", dto.getName(),
"firstName", dto.getFirstName(),
"phone", dto.getPhone()
)
// Conversion en JSON de la personne fraîchement créée en base (avec son ID)
response.header(CONTENT_TYPE, RESPONSE_TYPE)
return JsonStream.serialize(person)
}
}
Quant à la déclaration de cette route dans Sparkjava, imaginons qu'elle soit accessible depuis deux URL :
- POST:/api/person/contact
- POST:/api/contact
Nous la déclarerions de la sorte :
/*
* COPYRIGHT © ITAMETIS - TOUS DROITS RÉSERVÉS
* Pour plus d'information veuillez contacter : copyright@itametis.com
*
* -------------------------------------------------------------------
*
* Cet extrait de code est tiré des dojos de nos amis chez ITAMETIS.
* Merci de m'avoir permise de m'en service ici pour étayer mon
* propos.
*/
class Main {
companion object {
@JvmStatic
fun main(vararg params:String) {
val addRoute:Route = AddPerson()
Sparkjava.post("/api/person/contact", addRoute)
Sparkjava.post("/api/contact", addRoute)
}
}
}
Que remarque-t-on ?
Avec cette architecture nous remarquons plusieurs choses :
- Que nos routes sont adhérentes à Sparkjava et que nous pourrions encapuler les objets Request et Response dans un objet à nous afin de se découpler de Sparkjava.
- Que la couche de service a été supprimée. Le code est intégralement remonté au niveau de la route. Cela fait sens puisque le contrôleur
RESTful se substitue aux Services de JEE dès que l'on a une SPA. - Qu'il est possible de factoriser les routes en leur affectant plusieurs URL.
- Que la route est porteuse de son DTO => Autre route => Autre contrat d'interfaçage => Autre DTO. On peut tout de même s'autoriser à
faire hériter nos routes d'une route abstraite.
Améliorations possibles
On peut améliorer les routes en les décorants par d'autres routes implémentant la même interface :
/*
* COPYRIGHT © ITAMETIS - TOUS DROITS RÉSERVÉS
* Pour plus d'information veuillez contacter : copyright@itametis.com
*
* -------------------------------------------------------------------
*
* Cet extrait de code est tiré des dojos de nos amis chez ITAMETIS.
* Merci de m'avoir permise de m'en service ici pour étayer mon
* propos.
*/
class Main {
companion object {
@JvmStatic
fun main(vararg params:String) {
val addRoute:Route = LogRouteInvocation( // On log la requête quoi qu'il arrive
AuthorizationChecker( // Est-on autorisé à exécuter la route
DtoToJsonConverter( // Évite le 'return JsonStream.serialize(...)' et le 'response.header(...)'
AddPerson() // La route d'origine
)
)
)
Sparkjava.post("/api/person/contact", addRoute)
Sparkjava.post("/api/contact", addRoute)
}
}
}
Gotcha !!!
Je recherchais depuis hier un article exposant clairement la différence entre des tests unitaires et des tests en TDD.
C'est plus clair à présent
Anti-patterns de tests unitaires.