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)
}
}
}