Cédric Creusot

Artisan Logiciel sur Mobile

TDD en Dart

Qu’est-ce que le TDD ? C’est une méthode de développement de logiciel qui pousse l’écriture des tests avant le code du logiciel.

L’origine viendrait d’abord du mouvement TFD (Test First Developement), ce qu’apporte le TDD c’est la partie dite de réécriture de code (Refactoring).

La technique est assez simple, et se fait en 3 étapes:

  1. On écrit un test qui échoue.
  2. On écrit le code correspondant pour valider le test.
  3. On vérifie s’il n’y a pas de refactoring possible.

Les 3 étapes énumérées au-dessus doivent être réalisées en itération. C’est-à-dire, vous devez faire ces étapes autant de fois que nécessaires qu’il y a de tests à produire.

Schéma des 3 étapes du TDD

La théorie est jolie, mais comment s’y prend-on ?

Et bien comme l’adage le dit :

C’est en forgeant que l’on devient forgeron !

Nous allons donc mettre ces règles en pratique ! Comment ? En utilisant un Kata ! Comme dans les arts martiaux, nous allons nous entrainer !

Illustrons cela par un cas assez complexe : BankAccount

Nous nous exercerons avec Dart.

Que nous dit l’énoncé ? Il faut écrire une classe Account qui doit offrir 3 fonctionnalités déposées de l’argent, retirer de l’argent et enfin retourner une description des opérations.

Exemple :

Date        Amount  Balance
24.12.2015   +500      500
23.8.2016    -100      400

Dans un premier temps, si vous n’avez pas installé Dart suivez les instructions ici, puis créez-vous un nouveau projet en mode console.

Vous devriez avoir votre projet sous cette forme :

bankaccount --> bin/main.dart
            |-> lib/bankaccount.dart
            |-> test/bankaccount_test.dart
            |-> analysis_options.yaml
            |-> pubspec.yaml
            |-> README.md
            |-> CHANGELOG.md
            |-> pubspec.lock
            |-> .packages
            |-> .gitignore
            |-> .dart_tool

Fonction deposit(int)

Écrire son premier test

Nous avons un biais, qui est de vouloir écrire directement ce que l’on nous demande.

Alors que faut-il faire en TDD pour écrire son premier test ?

Mon premier conseil est de commencer par ce qui ne correspond pas au scope.

Prenons la première méthode qui est de déposer de l’argent. Si l’on réfléchit à ce que doit faire cette méthode, elle doit permettre de déposer une somme d’argent positive et jamais négative.

Notre premier test devrait ressembler à ceci :

import 'package:bankaccount/bankaccount.dart';
import 'package:test/test.dart';

void main() {
  test('deposit negative number should throw an invalid argument exception', () {
    Account account = Account();

    expect(() => account.deposit(-1), throwsA(
      isA<ArgumentError>().having(
        (error) => error.message,
        "message",
        "You can't deposit negative value")));
  });
}

Nous nous assurons que la somme déposée ne sera jamais négative. Donc quand on fait un dépôt d’argent négatif, notre class Account doit émettre une erreur, même si cela n’est clairement pas demander dans notre énoncé nous devons prévoir les cas d’erreur.

Notre premier test est écrit à l’exécution de celui-ci nous allons fatalement avoir un message d’erreur, or c’est ce que l’on souhaite. Le message d’erreur va nous indiquer que la fonction n’existe pas.

Ajoutons, la méthode deposit(int) à notre Account:

class Account {
  void deposit(int value) {
  }
}

Sans l’implémenter, nous allons exécuter notre test :

pub run test
00:01 +0 -1: test\bankaccount_test.dart: deposit negative number should throw an invalid argument exception [E]
  Expected: throws <Instance of 'ArgumentError'> with `message`: 'You can\'t deposit negative value'
    Actual: <Closure: () => void>
     Which: returned <null>

  package:test_api                 expect
  test\bankaccount_test.dart 10:5  main.<fn>

