Approche Fast-Unit ou déclarative des tests unitaires



salut! Je m'appelle Yuri Skvortsov , notre équipe est engagée dans des tests automatisés à Rosbank. L'une de nos tâches est de développer des outils pour automatiser les tests fonctionnels.



Dans cet article, je veux parler d'une solution qui a été conçue comme un petit utilitaire auxiliaire pour résoudre d'autres problèmes, mais qui s'est finalement transformée en un outil indépendant. Nous parlons du framework Fast-Unit, qui permet d'écrire des tests unitaires dans un style déclaratif et transforme le développement de tests unitaires en constructeur de composants. Le projet a été développé principalement pour tester notre produit principal - Tladianta - un framework BDD unifié pour tester 4 plates-formes: Desktop, Web, Mobile et Rest.



Pour commencer, tester un cadre d'automatisation n'est pas une tâche courante. Cependant, dans ce cas, il ne faisait pas partie d'un projet de test, mais d'un produit indépendant, nous avons donc rapidement réalisé le besoin d'unités.



Dans un premier temps, nous avons essayé d'utiliser des outils prêts à l'emploi tels que assertJ et Mockito, mais nous sommes rapidement tombés sur certaines des caractéristiques techniques de notre projet:



  • Tladianta utilise déjà JUnit4 comme dépendance, ce qui rend difficile l'utilisation d'une version différente de JUnit et rend plus difficile le travail avec Before;
  • Tladianta contient des composants pour travailler avec différentes plates-formes, il a de nombreuses entités qui sont «extrêmement proches» en termes de fonctionnalités, mais avec des hiérarchies et des comportements différents;
  • «» ( ) ;
  • , , , , ;
  • - (, Appium , , , );
  • , : Mockito .




Au départ, lorsque nous venons d'apprendre à remplacer le pilote, à créer de faux éléments Selenium et à écrire l'architecture de base du harnais de test, les tests ressemblaient à ceci:



@Test
public void checkOpenHint() {
    ElementManager.getInstance().register(xpath,ElementManager.Condition.VISIBLE,
ElementManager.Condition.DISABLED);
    new HintStepDefs().open(("");
    assertTrue(TestResults.getInstance().isSuccessful("Open"));
    assertTrue(TestResults.getInstance().isSuccessful("Click"));
}

@Test
public void checkCloseHint() {
    ElementManager.getInstance().register(xpath);
    new HintStepDefs().close("");
    assertTrue(TestResults.getInstance().isSuccessful("Close"));
    assertTrue(TestResults.getInstance().isSuccessful("Click"));
}


Ou même comme ça:



@Test
public void fillFieldsTestOld() {
    ElementManager.getInstance().register(ElementManager.Type.CHECK_BOX,"//check-box","",
ElementManager.Condition.NOT_SELECTED);
        ElementManager.getInstance().register(ElementManager.Type.INPUT,"//input","");
        ElementManager.getInstance().register(ElementManager.Type.RADIO_GROUP, 
"//radio-group","");
        DataTable dataTable = new Cucumber.DataTableBuilder()
                .withRow("", "true")
                .withRow("", "not selected element")
                .withRow(" ", "text")
                .build();
        new HtmlCommonSteps().fillFields(dataTable);
        assertEquals(TestResults.getInstance().getTestResult("set"), 
ElementProvider.getInstance().provide("//check-box").force().getAttribute("test-id"));
        assertEqualsTestResults.getInstance().getTestResult("sendKeys"), 
ElementProvider.getInstance().provide("//input").force().getAttribute("test-id"));
        assertEquals(TestResults.getInstance().getTestResult("selectByValue"), 
ElementProvider.getInstance().provide("//radio-group").force().getAttribute("test-id"));
    }


Il n'est pas difficile de trouver ce qui est testé dans le code ci-dessus, ainsi que de comprendre les vérifications, mais il y a une énorme quantité de code. Si vous incluez un logiciel de vérification et de description des erreurs, il devient alors très difficile à lire. Et nous essayons juste de vérifier que la méthode a été appelée sur l'objet désiré, alors que la vraie logique des vérifications est extrêmement primitive. Afin d'écrire un tel test, vous devez connaître ElementManager, ElementProvider, TestResults, TickingFuture (un wrapper pour implémenter un changement d'état d'un élément pendant un temps donné). Ces composants étaient différents dans différents projets, nous n'avions pas le temps de synchroniser les changements.



