Test Driven Development (TDD) : Guide pour un Développement Efficace

Découvrez le Test Driven Development : avantages, meilleures pratiques et impact sur la qualité du code. Maîtrisez l'art du TDD aujourd'hui !

L'origine


Le Test-Driven Development plus communément appelé TDD n'a pas été inventé par une seule personne mais par plusieurs développeurs. Kent Beck est souvent la personne la plus affiliée au TDD parce qu'il est celui qui l'a popularisé lors de ses travaux sur la méthodologie Extreme Programming à la fin des années 90. Néanmoins il est important de rappeler qu'il n'est pas le créateur de l'outil qu'est le TDD, il a été mis en place pour tout un tas de personne, dont notamment Martin Fowler (co-auteur du Manifeste Agile) ou encore Ward Cunningham et Ron Jeffries qui ont fondé l'Extreme Programming avec Kent Beck.


Un outil de travail


L'outil


Le TDD est un outil de travail qui permet au développeur de coder en étant guidé par les tests. Il permet d'avancer étape par étape, petit pas par petit pas, pour avancer vers une solution toujours plus fiable.


Cette solution plus fiable est notamment due à sa forte proximité avec les principes SOLID. En effet, le TDD facilite la mise en place des principes SOLID. En écrivant d'abord des tests, il est plus naturel de concevoir un code simple et cohérent qui respecte les principes tels que l'encapsulation, le découplage, la gestion des dépendances et la séparation des responsabilités. On se retrouve plus naturellement avec des modules indépendants et l'injection de dépendances, ce qui conduit à du code plus propre.


Le TDD et les principes SOLID vont donc de pair pour une conception logicielle de qualité.



  1. Nous avons une première phase (rouge sur le schéma) où nous devons écrire un test qui échoue, basé sur une spécification. NB : on n'écrit pas de code de production (code final) tant que le test n'est pas en échec.
  2. La deuxième phase (verte sur le schéma) consiste à faire passer le test en succès en écrivant du code de production et tout en restant le plus simple possible. NB : on écrit uniquement le code nécessaire pour faire passer le test et sans retourner dans le code du test.
  3. La dernière phase (bleue sur le schéma) concerne le refactoring. On met à jour le code en appliquant les bonnes pratiques, le clean code, etc. Une fois que le refactoring est fait, si les tests passent toujours, on conserve le refactor et on passe aux prochaines spécifications, sinon, on annule le refactor et on essaye autre chose. NB : ne doit jamais effectuer de refactoring dans les autres phases, seulement ici.


L'approche TDD est un développement itératif et incremental qui met l'accent sur la qualité du code.


Le manifeste


Le TDD respecte certains principes qui sont :


  • Baby steps instead of large-scale changes

On avance petit pas par petit pas pour avoir une boucle de feedback rapide et régulière.


  • Continuous refactoring instead of late quality improvements

On améliore le code en continue, on s'en occupe tout de suite parce qu'un refactor annoncé pour plus tard n'arrive finalement en général jamais.


  • Evolutionary design instead of big design up front

On développe ce qui est nécessaire et suffisant et on évolue progressivement.


  • Executable documentation instead of static documents

Les tests mis en place pendant le TDD sont en réalité une documentation executable. L'idée est de lier la documentation avec le code pour s'assurer qu'elle est bien à jour et maintenue.


  • Minimalist code instead of gold-plated solution

Un code simple et qui fonctionne plutôt qu'une solution surdimensionnées avec un niveau de complexité bien trop élevé et pas nécessaire.




Le test propre

Given When Then


L'approche Given When Then est basée sur une convention développée dans le cadre du Behaviour-Driven Development autrement appelé BDD. Il s'agit d'une approche de développement axée sur la collaboration et la spécification du comportement à travers ds scénarios clairs et compréhensibles.


En utilisant cette convention on divise le test en trois parties :

  1. Given, la condition préalable au test
  2. When, l'exécution du système testé
  3. Then, le comportement attendu


Exemple : Given user is not logged in When user logs in Then user is logged in successfully


Should When


L'approche Should When est une convention de nommage facile à lire et plus largement utilisée.


En utilisant cette convention on divise le test en deux parties :

  1. When, la condition préalable au test
  2. Should, le comportement attendu


Exemple : Should have user logged in When user logs in


Arrange Act Assert


Le modèle Arrange Act Assert autrement appelé AAA est un pattern descriptif et révélateur des intentions pour structurer le test.


Le test est alors organisé de la manière suivante :

  1. La partie Arrange contient la logique de configuration du test. C'est ici qu'on initialise le test.
  2. La partie Act execute le système que l'on souhaite tester. C'est ici qu'on fait l'appel d'une fonction, d'un composant, d'un call API, etc.
  3. La partie Assert, vérifie que le système testé se comporte comme prévu. C'est ici qu'on vérifie que le résultat obtenu correspond au résultat attendu.


Exemple :




F.I.R.S.T.


Le principe F.I.R.S.T. est un ensemble de principes qui définissent les caractéristiques d'un test propre et de qualité.


