Fermer

août 22, 2019

Des tests plus faciles grâce au minimalisme de framework et à l'architecture logicielle


À propos de l'auteur

Ryan M. Kay est un développeur autodidacte de développeurs mobiles Java, Kotlin et Android qui se passionne pour l'architecture logicielle appliquée, le développement piloté par les tests et l'écriture…
Plus d'informations sur
Ryan
Kay

Comme pour beaucoup d'autres sujets liés au développement logiciel, les tests et le développement piloté par les tests sont souvent compliqués inutilement en théorie et en implémentation en mettant trop l'accent sur l'apprentissage d'un large éventail de cadres de test. Dans cet article, nous reviendrons sur ce que le test signifie par une simple analogie, explorons les concepts de l’architecture logicielle, ce qui réduira directement le besoin de frameworks de test et exposera certains arguments pour expliquer pourquoi vous pourriez bénéficier d’une attitude minimalisme pour votre processus de test. .

Comme beaucoup d’autres développeurs sous Android, ma première incursion dans les tests sur la plate-forme m’a amené à être immédiatement confronté à un jargon démoralisant. En outre, quels sont les quelques exemples que j’ai rencontrés à l’époque (vers 2015) ne présentent pas de cas d’utilisation concrets qui m’auraient amené à penser que le rapport coût-avantages de l’apprentissage d’un outil tel que Espresso permettait de vérifier: TextView.setText (…) fonctionnait correctement et constituait un investissement raisonnable

Pour aggraver les choses, je ne comprenais pas bien l'architecture de logiciel en théorie ou en pratique, ce qui signifiait que même si je m'étais donné la peine d'apprendre ces structures, j'aurais écrit des tests pour des applications monolithiques composées de quelques classes de dieu écrites en code spaghetti . La conclusion est que la création, le test et la maintenance de telles applications constituent un exercice d'auto-sabotage indépendamment de votre expertise en matière de framework. pourtant, cette réalisation ne devient claire qu'après avoir construit une application modulaire faiblement couplée et hautement cohésive .

Nous en arrivons à une L’un des 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 et un langage simples), c’est que votre le code peut devenir plus facile à tester. L’application de ces principes présente d’autres avantages, mais la relation entre l’architecture logicielle et les tests est au centre de cet article.

Toutefois, 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 le jargon. Avant d'approfondir le sujet principal, nous examinerons également la raison pour laquelle il existe autant de cadres de test, car nous pourrions commencer à en voir les avantages, les limites et peut-être même une autre solution.

Testing: Why Et comment

Cette section ne constituera pas une information nouvelle pour les testeurs expérimentés, mais vous pourrez néanmoins apprécier cette analogie. Bien sûr, je suis un ingénieur en logiciel, pas un ingénieur en fusée, mais je vais emprunter un instant 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, bien que le milieu change, le processus est en principe assez identique.

Supposons un instant que nous sommes des ingénieurs fusés, et que notre travail consiste à construire le premier étage * propulseur de fusée d'une navette spatiale. Supposons également que nous ayons une conception utilisable pour que la première étape commence à construire et à tester dans diverses conditions.

«Première étape» fait référence aux propulseurs qui sont déclenchés lors du premier lancement de la fusée

