Exercice de TDD et spécification
Le calcul des colonnes du Kanban en mode "visualiser en colonnes et par mois"
Suite à une discussion sur le TDD, je partageais récemment sur LinkedIn un exercice de programmation inspiré d'une fonctionnalité récente de Klaro Cards.
L'exercice
Dans Klaro Cards, quand vous avez une série de cartes avec une date, vous pouvez les visualiser en colonnes. Dans l'exemple, les posts du blog peuvent être visualisés par mois, quadrimestre, ou année. Vous pouvez aussi décider d'afficher ou pas les colonnes vides.
Vous êtes en charge d'écrire la logique derrière cette fonctionnalité. Etant donné un ensemble de cartes avec une date de publication, calculer les colonnes à afficher. Une fois votre programme écrit, ajoutez l'option qui permet d'afficher ou cacher les colonnes vides.
Pour garder l'exercice simple, on s'intéresse uniquement à la fonction qui calcule les colonnes à afficher, et seulement avec une périodicité en mois. Vous n'êtes même pas obligé de calculer les groupes de cartes pour chaque colonne (mais vous pouvez).
Utilisez le langage que vous souhaitez, la méthode que vous souhaitez (BDD, TDD, Example Mapping, Vibe Coding, etc.). On s'intéresse en particulier à ce que vous appelez "Spécification" une fois le job terminé.
Le contexte
La raison pour laquelle j'ai proposé l'exercice, c'est qu'on tend parfois à faire du TDD un rituel obligatoire.
J'utilise moi-même le TDD très souvent, parce qu'il permet de découvrir une spécification par induction : on part d'exemples simples, et on monte en complexité dans un processus de généralisation (voir plus loin). Mais je n'utilise pas le TDD tout le temps, parce que j'ai d'autres armes :
- si je sais écrire directement la spécification, je l'utilise dans un schéma de testing plus classique (voir ci-dessous)
- si je sais résumer le problème à une propriété mathématique triviale, je peux même skipper les tests complètement
En pratique, j'utilise les 3 conjointement. Je vous propose de commencer par la fin, en les prenant dans l'ordre suivant :
- L'approche mathématique, en utilisant SQL puis Bmg
- L'approche spécification + tests, en Typescript
- L'approche TDD, en Typescript toujours
L'approche mathématique
Avec un peu d'abstraction, on comprend vite qu'on veut trouver tous les mois entre un MIN (début du mois de la plus petit date de publication) et un MAX (début du mois suivant la plus grande date de publication). Moi mon language "mathématique" réel et préféré, c'est encore SQL, et la solution est directe.
L'intuition (pseudo code), que je n'imagine même pas devoir tester, personnellement :
SELECT generate_series([min date], [max date], interval '1 month')
En pratique, parce que SQL est un peu verbeux, on va devoir écrire plus de code pour trouver ces MIN et MAX que de résoudre le problème lui-même. Pour autant la solution tient en quelques lignes qui ne nécessitent pas de temps de testing exagéré :
WITH
date_bounds AS (
SELECT
date_trunc('month', MIN(publication_date)) AS start_month,
date_trunc('month', MAX(publication_date)) AS end_month
FROM
cards
)
SELECT
a_month
FROM
date_bounds,
generate_series(start_month, end_month, INTERVAL '1 month') AS a_month
(ça m'aura pris 4 minutes)
Supprimer les colonnes vides
La magie de SQL, des langages déclaratifs en fait, c'est qu'on ne doit même pas refactorer le programme pour ajouter un requis. Pour supprimer les colonnes vides, on ajoute un WHERE
et le tour est joué. Là aussi, pas sûr que je passerais plein d'heures à tester :
[...]
WHERE EXISTS (
SELECT
*
FROM
cards
WHERE
publication_date >= a_month
AND
publication_date < a_month + INTERVAL '1 month'
);
L'approche relationnelle
Vous me direz peut-être : ok, mais si SQL n'est pas une option, je fais quoi ? Ce n'est pas pour rien que je fais la promotion du relationnel en dehors des bases de données. En Bmg, 100% Ruby, la solution ressemble à ceci :
## Trouver les min & max, et les ramener en début de mois
mm = cards.summarize([], {
:min => Bmg::Summarizer.min(:publication_date),
:max => Bmg::Summarizer.max(:publication_date),
}).transform(&:beginning_of_month).one
## Calculer les mois avec l'équivalent de generate_series
## filtrer sur ceux qui ont au moins une carte
Bmg::Relation
.generate(mm[:min], mm[:max], step: ->(d){ d.next_month }, as: :publication_date)
.matching(cards.transform(:publication_date => ->(t){ t.beginning_of_month }))
Je ne vais pas détailler cette solution ici, j'en ferai un post un peu plus long plus tard. Je cadre souvent l'écriture d'un tel code par un ou deux tests (c'est moins déclaratif que SQL). Une fois que le test passe, je sais que c'est 100% correct, parce que je connais la sémantique mathématique des opérateurs summarize
, transform
, generate
et matching
.
(ça m'aura pris 6 minutes)
L'approche spécification
Si je réflechis d'abord en terme de spécification, je vais aborder le problème tout à fait autrement. Je vais écrire le pseudo code suivant :
/**
* Retourne une liste de mois qui couvre les dates de publication des cartes
*
* PRE 1: l'ensemble des cartes peut être vide
* PRE 2: chaque carte a une date de publication obligatoire
*
* POST 1: la liste retournée est complète, i.e. chaque carte est au moins dans une colonne
* POST 2: la liste retournée est continue, i.e. la ligne du temps n'est pas interrompue
* POST 3: la liste retournée est minimale, i.e. premier et dernier mois ont au moins une carte
*/
function compute_columns(cards)
...
end
On voit que l'angle est complètement différent, et complémentaire :
- On avait pas pensé au
NULL
dans la solution précédente, on y a pensé naturellement ici - On a mieux compris les qualités de la solution qu'on veut construire
- On précise ce qu'il faudrait idéalement toujours tester (dans tous les exemples ou cas de tests) : les trois POST-conditions
Remonter vers les objectifs utilisateurs
Ces PRE
et POST
conditions sont une invitation à mieux comprendre les objectifs utilisateur. On va poser la question "POURQUOI".
PRE 1
: pourquoi supporter l'ensemble vide ? Parce que l'utilisateur pourrait n'avoir aucune cartePRE 2
: pourquoi imposer une date ? pour faire simple pour l'instant (à lever plus tard)POST 1
: Pourquoi complète ? Parce que l'utilisateur ne veut pas perdre de carte de vuePOST 2
: Pourquoi continue ? Parce que cela semble plus intuitif pour l'utilisateur d'avoir une ligne du temps non interrompuePOST 3
: Pourquoi minimale ? Parce que l'utilisateur ne veut pas devoir scroller à gauche pour voir les premières cartes, ni inutilement à droite alors qu'il ne trouvera plus de carte
Découvrir un conflit
Si la liste de cartes est vide (PRE 1
), aucun mois n'est retourné (à cause de POST 3
qui impose la minimalité), il n'y aura donc aucune colonne affichée.
Cela peut poser un souci de UX puisque cliquer dans une colonne permet de créer une nouvelle carte.
Cacher les colonnes vides
L'ajout de l'option qui permet de cacher les colonnes vides, revient à affiner la spécification :
/**
* [...]
* POST 2: si hide_empty_columns=false, alors la liste retournée est continue, i.e. la ligne du temps n'est pas interrompue
* [...]
* POST 4: si hide_empty_columns=true, alors la liste est compacte, i.e. chaque mois a au moins une carte
*/
function compute_columns(cards, hide_empty_columns)
...
end
Dériver les cas de tests
La spécification permet de plus facilement trouver les cas de tests qui nous intéressent :
- Aucune carte
- Deux cartes publiée le même mois
- Deux cartes publiées deux mois qui se suivent
- Deux cartes publiées avec un écart d'au moins un mois, sans cacher les colonnes vides
- Deux cartes publiées avec un écart d'au moins un mois, en cachant les colonnes vides
- Trois cartes réparties un peu aléatoirement
On testera sans doute les valeurs exactes, mais aussi les trois postconditions (voir plus loin).
L'approche TDD
Solutionner la problématique en TDD est nettement plus incrémental.
J'aurai fait 10 étapes environ. Ca m'aura pris environ 45 minutes en appliquant patiemment les incréments d'Uncle Bob dans The Transformation Priority Premise. Stupide erreur par contre: j'ai trop rapidement voulu intégrer le fait de cacher les colonnes vides (Step 6) et ai du changer d'avis à l'étape 8 pour y revenir plus tard.
L'algorithme au final
Je termine ici avec l'algorithme ci-dessous. J'avoue ne pas le trouver particulièrement élégant. Il m'aura surtout fallu bien plus de temps pour l'écrire que la version SQL.
import { DateTime } from 'luxon';
export type Card = { publication_date: DateTime }
export const monthOf = (card: Card): DateTime => card.publication_date.startOf('month')
export const computeColumns = (cards: Card[], hide_empty_columns: boolean = false) => {
if (!cards.length) return [];
const months = cards.map(card => monthOf(card)).sort();
const min = months[0];
const max = months[months.length - 1];
const result = [];
let current = min;
while (current <= max) {
if (!hide_empty_columns || cards.some(card => monthOf(card).equals(current))) {
result.push(current);
}
current = current.plus({month: 1})
}
return result;
}
Ajout de "vrais" tests de couverture
Comme je n'étais d'ailleurs pas vraiment convaincu que cet algorithme rencontrait sa spécification, j'ai fini par coder de vrais tests des post-conditions :
J'ai fini par ajouter un test qui vérifie ces post-conditions sur des données aléatoires. Comme les tests n'ont pas révélé de bug, je termine relativement sûr de moi, mais beaucoup moins sûr que la version SQL. Cette dernière cache peut-être un bug, cela dit, puisque je n'ai pas codé de tests. Il faudrait évidemment le faire en s'aidant de la spécification.
Conclusion
Excellent exercice, qui m'aura pris la matinée, écriture de ce blog post compris. Il m'a permi de montrer les trois approches. Je les trouve vraiment complémentaires. Personnellement j'utilise l'approche mathématique dès que le problème me le permet, en utilisant la spécification comme levier pour écrire des tests fonctionnels. J'utilise BDD et TDD systématiquement dans deux cas que j'admets fort fréquents :
- quand la spécification n'est pas claire au départ, et qu'il ne m'est pas possible de résoudre ce problème en cherchant à l'écrire
- quand le problème nécessite un vrai travail d'architecture, auquel cas j'utilise plutôt du BDD que du TDD
Et vous, quelle approche préférez-vous ?