C’est ce que la littérature éxprimé par ’ne pas tester le détail ou l’implémentation’. Un sujet très souvent évoqué, mais malheureusement mal compris. Dans mon entourage, il n’est pas rare que les collègues acceptent cette règle, mais ne comprennent pas comment l’appliquer en situation réelle.

L’une des situations que j’ai en horreur est lorsque les tests sont couplés avec la structure. Autrement dit, lorsque les tests se basent trop sur la structure de données plutôt que sur le comportement. Certes, beaucoup de développeurs que j’ai rencontré acceptent que l’on ne devrait pas coupler ainsi, mais il n’y a que très peu d’entre eux qui s’en assurent réellement.

Ceci induit une restriction forte : les tests empêchent le refactoring.

Observons les 2 situations avec un exemple :

Une machine à jouet comme state machine

Essayons de faire un code minime pour y arrive :

Tous les exemples de code sont sur https://www.github.com/worming004/CSharpStateMachineForBlog.

namespace Machine;

public class Bubble
{
    public string? Name { get; }

    public Bubble(string? name)
    {
        Name = name;
    }
}

public interface IState
{
    void PutMoney(decimal amount);
    Bubble Turn();
    StateName GetStateName();
}

public enum StateName
{
    Iddle,
    WithMoney
}

public class StateMachine
{
    private IState _state;
    private List<Bubble> _bubbles;
    private List<decimal> _moneys;
    private Random _random;

    protected StateMachine(IEnumerable<Bubble> bubbles, Random? random = null)
    {
        _bubbles = bubbles.ToList();
        _moneys = new();
        _random = random ?? new Random();
    }
    public virtual void PutMoney(decimal amount)
    {
        _state.PutMoney(amount);
    }
    public virtual Bubble Turn()
    {
        return _state.Turn();
    }

    internal virtual void Attach(IState state)
    {
        _state = state;
    }

    internal virtual void AddMoney(decimal amount)
    {
        _moneys.Add(amount);
    }

    internal virtual Bubble PopBubble()
    {
        if (_bubbles.Count == 0)
        {
            return null;
        }

        var index = _random.Next(_bubbles.Count);
        var bubble = _bubbles[index];
        _bubbles.RemoveAt(index);
        return bubble;
    }

    public virtual StateName GetStateName()
    {
        return _state.GetStateName();
    }

    public static StateMachine New(IEnumerable<Bubble> bubbles, Random? random = null)
    {
        var machine = new StateMachine(bubbles, random);
        var state = new IddleState(machine);
        return machine;
    }
}

internal class WithMoneyState : IState
{
    private StateMachine _machine;

    public WithMoneyState(StateMachine machine)
    {
        _machine = machine;
        _machine.Attach(this);
    }

    public void PutMoney(decimal amount)
    {
        // Nothing
    }

    public Bubble Turn()
    {
        var bubble = _machine.PopBubble();
        _machine.Attach(new IddleState(_machine));
        return bubble;
    }

    public StateName GetStateName()
    {
        return StateName.WithMoney;
    }
}

internal class IddleState : IState
{
    private StateMachine _machine;

    public IddleState(StateMachine machine)
    {
        _machine = machine;
        _machine.Attach(this);
    }

    public void PutMoney(decimal amount)
    {
        _machine.AddMoney(amount);
        _machine.Attach(new WithMoneyState(_machine));
    }

    public Bubble Turn()
    {
        // Nothing
        return null;
    }

    public StateName GetStateName()
    {
        return StateName.Iddle;
    }
}

La machine a 2 états possibles. Soit, elle est en IddleState, accepte de la monnaie pour être chargé. Soit, elle est en WithMoneyState, capable de faire un Turn pour délivrer une Bubble contenant un jouet.

Chaque IState applique une action si c’est une transition valide, où ne fait rien si c’est une transition invalide.

Et chaque State est associé à la machine, la machine s’attache au state pour pouvoir transmettre les prochaines transitions.

C’est grossièrement une machine à état par défaut proposé par un site que j’utilise comme référence : State Pattern

Note a moi-même, consulter le site de référence d’abord pour y voir que le code d’exemple est suffisant pour l’article, et y copier-coller le code d’exemple plutôt que de passer du temps à le coder soi-même.

Les tests couplés structurellement

À ce stade, il est tentant d’écrire des tests directement sur les 2 classes IddleState et WithMoneyState. Écrivons-en naïvement un.

using Machine;

namespace Machine.Tests.Coupling;

public class IddleStateTests
{
    private class NullMachine : StateMachine
    {
        internal NullMachine() : base(new List<Bubble>(), null)
        {
        }
        public IState ReceivedState { get; private set; }
        public override void PutMoney(decimal amount)
        {
        }
        public override Bubble Turn()
        {
            return null;
        }
        internal override void Attach(IState state)
        {
            ReceivedState = state;
        }
        internal override void AddMoney(decimal amount)
        {
        }
        internal Bubble PopBubble()
        {
            return null;
        }
    }

