Fast-Unit- oder deklarativer Ansatz für Unit-Tests



Hallo! Mein Name ist Yuri Skvortsov , unser Team beschäftigt sich mit automatisierten Tests bei Rosbank. Eine unserer Aufgaben ist die Entwicklung von Tools zur Automatisierung von Funktionstests.



In diesem Artikel möchte ich über eine Lösung sprechen, die als kleines Hilfsprogramm zur Lösung anderer Probleme konzipiert wurde, sich aber letztendlich zu einem unabhängigen Tool entwickelte. Wir sprechen über das Fast-Unit-Framework, mit dem Sie Unit-Tests in einem deklarativen Stil schreiben und die Entwicklung von Unit-Tests in einen Komponentenkonstruktor verwandeln können. Das Projekt wurde hauptsächlich zum Testen unseres Hauptprodukts Tladianta entwickelt - eines einheitlichen BDD-Frameworks zum Testen von 4 Plattformen: Desktop, Web, Mobile und Rest.



Das Testen eines Automatisierungsframeworks ist zunächst keine häufige Aufgabe. In diesem Fall war es jedoch kein Teil eines Testprojekts, sondern ein eigenständiges Produkt, sodass wir schnell den Bedarf an Einheiten erkannten.



In der ersten Phase haben wir versucht, vorgefertigte Tools wie assertJ und Mockito zu verwenden, sind jedoch schnell auf einige technische Merkmale unseres Projekts gestoßen:



  • Tladianta verwendet JUnit4 bereits als Abhängigkeit, was die Verwendung einer anderen Version von JUnit erschwert und die Arbeit mit Before erschwert.
  • Tladianta enthält Komponenten für die Arbeit mit verschiedenen Plattformen. Es verfügt über viele Entitäten, die in Bezug auf die Funktionalität „extrem nah“ sind, jedoch unterschiedliche Hierarchien und Verhaltensweisen aufweisen.
  • «» ( ) ;
  • , , , , ;
  • - (, Appium , , , );
  • , : Mockito .




Als wir gerade gelernt haben, den Treiber auszutauschen, gefälschte Selenium-Elemente zu erstellen und die grundlegende Architektur für das Testkabel zu schreiben, sahen die Tests zunächst so aus:



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


Oder sogar so:



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


Es ist nicht schwer zu finden, was im obigen Code getestet wird, und die Überprüfungen zu verstehen, aber es gibt eine große Menge an Code. Wenn Sie Software zum Überprüfen und Beschreiben von Fehlern einschließen, ist das Lesen sehr schwierig. Und wir versuchen nur zu überprüfen, ob die Methode für das gewünschte Objekt aufgerufen wurde, während die eigentliche Logik der Überprüfungen äußerst primitiv ist. Um einen solchen Test zu schreiben, müssen Sie über ElementManager, ElementProvider, TestResults, TickingFuture (einen Wrapper zum Implementieren einer Änderung des Status eines Elements während einer bestimmten Zeit) Bescheid wissen. Diese Komponenten waren in verschiedenen Projekten unterschiedlich. Wir hatten keine Zeit, Änderungen zu synchronisieren.



Eine weitere Herausforderung war die Entwicklung eines Standards. Unser Team hat den Vorteil von Automaten, viele von uns haben nicht genug Erfahrung in der Entwicklung von Komponententests, und obwohl es auf den ersten Blick einfach ist, den Code des anderen zu lesen, ist es ziemlich mühsam. Wir haben versucht, technische Schulden schnell genug zu liquidieren, und als Hunderte solcher Tests erschienen, wurde es schwierig, sie aufrechtzuerhalten. Darüber hinaus stellte sich heraus, dass der Code mit Konfigurationen überladen war, echte Überprüfungen verloren gingen und dicke Bänder dazu führten, dass anstatt die Funktionalität des Frameworks zu testen, unsere eigenen Bänder getestet wurden.



Und als wir versuchten, die Entwicklungen von einem Modul auf ein anderes zu übertragen, wurde klar, dass wir die allgemeine Funktionalität herausholen mussten. In diesem Moment entstand die Idee, nicht nur eine Bibliothek mit Best Practices zu erstellen, sondern auch einen Entwicklungsprozess für einzelne Einheiten innerhalb dieses Tools zu erstellen.



Philosophie ändern



Wenn Sie den Code als Ganzes betrachten, können Sie sehen, dass viele Codeblöcke „ohne Bedeutung“ wiederholt werden. Wir testen Methoden, verwenden jedoch ständig Konstruktoren (um zu vermeiden, dass Fehler zwischengespeichert werden). Die erste Transformation - Wir haben die Überprüfungen und die Generierung getesteter Instanzen in Anmerkungen verschoben.



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


Was hat sich geändert?



  • Die Prüfungen wurden an eine separate Komponente delegiert. Jetzt müssen Sie nicht mehr wissen, wie Elemente gespeichert werden, Testergebnisse.
  • : errDesc , .
  • , , , – runTest, , .
  • .
  • - , .