Ces caractéristiques sont les suivantes :

  • Fast, un test doit être rapide et efficace de manière à pouvoir être exécuté fréquemment pour avoir un feedback régulier
  • Independent, les tests doivent être indépendants les uns des autres afin d'être exécutable individuellement et efficacement
  • Repeatable, un test doit être répétable dans n'importe quel environnement et à tout moment
  • Self-Validating, les tests doivent retourner un succès ou un échec afin de vérifier lui même si l'exécution du test a réussi ou échoué sans évaluation manuelle
  • Timely, les tests doivent être écrit avant ou en même temps que le code de production. ils doivent être maintenus et exécutés régulièrement


Quand l'utiliser ?


Quand ne pas l'utiliser plutôt !


Il n'est pas nécessaire et pas utile de faire du TDD quand on a pas de spécifications, quand les tests n'apportent rien, quand les tests sont trop lents, quand il n'y a pas de logique.


La démo


L'incontournable Fizz Buzz


Le Fizz Buzz est un exercice populaire qui permet d'appréhender la méthode TDD. Ce n'est qu'un échantillon et qu'un début de la méthode, mais ça reste intéressant.


Les spécifications sont les suivantes :

  • On commence à compter à partir de 1 jusqu'à 100
  • Lorsqu'on rencontre un nombre divisible par 3, on retourne "Fizz"
  • Lorsqu'on rencontre un nombre divisible par 5, on retourne "Buzz"
  • Lorsqu'on rencontre un nombre divisible par 3 et par 5, on retourne "Fizz-Buzz"


Nous allons utiliser le TypeScript pour mettre en place cet algorithme et le tester.


Première spécification


On créé un fichier test fizz-buzz.test.ts et on créé notre premier test qui va gérer la spécification où on teste le nombre 1.





Ici, le test ne passe pas parce qu'on a pas encore créé la fonction fizzBuzz() et encore moins l'algorithme associé. Nous sommes donc à la première phase du TDD, la phase rouge, celle où on écrit un test qui échoue.


On va maintenant passer à la deuxième phase du TDD, celle où on va faire passer le test au vert. Pour cela, on va créer le fichier fizz-buzz.ts et écrire le code permettant de gérer notre cas de spécification.




Ici, on pourrait avoir tendance à faire un return String(n) directement, mais TDD nous dit de commence par écrire le code le plus simple possible pour faire passer le test au vert, et en réalité le plus simple et rapide et de tout simplement retourner 1 directement.


On va passer au prochain test qui est de tester l'input 2.



Le test échoue parce qu'on a pas encore géré ce cas dans notre fonction. On retourne donc dans notre fonction et on essaye de résoudre ce cas de la manière la plus simple et rapide.




Ici, encore une fois, le plus rapide est de retourner 2 si on a 2 en input.


Maintenant on arrive à la troisième et dernière phase du TDD, celle du refactor. En effet, actuellement nos tests passent avec succès, le code est simple, mais il pourrait l'être encore plus en appliquant de bonnes pratiques.


On va donc revenir sur notre fonction fizzBuzz() sans modifier les tests.




Notre fonction est maintenant simple, propre et concise et les tests sont toujours verts. Notre refactor est donc réussit, on peut passer à la prochaine spécification.


Deuxième spécification


Nous devons maintenant faire en sorte de retourner Fizz si l'input est divisible par 3.



Le test échoue, on va maintenant gérer le cas du Fizz dans la fonction.




Le code le plus simple pour retourner  Fizz quand on a 3 et de tester si n === 3.


On va maintenant gérer un deuxième cas où on a un nombre divisible par 3.




Le test échoue, on met à jour le code.




Le code le plus simple pour retourner Fizzquand on a 3 ou 6 et de faire un ||, mais on se rend bien compte qu'on peut améliorer le code en utilisant le modulo de 3. Les tests sont tous verts, donc on peut se permettre de passer à la phase de refactor en modifiant uniquement le code de la production.



Refactor terminé, tous les tests sont verts, on peut passer à la prochaine spécification.


Troisième spécification


Nous devons maintenant faire en sorte de retourner Buzz si l'input est divisible par 5.


On va aller un peu plus vite, mais le procédé est le même, on va d'abord faire un test avec un input 5 puis 10, puis refactor.




Dernière spécification


Nous devons maintenant faire en sorte de retourner Fizz-Buzz si l'input est divisible par 3 et par 5.


Comme pour les spécifications précédentes, on va faire un test avec un input 15 puis 30 et enfin refactor.


Et voilà !


Le mot de la fin


Stop aux amalgames ! Le TDD n'est pas un outil pour avoir une bonne couverture de code avec les tests, ce n'est pas simplement "faire des tests". Le TDD est un outil dont le but est de guider le développeur vers un objectif. Il permet de donner un feedback régulier afin de s'assurer qu'on est toujours sur la bonne voie. Il faut le voir comme un GPS, qui nous donne des directions à suivre (tourner à droite, aller tout droit pendant 2km, etc.) jusqu'à atteindre un objectif.


Les dédicaces


Sommaire
Nos autres catégories
Notre newsletter tous les mois :
Je m'abonne
Merci ! C'est dans la boîte :)
Oops! Something went wrong while submitting the form.
Partager sur :
Nos autres catégories
Notre newsletter tous les mois :
Je m'abonne
Merci ! C'est dans la boîte :)
Oops! Something went wrong while submitting the form.
Partager sur :

Nos experts vous parlent
Le décodeur

Git : initiation, merge vs rebase, démo
22/6/2023

Origine