00:01 +0 -1: Some tests failed.

Très bien, le moteur de test nous indique que la méthode ne fait pas ce qui est attendu. Il nous suffit ensuite d’ajouter ce qu’il manque à celle-ci :

class Account {
  void deposit(int value) {
    throw ArgumentError("You can't deposit negative value");
  }
}

On exécute le test de nouveau et nous devrions réussir à le passer cette fois :

pub run test
00:01 +1: All tests passed!

Premier test réussi ! Améliorons ça avec le 2nd.

Second test

Le second test ici sera encore un cas d’exception. Il nous faut couvrir le cas : chercher à ne déposer aucune somme (ou en tout cas 0).

Ajoutons notre nouveau test à notre fichier :

  test('deposit 0 should throw an invalid argument exception', () {
    Account account = Account();

    expect(() => account.deposit(0), throwsA(
      isA<ArgumentError>().having(
        (error) => error.message,
        "message",
        "You can't deposit 0")));
  });

Exécutons les tests à nouveau.

pub run test
00:01 +1 -1: test\bankaccount_test.dart: deposit 0 should throw an invalid argument exception [E]
  Expected: throws <Instance of 'ArgumentError'> with `message`: 'You can\'t deposit 0' 
    Actual: <Closure: () => void>
     Which: threw ArgumentError:<Invalid argument(s): You can't deposit negative value> 
            stack package:bankaccount/bankaccount.dart 7:5  Account.deposit
                  test\bankaccount_test.dart 20:26          main.<fn>.<fn>
                  package:test_api                          expect
                  test\bankaccount_test.dart 20:5           main.<fn>

            which has `message` with value 'You can\'t deposit negative value' which is 
different.
                  Expected: ... t deposit 0
                    Actual: ... t deposit negative v ...
                                          ^
                   Differ at offset 18

  package:test_api                 expect
  test\bankaccount_test.dart 20:5  main.<fn>

00:01 +1 -1: Some tests failed.

Le premier test continue de fonctionnaire or le second, ne passe pas non plus. Il y a ici une petite subtilité, nous avons changé le message d’erreur attendue.

Donc pour résoudre ce problème, il suffirait de rajouter ce message d’erreur dans notre classe Account :

class Account {
  void deposit(int value) {
    if (value == 0) {
      throw ArgumentError("You can't deposit 0");
    }
    throw ArgumentError("You can't deposit negative value");
  }
}

En exécutant la commande pour les tests :

pub run test
00:01 +2: All tests passed!

Nous avons un message nous indiquant que les 2 tests passent.

Troisième & Quatrième test

Prenons un instant pour comprendre l’intérêt d’avoir fait les 2 premiers tests avant d’entamer le 3ème test.

Pourquoi avoir fait ces 2 tests alors qu’il aurait bien pu suffire de se concentrer que sur le périmètre fonctionnel ?

Prenons le schéma suivant :

[Mettre le schéma]

L’intérêt de faire 2 tests farfelus était pour prévoir des cas d’erreurs, pouvant intervenir lorsque l’utilisateur de notre classe n’utilise pas celle-ci dans le petit périmètre défini.

À chaque périmètre d’erreur géré, on rend donc notre logiciel plus robuste.

Le dernier test est donc le test fonctionnel, l’attendue que l’on nous demande depuis le début. Ici pour la méthode deposit() ce sera ajouter une somme d’argent au compte.

Notre “dernier” test.

  test('deposit positive value should return new Account with positive balance', () {
    Account account = Account();

    Account newAccount = account.deposit(1);

    expect(newAccount.balance(), 1);
  });

Remarqués, ici nous ne cherchons pas à tester le retour du print statement, nous le verrons bien plus tard. Ici pour vérifier que l’on ajoute bien de l’argent à notre compte, nous utilisons pour l’instant une fonction qui calcule la balance de celui-ci.

Nous obtenons donc :

