Exercice de TDD et spécification

Le calcul des colonnes du Kanban en mode "visualiser en colonnes et par mois"

Tech 23.05.2025
Table des matières

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 :

  1. L'approche mathématique, en utilisant SQL puis Bmg
  2. L'approche spécification + tests, en Typescript
  3. 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 carte
  • PRE 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 vue
  • POST 2 : Pourquoi continue ? Parce que cela semble plus intuitif pour l'utilisateur d'avoir une ligne du temps non interrompue
  • POST 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.

Voir l'historique sur github

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 :

Voir l'historique sur github

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 ?

Table des matières