Git a été inventé et développé par Linus Torvalds en 2005. Il s’agit un logiciel libre et gratuit permettant aux développeurs de gérer les changements apportés au code au fil du temps. Linus Torvalds c’est aussi le petit génie qui est à l’origine du noyau Linux qu’il a commencé en 1991, donc bien avant Git.


Linux c’est un projet plutôt conséquent et il a donc dû nécessiter l’usage d’un outil de gestion de version. Cet outil, à l’époque, c’est BitKeeper. Le problème de BitKeeper, c’est qu’il s’agit d’un logiciel propriétaire et que toute la communauté qui gravite autour de Linux, elle n’aime pas vraiment les logiciels propriétaires. Alors que BitKeeper n’est déjà pas totalement apprécié par la communauté Linux, ils vont faire une annonce qui va déclencher la colère de toute cette communauté et surtout de Linus Torvalds. Ils vont cesser, du jour au lendemain, d’être gratuit. C’est à ce moment précis que Linus Torvalds décide de développer lui même son propre système de gestion de version du code source et tout comme Linux, ce système sera libre et gratuit.


Système de contrôle de version (VCS)


Git est donc un système de contrôle de version, il permet tout simplement de suivre l’évolution du code au fil du temps, à l’aide de branche, de fichiers et d’opérations sur ces fichiers.


Git est structuré comme suit :

  • on y retrouve des fichiers (le code source)
  • des branches (correspondant à une arborescence de fichiers)
  • et des opérations pour faire évoluer les fichiers dans les branches


Grâce à ces opérations, git permet de savoir qui a touché à quel fichier, à quel moment et comment.


GitHub, GitLab, Bitbucket, etc.


Git est un logiciel qui permet de sauvegarder et de gérer localement l’évolution du code source au fil du temps. GitHub, GitLab, Bitbucket, etc. sont des plateformes (web) qui se servent du logiciel git pour gérer le code source. Les dépôts ne sont alors plus gérer localement mais sur des serveurs distants et permettent donc notamment la collaboration avec plusieurs personnes. Ces plateformes proposent également de nombreuses fonctionnalités de gestion de projets et d’équipes (wiki, affectations de tâches, suivi des problèmes, roadmap, statistiques, etc.).

Les bases

Git Flow


Git Flow est une organisation de travail basé sur la capacité de Git à gérer des branches. Par défaut il existe une branche principale qui s’appelle main (anecdote : anciennement master, ce nom par défaut a changé pour des raisons culturelles, ne plus assimiler la notion de master/slave à l’industrie du développement face aux nombreux cas de racisme dans le monde). Il existe une deuxième branche que nous allons créer et qui sera également considérée comme principale, il s’agit de la branche develop.

Nous avons donc 2 branches principales :

  • main (anciennement master), qui représente le code source utilisé sur la production
  • develop, qui contient les dernières fonctionnalités dont la phase de développement est terminée

