Tests simplifiés grâce au minimalisme du framework et à l'architecture logicielle
Publié: 2022-03-10Comme beaucoup d'autres développeurs Android, ma première incursion dans les tests sur la plate-forme m'a amené à être immédiatement confronté à un degré de jargon démoralisant. De plus, les quelques exemples que j'ai rencontrés à l'époque (vers 2015) ne présentaient pas de cas d'utilisation pratiques qui m'ont peut-être incité à penser que le rapport coût-bénéfice de l'apprentissage d'un outil comme Espresso afin de vérifier qu'un TextView.setText( …) fonctionnait correctement, était un investissement raisonnable.
Pour aggraver les choses, je n'avais pas une compréhension pratique de l'architecture logicielle en théorie ou en pratique, ce qui signifiait que même si j'avais pris la peine d'apprendre ces frameworks, j'aurais écrit des tests pour des applications monolithiques composées de quelques classes god
, écrites en code spaghetti . L'essentiel est que la création, le test et la maintenance de telles applications constituent un exercice d'auto-sabotage, quelle que soit votre expertise en matière de framework ; pourtant, cette prise de conscience ne devient claire qu'après avoir construit une application modulaire , faiblement couplée et hautement cohérente .
De là, nous arrivons à l'un des principaux points de discussion de cet article, que je vais résumer en langage clair ici : Parmi les principaux avantages de l'application des principes d'or de l'architecture logicielle (ne vous inquiétez pas, je vais en discuter avec des exemples simples et langage), est que votre code peut devenir plus facile à tester. L'application de tels principes présente d'autres avantages, mais la relation entre l'architecture logicielle et les tests est au centre de cet article.
Cependant, pour ceux qui souhaitent comprendre pourquoi et comment nous testons notre code, nous allons d'abord explorer le concept de test par analogie ; sans vous obliger à mémoriser un jargon. Avant d'approfondir le sujet principal, nous examinerons également la question de savoir pourquoi tant de cadres de test existent, car en examinant cela, nous pouvons commencer à voir leurs avantages, leurs limites et peut-être même une solution alternative.
Tester : pourquoi et comment
Cette section ne sera pas une nouvelle information pour tout testeur chevronné, mais peut-être apprécierez-vous néanmoins cette analogie. Bien sûr, je suis un ingénieur en logiciel, pas un ingénieur de fusée, mais pour un instant, j'emprunterai une analogie qui concerne la conception et la construction d'objets à la fois dans l'espace physique et dans l'espace mémoire d'un ordinateur. Il s'avère que même si le support change, le processus est en principe assez le même.
Supposons un instant que nous soyons des ingénieurs de fusée et que notre travail consiste à construire le propulseur de fusée du premier étage* d'une navette spatiale. Supposons également que nous ayons mis au point une conception utilisable pour la première étape afin de commencer à construire et à tester dans diverses conditions.
"Premier étage" fait référence aux propulseurs qui sont tirés lorsque la fusée est lancée pour la première fois
Avant d'aborder le processus, je voudrais préciser pourquoi je préfère cette analogie : vous ne devriez avoir aucune difficulté à répondre à la question de savoir pourquoi nous prenons la peine de tester notre conception avant de la mettre dans des situations où des vies humaines sont en jeu. Bien que je n'essaie pas de vous convaincre que tester vos applications avant leur lancement pourrait sauver des vies (bien que cela soit possible selon la nature de l'application), cela pourrait sauver des notes, des critiques et votre travail. Au sens le plus large, les tests sont la manière dont nous nous assurons que des pièces individuelles, plusieurs composants et des systèmes entiers fonctionnent avant de les utiliser dans des situations où il est extrêmement important qu'ils ne tombent pas en panne.
Revenant à l'aspect comment de cette analogie, je présenterai le processus par lequel les ingénieurs testent une conception particulière : la redondance . La redondance est simple en principe : créez des copies du composant à tester selon les mêmes spécifications de conception que celles que vous souhaitez utiliser au moment du lancement. Testez ces copies dans un environnement isolé qui contrôle strictement les conditions préalables et les variables. Bien que cela ne garantisse pas que le propulseur de fusée fonctionnera correctement lorsqu'il sera intégré dans l'ensemble de la navette, on peut être certain que s'il ne fonctionne pas dans un environnement contrôlé, il est très peu probable qu'il fonctionne du tout.
Supposons que parmi les centaines, voire les milliers de variables contre lesquelles les copies de la conception de la fusée ont été testées, cela se résume aux températures ambiantes dans lesquelles le propulseur de la fusée sera testé. En testant à 35° Celsius, on voit que tout fonctionne sans erreur. Encore une fois, la fusée est testée à peu près à température ambiante sans échec. Le test final se fera à la température la plus basse enregistrée pour le site de lancement, à -5° Celsius. Au cours de ce test final, la fusée se déclenche, mais après une courte période, la fusée s'embrase et peu de temps après explose violemment; mais heureusement dans un environnement contrôlé et sécuritaire.
À ce stade, nous savons que les changements de température semblent être au moins impliqués dans l'échec du test, ce qui nous amène à considérer quelles parties du propulseur de fusée peuvent être affectées par des températures froides. Au fil du temps, on découvre qu'un composant clé, un joint torique en caoutchouc qui sert à arrêter le flux de carburant d'un compartiment à l'autre, devient rigide et inefficace lorsqu'il est exposé à des températures proches ou inférieures au point de congélation.
Il est possible que vous ayez remarqué que son analogie est vaguement basée sur les événements tragiques de la catastrophe de la navette spatiale Challenger . Pour ceux qui ne sont pas familiers, la triste vérité (dans la mesure où les enquêtes ont conclu) est qu'il y a eu beaucoup de tests ratés et d'avertissements des ingénieurs, et pourtant des problèmes administratifs et politiques ont incité le lancement à se poursuivre malgré tout. Dans tous les cas, que vous ayez ou non mémorisé le terme redondance , j'espère que vous avez saisi le processus fondamental pour tester des parties de tout type de système.
Concernant les logiciels
Alors que l'analogie précédente expliquait le processus fondamental de test des fusées (tout en prenant beaucoup de liberté avec les détails les plus fins), je vais maintenant résumer d'une manière qui est probablement plus pertinente pour vous et moi. Bien qu'il soit possible de tester un logiciel en lançant uniquement aux appareils une fois qu'il est dans n'importe quel état de déploiement, je suppose plutôt que nous pouvons d'abord appliquer le principe de redondance aux parties individuelles de l'application.
Cela signifie que nous créons des copies des plus petites parties de l'ensemble de l'application (communément appelées unités de logiciel), mettons en place un environnement de test isolé et voyons comment elles se comportent en fonction des variables, arguments, événements et réponses qui peuvent se produire. à l'exécution. Les tests sont vraiment aussi simples que cela en théorie, mais la clé pour arriver à ce processus réside dans la création d'applications qui sont réalisables en test. Cela se résume à deux préoccupations que nous examinerons dans les deux sections suivantes. La première préoccupation concerne l' environnement de test et la seconde concerne la manière dont nous structurons les applications.
Pourquoi avons-nous besoin de cadres ?
Afin de tester un logiciel (désormais appelé Unité , bien que cette définition soit volontairement trop simplificatrice), il est nécessaire de disposer d'une sorte d'environnement de test qui vous permette d'interagir avec votre logiciel au moment de l'exécution. Pour ceux qui créent des applications à exécuter uniquement sur un environnement JVM ( Java Virtual Machine ), tout ce qui est nécessaire pour écrire des tests est un JRE ( Java Runtime Environment ). Prenons par exemple cette classe Calculatrice très simple :
class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }
En l'absence de tout framework, tant que nous avons une classe de test contenant une fonction main
pour exécuter réellement notre code, nous pouvons le tester. Comme vous vous en souvenez peut-être, la fonction main
désigne le point de départ de l'exécution d'un programme Java simple. En ce qui concerne ce que nous testons, nous introduisons simplement des données de test dans les fonctions de la calculatrice et vérifions qu'elle exécute correctement l'arithmétique de base :
public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }
Tester une application Android est bien sûr une toute autre procédure. Bien qu'il existe une fonction main
enfouie profondément dans la source du fichier ZygoteInit.java (dont les détails les plus fins ne sont pas importants ici), qui est invoquée avant le lancement d'une application Android sur la JVM , même un développeur Android junior devrait sachez que le système lui-même est responsable de l'appel de cette fonction ; pas le développeur . Au lieu de cela, les points d'entrée pour les applications Android se trouvent être la classe Application
et toutes les classes d' Activity
vers lesquelles le système peut être pointé via le fichier AndroidManifest.xml .
Tout cela n'est qu'une introduction au fait que tester des unités dans une application Android présente un niveau de complexité plus élevé, strictement parce que notre environnement de test doit désormais tenir compte de la plate-forme Android.
Apprivoiser le problème du couplage serré
Le couplage étroit est un terme qui décrit une fonction, une classe ou un module d'application qui dépend de plates-formes, de cadres, de langages et de bibliothèques particuliers. C'est un terme relatif, ce qui signifie que notre exemple Calculator.java est étroitement couplé au langage de programmation Java et à la bibliothèque standard, mais c'est l'étendue de son couplage. Dans le même ordre d'idées, le problème des classes de test étroitement couplées à la plate-forme Android est qu'il faut trouver un moyen de travailler avec ou autour de la plate-forme.
Pour les classes étroitement couplées à la plateforme Android, vous avez deux options. La première consiste à déployer simplement vos cours sur un appareil Android (physique ou virtuel). Bien que je vous suggère de tester le déploiement de votre code d'application avant de l'expédier en production, il s'agit d'une approche très inefficace au cours des premières et moyennes étapes du processus de développement en termes de temps.
Une Unit , quelle que soit la définition technique que vous préférez, est généralement considérée comme une fonction unique dans une classe (bien que certains étendent la définition pour inclure des fonctions d'assistance ultérieures qui sont appelées en interne par l'appel de fonction unique initial). Quoi qu'il en soit, les unités sont censées être petites ; Construire, compiler et déployer une application entière pour tester une seule unité, c'est passer complètement à côté de l'intérêt de tester de manière isolée .
Une autre solution au problème du couplage étroit consiste à utiliser des frameworks de test pour interagir avec ou simuler (simuler) les dépendances de la plate-forme. Des frameworks tels qu'Espresso et Robolectric offrent aux développeurs des moyens beaucoup plus efficaces pour tester les unités que l'approche précédente ; le premier étant utile pour les tests exécutés sur un appareil (appelés "tests instrumentés" car apparemment les appeler tests d'appareil n'était pas assez ambigu) et le second étant capable de se moquer du framework Android localement sur une JVM.
Avant de continuer à m'opposer à de tels frameworks au lieu de l'alternative dont je parlerai sous peu, je tiens à préciser que je ne veux pas dire que vous ne devriez jamais utiliser ces options. Le processus qu'un développeur utilise pour créer et tester ses applications doit être né d'une combinaison de préférences personnelles et d'un souci d'efficacité.
Pour ceux qui n'aiment pas construire des applications modulaires et faiblement couplées, vous n'aurez d'autre choix que de vous familiariser avec ces frameworks si vous souhaitez avoir un niveau de couverture de test adéquat. De nombreuses applications merveilleuses ont été construites de cette façon, et je suis souvent accusé de rendre mes applications trop modulaires et abstraites. Que vous adoptiez mon approche ou que vous décidiez de vous appuyer fortement sur les frameworks, je vous félicite d'avoir consacré du temps et des efforts pour tester vos applications.
Gardez vos cadres à bout de bras
Pour le dernier préambule de la leçon principale de cet article, il vaut la peine de discuter de la raison pour laquelle vous pourriez vouloir avoir une attitude de minimalisme lorsqu'il s'agit d'utiliser des frameworks (et cela s'applique à plus que des frameworks de test). Le sous-titre ci-dessus est une paraphrase du professeur magnanime des meilleures pratiques logicielles : Robert « Uncle Bob » C. Martin. Parmi les nombreux joyaux qu'il m'a donnés depuis que j'ai étudié ses œuvres pour la première fois, il a fallu plusieurs années d'expérience directe pour saisir celle-ci.
Dans la mesure où je comprends de quoi il s'agit, le coût d'utilisation des frameworks réside dans l'investissement en temps nécessaire pour les apprendre et les maintenir. Certains d'entre eux changent assez fréquemment et certains d'entre eux ne changent pas assez fréquemment. Les fonctions deviennent obsolètes, les frameworks cessent d'être maintenus, et tous les 6 à 24 mois un nouveau framework arrive pour supplanter le précédent. Par conséquent, si vous pouvez trouver une solution qui peut être implémentée en tant que fonctionnalité de plate-forme ou de langage (qui a tendance à durer beaucoup plus longtemps), elle aura tendance à être plus résistante aux changements des différents types mentionnés ci-dessus.
Sur une note plus technique, des frameworks tels qu'Espresso et, dans une moindre mesure, Robolectric , ne peuvent jamais fonctionner aussi efficacement que de simples tests JUnit , ou même le test gratuit du framework plus tôt. Bien que JUnit soit en effet un framework, il est étroitement couplé à la JVM , qui a tendance à changer à un rythme beaucoup plus lent que la plate-forme Android proprement dite. Moins de frameworks signifie presque invariablement un code plus efficace en termes de temps d'exécution et d'écriture d'un ou plusieurs tests.
À partir de là, vous pouvez probablement en déduire que nous allons maintenant discuter d'une approche qui tirera parti de certaines techniques nous permettant de garder la plate-forme Android à distance ; tout en nous permettant une grande couverture de code, une efficacité de test et la possibilité d'utiliser encore un framework ici ou là lorsque le besoin s'en fait sentir.
L'art de l'architecture
Pour utiliser une analogie idiote, on pourrait penser que les frameworks et les plates-formes sont comme des collègues autoritaires qui prendront en charge votre processus de développement à moins que vous ne définissiez des limites appropriées avec eux. Les principes d'or de l'architecture logicielle peuvent vous donner les concepts généraux et les techniques spécifiques nécessaires à la fois pour créer et faire respecter ces limites. Comme nous le verrons dans un instant, si vous vous êtes déjà demandé quels sont vraiment les avantages de l'application des principes d'architecture logicielle dans votre code, certains directement, et beaucoup indirectement rendent votre code plus facile à tester.
Séparation des préoccupations
La séparation des préoccupations est, à mon avis, le concept le plus universellement applicable et le plus utile dans l'architecture logicielle dans son ensemble (sans vouloir dire que les autres doivent être négligés). La séparation des préoccupations (SOC) peut être appliquée, ou complètement ignorée, dans toutes les perspectives de développement logiciel que je connaisse. Pour résumer brièvement le concept, nous examinerons le SOC lorsqu'il est appliqué aux classes, mais sachez que le SOC peut être appliqué aux fonctions grâce à une utilisation intensive des fonctions d'assistance et qu'il peut être extrapolé à des modules entiers d'une application ("modules" utilisés dans le contexte d'Android/Gradle).
Si vous avez passé beaucoup de temps à rechercher des modèles d'architecture logicielle pour les applications GUI, vous aurez probablement rencontré au moins l'un des éléments suivants : Model-View-Controller (MVC), Model-View-Presenter (MVP) ou Model-View- Modèle de vue (MVVM). Ayant construit des applications dans tous les styles, je dirai d'emblée que je ne considère aucune d'entre elles comme la meilleure option pour tous les projets (ou même les fonctionnalités d'un seul projet). Ironiquement, le modèle que l'équipe Android a présenté il y a quelques années comme approche recommandée, MVVM, semble être le moins testable en l'absence de frameworks de test spécifiques à Android (en supposant que vous souhaitiez utiliser les classes ViewModel de la plate-forme Android, dont je suis certes un fan de).
Dans tous les cas, les spécificités de ces modèles sont moins importantes que leurs généralités. Tous ces modèles ne sont que des saveurs différentes de SOC qui mettent l'accent sur une séparation fondamentale de trois types de code que j'appelle : Data , User Interface , Logic .
Alors, comment la séparation des données , de l'interface utilisateur et de la logique vous aide-t-elle exactement à tester vos applications ? La réponse est qu'en extrayant la logique des classes qui doivent gérer les dépendances plate-forme/framework dans des classes qui possèdent peu ou pas de dépendances plate-forme/framework, les tests deviennent faciles et le framework minimal . Pour être clair, je parle généralement de classes qui doivent restituer l'interface utilisateur, stocker des données dans une table SQL ou se connecter à un serveur distant. Pour démontrer comment cela fonctionne, examinons une architecture simplifiée à trois couches d'une application Android hypothétique.
La première classe gérera notre interface utilisateur. Pour garder les choses simples, j'ai utilisé une activité à cette fin, mais j'opte généralement pour les fragments à la place en tant que classes d'interface utilisateur. Dans les deux cas, les deux classes présentent un couplage étroit similaire à la plate-forme Android :
public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }
Comme vous pouvez le voir, l' activité a deux tâches : premièrement, puisqu'elle est le point d'entrée d'une fonctionnalité donnée d'une application Android , elle agit comme une sorte de conteneur pour les autres composants de la fonctionnalité. En termes simples, un conteneur peut être considéré comme une sorte de classe racine à laquelle les autres composants sont finalement attachés via des références (ou des champs de membres privés dans ce cas). Il gonfle également, lie les références et ajoute des écouteurs à la disposition XML (l'interface utilisateur).
Tester la logique de contrôle
Plutôt que de faire en sorte que l' activité possède une référence à une classe concrète dans le back-end, nous la faisons parler à une interface de type CalculatorContract.IControlLogic.
Nous expliquerons pourquoi il s'agit d'une interface dans la section suivante. Pour l'instant, comprenez simplement que tout ce qui se trouve de l'autre côté de cette interface est censé être quelque chose comme un Presenter ou un Controller . Étant donné que cette classe contrôlera les interactions entre le front-end Activity et le back-end Calculator , j'ai choisi de l'appeler CalculatorControlLogic
:
public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }
public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }
Il y a beaucoup de choses subtiles dans la façon dont cette classe est conçue qui la rendent plus facile à tester. Tout d'abord, toutes ses références proviennent soit de la bibliothèque standard Java, soit d'interfaces définies dans l'application. Cela signifie que tester cette classe sans aucun framework est un jeu d'enfant absolu, et cela pourrait être fait localement sur une JVM . Une autre astuce petite mais utile est que toutes les différentes interactions de cette classe peuvent être appelées via une seule fonction générique handleInput(...)
. Cela fournit un point d'entrée unique pour tester chaque comportement de cette classe.
Notez également que dans la fonction evaluateExpression()
, je renvoie une classe de type Optional<String>
à partir du back-end. Normalement, j'utiliserais ce que les programmeurs fonctionnels appellent un Beit Monad , ou comme je préfère l'appeler, un Result Wrapper . Quel que soit le nom stupide que vous utilisez, c'est un objet capable de représenter plusieurs états différents via un seul appel de fonction. Optional
est une construction plus simple qui peut représenter soit un null , soit une valeur du type générique fourni. Dans tous les cas, étant donné que le back-end peut recevoir une expression invalide, nous voulons donner à la classe ControlLogic
un moyen de déterminer le résultat de l'opération du back-end ; compte à la fois du succès et de l'échec. Dans ce cas, null représentera un échec.
Vous trouverez ci-dessous un exemple de classe de test qui a été écrit à l'aide de JUnit , et une classe qui, dans le jargon des tests, est appelée Fake :
public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }
public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }
Comme vous pouvez le voir, non seulement cette suite de tests peut être exécutée très rapidement, mais elle n'a pas pris beaucoup de temps à écrire. Dans tous les cas, nous allons maintenant nous intéresser à certaines choses plus subtiles qui ont rendu l'écriture de cette classe de test très facile.
Le pouvoir de l'abstraction et de l'inversion des dépendances
Il existe deux autres concepts importants qui ont été appliqués à CalculatorControlLogic
qui l'ont rendu trivialement facile à tester. Tout d'abord, si vous vous êtes déjà demandé quels sont les avantages de l'utilisation des interfaces et des classes abstraites (collectivement appelées abstractions ) en Java, le code ci-dessus en est une démonstration directe. Étant donné que la classe à tester fait référence à des abstractions au lieu de classes concrètes , nous avons pu créer de faux doubles de test pour l' interface utilisateur et le back-end à partir de notre classe de test. Tant que ces doubles de test implémentent les interfaces appropriées, CalculatorControlLogic
ne se soucie pas moins qu'ils ne soient pas la vraie chose.
Deuxièmement, CalculatorControlLogic
a reçu ses dépendances via le constructeur (oui, c'est une forme de Dependency Injection ), au lieu de créer ses propres dépendances. Par conséquent, il n'a pas besoin d'être réécrit lorsqu'il est utilisé dans un environnement de production ou de test, ce qui est un bonus d'efficacité.
L'injection de dépendance est une forme d' inversion de contrôle , qui est un concept délicat à définir en langage clair. Que vous utilisiez Dependency Injection ou un Service Locator Pattern , ils réalisent tous deux ce que Martin Fowler (mon professeur préféré sur ces sujets) décrit comme « le principe de séparation de la configuration de l'utilisation ». Il en résulte des classes qui sont plus faciles à tester et plus faciles à construire indépendamment les unes des autres.
Tester la logique de calcul
Enfin, nous arrivons à la classe ComputationLogic
, qui est censée se rapprocher d'un périphérique IO tel qu'un adaptateur vers un serveur distant ou une base de données locale. Puisque nous n'avons besoin d'aucun de ceux-ci pour une calculatrice simple, il sera juste chargé d'encapsuler la logique nécessaire pour valider et évaluer les expressions que nous lui donnons :
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }
Il n'y a pas grand-chose à dire sur cette classe car il y aurait généralement un couplage étroit avec une bibliothèque back-end particulière qui présenterait des problèmes similaires à ceux d'une classe étroitement couplée à Android. Dans un instant, nous discuterons de ce qu'il faut faire à propos de ces classes, mais celle-ci est si facile à tester que nous pouvons tout aussi bien essayer :
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }
Les classes les plus faciles à tester sont celles auxquelles on attribue simplement une valeur ou un objet et qui sont censées renvoyer un résultat sans qu'il soit nécessaire d'appeler des dépendances externes. Dans tous les cas, il arrive un moment où, quelle que soit la magie de l'architecture logicielle que vous appliquez, vous devrez toujours vous soucier des classes qui ne peuvent pas être découplées des plates-formes et des frameworks. Heureusement, il existe encore un moyen d'utiliser l'architecture logicielle pour : Au pire, rendre ces classes plus faciles à tester, et au mieux, si simples que les tests peuvent être effectués en un coup d'œil .
Objets humbles et vues passives
Les deux noms ci-dessus font référence à un modèle dans lequel un objet qui doit communiquer avec des dépendances de bas niveau est tellement simplifié qu'il n'a sans doute pas besoin d'être testé. J'ai découvert ce modèle pour la première fois via le blog de Martin Fowler sur les variantes de Model-View-Presenter. Plus tard, à travers les travaux de Robert C. Martin, j'ai été initié à l'idée de traiter certaines classes comme des Humble Objects , ce qui implique que ce modèle n'a pas besoin d'être limité aux classes d'interface utilisateur (bien que je ne veuille pas dire que Fowler ait jamais impliquait une telle limitation).
Quoi que vous choisissiez d'appeler ce modèle, il est délicieusement simple à comprendre et, dans un certain sens, je pense qu'il n'est en fait que le résultat de l'application rigoureuse de SOC à vos cours. Bien que ce modèle s'applique également aux classes back-end, nous utiliserons notre classe d'interface utilisateur pour démontrer ce principe en action. La séparation est très simple : les classes qui interagissent avec les dépendances de la plate-forme et du framework ne pensent pas par elles-mêmes (d'où les surnoms Humble et Passive ). Lorsqu'un événement se produit, la seule chose qu'ils font est de transmettre les détails de cet événement à la classe logique qui écoute :
//from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });
La classe logique, qui devrait être trivialement facile à tester, est alors chargée de contrôler l' interface utilisateur de manière très fine. Plutôt que d'appeler une seule fonction générique updateUserInterface(...)
sur la classe user interface
et de la laisser faire le travail d'une mise à jour en bloc, l' user interface
(ou une autre classe similaire) possédera de petites fonctions spécifiques qui devraient être faciles à nommer et implémenter :
//Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…
En principe, ces deux exemples devraient vous donner suffisamment pour comprendre comment s'y prendre pour implémenter ce modèle. L'objet qui possède la logique est faiblement couplé, et l'objet qui est étroitement couplé à des dépendances embêtantes devient presque dépourvu de logique.
Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...)
calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. Aie.
It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.
Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic
class (whether it be a Controller
, Presenter
, or even a ViewModel
depending on how you use it), becomes a God
class.
While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.
This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.
Further Considerations
After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.
For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.
My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .
Lectures complémentaires sur SmashingMag :
- Sliding In And Out Of Vue.js
- Designing And Building A Progressive Web Application Without A Framework
- CSS Frameworks ou CSS Grid : que dois-je utiliser pour mon projet ?
- Utiliser Flutter de Google pour un développement mobile véritablement multiplateforme