Wir mochten diese Form der Notation und beschlossen, eine andere komplexe Komponente auf die gleiche Weise zu vereinfachen - die Erzeugung von Elementen. Die meisten unserer Tests sind vorgefertigten Schritten gewidmet, und wir müssen sicher sein, dass sie ordnungsgemäß funktionieren. Für solche Überprüfungen ist es jedoch erforderlich, die gefälschte Anwendung vollständig zu „starten“ und mit Elementen zu füllen (denken Sie daran, dass es sich um Web, Desktop und Mobile handelt, deren Tools unterscheiden sich ziemlich stark).



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


Jetzt ist der Testcode vollständig zur Vorlage geworden, die Parameter sind deutlich sichtbar und die gesamte Logik wird in die Vorlagenkomponenten verschoben. Die Standardeigenschaften ermöglichten das Entfernen leerer Zeilen und boten zahlreiche Möglichkeiten zum Überladen. Dieser Code entspricht fast dem BDD-Ansatz, der Vorbedingung, der Prüfung und der Aktion. Darüber hinaus haben sich alle Bindungen von der Logik der Tests gelöst, Sie müssen nicht mehr über Manager Bescheid wissen, Speicherung von Testergebnissen, der Code ist einfach und leicht zu lesen. Da Anmerkungen in Java fast nicht anpassbar sind, haben wir einen Mechanismus für Konverter eingeführt, der das Endergebnis von einer Zeichenfolge erhalten kann. Dieser Code überprüft nicht nur die Tatsache, dass die Methode aufgerufen wird, sondern auch die ID des Elements, das sie ausgeführt hat. Fast alle zu diesem Zeitpunkt existierenden Tests (mehr als 200 Einheiten) wurden schnell auf diese Logik übertragen und zu einer einzigen Vorlage zusammengefasst. Tests sind zu dem geworden, was sie sein sollten - Dokumentation,kein Code, also kamen wir zur Deklarativität. Dieser Ansatz bildete die Grundlage für Fast-Unit - Deklarativität, selbstdokumentierende Tests und Isolierung der getesteten Funktionalität. Der Test widmet sich ausschließlich der Überprüfung einer Testmethode.



Wir entwickeln uns weiter



Jetzt war es notwendig, die Möglichkeit hinzuzufügen, solche Komponenten unabhängig im Rahmen von Projekten zu erstellen, und die Möglichkeit hinzuzufügen, die Reihenfolge ihres Betriebs zu steuern. Zu diesem Zweck haben wir das Konzept der Phasen entwickelt: Im Gegensatz zu Junit existieren alle diese Phasen unabhängig voneinander in jedem Test und werden zum Zeitpunkt der Testausführung ausgeführt. Als Standardimplementierung haben wir den folgenden Lebenszyklus festgelegt:



  • Paket generieren - Verarbeiten von Anmerkungen zu Paketinformationen. Die damit verbundenen Komponenten bieten Konfigurationsdownloads und allgemeine Kabelbaumvorbereitungen.
  • Klassengenerieren - Verarbeiten von Anmerkungen, die einer Testklasse zugeordnet sind. Hier werden Konfigurationsaktionen für das Framework ausgeführt, die an die vorbereitete Bindung angepasst werden.
  • Generieren - Verarbeiten von Anmerkungen, die der Testmethode selbst zugeordnet sind (Einstiegspunkt).
  • Test - Vorbereiten einer Instanz und Ausführen der zu testenden Methode.
  • Assert - Überprüfungen durchführen.


Die zu verarbeitenden Anmerkungen werden folgendermaßen beschrieben:



@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) {
            //  
        }
    }
}


Die Fast-Unit-Funktion besteht darin, dass der Lebenszyklus für jede Klasse überschrieben werden kann. Dies wird durch die ITestClass-Annotation beschrieben, die die Klasse und die zu testenden Phasen angibt. Die Liste der Phasen wird einfach als String-Array angegeben, um Änderungen der Zusammensetzung und der Phasenfolge zu ermöglichen. Die Methoden, die Phasen behandeln, werden auch mithilfe von Anmerkungen gefunden, sodass Sie den erforderlichen Handler in Ihrer Klasse erstellen und markieren können (außerdem ist das Überschreiben innerhalb der Klasse verfügbar). Ein großes Plus war, dass eine solche Aufteilung es ermöglichte, den Test in Schichten aufzuteilen: Wenn ein Fehler im fertigen Test innerhalb der Paketgenerierungs- oder Generierungsphase auftrat, ist das Testkabel beschädigt. Wenn Klassen generiert werden, gibt es Probleme mit den Konfigurationsmechanismen des Frameworks. Wenn im Rahmen des Tests ein Fehler in der getesteten Funktionalität vorliegt.Die Testphase kann technisch sowohl Fehler in der Bindung als auch in der zu testenden Funktionalität auslösen. Daher haben wir mögliche Bindungsfehler in einen speziellen Typ - InnerException - eingeschlossen.