    [Fact]
    public void IddleState_AcceptMoney_ShouldChangeStateToWithMoney()
    {
        // Arrange
        var numMachine = new NullMachine();
        var iddleState = new IddleState(numMachine);

        // Act
        iddleState.PutMoney(10);

        // Assert
        Assert.Equal(numMachine.ReceivedState.GetType(), typeof(WithMoneyState));
    }
}

Un test au style courant, quoique la majorité de mes confrères m’auraient fait remarquer qu’il est mieux d’utiliser une interface sur StateMachine pour pouvoir le moquer plus facilement. Mais passons. Il utilise un NullObject, avec une légère pointe de Fake pour y stocker l’historique des changements appliqués.

Qu’est-ce que je reproche a ce test ? Qu’il a connaissance que la StateMachine soit coupé en interface. Le state pattern a été sélectionné dans le design de notre application, car il convient très bien à notre cas d’utilisation. Mais c’est avant tout un détail d’implémentation. Ce que nous voulions était un objet capable de passer d’un état à un autre, le pattern employé ne devrait pas être exposé.

Tant que nous parlons de patterns, avez-vous remarqué que la classe StateMachine est une façade ? Un objet spécialement désigné pour être exposé publiquement ? Et qu’il encapsule le State pattern ?

Que se passe-t-il si nous devions changer de pattern ? Nous devrions réécrire les tests pour les faire matcher avec le pattern précédent, ce qui est contraire à une de ses raisons d’être : valider qu’un refactoring n’ait rien cassé.

Il n’est pas évident de repérer quand un test couple structurellement. Même en étant attentif, il m’arrive de tomber dans ce piège qui nous semble tellement intuitif.

Les tests doivent tester le comportement, pas la structure

Observons cette fois un test qui ne couple pas structurellement. Acceptons ce test :

namespace Machine.Tests;

public class StateMachineTests
{
    private class NotRandom : Random
    {
        public int NextRandom { get; set; }
        public override int Next(int maxValue) => NextRandom;
    }

    [Fact]
    public void Machine_AcceptMoney_ShouldChangeStateToWithMoney()
    {
        // Arrange
        var machine = StateMachine.New(new List<Bubble>() { new Bubble("Charizard") }, null);

        // Act
        machine.PutMoney(10);

        // Assert
        Assert.Equal(StateName.WithMoney, machine.GetStateName());
    }

    [Fact]
    public void Machine_Turn_GotABubble()
    {
        // Arrange
        var notRandom = new NotRandom { NextRandom = 1 };
        var bubbles = new List<Bubble>() { new Bubble("Charizard"), new Bubble("Mewtwo") };
        var expectedBubble = bubbles[1];
        var machine = StateMachine.New(bubbles, notRandom);
        try
        {
            machine.PutMoney(10);
            Assert.Equal(StateName.WithMoney, machine.GetStateName());
        }
        catch (Exception ex)
        {
            throw new Exception("Cannot put state in wanted state", ex);
        }

        // Act
        var gotBubble = machine.Turn();

        // Assert
        Assert.Equal(StateName.Iddle, machine.GetStateName());
        Assert.Equal(expectedBubble, gotBubble);
    }

    [Fact]
    public void Machine_WholeUseCase_UserPutMoney_GotBubbles()
    {
        // Arrange
        var notRandom = new NotRandom { NextRandom = 1 };
        var bubbles = new List<Bubble>() { new Bubble("Charizard"), new Bubble("Mewtwo") };
        var expectedBubble = bubbles[1];
        var machine = StateMachine.New(bubbles, notRandom);

        machine.PutMoney(10);
        var gotBubble = machine.Turn();

        Assert.Equal(expectedBubble, gotBubble);
    }
}

Cette fois, Seul 3 éléments du projet Machine sont connus du test :

  • l’enum StateName
  • la façade StateMachine
  • le type d’objet Bubble

Ce sont 3 objets qui étaient de toute manière connus de la couche de l’application. L’enum est un type de donnée, la façade est une façade, et le type d’objet Bubble est un objet métier. Ce test n’a pas agrandi la surface d’API public. Ainsi, le format que prend ce test garanti que je peux changer la structure interne, et vérifier le comportement de mon domaine. Il y a aussi un effet de bord positif, mais involontaire : si je devais à appliquer un breaking change dans mon application, alors il est probable qu’un de ses tests le catch et m’en protège.

Tester les objets qui ont de la cohésion entre eux

Notre code d’exemple partage du code qui ont une grande cohésion. La StateMachine est le point d’entrée de toutes nos opérations, et tous les IStates ont pour seules raisons d’exister de servir la StateMachine. Par ce fait, ces deux types partagent une grande cohésion.

