Ce document liste les différentes pratiques de dev mises en place sur le projet, articulées autour des domaines suivants :
- Les conventions de code
- Gestion des cas non nominaux
- L'usage de Redux
- Les tests automatisés
- Les conventions Git
- Les conventions de code
- Gestion des cas non nominaux
- L'usage de Redux
- Les tests automatisés
- Les conventions Git
Il n'est pas question ici d'être exhaustif sur le code style d'un projet Flutter / Dart. À cet effet, le wiki de Flutter propose tout un ensemble de convention bien plus détaillées.
À date, c'est le code style par défaut de l'IDE Android Studio pour le langage Dart qui est utilisé. La seule spécificité
est de mettre le nombre de caractères par ligne à 120 : dans les préférences de l'IDE
Editor > Code Style > Dart > Line length
.
Contrairement au projet backend, le choix est fait ici de n'utiliser le français que pour les termes fonctionnels,
et ce même si la traduction du terme fonctionnel est évidente. Par exemple, il y a une classe Jeune
et non pas
Young/Youth
, et Offre
plutôt que Offer
. Pour ce qui est des verbes, même associés à des termes français, nous les
conservons en anglais s'ils ne sont pas fonctionnels. Ex : isVolontaire
plutôt que estVolontaire
.
La logique Clean Code est appliquée ici : plus l'utilisation de la variable est éloigné de là où elle est utilisé, plus elle est explicitement nommée.
Ex : pour une instance de la classe CallToAction
:
- si celle-ci est un attribut, elle est déclarée comme tel :
final CallToAction callToAction;
. - si celle-ci est une variable, ou un paramètre de lambda, elle est déclarée comme tel :
cta
.
Dart est un langage fortement typé qui permet facilement d'inférer les types des objets. Pour autant, par soucis de lisibilité et également pour d'avantage bénéficier du compilateur Dart, l'inférence de type est gérée comme suit :
- Toutes les méthodes (publiques et privées) doivent être typées.
- S'il n'y a pas de type de retour,
void
est quand même précisé. - En utilisant le type le plus générique dès que possible (ex : retourner
Widget
plutôt queScaffold
ouList<Widget>
plutôt queList<Text>
).
- S'il n'y a pas de type de retour,
- Tous les attributs (publics et privés) doivent être typés.
- Au sein d'une méthode, le typage n'est pas nécessaire (ex :
final text = viewModel.text;
).
Au sein d'un fichier ou d'une classe, tous les attributs et les const
doivent autant que faire ce peut être déclarés
privés (préfixés par un _
). Seule exception à la règle pour les constructeurs avec des attributs nommés, dans quel
cas l'usage du privé est trop verbeux. Les attributs sont alors laissés en public.
Autant que possible, toutes les variables sont déclarées en final
et non pas en var
. Dans la même logique, les
Widget
sont déclarés StatelessWidget
dans la très grande majorité des cas.
Les expression body
(ex : void method() => print('');
) ne sont utilisés que quand ils tiennent sur une ligne.
Dès qu'un objet prend en paramètre plusieurs autres objets de même type, l'usage des paramètres nommés s'impose.
Afin de conserver à part toutes les ressources du projet (wordings, assets…) :
- Tous les strings affichés dans l'application doivent être déclarées dans le fichier
strings.dart
. Les messages de logging ne sont pas concernés. - Toutes les images affichées dans l'application doivent être déclarées dans le fichier
drawables.dart
.
Un audit de sécurité nous a fait constater que les messages logués, même avec la méthode debugPrint()
,
apparaissaient en production. Pour éviter un tel comportement, les logs doivent se faire via la classe
Log, afin de s'assurer qu'ils ne s'affichent qu'en debug.
Le langage Dart propose des exceptions, mais il n'y a pas moyen de déclarer qu'une méthode est susceptible d'en lancer
autrement qu'en le documentant. Par contre, un retour de type nullable
est bien pris en compte par le compilateur Dart.
Aussi, le paradigme suivant est utilisé :
Quand il n'y a pas de différentiation applicative des cas non nominaux, c'est le retour nullable
qui indique si la
méthode a fonctionné nominalement ou pas. Si null
est retourné, c'est qu'il y a une erreur. Il en est de même pour les
retours de types List
: si null
est retourné, c'est qu'il y a une erreur. Si une liste vide est retournée, c'est que
le cas nominal ne renvoie aucun résultat.
Quand il y a une différentiation applicative des cas non nominaux, il est alors nécessaire d'utiliser une classe
de retour dédiée (façon sealed class
Kotlin) qui porte les retours nominaux et les différents cas d'erreur.
Dès qu'un des objets du projet interagit avec un objet (du projet ou d'une dépendance) qui lance une exception, c'est
à sa responsabilité de la try-catch
, et de propager un nullable, ou une classe de retour dédiée.
Il n'est pas question ici de définir le mécanisme de Redux, mais plutôt de partager les pratiques qui y sont liées dans le projet.
Afin d'avoir une meilleure organisation de l'ensemble des composants liés à Redux (actions
, reducers
,
middlewares
et states
), le projet est organisé avec un découpage par cas d'usage plutôt que par
un découpage technique.
Aussi, pour chaque nouveau cas d'usage, il convient de créer un répertoire dédié dans le répertoire
lib/features
, et d'y mettre tous les composants Redux correspondants.
Pour un cas d'usage donné, le nommage des fichiers actions
et states
correspond au dossier dans
lequel ils se trouvent.
Par exemple, pour le cas d'usage user_action/create
:
- les
actions
correspondantes sont placées dans le fichieruser_action_create_actions.dart
. - le
state
correspondant est placé dans le fichieruser_action_create_state.dart
.
Pour ce qui est des actions
spécifiques, leur type est à renseigner juste avant le suffixe Action
.
ex : UserActionCreateRequestAction
, UserActionCreateLoadingAction
.
Dans le cas nominaux d'usage des actions
, nous utilisons les qualificatifs :
Request
Loading
Success
Failure
Reset
Pour ce qui est des sous state
, leur type est à renseigner juste avant le suffixe State
.
ex : UserActionCreateSuccessState
, UserActionCreateLoadingState
.
Dans le cas nominaux d'usage des actions
, nous utilisons les qualificatifs :
Loading
Success
Failure
NotInitialized
Afin d'améliorer la lisibilité du code, le débogage, et de rester au maximum dans l'esprit
des spécifications Redux, il convient de ne créer qu'un seul
reducer
par sous état. Ainsi, quand on veut voir où un état est modifié, on s'assure qu'il n'y a
qu'un seul fichier auquel porter son attention.
Il est tout à fait possible que des actions
d'un autre cas d'usage modifient le sous état au sein
de ce reducer
. Par exemple, dans le cas d'une liste dans laquelle on peut créer un élément, les
actions liées à la création d'élément sont à la fois gérés par le reducer
lié au cas d'usage de la
création d'élément, et par le reducer
lié au cas d'usage de la liste pour y ajouter l'élément crée.
- Afin de n'affecter l'état global de l'application que lors de l'affichage d'un écran, par convention le
StoreConnector<AppState, ViewModel>
utilisé par leWidget
doit être paramétré comme suit :onInit: (store) => store.dispatch(Action<REQUEST, RESULT>.request(REQUEST))
pour charger l'état à l'affichage duWidget
.onDispose: (store) => store.dispatch(Action<REQUEST, RESULT>.reset())
pour réinitialiser l'état à la suppression duWidget
.
- Pour ne pas redessiner inutilement le
Widget
à chaque changement d'état, il est nécessaire de s'assurer que :- Le
StoreConnector<AppState, ViewModel>
utilisé par leWidget
doit être paramétré avecdistinct: true
. - Le
ViewModel
consommé par leWidget
surcharge les méthodes== ()
ethashcode()
, a fortiori en étendant la classeEquatable
de la librairieequatable
et en implémentant la méthodeList<Object?> get props
;
- Le
Afin d'assurer la stabilité et la documentation du code source, plusieurs types des tests automatisés doivent être systématiquement ajoutés à chaque nouvelle fonctionnalité.
La couche repository est testée "en boîte noire" à la façon d'un test d'intégration. L'usage d'un MockHttpClient
permet
de tester les repositories de la sorte. Il est possible de s'inspirer de ce qui est notamment fait dans le fichier
immersion_repository_test.dart
.
Le test du cas nominal doit permettre de s'assurer que les bons paramètres sont passés, et que le parsing est bien fait.
À cet effet, un payload nominal peut être ajouté en tant que fichier .json
au repertoire test/assets
.
Ce cas doit être testé pour s'assurer que le repository le prenne bien en compte.
Ce cas doit être également testé pour s'assurer qu'aucune exception ne soit propagée.
Afin de s'assurer que toute la boucle Redux fonctionne bien dans son ensemble (action > middleware > reducer > state),
la couche Redux est testée "en boîte noire" à la façon d'un test d'intégration. Les tests correspondants se trouvent
dans le répertoire test/feature
, et permettent de s'assurer qu'une action modifie bien le state comme attendu.
Des tests unitaires isolés de chacun des composants ne semblent pour l'heure pas pertinents.
Il est à noter que ces tests qui sont de natures asynchrones peuvent échouer à cause d'un timeout qui est dépassé. Par
défaut, le timeout est de 30 secondes. Pour raccourcir cette durée, il est possible de passer cette commande en
argument additionnel au test (en CLI ou via l'IDE) : --timeout 0.1x
. Dès lors, le test échouera après 3 secondes au
lieu de 30.
L'essentiel du fonctionnel de l'application est porté dans la couche ViewModel. Il est dès lors attendu que 100 % de la couche ViewModel soit testée unitairement.
Les dernières versions des librairies de tests doubles à l'état de l'art en Dart (ex : mockito) fonctionnent par de la génération de classe et offrent une developer experience bien moindre que leur pendant du monde Java. Pour l'heure, les tests doubles sont donc faits à la main comme suit :
- Dummy : à créer pour un objet qui renvoie toujours une valeur vide ou nulle.
- Mock : à créer pour un objet qui renvoie une valeur spécifique.
- Stub : à créer pour un objet qui renvoie une valeur spécifique en fonction de comment il est appelé.
- Spy : à créer pour un objet dont il est nécessaire de vérifier qu'il est bien appelé.
En dehors des cas mentionnés ci-dessus, il est à l'entière liberté du contributeur de tester de manière automatisée un composant qui lui semble nécessaire de l'être.
Les conventions Git du projet sont basées sur Git Conventional Commits
qui décrit de manière exhaustive les types de commits suivants : feat, fix, build, chore, ci, docs, style, refactor, perf, test
.
Pour faciliter le lien entre le Trello du projet et le projet lui-même, nous utilisons l'identifiant des tickets générés par Trello que l'on peut voir dans leurs URL.
Ex : pour ce ticket, l'URL est https://trello.com/c/lAC2Ykzp/204-doc-initier-le-contributingmd-du-projet-flutter
,
l'identifiant est donc 204
. À noter que dans Trello, l'URL https://trello.com/c/lAC2Ykzp/204
redirige bien vers le
bon ticket.
En se basant sur les prefixes de Git Conventional Commits, les branches sont nommées comme suit :
<type>/<path-de-l'url-Trello-du-ticket>
.
Ex : docs/204-doc-initier-le-contributingmd-du-projet-flutter
En se basant sur les prefixes de Git Conventional Commits, les commit sont nommés comme suit :
<type>: <id-de-l'url-Trello-du-ticket> - <description en anglais>
.
Ex : docs: 204 - initialize CONTRIBUTING.md