La plupart des projets sur lesquels je travaille considèrent le développement et les tests unitaires isolément, ce qui fait de l'écriture de tests unitaires ultérieurs un cauchemar. Mon objectif est de garder à l'esprit les tests pendant les phases de conception de haut niveau et de bas niveau.
Je veux savoir s'il existe des principes de conception bien définis qui favorisent le code testable. Un de ces principes que j'ai appris récemment est l'inversion de dépendance par injection de dépendance et l'inversion de contrôle.
J'ai lu qu'il existe quelque chose appelé SOLIDE. Je veux comprendre si le respect des principes SOLID entraîne indirectement un code facilement testable? Sinon, existe-t-il des principes de conception bien définis qui promeuvent le code testable?
Je suis conscient qu'il existe quelque chose appelé le développement piloté par les tests. Cependant, je suis plus intéressé par la conception de code avec des tests à l'esprit pendant la phase de conception elle-même plutôt que de conduire la conception à travers des 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 d'apporter des modifications au code et à la conception afin de pouvoir écrire un scénario de test unitaire pour chaque module?
Oui, SOLID est un très bon moyen de concevoir du code qui peut être facilement testé. En guise d'introduction courte:
S - Principe de responsabilité unique: Un objet doit faire exactement une chose, et doit être le seul objet de la base de code qui fait cette seule chose. Par exemple, prenez une classe de domaine, par exemple une facture. La classe Facture doit représenter la structure de données et les règles commerciales d'une facture telles qu'elles sont utilisées dans le système. Ce doit être la seule classe qui représente une facture dans la base de code. Cela peut être encore plus détaillé pour dire qu'une méthode devrait avoir un seul but et devrait être la seule méthode dans la base de code qui réponde à 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, et vous vous retrouvez généralement avec des fonctionnalités plus petites qui sont plus faciles à tester isolément.
O - Principe ouvert/fermé: Une classe doit être ouverte à l'extension, mais fermée à changer . Une fois qu'un objet existe et fonctionne correctement, idéalement, il ne devrait pas être nécessaire de revenir dans cet objet 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 connectant des implémentations de dépendance nouvelles ou différentes, pour 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 car il est déjà utilisé ailleurs.
En adhérant à ce principe, vous augmentez généralement la capacité du code à tolérer les "simulations" et vous évitez également d'avoir à réécrire des tests pour anticiper de nouveaux comportements; tous les tests existants pour un objet devraient toujours fonctionner sur l'implémentation non étendue, tandis que les nouveaux tests de nouvelles fonctionnalités utilisant l'implémentation étendue devraient également fonctionner.
L - Principe de substitution Liskov: Une classe A, dépendante de la classe B, devrait pouvoir utiliser n'importe quel X: B sans connaître la différence. Cela signifie essentiellement que tout ce que vous utilisez comme dépendance doit avoir un comportement similaire à celui de la classe dépendante. Par exemple, supposons que vous ayez une interface IWriter qui expose Write (chaîne), qui est implémentée par ConsoleWriter. Vous devez maintenant écrire dans un fichier à la place, vous créez donc FileWriter. Ce faisant, 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 dont la personne à charge peut interagir avec elle est en appelant Write (chaîne)), et donc des informations supplémentaires dont FileWriter peut avoir besoin pour ce faire. Le travail (comme le chemin d'accès et le fichier dans lequel écrire) doit être fourni ailleurs que dans la personne à charge.
C'est énorme pour écrire du code testable, car une conception qui se conforme au LSP peut avoir un objet "simulé" substitué à la chose réelle à tout moment sans changer le comportement attendu, permettant à de petits morceaux de code d'être testés de manière isolée avec la confiance que le système fonctionnera alors avec les vrais objets branchés.
I - Principe de ségrégation d'interface: Une interface devrait avoir aussi peu de méthodes que possible pour fournir la fonctionnalité du rôle défini par l'interface . Autrement dit, plus d'interfaces plus petites sont meilleures que moins d'interfaces plus grandes. Cela est dû au fait qu'une grande interface a plus de raisons de changer et provoque d'autres modifications ailleurs dans la base de code qui peuvent ne pas être nécessaires.
L'adhésion au FAI améliore la testabilité en réduisant la complexité des systèmes testés et des dépendances de ces SUT. Si l'objet que vous testez dépend d'une interface IDoThreeThings qui expose DoOne (), DoTwo () et DoThree (), vous devez vous moquer d'un objet qui implémente les trois méthodes même si l'objet utilise uniquement la méthode DoTwo. Mais, si l'objet ne dépend que d'IDoTwo (qui expose uniquement DoTwo), vous pouvez plus facilement se moquer d'un objet qui a cette seule méthode.
D - Principe d'inversion des dépendances: Les concrétions et les abstractions ne devraient jamais dépendre d'autres concrétions, mais d'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 soucier 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, cela est énorme pour la testabilité, car il vous permet, une fois de plus, d'injecter une implémentation fictive d'une dépendance au lieu d'une implémentation de "production" dans votre objet testé, tout en testant l'objet sous la forme exacte qu'il aura tout en en production. C'est la clé du test unitaire "en isolation".
J'ai lu qu'il existe quelque chose appelé SOLIDE. Je veux comprendre si le fait de suivre les principes SOLID entraîne indirectement un code facilement testable?
Si appliqué correctement, oui. Il y a article de blog de Jeff expliquant SOLID dans un vraiment court (le podcast mentionné vaut également la peine d'être écouté), je suggère de donner jetez un œil là-bas si des descriptions plus longues vous découragent.
D'après mon expérience, 2 principes de SOLID jouent un rôle majeur dans la conception de code testable:
Je crois que ces deux-là vous aideront le plus lors de la conception pour la testabilité. Les autres ont également un impact, mais je dirais que ce n'est pas aussi important.
(...) s'il est correct de refacturer 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 tests unitaires existants, c'est tout simplement posé - demandant des problèmes. Le test unitaire est votre garantie que votre code fonctionne. Introduire des changements de rupture est repéré immédiatement si vous avez une couverture de tests appropriée.
Maintenant, si vous voulez changer le code existant pour ajouter des tests unitaires, cela introduit un écart où vous don 'ai pas encore de tests, mais j'ai déjà changé de code . Naturellement, vous pourriez ne pas avoir la moindre idée de ce que vos changements ont cassé. C'est une situation que vous voulez éviter.
Les tests unitaires valent quand même la peine d'être écrits, même contre du code difficile à tester. Si votre code fonctionne, mais n'est pas testé à l'unité, la solution appropriée serait d'écrire des tests pour lui et alors d'introduire des changements. Cependant, notez que la modification du code testé afin de le rendre plus facilement testable est quelque chose que votre direction pourrait ne pas vouloir dépenser (vous entendrez probablement que cela apporte peu ou pas de valeur commerciale).
VOTRE PREMIÈRE QUESTION:
SOLID est en effet la voie à suivre. Je trouve que les deux aspects les plus importants de l'acronyme SOLID, en ce qui concerne la testabilité, est le S (responsabilité unique) et le D (injection de dépendance).
Responsabilité unique: Vos classes ne devraient vraiment faire qu'une seule chose, et une seule chose. 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 la conception des 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 classe ou l'appel de méthode. Lorsque vous n'effectuez pas les tests, vous remplacez simplement les classes réelles par des talons ou des simulacres, 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 de fonctionnement 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 abandonné beaucoup de fois. Les classes peuvent être si laides et désordonnées qu'une réécriture est le seul moyen de les rendre testables.
En plus des autres réponses, qui se concentrent sur la réalisation d'un couplage lâche, je voudrais dire un mot sur le test d'une logique compliquée.
J'ai dû tester une fois 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 un machine d'état. La logique est devenue beaucoup plus simple à suivre, puisque les différents états de l'ancienne classe sont devenus explicites. Chaque classe d'État était indépendante des autres et était donc facilement testable.
Le fait que les états soient explicites a permis d'énumérer plus facilement tous les chemins possibles du code (les transitions d'états), et donc d'écrire un test unitaire pour chacun.
Bien sûr, toutes les logiques complexes ne peuvent pas être modélisées comme une machine à états.
SOLID est un excellent début, d'après mon expérience, quatre des aspects de SOLID fonctionnent vraiment bien avec les tests unitaires.
J'examinerais également 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 Moq ou un autre cadre de simulation pour remplacer cette méthode virtuelle et retourner une interface de votre conception. Mais en ce qui concerne le code de mise en œuvre, l'usine n'a pas changé. Vous pouvez également masquer de nombreux détails de votre implémentation de cette façon, votre code d'implémentation ne se soucie pas de la façon dont l'interface est construite, tout ce qui compte c'est de récupérer une interface.
Si vous souhaitez développer un peu cela, 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 c'est une lecture assez rapide.