. Pour en venir au processus, je voudrais indiquer pourquoi je préfère cette analogie: vous ne devriez pas avoir de difficulté à répondre à la question de pourquoi nous nous soucions de tester notre conception avant de la mettre dans des situations où des vies humaines sont en jeu en jeu. Bien que je ne tente pas de vous convaincre que le fait de tester vos applications avant le lancement du pourrait sauver des vies (bien que cela soit possible en fonction de la nature de l'application), cela pourrait vous permettre d'économiser notes, avis et votre travail. Au sens le plus large, le test est le moyen par lequel nous nous assurons que des pièces uniques, plusieurs composants et des systèmes entiers fonctionnent avant de les utiliser dans des situations où il est extrêmement important pour eux de ne pas échouer.

Retour à 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 booster de fusée fonctionnera correctement une fois intégré dans l'ensemble de la navette, on peut être certain que s'il ne fonctionne pas dans un environnement contrôlé, il sera très peu probable qu'il fonctionne du tout.

Supposons que parmi les centaines, voire les milliers de variables contre lesquelles les copies du modèle de fusée ont été testées, il s'agit des températures ambiantes dans lesquelles le servomoteur de fusée être mis à feu. Lors de tests à 35 ° Celsius, nous constatons que tout fonctionne sans erreur. Encore une fois, la fusée est testée à peu près à la température ambiante sans défaillance. L'essai final aura lieu à la température la plus basse enregistrée pour le site de lancement, à -5 ° Celsius. Au cours de cet essai final, la fusée tire, mais après une courte période, la fusée prend feu et, peu après, explose violemment. mais heureusement dans un environnement contrôlé et sûr.

À ce stade, nous savons que les variations de température semblent au moins être impliquées dans l’essai ayant échoué, ce qui nous amène à nous demander quelles parties de l’appareil de survol de fusée peut être affecté négativement par les températures froides. Au fil du temps, il a été découvert qu'un composant clé, un joint torique en caoutchouc servant à arrêter l'écoulement du carburant d'un compartiment à l'autre, devient rigide et inefficace lorsqu'il est exposé à des températures proches ou inférieures au gel. 19659005] Il est possible que vous ayez remarqué que son analogie est vaguement basée sur les événements tragiques du désastre de la navette spatiale Challenger . Pour ceux qui ne sont pas familiers, la triste vérité (dans la mesure où les enquêtes ont été conclues) est que les ingénieurs ont échoué lors des tests et avertissements, mais que des problèmes administratifs et politiques ont néanmoins incité le lancement à procéder. En tout état de cause, que vous ayez mémorisé ou non le terme de licenciement j'espère que vous avez compris le processus fondamental de test des pièces de tout type de système.

Concernant Software

Attendu que le précédent L’analogie expliquait le processus fondamental de test des fusées (tout en laissant 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 le lançant uniquement sur des appareils une fois qu’elle est dans un état déployable, je suppose plutôt que nous pouvons appliquer le principe de Redondance aux différentes parties de la demande en premier.

Cela signifie que nous créons des copies des plus petites parties de l’ensemble de l’application (communément appelée Unités du logiciel), configurez un environnement de test isolé et observez leur comportement en fonction des variables, arguments, événements et réponses susceptibles de se produire au moment de l’exécution. Les tests sont vraiment aussi simples que cela en théorie, mais la clé même pour accéder à ce processus réside dans la création d'applications pouvant être testées de manière pratique. Cela revient à deux préoccupations que nous examinerons dans les deux prochaines sections. 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 logiciel (appelé désormais unité bien que cette définition soit délibérément trop simpliste), il est nécessaire de disposer d’un environnement de test permettant d’interagir avec votre logiciel au moment de l’exécution. Pour que les applications de construction s'exécutent uniquement sur un environnement JVM ( Java Virtual Machine ), il suffit de JRE () pour écrire des tests. Environnement d'exécution Java ). Prenons par exemple cette très simple calculatrice classe:

 class Calculator {
    privé int ajouter (int a, int b) {
        retourne a + b;
    }

    privé int soustraire (int a, int b) {
        retourne a - b;
    }
}

En l'absence de tout cadre, tant que nous avons une classe de test contenant une fonction main pour exécuter notre code, nous pouvons le tester. Comme vous vous en souvenez peut-être, la fonction principale désigne le point de départ de l'exécution d'un programme Java simple. Pour ce que nous testons, nous introduisons simplement quelques données de test dans les fonctions de la calculatrice et nous vérifions qu’elle exécute correctement les calculs de base:

 public class Main {

    public static void main (String [] args) {
    // créer une copie de l'unité à tester
        Calculatrice calc = nouvelle calculatrice ();
    // crée des conditions de test pour vérifier le comportement
        int addTest = calc.add (2, 2);
        int soustractTest = calc.subtract (2, 2);

    // vérifie le comportement par assertion
        if (addTest == 4) System.out.println ("addTest a réussi.");
        else System.out.println ("addTest a échoué.");

        if (soustractTest == 0) System.out.println ("soustractTest a passé.");
        else System.out.println ("subtractTest a échoué.");
    }
}

Tester une application Android est bien sûr une procédure complètement différente. Bien qu'il existe une fonction principale enfouie au plus profond de la source du fichier ZygoteInit.java (dont les détails sont sans importance ici), qui est invoquée avant l'application Android. lancé sur la JVM même un développeur Android junior devrait savoir 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 des applications Android se trouvent être la classe Application et toutes les classes Activité auxquelles le système peut être pointé via le fichier AndroidManifest.xml . .

Tout cela n’est qu’un signe avant-coureur du fait que le fait de tester des unités dans une application Android présente un niveau de complexité accru, strictement parce que notre environnement de test doit désormais prendre en compte la plate-forme Android. [19659027] Apprivoiser le problème du couplage étroit

Le terme couplage étroit est un terme décrivant une fonction, une classe ou un module d'application qui dépend de de plates-formes, cadres, langages et bibliothèques particuliers. . Il s’agit d’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’ampleur de son couplage. Dans le même ordre d'idées, le problème de test des classes étroitement couplées à la plate-forme Android est que vous devez trouver un moyen de travailler avec ou autour de la plate-forme.

Pour les classes étroitement couplées à Android plate-forme, vous avez deux options. La première consiste à déployer simplement vos classes sur un périphérique Android (physique ou virtuel). Bien que je vous suggère de tester votre code d'application avant de le mettre en production, il s'agit d'une approche extrêmement inefficace au début et au milieu du processus de développement en ce qui concerne le temps.

Une unité Cependant, la définition technique que vous préférez est généralement considérée comme une fonction unique dans une classe (même si certains élargissent la définition pour inclure les fonctions d'assistance suivantes, appelées en interne par l'appel initial d'une seule fonction). Quoi qu'il en soit, les unités sont censées être petites; Construire, compiler et déployer une application complète pour tester une unité unique est un point manquant. Il est également inutile de tester séparément .

Une autre solution au problème du couplage étroit consiste à: d'utiliser des frameworks de test pour interagir avec, ou simuler (simuler) des dépendances de plate-forme. Des cadres tels que Espresso et Robolectric offrent aux développeurs des moyens beaucoup plus efficaces pour tester les unités que l'approche précédente; les premiers étant utiles pour les tests exécutés sur un périphérique (connus sous le nom de «tests instrumentés» car apparemment les appeler tests de périphérique n'était pas assez ambigus) et les seconds étant capables de se moquer du cadre Android localement sur une machine virtuelle Java.

Je me tiens à préciser que je ne veux pas laisser entendre que vous ne devriez jamais utiliser ces options. Le processus utilisé par un développeur pour créer et tester ses applications doit reposer sur une combinaison de préférences personnelles et sur le souci de l'efficacité.

Pour ceux qui n'aiment pas créer des applications modulaires et à couplage lâche, vous n'aurez d'autre choix que: Familiarisez-vous avec ces cadres si vous souhaitez avoir un niveau adéquat de couverture de test. De nombreuses applications merveilleuses ont été construites de cette façon, et on me reproche souvent de rendre mes applications trop modulaires et abstraites. Que vous adoptiez mon approche ou que vous décidiez de vous appuyer lourdement sur des cadres, je vous félicite d’avoir consacré du temps et des efforts à la mise à l’essai de vos applications.

Gardez vos cadres à longueur d’armée

Pour le dernier préambule de la leçon principale de la présente article, il vaut la peine de discuter des raisons pour lesquelles vous voudrez peut-être adopter une attitude de minimalisme lorsqu’il s’agit d’utiliser des frameworks (et cela ne concerne pas seulement les frameworks de test). Le sous-titre ci-dessus est une paraphrase du magnanime enseignant des meilleures pratiques en matière de logiciels: Robert “Oncle Bob” C. Martin. Parmi les nombreux joyaux qu’il m’a donnés depuis que j’ai étudié pour la première fois ses travaux, celui-ci a nécessité plusieurs années d’expérience directe.

Pour autant que je sache de quoi cette déclaration parle, le coût d’utilisation des cadres est dans l’investissement de temps requis. les apprendre et les entretenir. Certains changent assez souvent et d'autres pas assez souvent. Les fonctions deviennent obsolètes, les cadres cessent d'être maintenus et, tous les 6 à 24 mois, un nouveau cadre arrive pour remplacer le dernier. Par conséquent, si vous pouvez trouver une solution pouvant être mise en œuvre sous la forme d'une fonctionnalité de plate-forme ou de langage (qui a tendance à durer beaucoup plus longtemps), elle aura tendance à être plus résistante aux modifications des différents types mentionnés ci-dessus.

De manière plus technique remarque, des frameworks tels que Espresso et dans une moindre mesure Robolectric ne peuvent jamais fonctionner aussi efficacement que de simples tests JUnit ni même le test gratuit de framework précédemment . Bien que JUnit soit effectivement un cadre, il est étroitement associé à la JVM qui a tendance à évoluer beaucoup plus lentement que la plate-forme Android proprement dite. Moins de frameworks presque correspondent invariablement à un code plus efficace en termes de temps nécessaire à l'exécution et à l'écriture d'un ou plusieurs tests.

Vous pouvez probablement en déduire que nous allons maintenant discuter d'une approche. qui utilisera certaines techniques permettant de garder la plate-forme Android à portée de main; tout en nous permettant une couverture étendue du code, l'efficacité des tests et la possibilité d'utiliser un cadre ici ou là quand le besoin s'en fait sentir.

The Art Of Architecture

Pour utiliser une analogie stupide, on pourrait penser à des cadres. et les plates-formes sont comme des collègues dominateurs qui prendront en charge votre processus de développement à moins que vous ne leur fixiez des limites appropriées. Les principes d’or de l’architecture logicielle peuvent vous fournir les concepts généraux et les techniques spécifiques nécessaires pour créer et appliquer ces limites. Comme nous le verrons dans un moment, si vous vous êtes déjà demandé quels sont les avantages réels de l'application des principes d'architecture logicielle dans votre code, certains directement et d'autres indirectement, facilitent le test de votre code.

Separation Of Concerns

Separation Of Concerns est, à mon sens, le concept le plus universellement applicable et le plus utile de l’architecture logicielle dans son ensemble (sans vouloir dire que d’autres devraient être négligés). La séparation des problèmes (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 appliqué aux classes, mais sachez que le SOC peut être appliqué à des fonctions par le biais d'une utilisation intensive des fonctions auxiliaires 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 à interface graphique, vous aurez probablement rencontré au moins un des éléments suivants: Modèle-View-Controller (MVC), Modèle-View-Presenter (MVP) ou Model-View-ViewModel (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 (ni même pour les fonctionnalités d'un projet unique). Ironiquement, le modèle présenté par l'équipe Android il y a quelques années comme étant leur 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 fan

En tout état de cause, 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 versions différentes du SOC qui insistent sur la séparation fondamentale de trois types de code que je qualifie de: Data Interface utilisateur Logique .

En quoi la séparation de Data User Interface et Logic vous aide-t-elle à tester vos applications? La réponse est que, en retirant la logique des classes devant traiter les dépendances plate-forme / framework dans des classes possédant peu ou pas de dépendances plate-forme / framework, les tests deviennent faciles et le cadre 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 montrer comment cela fonctionne, examinons l'architecture simplifiée à trois couches d'une application Android hypothétique.

La première classe gérera notre interface utilisateur. Pour simplifier les choses, j’ai utilisé une activité à cette fin, mais j’optais généralement pour 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 s'étend à Activity implements CalculatorContract.IUserInterface {

    affichage TextView privé;
    private CalculatorContract.IControlLogic controlLogic;
    private final String INVALID_MESSAGE = "Expression invalide.";

    @Passer outre
    Void protégé onCreate (Bundle savedInstanceState) {
        super.onCreate (savedInstanceState);

        controlLogic = new DependencyProvider (). ProvideControlLogic (this);

        display = findViewById (R.id.textViewDisplay);
        Bouton d'évaluation = findViewById (R.id.buttonEvaluate);
        evaluer.setOnClickListener (new View.OnClickListener () {
            @Passer outre
            public void onClick (Afficher la vue) {
                controlLogic.handleInput ('=');
            }
        });
        //..bindings pour le reste des boutons de la calculatrice
    }

    @Passer outre
    public void updateDisplay (String displayText) {
        display.setText (displayText);
    }

    @Passer outre
    public String getDisplay () {
        retour display.getText (). toString ();
    }

    @Passer outre
    public void showError () {
        Toast.makeText (this, INVALID_MESSAGE, Toast.LENGTH_LONG) .show ();
    }
}

Comme vous pouvez le constater, l'activité a deux emplois: premièrement, puisqu'il s'agit du point d'entrée d'un composant donné d'une application Android elle agit comme une sorte de conteneur pour les autres composants de la fonction. En termes simples, un conteneur peut être considéré comme une sorte de classe racine à laquelle les autres composants sont finalement rattachés via des références (ou des champs de membres privés dans ce cas). Elle gonfle, lie également les références et ajoute des écouteurs à la présentation XML (l'interface utilisateur).

Testing Control Logic

Plutôt que de faire l'activité possède une référence à une classe concrète à l'arrière. fin, nous le faisons parler à une interface de type CalculatorContract.IControlLogic. Nous verrons pourquoi il s’agit d’une interface dans la section suivante. Pour l’instant, il suffit de comprendre que tout ce qui se trouve de l’autre côté de cette interface est censé ressembler à un contrôleur Presenter ou . Etant donné que cette classe contrôlera les interactions entre l'activité de front-end et le calculateur dorsal j'ai choisi de l'appeler CalculatorControlLogic :

 public La classe CalculatorControlLogic implémente CalculatorContract.IControlLogic {

    private CalculatorContract.IUserInterface ui;
    private CalculatorContract.IComputationLogic comp;

    public CalculatorControlLogic (CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) {
        this.ui = ui;
        this.comp = comp;
    }

    @Passer outre
    public void handleInput (char inputChar) {
        commutateur (inputChar) {
            case '=':
                évaluerExpression ();
                Pause;
            //... gérer d'autres événements d'entrée
        }
    }
    void privé evaluExpression () {
        Facultatif  result = comp.computeResult (ui.getDisplay ());

        if (result.isPresent ()) ui.updateDisplay (result.get ());
        sinon ui.showError ();
    }
}
 

Il y a beaucoup de choses subtiles dans la façon dont cette classe est conçue qui facilitent les tests. Tout d'abord, toutes ses références proviennent de la bibliothèque standard Java ou d'interfaces définies dans l'application. Cela signifie que tester cette classe sans aucun framework est un jeu d'enfant, et cela pourrait être fait localement sur une JVM . Un autre conseil utile mais utile est que toutes les interactions de cette classe peuvent être appelées via une fonction générique unique handleInput (...) . Cela fournit un point d'entrée unique pour tester chaque comportement de cette classe.

Notez également que dans la fonction evaluExpression () je retourne une classe de type Facultatif de l'arrière. Normalement, j'utiliserais ce que les programmeurs fonctionnels appellent un Soit Monad ou comme je préfère l'appeler, un Result Wrapper . Quel que soit le nom stupide que vous utilisiez, c’est un objet capable de représenter plusieurs états différents par le biais d’un seul appel de fonction. Facultatif est une construction plus simple qui peut représenter un null ou une valeur du type générique fourni. Dans tous les cas, puisque l'expression dorsale peut être invalide, nous voulons donner à la classe ControlLogic un moyen de déterminer le résultat de l'opération dorsale; rendre 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 tests qui a été écrit en utilisant JUnit et une classe qui, dans le jargon de test, est appelée . ] Fake :

 public class CalculatorControlLogicTest {

    @Tester
    public void validExpressionTest () {

        CalculatorContract.IComputationLogic comp = new FakeComputationLogic ();
        CalculatorContract.IUserInterface ui = new FakeUserInterface ();
        Contrôleur CalculatorControlLogic = new CalculatorControlLogic (ui, comp);

        controller.handleInput ('=');

        assertTrue ((((FakeUserInterface) ui) .displayUpdateCalled);
        assertTrue ((((FakeUserInterface) ui) .displayValueFinal.equals ("10.0"));
        assertTrue ((((FakeComputationLogic) comp) .computeResultCalled));

    }

    @Tester
    public void invalidExpressionTest () {

        CalculatorContract.IComputationLogic comp = new FakeComputationLogic ();
        ((FakeComputationLogic) comp) .returnEmpty = true;
        CalculatorContract.IUserInterface ui = new FakeUserInterface ();
        ((FakeUserInterface) ui) .displayValueInitial = "+ 7 + 7";
        Contrôleur CalculatorControlLogic = new CalculatorControlLogic (ui, comp);

        controller.handleInput ('=');

        assertTrue ((((FakeUserInterface) ui) .showErrorCalled);
        assertTrue ((((FakeComputationLogic) comp) .computeResultCalled));

    }

    La classe privée FakeUserInterface implémente CalculatorContract.IUserInterface {
        booléen displayUpdateCalled = false;
        booléen showErrorCalled = false;
        String displayValueInitial = "5 + 5";
        String displayValueFinal = "";

        @Passer outre
        public void updateDisplay (String displayText) {
            displayUpdateCalled = true;
            displayValueFinal = displayText;
        }

        @Passer outre
        public String getDisplay () {
            return displayValueInitial;
        }

        @Passer outre
        public void showError () {
            showErrorCalled = true;
        }
    }

    La classe privée FakeComputationLogic implémente CalculatorContract.IComputationLogic {
        boolean computeResultCalled = false;
        boolean returnEmpty = false;

        @Passer outre
        public Facultatif  computeResult (expression de chaîne) {
            computeResultCalled = true;
            if (returnEmpty) return Facultatif.empty ();
            else return Facultatif.of ("10.0");
        }
    }
}
 

Comme vous pouvez le constater, 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 examiner certaines choses plus subtiles qui ont rendu très facile l'écriture de cette classe de tests.

Le pouvoir de l'abstraction et de l'inversion des dépendances

Deux autres concepts importants 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 d'utiliser les interfaces et les classes abstraites (collectivement appelées abstractions ) à Java, le code ci-dessus est un démonstration directe. Comme la classe à tester référence abstractions au lieu de classes de béton nous avons pu créer le test de Fake double pour l'interface utilisateur et back end dans notre classe de tests. Tant que ces doubles doublons implémentent les interfaces appropriées, CalculatorControlLogic se moque bien de ne pas être la réalité.

Deuxièmement, CalculatorControlLogic a été paramétré en fonction du constructeur. (oui, c’est une forme de Dependency Injection ), au lieu de créer ses propres dépendances. Par conséquent, il n'est pas nécessaire de le réécrire lorsqu'il est utilisé dans un environnement de production ou de test, ce qui constitue un bonus d'efficacité.

L'injection de dépendance est une forme d'inversion de contrôle . ]concept difficile à 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 enseignant préféré sur ces sujets) décrit comme «le principe de la séparation de la configuration de l’utilisation». résultats dans des classes qui sont plus faciles à tester et à construire isolément les unes des autres.

Test de la logique de calcul

Enfin, nous arrivons à la classe ComputationLogic qui est supposée approcher un Périphérique IO tel qu'un adaptateur pour serveur distant ou une base de données locale. Comme nous n'avons besoin d'aucun de ceux-ci pour une calculatrice simple, il sera simplement responsable de l'encapsulation de la logique nécessaire pour valider et évaluer les expressions que nous lui donnons:

 La classe publique CalculatorComputationLogic implémente CalculatorContract.IComputationLogic {.

    caractère final privé ADD = '+';
    caractère final privé SUBTRACT = '-';
    caractère final privé MULTIPLY = '*';
    caractère final privé DIVIDE = '/';

    @Passer outre
    public Facultatif  computeResult (expression de chaîne) {
        if (hasOperator (expression)) renvoie tentEvaluation (expression);
        else return Facultatif.empty ();

    }

    private Facultatif  tryEvaluation (Expression de chaîne) {
        Délimiteur de chaîne = getOperator (expression);
        Binomial b = buildBinomial (expression, délimiteur);
        renvoyer evaluBinomial (b);
    }

    private Facultatif  evaluBinomial (Binomial b) {
        Résultat de chaîne;
        switch (b.getOperatorChar ()) {
            cas AJOUTER:
                resultat = Double.toString (b.firstTerm + b.secondTerm);
                Pause;
            cas soustrait:
                resultat = Double.toString (b.firstTerm - b.secondTerm);
                Pause;
            cas MULTIPLI:
                resultat = Double.toString (b.firstTerm * b.secondTerm);
                Pause;
            cas diviser:
                resultat = Double.toString (b.firstTerm / b.secondTerm);
                Pause;
            défaut:
                return Facultatif.empty ();
        }
        return Facultatif.of (résultat);
    }

    private Binomial buildBinomial (expression de chaîne, délimiteur de chaîne) {
        String [] opérandes = expression.split (délimiteur);
        retourne un nouveau binôme (
                délimiteur,
                Double.parseDouble (opérandes [0]),
                Double.parseDouble (opérandes [1])
        )
    }

    private String getOperator (expression de chaîne) {
        pour (char c: expression.toCharArray ()) {
            if (c == ADD || c == SOUSTRACTION || c == MULTIPLI || c == DIVIDE)
                retourne "\" + c;
        }

        //défaut
        retourne "+";
    }

    booléen privé hasOperator (expression de chaîne) {
        pour (char c: expression.toCharArray ()) {
            if (c == ADD || c == SOUSTRACTION || c == MULTIPLI || c == DIVIDE) renvoie true;
        }
        retourne faux;
    }

    classe privée Binomial {
        Opérateur de chaîne;
        double premier temps;
        double seconde

        Binomial (Opérateur de chaîne, double premierTerm, double deuxièmeTerm) {
            this.operator = opérateur;
            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 typiquement, il y aurait un couplage étroit avec une bibliothèque particulière qui présenterait des problèmes similaires à ceux d'une classe étroitement couplée à Android. Dans quelques instants, nous verrons quoi faire à propos de telles classes, mais celle-ci est si facile à tester que nous pouvons aussi bien essayer:

 public class CalculatorComputationLogicTest {

    private CalculatorComputationLogic comp = new CalculatorComputationLogic ();

    @Tester
    public void additionTest () {
        String EXPRESSION = "5 + 5";
        String ANSWER = "10.0";

        Facultatif  result = comp.computeResult (EXPRESSION);

        assertTrue (result.isPresent ());
        assertEquals (result.get (), REPONSE);
    }

    @Tester
    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());
    }
}

The easiest classes to test, are those which are simply given some value or object, and are expected to return a result without the necessity of calling some external dependencies. In any case, there comes a point where no matter how much software architecture wizardry you apply, you will still need to worry about classes which cannot be decoupled from platforms and frameworks. Fortunately, there is still a way we can employ software architecture to: At worst make these classes easier to test, and at best, so trivially simple that testing can be done at a glance.

Humble Objects And Passive Views

The above two names refer to a pattern in which an object that must talk to low-level dependencies, is simplified so much that it arguably does not need to be tested. I was first introduced to this pattern via Martin Fowler’s blog on variations of Model-View-Presenter. Later on, through Robert C. Martin’s works, I was introduced to the idea of treating certain classes as Humble Objectswhich implies that this pattern does not need to be limited to user interface classes (although I do not mean to say that Fowler ever implied such a limitation).

Whatever you choose to call this pattern, it is delightfully simple to understand, and in some sense I believe it is actually just the result of rigorously applying SOC to your classes. While this pattern applies also to back end classes, we will use our user interface class to demonstrate this principle in action. The separation is very simple: Classes which interact with platform and framework dependencies, do not think for themselves (hence the monikers Humble and Passive). When an event occurs, the only thing they do is forward the details of this event to whatever logic class happens to be listening:

//from CalculatorActivity's onCreate() function:
evaluate.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                controlLogic.handleInput('=');
            }
        });

The logic class, which should be trivially easy to test, is then responsible for controlling the user interface in a very fine-grained manner. Rather than calling a single generic updateUserInterface(...) function on the user interface class and leaving it to do the work of a bulk update, the user interface (or other such class) will possess small and specific functions which should be easy to name and implement:

//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();
    }
//…

In principal, these two examples ought to give you enough to understand how to go about implementing this pattern. The object which possesses the logic is loosely coupled, and the object which is tightly coupled to pesky dependencies becomes almost devoid of logic.

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. Ouch.

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 ControllerPresenteror 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 Mockitobut 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 RobolectricMockWebServerand 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 analysiswill come to see that getting started with testing really can be simple and framework minimal.

Further Reading on SmashingMag:

Smashing Editorial(dm, il)




Source link