Quels sont les principes de conception qui promeuvent du code testable? (conception de code testable vs conception de conduite à travers des tests)


54

La plupart des projets sur lesquels je travaille considèrent le développement et les tests unitaires de manière isolée, ce qui fait de l'écriture de tests unitaires ultérieurement un cauchemar. Mon objectif est de garder à l'esprit les tests lors des phases de conception de haut niveau et de bas niveau.

Je veux savoir s’il existe des principes de conception bien définis promouvant un code testable. L’un de ces principes que j’ai compris récemment est l’inversion de dépendance par injection de dépendance et inversion de contrôle.

J'ai lu qu'il y a quelque chose qui s'appelle SOLID. Je veux comprendre si le respect des principes SOLID donne indirectement un code facilement vérifiable. Si ce n'est pas le cas, existe-t-il des principes de conception bien définis promouvant un code testable?

Je suis conscient qu'il existe quelque chose de connu sous le nom de développement piloté par les tests. Bien que je sois plus intéressé par la conception de code avec des tests à l’esprit pendant la phase de conception elle-même que par la conception à travers les tests. J'espère que cela a du sens.

Une autre question liée à ce sujet est de savoir s'il est correct de re-factoriser un produit / projet existant et de modifier le code et la conception afin de pouvoir écrire un scénario de test unitaire pour chaque module.



Je vous remercie. Je viens juste de commencer à lire l'article et cela a déjà du sens.

1
C'est l'une de mes questions d'entrevue ("Comment concevez-vous le code pour qu'il soit facilement testé par unité?"). Cela me montre de manière très simple s’ils comprennent les tests unitaires, les moqueries / stubbing, les OOD et potentiellement les TDD. Malheureusement, les réponses sont généralement du type "Créer une base de données de test".
Chris Pitman

Réponses:


57

Oui, SOLID est un très bon moyen de concevoir du code facilement testable. En quelques mots:

S - Principe de responsabilité unique: un objet doit faire exactement une chose et doit être le seul objet de la base de code qui le fait. Par exemple, prenons une classe de domaine, disons une facture. La classe Invoice doit représenter la structure de données et les règles de gestion d'une facture, telles qu'elles sont utilisées dans le système. Ce devrait être la seule classe qui représente une facture dans la base de code. Cela peut encore être décomposé pour dire qu'une méthode devrait avoir un but et devrait être la seule méthode de la base de code qui répond à ce besoin.

En suivant ce principe, vous augmentez la testabilité de votre conception en diminuant le nombre de tests que vous devez écrire pour tester la même fonctionnalité sur différents objets. Vous obtenez également généralement de plus petites fonctionnalités qui sont plus faciles à tester isolément.

O - Principe ouvert / fermé: une classe doit être ouverte à l’extension, mais fermée au changement . Une fois qu'un objet existe et fonctionne correctement, idéalement, il ne devrait pas être nécessaire d'y revenir pour apporter des modifications qui ajoutent de nouvelles fonctionnalités. Au lieu de cela, l'objet doit être étendu, soit en le dérivant, soit en y insérant des implémentations de dépendance nouvelles ou différentes, afin de fournir cette nouvelle fonctionnalité. Cela évite la régression; vous pouvez introduire la nouvelle fonctionnalité quand et où elle est nécessaire, sans changer le comportement de l'objet tel qu'il est déjà utilisé ailleurs.

En adhérant à ce principe, vous augmentez généralement la capacité du code à tolérer les "simulacres", et vous évitez également de devoir réécrire des tests pour anticiper un nouveau comportement. tous les tests existants pour un objet doivent toujours fonctionner sur l'implémentation non étendue, tandis que les nouveaux tests pour les nouvelles fonctionnalités utilisant l'implémentation étendue doivent également fonctionner.

L - Principe de substitution de Liskov: Une classe A, dépendant de la classe B, devrait pouvoir utiliser n’importe quel X: B sans connaître la différence. Cela signifie fondamentalement que tout ce que vous utilisez comme dépendance devrait avoir un comportement similaire à celui observé par la classe dépendante. Par exemple, supposons que votre interface IWriter expose Write (chaîne), qui est implémentée par ConsoleWriter. Maintenant, vous devez écrire dans un fichier, vous créez donc FileWriter. Pour ce faire, vous devez vous assurer que FileWriter peut être utilisé de la même manière que ConsoleWriter (ce qui signifie que la seule façon pour la personne dépendante d'interagir avec elle est d'appeler Write (chaîne)), et donc des informations supplémentaires dont FileWriter peut avoir besoin pour ce faire. Le travail (comme le chemin et le fichier dans lequel écrire) doit être fourni ailleurs que par la personne à charge.