Un autre défi a été l'élaboration d'une norme. Notre équipe a l'avantage des automatismes, beaucoup d'entre nous n'ont pas assez d'expérience dans le développement de tests unitaires, et même si, à première vue, c'est simple, lire le code de l'autre est assez laborieux. Nous avons essayé de liquider la dette technique assez rapidement, et lorsque des centaines de ces tests sont apparus, il est devenu difficile à maintenir. De plus, le code s'est avéré surchargé de configurations, de vrais contrôles ont été perdus et des sangles épaisses ont conduit au fait qu'au lieu de tester la fonctionnalité du framework, nos propres sangles ont été testées.



Et lorsque nous avons essayé de transférer les développements d'un module à un autre, il est devenu clair que nous devions faire ressortir les fonctionnalités générales. À ce moment, l'idée est née non seulement de créer une bibliothèque avec les meilleures pratiques, mais aussi de créer un processus de développement d'unité unique au sein de cet outil.



Changer de philosophie



Si vous regardez le code dans son ensemble, vous pouvez voir que de nombreux blocs de code sont répétés «sans signification». Nous testons des méthodes, mais nous utilisons des constructeurs tout le temps (pour éviter la possibilité qu'une sorte d'erreur soit mise en cache). La première transformation - nous avons déplacé les vérifications et la génération des instances testées dans des annotations.