Un principe moins connu est de tester ensemble les objets qui ont une grande cohésion. C’est à peu près tout le point de cet article. À la fois, en testant conjointement les objets, vous vous assurez que l’application fonctionne correctement, les probabilités de tester de vrais cas d’utilisations deviennent plus probable. Est-ce réellement intéressant qu’un IddleState retourne applique un WithMoneyState sur autre chose ? Un peu. Est-ce mieux de tester qu’une fois que le IddleState a un comportement espéré dans son ensemble, pour arriver à un résultat réel et similaire a ce qui tournera en production ? Je suis convaincu que oui.

Toutefois, Chaque IState ont aussi un couplement faible. C’est une qualité du StatePattern qui permet de refactorer facilement, mais aussi de faire des units tests tels qu’ils sont dans la version que je critique. Parce qu’il est possible d’unit-tester IddleState sans la StateMachine d’origine est une bonne chose. Le faire n’est pas bon.

D’où vient la confusion ?

La règle populaire connu de presque tous est “tester une seule chose”. Il est facile et naturel d’interpréter que cela signifie qu’il faut tester qu’une seule classe. Visuellement, lorsque nous ouvrons un fichier, et habituellement dans la communauté C#, nous ne voyons qu’une seule classe. L’erreur de déduction est de croire que l’unité à tester est la classe.

La véritable intension d’un test unitaire est de tester une seule fonctionnalité. La fonctionnalité peut être implémentée via plusieurs classes, plusieurs méthodes, et parfois demande de la préparation pour arriver à l’état initial requis pour y arriver.

    [Fact]
    public void Machine_Turn_GotABubble()
    {
        // Arrange
        var notRandom = new NotRandom { NextRandom = 1 };
        var bubbles = new List<Bubble>() { new Bubble("Charizard"), new Bubble("Mewtwo") };
        var expectedBubble = bubbles[1];
        var machine = StateMachine.New(bubbles, notRandom);
        try
        {
            machine.PutMoney(10);
            Assert.Equal(StateName.WithMoney, machine.GetStateName());
        }
        catch (Exception ex)
        {
            throw new Exception("Cannot put state in wanted state", ex);
        }

        // Act
        var gotBubble = machine.Turn();

        // Assert
        Assert.Equal(StateName.Iddle, machine.GetStateName());
        Assert.Equal(expectedBubble, gotBubble);
    }

Par exemple dans le code ci-dessus, la tentative de mettre la machine dans un état WithMoney est une préparation pour le test. C’est une étape nécessaire pour arriver à l’état voulu.

Ne regrouper que ce qui a de la cohésion

Gardez en tête que vous ne devez pas non plus unit tester tout ensemble non plus. Il est normal de consommer des mocks/stubs venant de principes plus distants. Je vois mal un test qui couvrirait à la fois les états de notre machine, ainsi que le système qui récolte la monnaie de chaque machine (non implémenté). Il pourrait bien y avoir un test pour couvrir le fait que l’on extraie la monnaie de la machine, mais il n’est pas intéressant de le coupler avec le système qui fait un reporting de toutes les machines. Dans ce cas-là, appliquer un stub/mock est sensé.

Résumé et mot de la fin

Cet article tente de donner une alternative à la manière la plus courante d’écrire les tests. Nous avons exposé qu’écrire les tests directement sur chaque classe empêche le refactoring. Pour limiter cette baisse d’opportunité de retravailler le code ainsi que de permettre aux tests de vérifier nos refactorings, les tests doivent consommer au maximum les APIs publiques qui sont stables, ainsi que de tester ensemble les tests qui partagent une grande cohésion ensemble. Les tests unitaires ont été créés dans cette optique. Malheureusement, une mauvaise interprétation, ainsi que les biais humains nous ont amenés à une écriture sous-efficace des tests unitaires.

Les tests unitaires sont encore aujourd’hui controversés. Il m’arrive encore de discuter avec des collègues convaincus que les tests sont une perte de temps, notamment causés par le fait que les tests apportent une inertie au code (et d’autres arguments). Vous l’aurez compris, cet article leur est directement adressé. Parfois un sujet sensible, et pas évident de présenter en 5 minutes autour d’un café, et notre emploi du temps ne nous permet pas non plus de partager mon opinion avec tous le sérieux que je lui donne. L’article expose via un exemple relativement complet et fonctionnel mes arguments et mes habitudes sur le sujet.

Ajout du 28/04/2024, faire 2 articles de blog m’a motivé à lire Unit Testing Principles, Practices, Patterns (Vladimir Khorikov, 2020). Il nomme les 2 formes de tests comme étant les classiques et les londoniens. Quelques chapitres sont centrés sur le même sujet que l’article. Si vous êtes maintenant curieux, je recommande ce livre.