class Account {
  Account deposit(int value) {
    if (value == 0) {
      throw ArgumentError("You can't deposit 0");
    } 
    if (value < 0) {
      throw ArgumentError("You can't deposit negative value");
    }
    return Account();
  }

  int balance() {
    return 1;
  }
}

Eh oui, ici, j’ai décidé de tricher. Comment donc m’assurer que j’ai bien ajouter le l’argent ? Avec un 4ème test…

  test('deposit 10 then 100 should return new Account with 110 for balance', () {
    Account account = Account();

    Account tmp = account.deposit(10);
    Account newAccount = tmp.deposit(100);

    expect(newAccount.balance, 110);
  });

Ce qui nous donneras cette implémentation :

class Account {
  final int balance;

  Account({this.balance = 0});

  Account deposit(int value) {
    if (value == 0) {
      throw ArgumentError("You can't deposit 0");
    } 
    if (value < 0) {
      throw ArgumentError("You can't deposit negative value");
    }
    return Account(balance: balance + value);
  }
}

Nous avons une classe Account qui nous permet de déposer de l’argent ! Et qui nous fournis la balance courante.

Fonction withdraw(int)

Même opération pour le withdraw, à l’exception que l’on n’aura pas besoin d’écrire 4 cas de test. Ici on en écrira que 3. Je vous laisse expérimenter par vous-même. ;-)

Garder en tête que vous devez toujours tester l’inconnue qui est en dehors de votre scope.

Schéma de différent scopes en TDD

Remarques :

  • Vous aurez remarqué que j’ai fait le choix de renvoyer un nouveau Account à chaque ajout. Faite de même pour le retrait. C’est un choix personnel, un petit défi supplémentaire que j’ai choisi de m’imposer.

  • Ici nous passons souvent par une phase Écriture du Test -> Exécution du dit Test -> Validation de Échec -> Écriture de la solution -> réexécution du Test -> Validation du passage du Test. Or il nous manque souvent cette phase de réfactorisation. Celle-ci peut s’appliquer sur les Tests comme sur le code implémenté.

Nous respectons pas pour l’instant ce schéma :

Passons à l’étape du printStatement() qui doit nous retourner une chaine de caractère. Nous verrons le refactoring, mais un peu plus tard, c’est souvent une appréciation personnelle.

Fonction String printStatement()

Pour illustrer que l’on ne pense pas à tout, je vais me baser sur la première idée que j’aurais pour faire un test pour cette méthode.

    test('printStatement after a deposit should show the date of the deposit and the value given and the current balance',
      () {
    var account = Account();

    var statement = account.deposit(500).printStatement();

    var currentDate = DateTime.now();

    expect(statement, '''
    Date\t\t\t\tAmount\t\t\t\tBalance
    ${currentDate.day}.${currentDate.month}.${currentDate.year}\t\t\t\t500\t\t\t\t500
    ''');

  });

Mon code de printStatement() pour l’instant ressemble à ça :

  String printStatement() {
    return null;
  }

Cela ne vous surprendra donc pas que ce test va échouer. Pour résoudre l’erreur, il suffit donc d’implémenter la méthode :

  String printStatement() {
    var currentDate = DateTime.now();
    return '''
    Date\t\t\t\tAmount\t\t\t\tBalance
    ${currentDate.day}.${currentDate.month}.${currentDate.year}\t\t\t\t$balance\t\t\t\t$balance
    ''';
  }

Ce test me tracasse, il semblerait que j’ai souhaité allez un peu trop vite. Le premier test que j’aurais dû appliquer est celui-ci :

  test('printStatement without statement deposit', () {
    var account = Account();

    var statement = account.printStatement();

    expect(statement, '''
    Date\t\t\t\tAmount\t\t\t\tBalance
        \t\t\t\t      \t\t\t\t0
    ''');
  });

En effet, si je possède un tout nouveau compte, il est normal de n’avoir aucune opération et une balance de 0. Ce choix est arbitraire. J’aurais très bien pu me concentrer sur le retour de la première ligne.