@IExpectTestResult(errDesc = "    set", value = "set",
expected = "//check-box", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    sendKeys", value = "sendKeys", 
expected = "//input", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    selectByValue", value = "selectByValue",
expected = "//radio-group", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@Test
public void fillFieldsTestOld() {
    ElementManager.getInstance().register(ElementManager.Type.CHECK_BOX, "//check-box", "",
ElementManager.Condition.NOT_SELECTED);
    ElementManager.getInstance().register(ElementManager.Type.INPUT, "//input", "");
    ElementManager.getInstance().register(ElementManager.Type.RADIO_GROUP, 
"//radio-group", "");
    DataTable dataTable = new Cucumber.DataTableBuilder()
            .withRow("", "true")
            .withRow("", "not selected element")
            .withRow(" ", "text")
            .build();
    runTest("fillFields", dataTable);
}


Qu'est ce qui a changé?



  • Les contrôles ont été délégués à un composant distinct. Vous n'avez plus besoin de savoir comment les éléments sont stockés, les résultats des tests.
  • : errDesc , .
  • , , , – runTest, , .
  • .
  • - , .


Nous avons aimé cette forme de notation, et nous avons décidé de simplifier un autre composant complexe de la même manière - la génération d'éléments. La plupart de nos tests sont consacrés à des étapes prêtes à l'emploi, et nous devons nous assurer qu'ils fonctionnent correctement, cependant, pour de tels contrôles, il est nécessaire de «lancer» complètement la fausse application et de la remplir d'éléments (rappelons que nous parlons de Web, Desktop et Mobile, les outils pour lesquels diffèrent assez fortement).



@IGenerateElement(type = ElementManager.Type.CHECK_BOX)
@IGenerateElement(type = ElementManager.Type.RADIO_GROUP)
@IGenerateElement(type = ElementManager.Type.INPUT)
@Test
@IExpectTestResult(errDesc = "    set", value = "set", 
expected = "//check-box", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    sendKeys", value = "sendKeys", 
expected = "//input", convertedBy = Converters.XpathToIdConverter.class, soft = true)
@IExpectTestResult(errDesc = "    selectByValue", value = "selectByValue",
expected = "//radio-group", convertedBy = Converters.XpathToIdConverter.class, soft = true)
public void fillFieldsTest() {
    DataTable dataTable = new Cucumber.DataTableBuilder()
            .withRow("", "true")
            .withRow("", "not selected element")
            .withRow(" ", "text")
            .build();
    runTest("fillFields", dataTable);
}


Maintenant, le code de test est devenu complètement modèle, les paramètres sont clairement visibles et toute la logique est déplacée vers les composants du modèle. Les propriétés par défaut permettaient de supprimer les lignes vides et offraient de nombreuses opportunités de surcharge. Ce code est presque conforme à l'approche BDD, préconditionnement, contrôle, action. De plus, toutes les liaisons ont décollé de la logique des tests, vous n'avez plus besoin de connaître les gestionnaires, les stockages des résultats des tests, le code est simple et facile à lire. Comme les annotations en Java ne sont presque pas personnalisables, nous avons introduit un mécanisme pour les convertisseurs qui peuvent recevoir le résultat final d'une chaîne. Ce code vérifie non seulement le fait d'appeler la méthode, mais également l'id de l'élément qui l'a exécutée. Presque tous les tests qui existaient à cette époque (plus de 200 unités) ont été rapidement transférés dans cette logique, les amenant à un seul modèle. Les tests sont devenus ce qu'ils devraient être - documentation,pas du code, nous sommes donc arrivés à la déclarativité. C'est cette approche qui a formé la base de Fast-Unit - déclarativité, tests d'auto-documentation et isolement de la fonctionnalité testée, le test est entièrement consacré à la vérification d'une méthode de test.



Nous continuons à développer



Il fallait maintenant ajouter la possibilité de créer de tels composants de manière indépendante dans le cadre de projets, ajouter la possibilité de contrôler la séquence de leur fonctionnement. Pour ce faire, nous avons développé le concept de phases: contrairement à Junit, toutes ces phases existent indépendamment au sein de chaque test et sont exécutées au moment du test. Par défaut, nous avons défini le cycle de vie suivant:



  • Package-generate - traitement des annotations liées à package-info. Les composants associés à ceux-ci fournissent des téléchargements de configuration et une préparation générale du faisceau.
  • Génération de classe - traitement des annotations associées à une classe de test. Les actions de configuration liées au framework sont effectuées ici, en l'adaptant à la liaison préparée.
  • Générer - traiter les annotations associées à la méthode de test elle-même (point d'entrée).
  • Test - préparation d'une instance et exécution de la méthode testée.
  • Assert - effectuer des vérifications.


Les annotations à traiter sont décrites comme suit:



@Target(ElementType.PACKAGE) //  
@IPhase(value = "package-generate", processingClass = IStabDriver.StabDriverProcessor.class,
priority = 1) //    (      )
public @interface IStabDriver {

    Class<? extends WebDriver> value(); //   ,     

    class StabDriverProcessor implements PhaseProcessor<IStabDriver> { // 
        @Override
        public void process(IStabDriver iStabDriver) {
            //  
        }
    }
}


La fonction Fast-Unit est que le cycle de vie peut être remplacé pour n'importe quelle classe - il est décrit par l'annotation ITestClass, qui est conçue pour indiquer la classe et les phases testées. La liste des phases est spécifiée simplement sous la forme d'un tableau de chaînes, permettant le changement de composition et la séquence des phases. Les méthodes qui gèrent les phases se trouvent également à l'aide d'annotations, il est donc possible de créer le gestionnaire nécessaire dans votre classe et de le marquer (en plus, le remplacement dans la classe est disponible). Un gros avantage était que cette séparation nous permettait de diviser le test en couches: si une erreur dans le test terminé se produisait pendant la phase de génération ou de génération de package, alors le faisceau de test était endommagé. Si classe-generate - il y a des problèmes dans les mécanismes de configuration du framework. Si dans le cadre du test il y a une erreur dans la fonctionnalité testée.La phase de test peut techniquement donner des erreurs à la fois dans la liaison et dans la fonctionnalité testée, nous avons donc encapsulé les erreurs de liaison possibles dans un type spécial - InnerException.



Chaque phase est isolée, c'est-à-dire ne dépend pas et n'interagit pas directement avec les autres phases, la seule chose qui est passée entre les phases sont des erreurs (la plupart des phases seront ignorées si une erreur s'est produite dans les précédentes, mais ce n'est pas nécessaire, par exemple, la phase d'assertion fonctionnera de toute façon).



Ici, probablement, la question s'est déjà posée, d'où viennent les instances de test. Si le constructeur est vide, c'est évident: en utilisant l'API Reflection, vous créez simplement une instance de la classe testée. Mais comment pouvez-vous passer des paramètres dans cette construction ou configurer l'instance après le déclenchement du constructeur? Que se passe-t-il si l'objet est construit par le constructeur ou s'il s'agit en général de tests statiques? Pour cela, le mécanisme des fournisseurs a été développé, qui cachent derrière eux la complexité du constructeur.



Paramétrage par défaut:



@IProvideInstance
CheckBox generateCheckBox() {
    return new CheckBox((MobileElement) ElementProvider.getInstance().provide("//check-box")
.get());
}


Pas de paramètres - pas de problème (nous testons la classe CheckBox et enregistrons une méthode qui créera des instances pour nous). Puisque le fournisseur par défaut est remplacé ici, il n'est pas nécessaire d'ajouter quoi que ce soit dans les tests eux-mêmes, ils utiliseront automatiquement cette méthode comme source. Cet exemple illustre clairement la logique de Fast-Unit - nous cachons le complexe et l'inutile. D'un point de vue test, peu importe comment et d'où vient l'élément mobile enveloppé avec la classe CheckBox. Tout ce qui compte pour nous, c'est qu'il existe un objet CheckBox qui répond aux exigences spécifiées.



Injection d'argument automatique: supposons que nous ayons un constructeur comme celui-ci:



public Mask(String dataFormat, String fieldFormat) {
    this.dataFormat = dataFormat;
    this.fieldFormat = fieldFormat;
}


Ensuite, un test de cette classe utilisant l'injection d'arguments ressemblera à ceci:



Object[] dataMask={"_:2_:2_:4","_:2/_:2/_:4"};

@ITestInstance(argSource = "dataMask")
@Test
@IExpectTestResult(errDesc = "  ", value = FAST_RESULT,
expected = "12/10/2012")
public void convert() {
    runTest("convert","12102012");
}


Fournisseurs nommés



Enfin, si nous avons besoin de plusieurs fournisseurs, nous utilisons la liaison de nom, masquant non seulement la complexité du constructeur, mais montrant également sa vraie signification. Le même problème peut être résolu comme ceci:



@IProvideInstance("")
Mask createDataMask(){
    return new Mask("_:2_:2_:4","_:2/_:2/_:4");
} 

@ITestInstance("")
@Test
@IExpectTestResult(errDesc = "  ", value = FAST_RESULT,
expected = "12/10/2012")
public void convert() {
    runTest("convert","12102012");
}


IProvideInstance et ITestInstance sont des annotations associées qui vous permettent d'indiquer à la méthode où obtenir l'instance à tester (pour la statique, elle renvoie simplement null, car cette instance est finalement utilisée via l'API Reflection). L'approche du fournisseur donne beaucoup plus d'informations sur ce qui se passe réellement dans le test, en remplaçant l'appel au constructeur par des paramètres par du texte décrivant les conditions préalables, donc si le constructeur change soudainement, nous n'aurons qu'à corriger le fournisseur, mais le test restera inchangé jusqu'à ce que la fonctionnalité réelle change. Si, lors de l'examen, vous voyez plusieurs prestataires, vous ferez attention à la différence entre eux, et donc aux particularités du comportement de la méthode testée. Même sans connaître du tout le cadre, mais ne connaissant que les principes de fonctionnement de Fast-Unit,le développeur pourra lire le code de test et comprendre ce que fait la méthode testée.



Conclusions et résultats



Notre approche s'est avérée présenter de nombreux avantages:



  • Portabilité de test facile.
  • Cacher la complexité des liaisons, la possibilité de les refactoriser sans casser les tests.
  • Compatibilité descendante garantie - les modifications apportées aux noms de méthodes seront enregistrées comme des erreurs.
  • Les tests se sont transformés en une documentation assez détaillée pour chaque méthode.
  • La qualité des inspections s'est considérablement améliorée.
  • Le développement de tests unitaires est devenu un processus de pipeline, et la vitesse de développement et d'examen a considérablement augmenté.
  • Stabilité des tests développés - bien que le framework et le Fast-Unit lui-même se développent activement, il n'y a pas de dégradation des tests


Malgré l'apparente complexité, nous avons pu rapidement implémenter cet outil. Désormais la plupart des unités y sont écrites, et elles ont déjà confirmé leur fiabilité avec une migration assez complexe et volumineuse, elles ont pu identifier des défauts assez complexes (par exemple, en attente d'éléments et de vérifications de texte). Nous avons pu rapidement éliminer la dette technique et établir un travail efficace avec les unités, les faisant partie intégrante du développement. Nous envisageons maintenant des options pour une implémentation plus active de cet outil dans d'autres projets en dehors de notre équipe.



Problèmes et plans actuels:



  • , . , ( - ).
  • .
  • .
  • , -.
  • Fast-Unit junit4, junit5 testng



All Articles