Jede Phase ist isoliert, d.h. hängt nicht von anderen Phasen ab und interagiert nicht direkt mit diesen. Das einzige, was zwischen den Phasen übertragen wird, sind Fehler (die meisten Phasen werden übersprungen, wenn in den vorherigen Phasen ein Fehler aufgetreten ist, dies ist jedoch nicht erforderlich, z. B. funktioniert die Assert-Phase trotzdem).



Hier hat sich wahrscheinlich schon die Frage gestellt, woher die Testinstanzen kommen. Wenn der Konstruktor leer ist, ist es offensichtlich: Mit der Reflection-API erstellen Sie einfach eine Instanz der zu testenden Klasse. Aber wie können Sie Parameter in diesem Konstrukt übergeben oder die Instanz konfigurieren, nachdem der Konstruktor ausgelöst wurde? Was tun, wenn das Objekt vom Builder erstellt wird oder es sich im Allgemeinen um statische Tests handelt? Hierzu wurde der Mechanismus von Anbietern entwickelt, die die Komplexität des Konstruktors hinter sich verbergen.



Standardparametrierung:



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


Keine Parameter - kein Problem (wir testen die CheckBox-Klasse und registrieren eine Methode, die Instanzen für uns erstellt). Da der Standardanbieter hier überschrieben wird, müssen die Tests selbst nicht hinzugefügt werden. Diese Methode wird automatisch als Quelle verwendet. Dieses Beispiel zeigt deutlich die Fast-Unit-Logik - wir verbergen das Komplexe und Unnötige. Unter Testgesichtspunkten spielt es keine Rolle, wie und woher das mit der CheckBox-Klasse umschlossene mobile Element stammt. Für uns ist nur wichtig, dass es ein CheckBox-Objekt gibt, das die angegebenen Anforderungen erfüllt.



Automatische Argumentinjektion: Nehmen wir an, wir haben einen Konstruktor wie diesen:



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


Dann sieht ein Test dieser Klasse mit Argumentinjektion folgendermaßen aus:



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");
}


Benannte Anbieter



Wenn wir mehrere Anbieter benötigen, verwenden wir schließlich die Namensbindung, um nicht nur die Komplexität des Konstruktors zu verbergen, sondern auch seine wahre Bedeutung aufzuzeigen. Das gleiche Problem kann folgendermaßen gelöst werden:



@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 und ITestInstance sind zugeordnete Anmerkungen, mit denen Sie der Methode mitteilen können, wo die zu testende Instanz abgelegt werden soll (für statisch wird null einfach zurückgegeben, da diese Instanz letztendlich über die Reflection-API verwendet wird). Der Provider-Ansatz bietet viel mehr Informationen darüber, was tatsächlich im Test passiert, und ersetzt den Aufruf des Konstruktors durch einige Parameter durch Text, der die Voraussetzungen beschreibt. Wenn sich der Konstruktor also plötzlich ändert, müssen wir nur den Provider korrigieren, aber Der Test bleibt unverändert, bis sich die tatsächliche Funktionalität ändert. Wenn Sie während der Überprüfung mehrere Anbieter sehen, werden Sie den Unterschied zwischen ihnen und damit die Besonderheiten des Verhaltens der getesteten Methode bemerken. Auch ohne den Rahmen überhaupt zu kennen, aber nur die Prinzipien des Fast-Unit-Betriebs zu kennen,Der Entwickler kann den Testcode lesen und verstehen, was die getestete Methode bewirkt.



Schlussfolgerungen und Ergebnisse



Unser Ansatz hat viele Vorteile:



  • Einfache Testportabilität.
  • Versteckt die Komplexität der Bindungen, die Möglichkeit, sie umzugestalten, ohne die Tests zu unterbrechen.
  • Abwärtskompatibilität garantiert - Änderungen an Methodennamen werden als Fehler aufgezeichnet.
  • Die Tests haben sich zu einer ziemlich detaillierten Dokumentation für jede Methode entwickelt.
  • Die Qualität der Inspektionen hat sich erheblich verbessert.
  • Die Entwicklung von Unit-Tests ist zu einem Pipeline-Prozess geworden, und die Geschwindigkeit der Entwicklung und Überprüfung hat erheblich zugenommen.
  • Stabilität der entwickelten Tests - obwohl sich das Framework und die Fast-Unit selbst aktiv entwickeln, gibt es keine Verschlechterung der Tests


Trotz der offensichtlichen Komplexität konnten wir dieses Tool schnell implementieren. Jetzt sind die meisten Einheiten darin geschrieben, und sie haben ihre Zuverlässigkeit bereits durch eine ziemlich komplexe und umfangreiche Migration bestätigt. Sie konnten ziemlich komplexe Fehler identifizieren (z. B. beim Warten auf Elemente und Textprüfungen). Wir konnten technische Schulden schnell beseitigen und eine effektive Arbeit mit Einheiten aufbauen, was sie zu einem integralen Bestandteil der Entwicklung machte. Jetzt erwägen wir Optionen für eine aktivere Implementierung dieses Tools in anderen Projekten außerhalb unseres Teams.



Aktuelle Probleme und Pläne:



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



All Articles