Tout au long du développement du projet, de nombreuses branches seront créées lors du développement des fonctionnalités et des corrections diverses. Ces branches respecteront des conventions de nommages comme suit :

  • feature/*, pour les branches de fonctionnalités
  • hotfix/* ou bugfix/*, pour les branches de corrections
  • refactor/*, pour améliorer la qualité du code

Conventional Commits


Conventional Commits est une spécification dont le but est d’améliorer la lisibilité des commits et l’historique des modifications du code source. À l’aide de ces conventions on peut identifier immédiatement le type, le contexte et l’objectif des modifications apportées au code sur un commit (nota bene : Cela permet aussi d’être compris par des outils automatisé pour générer de la documentation en autres).

Format Conventional Commits

Les types de commits les plus utilisés sont :

  • feat, développement d’une feature
  • fix, correction d’un bug
  • refactor, amélioration du code
  • test, ajout ou mise à jour de tests
  • chore, tâche technique non assimilée à une feature
  • remove, revert, style, ci, docs, etc.
Exemples de commits suivant la convention

Workflow : merge vs rebase


Avec Git Flow on travaille donc sur des branches partant de develop (ou autre) et une fois le travail terminé on met à jour develop pour qu’il ai connaissance des modifications apportées.

Il existe plusieurs façons de ramener les modifications d’une branche vers une autre. On peut utiliser la politique de merge ou bien la politique de rebase. Ces deux méthodes ont des avantages et des inconvénients.


Politique de merge


Lorsque le travail de développement est terminé sur une branche (de feature, de refactor, etc.), la branche contient un certain nombre d’opération qui n’existent pas sur la branche d’origine. Le principe de la politique de merge est simple : récupérer les modifications faites sur une branche et les ramener sur une autre branche qui n’a pas connaissance de ces modifications. Ces modifications sont ramenées telles quelles.

Avantages :

  • traçabilité totale, l’historique du code source correspond totalement à ce qui a été fait
  • résolution des conflits en une seule fois (peut être un inconvénient dans certains cas)


Inconvénients :

  • historique du code source vite pollué par des opérations inutiles “wip” ou des opérations qui s’annulent
  • historique peu fiable et difficile à debugger
  • résolution des conflits en une seule fois (peut être un avantage dans certains cas)


Politique de rebase


Lorsque le travail de développement est terminé sur une branche (de feature, de refactor, etc.), la branche a donc un certain nombre d’opération qui diverge de la branche principale. Lorsqu’on suit une politique de rebase, notre objectif va être de nettoyer ces opérations en les réécrivant jusqu’à avoir le nombre minimum d’opérations pertinentes.  

Avantages :

  • historique du code source linéaire et lisible qui peut servir de documentation
  • messages de commit clairs et respectant les conventions, plus de “wip”
  • plus de commits qui s’annulent et donc une fiabilité de l’historique
  • facilité pour revenir en arrière et trouver l’origin d’un bug car l’historique n’est pas pollué
  • facilité pour revoir une feature complète, pour la modifier ou l’annuler
  • résolution des conflits opération par opération (peut être un inconvénient dans certains cas)


Inconvénients :

  • demande une grande rigueur car on réécrit en permanence l’historique
  • demande une bonne communication ou des règles précises si on travaille en équipe sur la même branche
  • la réduction d’une nombre d’opération au minimum est parfois trop extrême et atténue la clarté du contexte dans certains cas
  • résolution des conflits opération par opération (peut être un avantage dans certains cas)


Nettoyage avec rebase Interactif


Avec la politique de rebase on réécrit l’historique des opérations faites sur le code source. Pour cela on peut utiliser des outils comme GitKraken ou autre, mais on peut également utiliser la commande git rebase interactive.


Les rebases réécrivent l’historique et donc écrasent totalement ce qui existait avant. En équipe il est donc indispensable de bien communiquer, de bien se mettre à jour et de prendre le soin de ne pas travailler sur la même branche. Si ces règles ne sont pas respecter, les pertes de code sont plus que probables !


Le processus classique de développement pour ne pas rencontrer de problèmes et profiter de la puissance du rebase est le suivant (en plus  :

  • travailler en local en faisant autant d’opérations que nécessaires
  • lorsque le travail est terminé et que tout fonctionne comme il faut, créer une pull request et demander une revue de code en gardant l’historique de code tel qu’il est pour garder du contexte et donner à l’auteur de la revue de code un moyen de comprendre le cheminement de pensé qui a amené à ces modifications
  • une fois la revue de code terminée et acceptée, il faut utiliser le rebase interactive pour nettoyer le code et ne garder que les opérations nécessaires
  • intégrer les modifications sur la branche d’origine et supprimer la branche créée précédemment

🧑💻 Démonstration


Supposons nous avons un dépôt git avec une seule branche main et un seul fichier hello.ts qui contient une fonction “Hello World !” comme suit :

Maintenant, nous devons développer la fonctionnalité “Good Bye World!”.

Pour cela, nous allons donc commencer par créer une branche qui respecte les conventions de nommage : git checkout -b feature/good-bye
Puis, nous allons créer un fichier good-bye.ts et écrire la fonction suivante :

Et nous allons créer un commit contenant cette fonctionnalité : git commit -m “feat: good bye world”.


Vous l’avez peut-être remarqué, une erreur s’est glissée dans la fonction, nous allons donc faire un commit pour la corriger :

Avec le commit suivant : git commit -m “fix(good-bye): typo”.

Nous avons donc 2 commits alors qu’il serait plus pertinent d’en avoir qu’un seul. Nous allons donc utiliser le git rebase interactive pour réécrire l’historique des modifications.

Pour initialiser le rebase interactive on utilise la commande suivante : git rebase interactive HEAD~2

Cette commande va ouvrir l’interface suivante :

Sur cette interface on voit les 2 derniers commits de mon dépôt git (parce qu’on a utilisé HEAD~2). On y retrouve également une documentation des commandes qu’on peut utiliser devant l’identifiant de chaque commit.


Dans notre cas, on veut fusionner les modifications du deuxième commit avec le premier commit. C’est donc la commande fixup qui nous intéresse, nous allons donc remplacer pick devant le commit fix(good-bye): typo par fixup.


On enregistre et on obtient l’historique suivant (un seul commit) :

7f077f4 (HEAD -> feature/good-bye-world) feat: good bye world

Liens utiles

Systèmes d'exploitation pour smartphone iOS et Android
14/9/2023

À l’heure du tout numérique et de l’utilisation massive des smartphones, on compte pas moins de 485 000 nouveaux téléchargements d’applications mobile par minute. Le développement de ces applications représente ainsi un excellent moyen pour les entreprises pour promouvoir leurs produits ou proposer des fonctionnalités innovantes. 

Dans ce domaine hautement concurrentiel, deux acteurs majeurs ont su tirer leur épingle du jeu entre le milieu des années 2000 et aujourd’hui. Ils dominent désormais le marché mondial du smartphone.

Ces deux géants très populaires font partie des GAFAM. En effet, il s’agit de la société Apple et son système d’exploitation (ou OS pour Operating System) pour smartphone « iOS » et Google avec son OS mobile Android.

Avec des utilisateurs divisés entre ces deux systèmes d’exploitation, il est crucial, pour les développeurs, de déterminer sous quel OS faire tourner leur future solution mobile.

Dans cet article, nous explorerons les langages natifs permettant de développer une application dédiée au système d’exploitation smartphone de son choix. Ensuite, nous nous concentrerons sur le développement multi-plateforme (ou cross-platform), avec l’utilisation de frameworks comme React Native.

iOS et Android : les titans du système d'exploitation smartphone

En 2007, Apple et son système d'exploitation pour iPhone (ou iOS) redéfinissent le monde des smartphones.

Conçu exclusivement pour les mobiles de la marque à la pomme, cet OS est optimisé pour offrir une expérience utilisateur fluide et graphiquement homogène.

Par ailleurs, l'App Store, qui héberge les applications pour iOS, est un environnement strictement contrôlé, garantissant aux utilisateurs une certaine sécurité et qualité.

Notez que la publication d’applications iOS sur ce magasin en ligne nécessite un compte « Apple Developer » à $99 par an. L’environnement de développement (ou IDE) Xcode, logiciel exclusif aux ordinateurs portables et fixes de Apple est également un prérequis à la publication d’application iOS.

De son côté, le système d'exploitation Android, développé par Google, est aujourd’hui le cœur de nombreux smartphones et tablettes.

En étant open source et basé sur le noyau Linux, Android offre une grande flexibilité aux fabricants et aux développeurs.

En outre, le Google Play Store, magasin phare de l’OS, abrite une myriade d'applications mobiles, des plus utiles aux plus innovantes. 

Pour être reconnu par Google en qualité de développeur Android à vie, il suffit de payer $25 et ainsi pouvoir publier des jeux et applications depuis la Google Play Console.

Les langages de natifs iOS et Android

Vous l’aurez compris, pour toucher un maximum d’utilisateurs, les développeurs doivent créer des applications mobiles fonctionnant à la fois sur iOS et Android.

Pour autant, chaque système d’exploitation requiert l’utilisation du langage de programmation natif associé. Chaque langage demandant une expertise et un temps de développement dédié, les coûts et les ressources sont bien souvent doublés.

Côté iOS, c’est Swift le langage natif officiel développé en 2014 par Apple pour la création d’applications mobiles. Il est puissant, intuitif et conçu pour être efficace tout en étant facile à lire et à écrire. Swift s’inspire d’ailleurs beaucoup du langage Objective C, créé en 1980, qui était auparavant utilisé par les développeurs iOS.

Swift demande néanmoins aux développeurs d’applications iOS d’utiliser l’environnement de développement Xcode, exclusif aux ordinateurs fixes et portables commercialisés par la marque à la pomme. Même si l’installation de machines virtuelle Mac OS est envisageable sous Windows ou Linux, cette seule contrainte a déjà refroidie de nombreuses entreprises et développeurs. Pour créer une application iOS, c’est vers des agences de développement spécialisées dans cette technologie que beaucoup se tournent.

Pour Android, c’est le langage Kotlin qui a rapidement gagné en popularité comme la solution de prédilection pour le développement d’application Android. Kotlin est développé et maintenu par Google depuis 2011. Anciennement, c’était le langage Java, créé en 1995, qui était utilisé par les développeurs Android.

Enfin, c’est l’environnement de développement Android Studio qui est majoritairement utilisé par les développeurs Android pour compiler leurs applications mobiles.

L'avènement du développement cross-platform

Les langages natifs nécessitent alors une de doubles expertises pour les développeurs. Il est également souhaitable d’éviter d’avoir à écrire du code source deux fois afin de réduire les coûts et temps de développement. C’est dans ce contexte complexe que des solutions cross-platform ont commencé à émerger dès la fin des années 2000.

En effet, le framework JavaScript React Native, développé par Meta (anciennement Facebook) en 2015, permet de construire une application fonctionnant sur iOS et Android.

C'est une aubaine pour les développeurs puisque React Native et les solutions similaires induisent ainsi plusieurs avantages notables :

  • Des temps de développement réduit,
  • Des ressources nécessaires moindres,
  • Une mise à jour synchronisée pour les deux systèmes d’exploitation.

React Native fait aujourd’hui partie des technologies cross-platform les plus populaires au monde mais il en existe d’autres tout aussi pertinentes. Il faudra surtout connaître ses besoins et ses prérequis techniques avant de faire un choix. En voici une sélection :

  • Apache Cordova développé en 2011 et écrit en JavaScript,
  • Xamarin développé en 2013 et écrit en C#,
  • Ionic développé en 2013 et écrit en JavaScript 
  • Flutter développé en 2017 et écrit en Dart.

Pour vous démontrer l’intérêt majeur d’une telle technologie, découvrons ensemble quelques exemples basiques de code source.

Le langage Swift pour iOS

Dans cet exemple de code Swift, nous créons une simple interface utilisateur avec un message de bienvenue.

Néanmoins, si nous souhaitons que notre application mobile, aussi basique soit-elle, puisse tourner également sur un téléphone Android, il nous faut développer la même chose à l’aide du langage natif Kotlin.

Le langage Kotlin pour Android 

Dans cet exemple de code Kotlin, nous affichons encore un message de bienvenue. Autrement dit, nous réalisons un traitement purement similaire à celui développé plus haut. 

Heureusement pour nous, une solution cross-platform, comme React Native, demande un code source unique, pour obtenir un rendu similaire sur les deux systèmes OS.

Remarque : Notez que, comme son nom l’indique, React Native est un framework dérivé de la librairie web frontend React. Ces deux technologies utilisent l’extension syntaxique au langage JavaScript « JSX », également développée par Meta.

Le framework React Native pour le développement cross-platform

Dans cet exemple de code React Native, nous utilisons des composants de base pour créer une interface qui fonctionne à la fois sur les systèmes iOS et Android. C'est la magie du cross-platform : un seul code qui existe, s'adapte et évolue sur plusieurs systèmes d'exploitation simultanément !

Ces exemples de code démontrent la différence entre le développement natif et le développement cross-platform.

Les codes natifs Swift et Kotlin nécessitent une connaissance spécifique de chaque plateforme. De son côté, React Native, grâce à sa nature cross-platform, simplifie le processus. Les développeurs peuvent alors utiliser une base de code pour les deux plateformes sans être expert avec d’autres langages que le JavaScript utilisé par React Native.

Avantages et limites du cross-platform

Malgré une promesse alléchante qui semble rendre obsolète les langages natifs, les solutions cross-platform présentent quelques inconvénients non négligeables.

  • Des performances moindres : Les applications natives ont généralement de meilleures performances car elles sont spécifiquement optimisées pour leur OS respectif.
  • Des fonctionnalités spécifiques non disponibles : Certaines fonctionnalités natives peuvent parfois ne pas être facilement accessibles. Elles peuvent même nécessiter des solutions de contournement lorsque l’on développe à l’aide d’une solution cross-platform comme React Native.

Pour illustrer ce dernier aspect, découvrons une implémentation basique consistant à donner à l’application mobile l’accès à une fonctionnalité matérielle spécifique. En l’occurrence, nous avons choisi de nous concentrer sur le moteur de vibration du smartphone. 

Le code source Swift

Une image contenant texte, capture d’écranDescription générée automatiquement

Cet exemple de code permet de faire vibrer un iPhone grâce à l'API native d'iOS qui permet d’accéder facilement et avec précision au moteur de vibration. 

Le code source Kotlin

Une image contenant texte, capture d’écranDescription générée automatiquement

Là encore, avec l'API native d'Android, on utilise le service de vibration avec des motifs de vibration plus ou moins complexes.

Le code source React Native

Une image contenant texte, capture d’écran, Police, carte de visiteDescription générée automatiquement

Comme vous pouvez le constater, avec React Native, le moteur de vibration reste accessible pour les deux systèmes d’exploitation mais il est moins flexible en termes de fonctionnalités avancées, comme la définition de motifs complexes.

Bien que React Native offre la possibilité d’accéder aux fonctionnalités matérielles du smartphone comme pour le faire vibrer, ces exemples de code démontrent que les options et la précision sont plus limitées en comparaison avec les APIs natives de Swift et Kotlin.

Dans le cadre de cas concrets spécifiques plus avancés, l’interaction avec des fonctionnalités matérielles plus évoluées pourrait nécessiter un développement natif.

C'est pour ces problématiques spécifiques ou avancées que le développement natif offre davantage de flexibilité et de performance. Notez néanmoins que pour la majorité des besoins courants des applications, React Native offre un réel équilibre entre portabilité, temps de développement et performance.

Pour conclure, les systèmes d’exploitation pour smartphone jouent un rôle crucial dans l’ère numérique actuelle. Dans les faits, le choix entre le développement natif et cross-platform doit surtout dépendre du projet à mener et des objectifs qui lui sont associés.

Pour autant, gardez à l’esprit qu’avec des technologies comme React Native, les frontières entre iOS et Android s'amenuisent. Ces puissantes solutions offrent aux développeurs et aux utilisateurs une expérience plus simple et accessible. Par ailleurs, les prérequis techniques et fonctionnels étant également amoindris, une plus vaste communauté peut s’adonner à la création d’application mobile.

Enfin, si cet article vous a motivé à construire votre propre solution pour smartphone iOS et Android, découvrez aussi nos conseils pour créer une application de A à Z ainsi que nos 10 astuces pour créer la meilleure application mobile !

L’Architecture Hexagonale sur un projet Web + Mobile (Partie 2 sur 5)
28/2/2024

Dans l'article précédent nous avons initialisé notre monorepo, la CI, le framework de test et préparé la structure de notre projet et plus précisément de notre Architecture Hexagonale pour la lib core.

Dans ce nouvel article de la série notre objectif va être de mettre en place l'Architecture Hexagonale et de montrer comment grâce à elle nous allons pouvoir développer et créer de la logique métier sans UI (donc sans ouvrir le navigateur ou l'app mobile). Pour cela nous allons travailler en TDD (Test-Driven Development, vous pouvez voir mon article à ce sujet) et utiliser le feedback des tests.

L'Architecture Hexagonale

La structure cible

Pour rappel, voici la structure que l'on va mettre en place à l'issue de cet article :

- src
    - wallet
       - __ tests __
         - wallet.service.test.ts
       - domain
         - wallet.ts
         - wallet.repository.ts
         - wallet.service.ts
       - infrastructure
         - in-memory-wallet.repository.ts
         - local-storage-wallet.repository.ts
         - mmkv-wallet.repository.ts
       - user-interface
         - wallet.store.ts

Chose promise, chose due ! Nous allons maintenant rentrer dans le détail de chaque fichier, à quoi ils servent et ce qu'ils contient.

Développer en TDD

Lorsqu'on travaille en TDD on commence par le test et ce test va nous guider vers un objectif. Il va nous assurer qu'on suit le bon chemin à l'aide de la boucle de feedback régulière qu'on obtient à l'aide des tests. Pour en savoir plus sur la méthodologie à suivre pour faire du TDD je vous invite à nouveau à lire mon article à ce sujet.

Nous allons commencer par travailler sur l'entité Wallet qui correspond à un portefeuille qui a un solde négatif ou positif (par exemple on peut avoir le portefeuille "Compte Principal Julien" qui a un solde positif de 1000€).

Voici les tests mis en place pour cette entité :

describe('Wallet Service', () => {
	let service: WalletService

	beforeEach(() => {
		const repository = new InMemoryWalletRepository()
		service = new WalletService(repository)
	})

	test('getAll > should retrieve all wallets', async () => {
		const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }

		await service.create(newWallet)
		const retrievedWallets = await service.getAll()

		expect(retrievedWallets).toEqual([newWallet])
	})

	test('get > should retrieve a wallet according to an id', async () => {
		const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }

		await service.create(newWallet)
		const retrievedWallet = await service.get(newWallet.id)

		expect(retrievedWallet).toEqual(newWallet)
	})

	test('create > shoudl create a wallet', async () => {
		const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }

		const createdWallet = await service.create(newWallet)
		const retrievedWallets = await service.getAll()
		const retrievedWallet = await service.get(createdWallet.id)

		expect(createdWallet).toEqual(newWallet)
		expect(retrievedWallets).toEqual([newWallet])
		expect(retrievedWallet).toEqual(newWallet)
	})

	test('update > should update the specified wallet', async () => {
		const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }
		const updatedWallet = { id: '1', name: 'Wallet 1', balance: 100 }

		await service.create(newWallet)
		const retrievedWallet = await service.get(newWallet.id)
		const modifiedWallet = await service.update(updatedWallet)
		const retrievedModifiedWallet = await service.get(modifiedWallet.id)

		expect(retrievedWallet).toEqual(newWallet)
		expect(modifiedWallet).toEqual(updatedWallet)
		expect(retrievedModifiedWallet).toEqual(updatedWallet)
	})

	test('delete > should delete a wallet according to an id', async () => {
		const newWallet = { id: '1', name: 'Wallet 1', balance: 0 }

		await service.create(newWallet)
		const retrievedWallet = await service.get(newWallet.id)
		await service.delete(newWallet.id)
		const retrievedWallets = await service.getAll()

		expect(retrievedWallet).toEqual(newWallet)
		expect(retrievedWallets).toEqual([])
	})
})

On peut comprendre via ces tests que les cas d'utilisations de notre entité sont :

  • getAll, récupération de tous les portefeuilles
  • get, récupération d'un portefeuille en particulier
  • create, création d'un portefeuille
  • update, mise à jour d'un portefeuille
  • delete, suppression d'un portefeuille

Nous allons voir maintenant comme réussir à mettre en place ces tests.

Domain

Nous allons commencé par créer le contenu de la partie Domain. Dans cette partie nous allons retrouver tout ce qui représente le problème à résoudre (problème métier). C'est une partie qui doit être totalement indépendante.

L'entité

Commençons par créer notre entité Wallet correspondant à un portefeuille.

type Wallet = {
	// un identifiant unique (ex: 4d0c2e72-be1a-4e2c-a189-2f321fcdc3a4)
	id: string

	// un nom (ex: Compte Principal Julien)
	name: string

	// un nombre positif ou négatif pour le solde (ex: +1000€)
	balance: number
}


Le repository

Maintenant que notre entité est définie, nous allons définir une interface que l'on appelle également port qui va préciser comment interagir avec cette entité. Nous utilisons ici un modèle de conception d'inversion de dépendances qui nous permet de rester totalement libre sur les outils à utiliser pour respecter cette interface. Nous pourrons très bien implémenté cette interface en utilisant une base de données, une API ou un localStorage par exemple, le domaine s'en fiche.

interface WalletRepository {
	getAll(): Promise<Wallet[]>
	get(walletId: string): Promise<Wallet | undefined>
	create(wallet: Wallet): Promise<Wallet>
	update(wallet: Wallet): Promise<Wallet>
	delete(walletId: string): Promise<void>
}

Le service

Nous avons notre entité et nous savons commencer interagir avec, maintenant nous allons créer un service qui va consumer une implémentation du de notre interface repository (partie suivante dans l'infrastructure).

class WalletService implements WalletRepository {
	constructor(private repository: WalletRepository) {}

	getAll() {
		return this.repository.getAll()
	}

	get(walletId: string) {
		return this.repository.get(walletId)
	}

	create(wallet: Wallet) {
		return this.repository.create(wallet)
	}

	update(wallet: Wallet) {
		return this.repository.update(wallet)
	}

	delete(walletId: string) {
		return this.repository.delete(walletId)
	}
}

Infrastructure

L'infrastructure est composée des différentes implémentations des ports du domaine, on les appelle également Adapters. Ici, nous aurons du code spécifique pour consommer une technologie concrète (une base de données, une API, etc.). C'est une partie qui ne doit dépendre uniquement du domaine.

L'implémentation du repository

Nous allons maintenant voir l'une des implémentation possible de notre WalletRepository. Pour commencer nous allons faire du in-memory, pratique notamment pour la mise en place des premiers tests de nos cas d'utilisations.

class InMemoryWalletRepository implements WalletRepository {
	private wallets: Wallet[] = []

	getAll() {
		return Promise.resolve(this.wallets)
	}

	get(walletId: string) {
		return Promise.resolve(this.wallets.find((wallet) => wallet.id === walletId))
	}

	create(wallet: Wallet) {
		this.wallets.push(wallet)
		return Promise.resolve(wallet)
	}

	update(wallet: Wallet) {
		const index = this.wallets.findIndex((w) => w.id === wallet.id)
		this.wallets[index] = wallet
		return Promise.resolve(wallet)
	}

	delete(walletId: string) {
		const index = this.wallets.findIndex((w) => w.id === walletId)
		this.wallets.splice(index, 1)
		return Promise.resolve()
	}
}

Comment dis précédemment, il s'agit d'une des multiples implémentation possible de notre WalletRepository. Nous pouvons très bien imaginer plus tard mettre en place un LocalStorageWalletRepository ou bien un SupabaseWalletRepostory.

Vous pouvez consulter mon répertoire public de broney sur GitHub pour voir mon implémentation de ces 2 repository et notamment de comment j'ai adapté ma série de test pour garantir leur bon fonctionnement.

User Interface

La partie user interface est composée de tous les adaptateurs qui constituent les points d'entrée de l'application. Les utilisateurs utilisent ces adaptateurs pour pouvoir interagir avec le coeur de l'application. Dans notre cas nous allons régulièrement utiliser des stores en utilisant la libraire Zustand. Il s'agit d'une libraire JS minimaliste pour la gestion d'états (une solution plus complexe serait par exemple Redux).

Voyons voir comment articuler notre store Zustand pour permettre à l'utilisateur d'interagir avec le coeur de l'application.

import { createStore } from 'zustand/vanilla'
import { InMemoryWalletRepository } from '../infrastructure/in-memory-wallet.repository'
import { WalletService } from '../domain/wallet.service'
import { Wallet } from '../domain/wallet'

const repository = new InMemoryWalletRepository()
const service = new WalletService(repository)

type States = {
	wallets: Wallet[]
	currentWallet: Wallet | undefined
}

type Actions = {
	load: () => void
	setCurrentWallet: (wallet: Wallet) => void
	getWallet: (walletId: string) => void
	createWallet: (wallet: Wallet) => void
	updateWallet: (wallet: Wallet) => void
	deleteWallet: (walletId: string) => void
}

export const walletStore = createStore<States & Actions>()((set) => ({
	wallets: [],
	currentWallet: undefined,

	load: async () => {
		const allWallets = await service.getAll()
		set({ wallets: allWallets })
	},

	setCurrentWallet: (wallet) => set({ currentWallet: wallet }),

	getWallet: async (walletId: string) => {
		const wallet = await service.get(walletId)
		set({ currentWallet: wallet })
	},

	createWallet: async (wallet: Wallet) => {
		const newWallet = await service.create(wallet)
		set((state) => ({ wallets: [...state.wallets, newWallet] }))
	},

	updateWallet: async (wallet: Wallet) => {
		const updatedWallet = await service.update(wallet)
		set((state) => ({
			wallets: state.wallets.map((w) => (w.id === updatedWallet.id ? updatedWallet : w)),
			currentWallet: state.currentWallet?.id === updatedWallet.id ? updatedWallet : state.currentWallet,
		}))
	},

	deleteWallet: async (walletId: string) => {
		await service.delete(walletId)
		set((state) => ({
			wallets: state.wallets.filter((w) => w.id !== walletId),
			currentWallet: state.currentWallet?.id === walletId ? undefined : state.currentWallet,
		}))
	},
}))


Avec ce store on remarque qu'on va pouvoir facilement, dans n'importe quel environnement JavaScript, charger, définir, récupérer, créer, mettre à jour et supprimer des portefeuilles, tout en maintenant un état global pour l'ensemble des portefeuilles et du portefeuille courant.

Conclusion

Nous avons maintenant terminé ce deuxième article de cette série sur le développement d'une application web et mobile avec l'Architecture Hexagonale et le partage de la logique métier et des composants UI.

Dans cette deuxième partie nous avons vu comment travailler en TDD et surtout comment écrire de la logique métier sans avoir à ouvrir une quelque interface à l'exception du terminal pour les retours de tests.

Nous avons également eu un aperçu de comment nous allons interagir avec nos applications avec le coeur de l'application, via notre store Zustand. Nous irons plus loin à ce sujet dans le prochain article, la troisième partie : Partager de la logique métier et des composants entre le Web et le Mobile.

Échangeons
sur votre projet !

Nous contacter

Simulateur

Bienvenue dans le
simulateur d’estimation

Sélectionnez
vos besoins

Sélectionnez un ou plusieurs choix

Définissez les
fonctionnalités

Sélectionnez un ou plusieurs choix

Dernière
étape !

Renseignez votre adresse mail pour recevoir l’estimation !
Obtenez l’estimation
Précédent
Suivant

Bravo ! Vous avez terminé
l’estimation de votre future app !

Vous recevrez dans votre boite mail l’estimation personnalisé. Une estimation vous offre la possibilité de vous projeter dans un budget, vous permettant ainsi de planifier en toute confiance. Néanmoins, chez Yield, nous adoptons une approche agile, prêts à remettre en question et ajuster nos évaluations en fonction de l'évolution de vos besoins et des spécificités de votre projet.
Retour au site
Oops! Something went wrong while submitting the form.