Remarque en rajoutant ce test, je me suis rendue à l’évidence qu’il serait préférable de parler de ces opérations. J’ai donc choisi de refactorer la classe Account de cette façon :

class Account {
  final List<Transaction> transactions;

  int get balance =>
      transactions.fold(0, (previous, current) => previous + current.value);

  Account({this.transactions = const []});

  Account deposit(int value) {
    if (value == 0) {
      throw ArgumentError("You can't deposit 0");
    }
    if (value < 0) {
      throw ArgumentError("You can't deposit negative value");
    }
    var newList = List.of(transactions);
    newList.add(Transaction(value));
    return Account(transactions: newList);
  }

  Account withdraw(int value) {
    if (value == 0) {
      throw ArgumentError("You can't withdraw 0");
    }
    if (value < 0) {
      throw ArgumentError("You can't withdraw negative value");
    }
    var newList = List.of(transactions);
    newList.add(Transaction(-value));
    return Account(transactions: newList);
  }

  String printStatement() {
    var currentDate = DateTime.now();
    if (transactions.isEmpty) {
      return '''
    Date\t\t\t\tAmount\t\t\t\tBalance
        \t\t\t\t      \t\t\t\t$balance
    ''';
    }
    return '''
    Date\t\t\t\tAmount\t\t\t\tBalance
    ${currentDate.day}.${currentDate.month}.${currentDate.year}\t\t\t\t$balance\t\t\t\t$balance
    ''';
  }
}

J’ai choisi de matérialiser les transactions, et je garde toujours ma propriété qui me permet de calculer la balance. La classe Transaction :

class Transaction {
  final DateTime date = DateTime.now();
  final int value;

  Transaction(this.value);
}

Passons à la suite.

Le dernier test

J’opte pour l’utilisation de plusieurs opérations pour vérifier si mon algorithme pour le String printStatement() va fonctionner.

    test(
      'printStatement after a deposit and withdraw should show the date of the deposits and the values given and the current balance for each operations',
      () {
    var account = Account();

    var statement = account.deposit(500).withdraw(100).deposit(200).printStatement();

    var currentDate = DateTime.now();

    expect(statement, '''
    Date\t\t\t\tAmount\t\t\t\tBalance
    ${currentDate.day}.${currentDate.month}.${currentDate.year}\t\t\t\t500\t\t\t\t500
    ${currentDate.day}.${currentDate.month}.${currentDate.year}\t\t\t\t-100\t\t\t\t400
    ${currentDate.day}.${currentDate.month}.${currentDate.year}\t\t\t\t200\t\t\t\t600
    ''');
  });

Ce qui nous donne cette solution :

  String printStatement() {
    var balance = 0;
    if (transactions.isEmpty) {
      return '''
    Date\t\t\t\tAmount\t\t\t\tBalance
        \t\t\t\t      \t\t\t\t$balance
    ''';
    }
    var transactionStatements = <String>[];
    for (var transaction in transactions) {
      balance += transaction.value;
      transactionStatements.add("${transaction.date.day}.${transaction.date.month}.${transaction.date.year}\t\t\t\t${transaction.value}\t\t\t\t$balance");
    }
    return '''
    Date\t\t\t\tAmount\t\t\t\tBalance${transactionStatements.fold("", (previous, current) => previous + "\n    " + current)}
    ''';
  }

Les tests passes tous, mais je considère que le code dans l’ensemble peut-être amélioré.

Refactoring

Que peut-on améliorer ? Sur le dernier test nous nous basons que sur la date en cours. Or, l'Account peut être créée et vivre très longtemps dans le temps, il faut pouvoir le modéliser. Comment y procéder ?

Je dirais en créant une interface qui permet de délivrer la date du jour. En Dart nous n’avons pas la possibilité de faire des interfaces alors nous partons sur une classe abstraite.