C'est énorme pour l'écriture de code testable, car une conception conforme au LSP peut avoir un objet "simulé" à la place de la chose réelle à tout moment sans changer le comportement attendu, permettant ainsi de tester de petits morceaux de code de manière isolée avec la confiance. que le système fonctionnera ensuite avec les objets réels branchés.

I - Principe de séparation des interfaces: une interface doit avoir le moins de méthodes possible pour fournir les fonctionnalités du rôle défini par l'interface . En termes simples, plus d'interfaces plus petites sont meilleures que moins d'interfaces plus grandes. En effet, une grande interface a plus de raisons de changer et provoque plus de modifications ailleurs dans la base de code qui peuvent ne pas être nécessaires.

L'adhésion à l'ISP améliore la testabilité en réduisant la complexité des systèmes testés et des dépendances de ces unités sous test. Si l'objet que vous testez dépend d'une interface IDoThreeThings qui expose DoOne (), DoTwo () et DoThree (), vous devez simuler un objet qui implémente les trois méthodes, même s'il utilise uniquement la méthode DoTwo. Cependant, si l'objet ne dépend que de IDoTwo (qui n'expose que DoTwo), vous pouvez plus facilement simuler un objet qui possède cette méthode.

D - Principe de l'inversion de dépendance: Les concrétions et les abstractions ne doivent jamais dépendre d'autres concrétions, mais bien des abstractions . Ce principe applique directement le principe du couplage lâche. Un objet ne devrait jamais avoir à savoir ce qu'est un objet. il devrait plutôt se préoccuper de ce que fait un objet. Ainsi, l'utilisation d'interfaces et / ou de classes de base abstraites doit toujours être préférée à l'utilisation d'implémentations concrètes lors de la définition des propriétés et des paramètres d'un objet ou d'une méthode. Cela vous permet d’échanger une implémentation pour une autre sans avoir à changer l’utilisation (si vous suivez également LSP, qui va de pair avec DIP).

Encore une fois, c’est énorme pour la testabilité, car cela vous permet, encore une fois, d’injecter une implémentation fictive d’une dépendance plutôt que celle de "production" dans votre objet en cours de test, tout en continuant de tester l’objet sous la forme exacte en production. C'est la clé du test unitaire "en vase clos".


16

J'ai lu qu'il y a quelque chose qui s'appelle SOLID. Je veux comprendre si le respect des principes SOLID donne indirectement un code facilement vérifiable.

Si appliqué correctement, oui. Il y a un article de blog de Jeff expliquant les principes de SOLID de manière très brève (le podcast mentionné vaut la peine d'être écouté également).

D'après mon expérience, 2 principes de SOLID jouent un rôle majeur dans la conception de code testable:

  • Principe de séparation des interfaces : vous devez préférer de nombreuses interfaces spécifiques au client plutôt que moins d'interfaces générales. Cela va de pair avec le principe de responsabilité unique et vous aide à concevoir des classes orientées fonction / tâche, qui sont en retour beaucoup plus faciles à tester (par rapport aux classes plus générales, ou aux "gestionnaires" et "contextes" souvent abusés ) - moins de dépendances , moins de complexité, plus précis, des tests évidents. En bref, les petits composants conduisent à des tests simples.
  • Principe d'inversion de dépendance - conception par contrat, pas par implémentation. Cela vous sera plus utile lorsque vous testerez des objets complexes et que vous réaliserez que vous n'avez pas besoin d'un graphe complet de dépendances pour le configurer , mais vous pouvez simplement vous moquer de l'interface et en finir.

Je pense que ces deux éléments vous aideront le plus lors de la conception de la testabilité. Les autres ont également un impact, mais je dirais que ce n'est pas aussi important.

(...) est-il correct de reformuler un produit / projet existant et d'apporter des modifications au code et à la conception afin de pouvoir écrire un scénario de test unitaire pour chaque module?

Sans les tests unitaires existants, il s’agit simplement de poser des problèmes. Le test unitaire vous garantit que votre code fonctionne . L’introduction d’un changement radical est immédiatement détectée si les tests sont bien couverts.

Désormais, si vous souhaitez modifier le code existant afin d’ ajouter des tests unitaires , cela introduit une lacune dans laquelle vous n’avez pas encore de tests, mais avez déjà changé de code . Naturellement, vous pourriez ne pas avoir la moindre idée de ce que vos changements ont brisé. C'est la situation que vous voulez éviter.

De toute façon, les tests unitaires valent la peine d’être écrits, même avec un code difficile à tester. Si votre code fonctionne , mais pas l' unité testée, la solution appropriée serait à des tests d'écriture pour et ensuite introduire des changements. Toutefois, notez que votre direction pourrait ne pas vouloir dépenser d’argent pour changer le code testé afin de le rendre plus facilement testable (vous entendrez probablement qu’il n’apporterait que peu ou pas de valeur pour votre entreprise).


iaw haute cohésion et faible couplage
jk.

8

VOTRE PREMIÈRE QUESTION:

SOLID est en effet la voie à suivre. Je trouve que les deux aspects les plus importants de l’acronyme SOLID, s’agissant de la testabilité, sont les suivants: S (Single Responsibility) et D (Dependency Injection).

Responsabilité unique : Vos cours ne devraient en réalité faire qu’une chose, et une seule. une classe qui crée un fichier, analyse une entrée et l'écrit dans le fichier fait déjà trois choses. Si votre classe ne fait qu'une chose, vous savez exactement à quoi vous attendre, et concevoir les cas de test pour cela devrait être assez facile.

Injection de dépendance (DI): Cela vous permet de contrôler l'environnement de test. Au lieu de créer des objets forreign dans votre code, vous l'injectez via le constructeur de la classe ou l'appel de méthode. Lorsque vous désinfectez, vous remplacez simplement les classes réelles par des stubs ou des mocks, que vous contrôlez entièrement.

VOTRE SECONDE QUESTION: Idéalement, vous écrivez des tests qui documentent le fonctionnement de votre code avant de le refactoriser. De cette façon, vous pouvez documenter que votre refactoring reproduit les mêmes résultats que le code d'origine. Cependant, votre problème est que le code qui fonctionne est difficile à tester. C'est une situation classique! Mon conseil est le suivant: réfléchissez bien à la refactorisation avant les tests unitaires. Si tu peux; écrire des tests pour le code de travail, puis refactoriser le code, puis refactoriser les tests. Je sais que cela coûtera des heures, mais vous serez plus certain que le code refactorisé fait la même chose que l'ancien. Cela dit, j'ai beaucoup abandonné. Les classes peuvent être si laides et désordonnées qu’une réécriture est le seul moyen de les rendre testables.


4

Outre les autres réponses, qui mettent l'accent sur l'obtention d'un couplage lâche, j'aimerais dire quelques mots sur le test de la logique compliquée.

Une fois, j'ai eu à tester une classe dont la logique était complexe, avec beaucoup de conditions, et où il était difficile de comprendre le rôle des champs.

J'ai remplacé ce code par de nombreuses petites classes qui représentent une machine à états . La logique est devenue beaucoup plus simple à suivre puisque les différents états de la classe précédente sont devenus explicites. Chaque classe d’États était indépendante des autres et était donc facilement vérifiable.

Le fait que les états soient explicites facilite l'énumération de tous les chemins possibles du code (les transitions d'état) et permet ainsi d'écrire un test unitaire pour chacun.

Bien entendu, toutes les logiques complexes ne peuvent pas être modélisées comme une machine à états.


3

SOLID est un excellent début. D'après mon expérience, quatre des aspects de SOLID fonctionnent vraiment bien avec les tests unitaires.

  • Principe de responsabilité unique - chaque classe fait une chose et une seule chose. Calculer une valeur, ouvrir un fichier, analyser une chaîne, peu importe. La quantité d'entrées et de sorties, ainsi que les points de décision devraient donc être très minimes. Ce qui facilite la rédaction de tests.
  • Principe de substitution de Liskov - vous devriez pouvoir substituer des moignons et des fous sans modifier les propriétés souhaitables (les résultats attendus) de votre code.
  • Principe de séparation des interfaces - La séparation des points de contact par interfaces facilite l’utilisation d’un framework moqueur tel que Moq pour créer des moignons et des faux. Au lieu de devoir compter sur les classes concrètes, vous vous contentez de quelque chose qui implémente l'interface.
  • Principe d'injection de dépendance - C'est ce qui vous permet d'injecter ces stubs et ces mocks dans votre code par le biais d'un constructeur, d'une propriété ou d'un paramètre de la méthode que vous souhaitez tester.

Je voudrais aussi examiner différents modèles, en particulier le modèle d'usine. Disons que vous avez une classe concrète qui implémente une interface. Vous devez créer une fabrique pour instancier la classe concrète, mais renvoyer l'interface à la place.

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

Dans vos tests, vous pouvez utiliser Moq ou un autre framework moqueur pour remplacer cette méthode virtuelle et renvoyer une interface de votre conception. Mais en ce qui concerne le code d’application, l’usine n’a pas changé. Vous pouvez également masquer une grande partie de vos détails d'implémentation de cette façon, votre code d'implémentation ne tient pas compte de la manière dont l'interface est construite, il se soucie uniquement de récupérer une interface.

Si vous souhaitez développer un peu ce sujet, je vous recommande fortement de lire The Art of Unit Testing . Il donne d'excellents exemples sur la façon d'utiliser ces principes, et sa lecture est assez rapide.


1
Cela s'appelle le principe "d'inversion" de dépendance, pas le principe "d'injection".
Mathias Lykkegaard Lorenzen
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.