abstract class DateProvider {
  DateTime current();
}

Nous pouvons maintenant procéder à une évolution de la classe Account

class Account {
  final List<Transaction> transactions;
  final DateProvider _dateProvider;

  Account(this._dateProvider, {this.transactions = const []});

  Account deposit(int value) {
    if (value == 0) {
      throw ArgumentError("You can't deposit 0");
    }
    if (value < 0) {
      throw ArgumentError("You can't deposit negative value");
    }
    var newList = List.of(transactions);
    newList.add(Transaction(_dateProvider.current(), value));
    return Account(_dateProvider, transactions: newList);
  }

  Account withdraw(int value) {
    if (value == 0) {
      throw ArgumentError("You can't withdraw 0");
    }
    if (value < 0) {
      throw ArgumentError("You can't withdraw negative value");
    }
    var newList = List.of(transactions);
    newList.add(Transaction(_dateProvider.current(), -value));
    return Account(_dateProvider, transactions: newList);
  }

  String printStatement() {
    var balance = 0;
    if (transactions.isEmpty) {
      return '''
    Date\t\t\t\tAmount\t\t\t\tBalance
        \t\t\t\t      \t\t\t\t$balance
    ''';
    }
    var transactionStatements = <String>[];
    for (var transaction in transactions) {
      balance += transaction.value;
      transactionStatements.add("${transaction.date.day}.${transaction.date.month}.${transaction.date.year}\t\t\t\t${transaction.value}\t\t\t\t$balance");
    }
    return '''
    Date\t\t\t\tAmount\t\t\t\tBalance${transactionStatements.fold("", (previous, current) => previous + "\n    " + current)}
    ''';
  }
}

Très bien notre classe a évolué, mais que pouvons-nous faire pour les tests ? Parce que a vue d’oeil cela ne change pas grand-chose. Et bien l’avantage d’utiliser maintenant une classe abstraite DateProvider nous pouvons facilement la mocker dans les tests

class MockDateProvider extends Mock implements DateProvider {}

test(
      'printStatement after a deposit and withdraw should show the date of the deposits and the values given and the current balance for each operations',
      () {
    when(dateProvider.current()).thenReturn(DateTime.utc(2019, 2, 2));
    var newAccount = account.deposit(500);
    when(dateProvider.current()).thenReturn(DateTime.utc(2019, 6, 24));
    newAccount = newAccount.withdraw(100);
    when(dateProvider.current()).thenReturn(DateTime.utc(2020, 1, 29));
    newAccount = newAccount.deposit(200);
    var statement = newAccount.printStatement();

    expect(statement, '''
    Date\t\t\t\tAmount\t\t\t\tBalance
    2.2.2019\t\t\t\t500\t\t\t\t500
    24.6.2019\t\t\t\t-100\t\t\t\t400
    29.1.2020\t\t\t\t200\t\t\t\t600
    ''');
  });

Comme vous pouvez le voir, nous avons un meilleur contrôle sur la date, donc nous pouvons faire voyager dans le temps nos transactions.

Nous avons donc réglé ce problème de temps. Il reste un dernier point. Il y a plusieurs tests qui dans leurs finalités ne servent plus à rien.

L’utilisation de la balance n’a plus vraiment d’intérêt. C’est pour ça que nous allons nous en séparer et de même pour les tests qui l’utilisent.

Ce qui donne ce refactoring final pour la classe Account :

class Account {
  final List<Transaction> transactions;
  final DateProvider _dateProvider;

  Account(this._dateProvider, {this.transactions = const []});

  Account deposit(int value) {
    if (value == 0) {
      throw ArgumentError("You can't deposit 0");
    }
    if (value < 0) {
      throw ArgumentError("You can't deposit negative value");
    }
    var newList = List.of(transactions);
    newList.add(Transaction(_dateProvider.current(), value));
    return Account(_dateProvider, transactions: newList);
  }

  Account withdraw(int value) {
    if (value == 0) {
      throw ArgumentError("You can't withdraw 0");
    }
    if (value < 0) {
      throw ArgumentError("You can't withdraw negative value");
    }
    var newList = List.of(transactions);
    newList.add(Transaction(_dateProvider.current(), -value));
    return Account(_dateProvider, transactions: newList);
  }

  String printStatement() {
    var balance = 0;
    if (transactions.isEmpty) {
      return '''
    Date\t\t\t\tAmount\t\t\t\tBalance
        \t\t\t\t      \t\t\t\t$balance
    ''';
    }
    var transactionStatements = <String>[];
    for (var transaction in transactions) {
      balance += transaction.value;
      transactionStatements.add("${transaction.date.day}.${transaction.date.month}.${transaction.date.year}\t\t\t\t${transaction.value}\t\t\t\t$balance");
    }
    return '''
    Date\t\t\t\tAmount\t\t\t\tBalance${transactionStatements.fold("", (previous, current) => previous + "\n    " + current)}
    ''';
  }
}

Et enfin les tests dans leurs états finaux :

class MockDateProvider extends Mock implements DateProvider {}

void main() {
  var dateProvider;
  var account;
  setUp(() {
    dateProvider = MockDateProvider();
    account = Account(dateProvider);
  });

  test('deposit negative number should throw an invalid argument exception',
      () {
    expect(
        () => account.deposit(-1),
        throwsA(isA<ArgumentError>().having((error) => error.message, 'message',
            "You can't deposit negative value")));
  });

  test('deposit 0 should throw an invalid argument exception', () {
    expect(
        () => account.deposit(0),
        throwsA(isA<ArgumentError>().having(
            (error) => error.message, 'message', "You can't deposit 0")));
  });

  test('withdraw negative number should throw an invalid argument exception',
      () {
    expect(
        () => account.withdraw(-1),
        throwsA(isA<ArgumentError>().having((error) => error.message, 'message',
            "You can't withdraw negative value")));
  });

  test('withdraw 0 should throw an invalid argument exception', () {
    expect(
        () => account.withdraw(0),
        throwsA(isA<ArgumentError>().having(
            (error) => error.message, 'message', "You can't withdraw 0")));
  });

  test('printStatement without statement deposit', () {
    var statement = account.printStatement();

    expect(statement, '''
    Date\t\t\t\tAmount\t\t\t\tBalance
        \t\t\t\t      \t\t\t\t0
    ''');
  });

  test(
      'printStatement after a deposit and withdraw should show the date of the deposits and the values given and the current balance for each operations',
      () {
    when(dateProvider.current()).thenReturn(DateTime.utc(2019, 2, 2));
    var newAccount = account.deposit(500);
    when(dateProvider.current()).thenReturn(DateTime.utc(2019, 6, 24));
    newAccount = newAccount.withdraw(100);
    when(dateProvider.current()).thenReturn(DateTime.utc(2020, 1, 29));
    newAccount = newAccount.deposit(200);
    var statement = newAccount.printStatement();

    expect(statement, '''
    Date\t\t\t\tAmount\t\t\t\tBalance
    2.2.2019\t\t\t\t500\t\t\t\t500
    24.6.2019\t\t\t\t-100\t\t\t\t400
    29.1.2020\t\t\t\t200\t\t\t\t600
    ''');
  });
}

Mot de la fin, il y a bien entendu pas qu’une façon de faire ce genre d’exercice. L’idée ici était de vous entrainez à la méthode TDD en vous faisant parcourir avec moi, un exercice tel que le BankAccount.

Retenez ce schéma :

Les 3 étapes du TDD

Essayez-vous aux autres exercices pour vous entrainer ! Variez les langages et technologies utilisées. Ils sont utiles lors d’apprentissage d’un nouveau langage. Donc, entrainez-vous ! Seule ou à plusieurs ;-)

source: Wikipedia

source: Kata-log.rocks