Einfacheres Testen durch Framework-Minimalismus und Softwarearchitektur

Veröffentlicht: 2022-03-10
Kurze Zusammenfassung ↬ Wie bei vielen anderen Themen in der Softwareentwicklung werden Tests und testgetriebene Entwicklung in Theorie und Implementierung oft unnötig komplex gemacht, indem zu viel Wert auf das Erlernen einer breiten Palette von Testframeworks gelegt wird. In diesem Artikel werden wir anhand einer einfachen Analogie erneut untersuchen, was Testen bedeutet, Konzepte in der Softwarearchitektur untersuchen, die direkt zu einem geringeren Bedarf an Testframeworks führen, und einige Argumente dafür, warum Sie von einer minimalistischen Einstellung für Ihren Testprozess profitieren könnten .

Wie viele andere Android-Entwickler führte mich mein anfänglicher Ausflug in das Testen auf der Plattform dazu, dass ich sofort mit einem demoralisierenden Grad an Fachjargon konfrontiert wurde. Darüber hinaus stellten die wenigen Beispiele, auf die ich damals (ca. 2015) gestoßen bin, keine praktischen Anwendungsfälle dar, die mich möglicherweise zu der Annahme veranlasst haben, dass das Kosten-Nutzen-Verhältnis beim Erlernen eines Tools wie Espresso zur Überprüfung, ob ein TextView.setText( …) einwandfrei funktionierte, war eine sinnvolle Investition.

Um die Sache noch schlimmer zu machen, hatte ich kein funktionierendes Verständnis von Softwarearchitektur in Theorie oder Praxis, was bedeutete, dass ich, selbst wenn ich mir die Mühe gemacht hätte, diese Frameworks zu lernen, Tests für monolithische Anwendungen geschrieben hätte, die aus ein paar god bestehen im Spaghetti-Code . Die Pointe ist, dass das Erstellen, Testen und Warten solcher Anwendungen eine Übung in Selbstsabotage ist, unabhängig von Ihrer Framework-Expertise. Diese Erkenntnis wird jedoch erst klar, nachdem man eine modulare , lose gekoppelte und hochkohäsive Anwendung erstellt hat.

Von hier aus kommen wir zu einem der Hauptdiskussionspunkte dieses Artikels, den ich hier im Klartext zusammenfasse: Zu den primären Vorteilen der Anwendung der goldenen Prinzipien der Softwarearchitektur (keine Sorge, ich werde sie mit einfachen Beispielen diskutieren und Sprache), ist, dass Ihr Code einfacher zu testen ist. Die Anwendung solcher Prinzipien hat noch andere Vorteile, aber die Beziehung zwischen Softwarearchitektur und Testen steht im Mittelpunkt dieses Artikels.

Für diejenigen, die verstehen möchten, warum und wie wir unseren Code testen, werden wir jedoch zunächst das Konzept des Testens durch Analogie untersuchen; ohne dass Sie sich irgendeinen Fachjargon merken müssen. Bevor wir uns eingehender mit dem Hauptthema befassen, werden wir uns auch mit der Frage befassen, warum es so viele Test-Frameworks gibt, denn wenn wir dies untersuchen, werden wir möglicherweise ihre Vorteile, Einschränkungen und vielleicht sogar eine alternative Lösung erkennen.

Mehr nach dem Sprung! Lesen Sie unten weiter ↓

Testen: Warum und wie

Dieser Abschnitt wird für erfahrene Tester keine neuen Informationen sein, aber vielleicht gefällt Ihnen diese Analogie trotzdem. Natürlich bin ich Software-Ingenieur, kein Raketeningenieur, aber für einen Moment leihe ich mir eine Analogie, die sich auf das Entwerfen und Bauen von Objekten sowohl im physischen Raum als auch im Speicherbereich eines Computers bezieht. Es stellt sich heraus, dass sich zwar das Medium ändert, der Prozess aber im Prinzip derselbe ist.

Nehmen wir für einen Moment an, dass wir Raketeningenieure sind und unsere Aufgabe darin besteht, den Raketenverstärker der ersten Stufe* eines Space Shuttles zu bauen. Nehmen wir auch an, dass wir ein brauchbares Design für die erste Stufe entwickelt haben, um mit dem Bau und dem Testen unter verschiedenen Bedingungen zu beginnen.

„Erste Stufe“ bezieht sich auf Booster, die gezündet werden, wenn die Rakete zum ersten Mal gestartet wird

Bevor wir zum Prozess kommen, möchte ich darauf hinweisen, warum ich diese Analogie bevorzuge: Sie sollten keine Schwierigkeiten haben, die Frage zu beantworten, warum wir uns die Mühe machen, unser Design zu testen, bevor wir es in Situationen einsetzen, in denen Menschenleben auf dem Spiel stehen. Ich werde zwar nicht versuchen, Sie davon zu überzeugen, dass das Testen Ihrer Anwendungen vor dem Start Leben retten könnte (obwohl dies je nach Art der Anwendung möglich ist), aber es könnte Bewertungen, Rezensionen und Ihren Job retten. Im weitesten Sinne ist Testen die Art und Weise, wie wir sicherstellen, dass einzelne Teile, mehrere Komponenten und ganze Systeme funktionieren, bevor wir sie in Situationen einsetzen, in denen es entscheidend darauf ankommt, dass sie nicht ausfallen.

Um auf den Wie-Aspekt dieser Analogie zurückzukommen, werde ich den Prozess vorstellen, mit dem Ingenieure ein bestimmtes Design testen: Redundanz . Redundanz ist im Prinzip einfach: Erstellen Sie Kopien der zu testenden Komponente nach denselben Designspezifikationen, die Sie zum Startzeitpunkt verwenden möchten. Testen Sie diese Kopien in einer isolierten Umgebung, in der Vorbedingungen und Variablen streng kontrolliert werden. Dies garantiert zwar nicht, dass der Raketenbooster richtig funktioniert, wenn er in das gesamte Shuttle integriert ist, aber man kann sicher sein, dass es sehr unwahrscheinlich ist, dass er überhaupt funktioniert, wenn er nicht in einer kontrollierten Umgebung funktioniert.

Angenommen, von den Hunderten oder vielleicht Tausenden von Variablen, gegen die die Kopien des Raketendesigns getestet wurden, kommt es auf die Umgebungstemperaturen an, bei denen der Raketenverstärker getestet wird. Beim Testen bei 35° Celsius sehen wir, dass alles fehlerfrei funktioniert. Auch hier wird die Rakete ohne Ausfall bei ungefähr Raumtemperatur getestet. Der abschließende Test findet bei der niedrigsten aufgezeichneten Temperatur für den Startplatz bei -5 °C statt. Während dieses letzten Tests zündet die Rakete, aber nach kurzer Zeit flammt die Rakete auf und explodiert kurz darauf heftig; aber glücklicherweise in einer kontrollierten und sicheren Umgebung.

An diesem Punkt wissen wir, dass Temperaturänderungen zumindest an dem fehlgeschlagenen Test beteiligt zu sein scheinen, was uns dazu veranlasst, zu überlegen, welche Teile des Raketenverstärkers durch kalte Temperaturen beeinträchtigt werden könnten. Im Laufe der Zeit hat sich herausgestellt, dass eine Schlüsselkomponente, ein Gummi -O-Ring , der dazu dient, den Kraftstofffluss von einem Raum zum anderen zu stoppen, starr und unwirksam wird, wenn er Temperaturen nahe oder unter dem Gefrierpunkt ausgesetzt wird.

Es ist möglich, dass Sie bemerkt haben, dass seine Analogie lose auf den tragischen Ereignissen der Challenger -Space-Shuttle-Katastrophe basiert. Für diejenigen, die sich nicht auskennen, ist die traurige Wahrheit (soweit die Untersuchungen abgeschlossen sind), dass es viele fehlgeschlagene Tests und Warnungen von den Ingenieuren gab, und dennoch spornten administrative und politische Bedenken den Start an, trotzdem fortzufahren. Unabhängig davon, ob Sie den Begriff Redundanz auswendig gelernt haben oder nicht, hoffe ich, dass Sie den grundlegenden Prozess zum Testen von Teilen jeder Art von System verstanden haben.

Apropos Software

Während die vorherige Analogie den grundlegenden Prozess zum Testen von Raketen erläuterte (wobei ich mir viel Freiheit bei den feineren Details nehme), werde ich jetzt auf eine Weise zusammenfassen, die wahrscheinlich für Sie und mich relevanter ist. Es ist zwar möglich, Software nur durch Starten zu testen es auf Geräte übertragen, sobald es sich in irgendeinem einsatzbereiten Zustand befindet, nehme ich stattdessen an, dass wir das Prinzip der Redundanz zuerst auf die einzelnen Teile der Anwendung anwenden können.

Das bedeutet, dass wir Kopien der kleineren Teile der gesamten Anwendung (allgemein als Softwareeinheiten bezeichnet) erstellen, eine isolierte Testumgebung einrichten und sehen, wie sie sich basierend auf den auftretenden Variablen, Argumenten, Ereignissen und Antworten verhalten zur Laufzeit. Das Testen ist in der Theorie wirklich so einfach, aber der Schlüssel, um überhaupt zu diesem Prozess zu gelangen, liegt darin, Anwendungen zu entwickeln, die durchführbar testbar sind. Dies läuft auf zwei Bedenken hinaus, die wir in den nächsten beiden Abschnitten betrachten werden. Die erste Sorge hat mit der Testumgebung zu tun, und die zweite Sorge hat mit der Art und Weise zu tun, wie wir Anwendungen strukturieren.

Warum brauchen wir Frameworks?

Um eine Software zu testen (im Folgenden als Unit bezeichnet, obwohl diese Definition bewusst zu stark vereinfacht ist), ist eine Art Testumgebung erforderlich, die es Ihnen ermöglicht, zur Laufzeit mit Ihrer Software zu interagieren. Für diejenigen, die Anwendungen erstellen, die ausschließlich in einer JVM -Umgebung ( Java Virtual Machine ) ausgeführt werden sollen, ist zum Schreiben von Tests lediglich eine JRE ( Java Runtime Environment ) erforderlich. Nehmen Sie zum Beispiel diese sehr einfache Rechnerklasse :

 class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }

Solange wir keine Frameworks haben, können wir sie testen, solange wir eine Testklasse haben, die eine main enthält, um unseren Code tatsächlich auszuführen. Wie Sie sich vielleicht erinnern, bezeichnet die main Funktion den Startpunkt der Ausführung für ein einfaches Java-Programm. Was wir testen, geben wir einfach einige Testdaten in die Funktionen des Rechners ein und überprüfen, ob er die grundlegende Arithmetik richtig ausführt:

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

Das Testen einer Android-Anwendung ist natürlich ein völlig anderes Verfahren. Obwohl tief in der Quelle der ZygoteInit.java -Datei eine main verborgen ist (deren feinere Details hier nicht wichtig sind), die aufgerufen wird, bevor eine Android-Anwendung auf der JVM gestartet wird, sollte selbst ein Junior-Android-Entwickler dies tun wissen, dass das System selbst für den Aufruf dieser Funktion verantwortlich ist; nicht der Entwickler . Stattdessen sind die Einstiegspunkte für Android-Anwendungen die Application und alle Activity , auf die das System über die Datei AndroidManifest.xml verweisen kann.

All dies ist nur ein Hinweis darauf, dass das Testen von Einheiten in einer Android-Anwendung ein höheres Maß an Komplexität darstellt, da unsere Testumgebung jetzt die Android-Plattform berücksichtigen muss.

Zähmung des Problems der festen Kopplung

Enge Kopplung ist ein Begriff, der eine Funktion, Klasse oder ein Anwendungsmodul beschreibt, das von bestimmten Plattformen, Frameworks, Sprachen und Bibliotheken abhängig ist. Es ist ein relativer Begriff, was bedeutet, dass unser Calculator.java -Beispiel eng mit der Programmiersprache Java und der Standardbibliothek gekoppelt ist, aber das ist das Ausmaß seiner Kopplung. In ähnlicher Weise besteht das Problem beim Testen von Klassen, die eng an die Android-Plattform gekoppelt sind, darin, dass Sie einen Weg finden müssen, mit oder um die Plattform herum zu arbeiten.

Für Klassen, die eng an die Android-Plattform gekoppelt sind, haben Sie zwei Möglichkeiten. Die erste besteht darin, Ihre Klassen einfach auf einem Android-Gerät (physisch oder virtuell) bereitzustellen. Obwohl ich vorschlage, dass Sie Ihren Anwendungscode testen, bevor Sie ihn an die Produktion senden, ist dies in Bezug auf die Zeit in den frühen und mittleren Phasen des Entwicklungsprozesses ein höchst ineffizienter Ansatz.

Eine Unit , egal welche technische Definition Sie bevorzugen, wird im Allgemeinen als eine einzelne Funktion in einer Klasse betrachtet (obwohl einige die Definition erweitern, um nachfolgende Hilfsfunktionen einzubeziehen, die intern durch den anfänglichen einzelnen Funktionsaufruf aufgerufen werden). In jedem Fall sollen Einheiten klein sein; Das Erstellen, Kompilieren und Bereitstellen einer gesamten Anwendung zum Testen einer einzelnen Unit führt dazu, dass der Sinn des isolierten Testens völlig verfehlt wird .

Eine weitere Lösung für das Problem der engen Kopplung besteht darin, Testframeworks zu verwenden, um mit Plattformabhängigkeiten zu interagieren oder diese zu simulieren (zu simulieren). Frameworks wie Espresso und Robolectric bieten Entwicklern weitaus effektivere Mittel zum Testen von Units als der vorherige Ansatz; Ersteres ist nützlich für Tests, die auf einem Gerät ausgeführt werden (bekannt als „instrumentierte Tests“, weil die Bezeichnung Gerätetests anscheinend nicht mehrdeutig genug war), und letzteres kann das Android-Framework lokal auf einer JVM verspotten.

Bevor ich gegen solche Frameworks wettere, anstatt gegen die Alternative, die ich gleich erörtern werde, möchte ich klarstellen, dass ich nicht implizieren möchte, dass Sie diese Optionen niemals verwenden sollten. Der Prozess, den ein Entwickler zum Erstellen und Testen seiner Anwendungen verwendet, sollte aus einer Kombination aus persönlichen Vorlieben und einem Auge für Effizienz hervorgehen.

Für diejenigen, die es nicht mögen, modulare und lose gekoppelte Anwendungen zu erstellen, haben Sie keine andere Wahl, als sich mit diesen Frameworks vertraut zu machen, wenn Sie eine angemessene Testabdeckung wünschen. Viele wunderbare Anwendungen sind auf diese Weise entstanden, und mir wird nicht selten vorgeworfen, meine Anwendungen seien zu modular und abstrakt. Unabhängig davon, ob Sie meinen Ansatz wählen oder sich stark auf Frameworks stützen, begrüße ich Sie dafür, dass Sie Zeit und Mühe investieren, um Ihre Anwendungen zu testen.

Halten Sie Ihre Frameworks auf Distanz

Für die letzte Präambel der Kernlektion dieses Artikels lohnt es sich zu diskutieren, warum Sie vielleicht eine minimalistische Einstellung haben sollten, wenn es um die Verwendung von Frameworks geht (und dies gilt nicht nur für das Testen von Frameworks). Der obige Untertitel ist eine Paraphrase des großmütigen Lehrers für bewährte Softwareverfahren: Robert „Uncle Bob“ C. Martin. Von den vielen Edelsteinen, die er mir gegeben hat, seit ich mich zum ersten Mal mit seinen Werken befasste, bedurfte es mehrerer Jahre direkter Erfahrung, um diesen zu begreifen.

Soweit ich verstehe, worum es in dieser Aussage geht, liegen die Kosten für die Verwendung von Frameworks in der Zeitinvestition, die erforderlich ist, um sie zu lernen und zu pflegen. Einige von ihnen ändern sich ziemlich häufig und einige von ihnen ändern sich nicht häufig genug. Funktionen werden veraltet, Frameworks werden nicht mehr gewartet, und alle 6 bis 24 Monate kommt ein neues Framework, um das letzte zu ersetzen. Wenn Sie also eine Lösung finden, die als Plattform- oder Sprachfunktion implementiert werden kann (die in der Regel viel länger hält), ist sie tendenziell widerstandsfähiger gegen Änderungen der verschiedenen oben genannten Arten.

Technisch gesehen können Frameworks wie Espresso und in geringerem Maße Robolectric niemals so effizient ausgeführt werden wie einfache JUnit -Tests oder sogar der Framework-freie Test von früher. Obwohl JUnit tatsächlich ein Framework ist, ist es eng mit der JVM gekoppelt, die sich tendenziell viel langsamer ändert als die eigentliche Android-Plattform. Weniger Frameworks bedeuten fast immer Code, der in Bezug auf die Zeit, die zum Ausführen und Schreiben eines oder mehrerer Tests benötigt wird, effizienter ist.

Daraus können Sie wahrscheinlich entnehmen, dass wir jetzt einen Ansatz diskutieren werden, der einige Techniken nutzt, die es uns ermöglichen, die Android-Plattform auf Distanz zu halten; während wir gleichzeitig viel Codeabdeckung, Testeffizienz und die Möglichkeit haben, hier oder da noch ein Framework zu verwenden, wenn dies erforderlich ist.

Die Kunst der Architektur

Um eine dumme Analogie zu verwenden, könnte man sich Frameworks und Plattformen als anmaßende Kollegen vorstellen, die Ihren Entwicklungsprozess übernehmen, wenn Sie ihnen keine angemessenen Grenzen setzen. Die goldenen Prinzipien der Softwarearchitektur können Ihnen die allgemeinen Konzepte und spezifischen Techniken vermitteln, die erforderlich sind, um diese Grenzen sowohl zu schaffen als auch durchzusetzen. Wie wir gleich sehen werden, wenn Sie sich jemals gefragt haben, welche Vorteile die Anwendung von Softwarearchitekturprinzipien in Ihrem Code wirklich hat, machen einige direkt und viele indirekt Ihren Code leichter zu testen.

Trennung von Bedenken

Separation Of Concerns ist meiner Meinung nach das universellste und nützlichste Konzept in der Softwarearchitektur insgesamt (ohne zu sagen, dass andere vernachlässigt werden sollten). Separation of Concerns (SOC) kann in allen mir bekannten Perspektiven der Softwareentwicklung angewendet oder völlig ignoriert werden. Um das Konzept kurz zusammenzufassen, betrachten wir SOC, wenn es auf Klassen angewendet wird, aber seien Sie sich bewusst, dass SOC durch umfangreiche Verwendung von Hilfsfunktionen auf Funktionen angewendet und auf ganze Module einer Anwendung extrapoliert werden kann („Module“ verwendet in Kontext von Android/Gradle).

Wenn Sie viel Zeit damit verbracht haben, Softwarearchitekturmuster für GUI-Anwendungen zu erforschen, sind Sie wahrscheinlich auf mindestens eines der folgenden gestoßen: Model-View-Controller (MVC), Model-View-Presenter (MVP) oder Model-View- ViewModel (MVVM). Nachdem ich Anwendungen in allen Stilrichtungen erstellt habe, möchte ich gleich sagen, dass ich keine davon als die beste Option für alle Projekte (oder sogar Funktionen innerhalb eines einzelnen Projekts) betrachte. Ironischerweise scheint das Muster, das das Android-Team vor einigen Jahren als empfohlenen Ansatz präsentiert hat, MVVM, am wenigsten testbar zu sein, wenn es keine Android-spezifischen Test-Frameworks gibt (vorausgesetzt, Sie möchten die ViewModel-Klassen der Android-Plattform verwenden, von denen ich zugegebenermaßen ein Fan bin von).

In jedem Fall sind die Besonderheiten dieser Muster weniger wichtig als ihre Allgemeinheiten. Alle diese Muster sind nur unterschiedliche Arten von SOC, die eine grundlegende Trennung von drei Arten von Code betonen, die ich als bezeichne: Data , User Interface , Logic .

Wie genau hilft Ihnen also die Trennung von Data , User Interface und Logic beim Testen Ihrer Anwendungen? Die Antwort ist, dass durch das Herausziehen von Logik aus Klassen, die sich mit Plattform-/Framework-Abhängigkeiten befassen müssen, in Klassen, die wenig oder gar keine Plattform-/Framework-Abhängigkeiten besitzen, das Testen einfach und das Framework minimal wird. Um es klar zu sagen, ich spreche im Allgemeinen von Klassen, die die Benutzeroberfläche rendern, Daten in einer SQL-Tabelle speichern oder eine Verbindung zu einem Remote-Server herstellen müssen. Um zu demonstrieren, wie dies funktioniert, betrachten wir eine vereinfachte dreischichtige Architektur einer hypothetischen Android-Anwendung.

Die erste Klasse verwaltet unsere Benutzeroberfläche. Um die Dinge einfach zu halten, habe ich für diesen Zweck eine Aktivität verwendet, aber ich entscheide mich normalerweise für Fragmente stattdessen als Benutzeroberflächenklassen. In beiden Fällen weisen beide Klassen eine ähnlich enge Kopplung zur Android- Plattform auf:

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

Wie Sie sehen können, hat die Aktivität zwei Aufgaben: Erstens fungiert sie, da sie der Einstiegspunkt einer bestimmten Funktion einer Android -Anwendung ist, als eine Art Container für die anderen Komponenten der Funktion. Einfach ausgedrückt kann man sich einen Container als eine Art Root-Klasse vorstellen, an die die anderen Komponenten letztendlich über Referenzen (oder in diesem Fall private Member-Felder) angebunden sind. Es bläst auch auf, bindet Referenzen und fügt Listener zum XML-Layout (der Benutzeroberfläche) hinzu.

Kontrolllogik testen

Anstatt dass die Activity einen Verweis auf eine konkrete Klasse im Backend besitzt, sprechen wir mit einer Schnittstelle vom Typ CalculatorContract.IControlLogic. Wir werden im nächsten Abschnitt diskutieren, warum dies eine Schnittstelle ist. Verstehen Sie vorerst einfach, dass alles, was sich auf der anderen Seite dieser Schnittstelle befindet, so etwas wie ein Presenter oder Controller sein soll. Da diese Klasse die Interaktionen zwischen der Front-End-Aktivität und dem Back-End Calculator steuert, habe ich mich entschieden, sie CalculatorControlLogic zu nennen:

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

Es gibt viele subtile Dinge im Design dieser Klasse, die das Testen erleichtern. Erstens stammen alle seine Referenzen entweder aus der Java-Standardbibliothek oder aus Schnittstellen, die innerhalb der Anwendung definiert sind. Dies bedeutet, dass das Testen dieser Klasse ohne Frameworks ein Kinderspiel ist und lokal auf einer JVM durchgeführt werden könnte. Ein weiterer kleiner, aber nützlicher Tipp ist, dass alle verschiedenen Interaktionen dieser Klasse über eine einzige generische handleInput(...) Funktion aufgerufen werden können. Dies bietet einen einzigen Einstiegspunkt , um jedes Verhalten dieser Klasse zu testen.

Beachten Sie auch, dass ich in der Funktion evaluateExpression() “ eine Klasse vom Typ „ Optional<String> vom Backend zurückgebe. Normalerweise würde ich das verwenden, was funktionale Programmierer eine Entweder Monad nennen, oder wie ich es lieber nenne, einen Result Wrapper . Welchen dummen Namen Sie auch verwenden, es ist ein Objekt, das mehrere verschiedene Zustände durch einen einzigen Funktionsaufruf darstellen kann. Optional ist ein einfacheres Konstrukt, das entweder einen null oder einen Wert des bereitgestellten generischen Typs darstellen kann. Da dem Backend auf jeden Fall ein ungültiger Ausdruck gegeben werden könnte, möchten wir der ControlLogic -Klasse eine Möglichkeit geben, das Ergebnis der Backend-Operation zu bestimmen; Bilanzierung von Erfolg und Misserfolg. In diesem Fall steht null für einen Fehler.

Unten ist eine Beispiel-Testklasse, die mit JUnit geschrieben wurde, und eine Klasse, die im Testing-Jargon als Fake bezeichnet wird:

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

Wie Sie sehen können, kann diese Testsuite nicht nur sehr schnell ausgeführt werden, sondern es hat auch überhaupt nicht viel Zeit in Anspruch genommen, sie zu schreiben. Auf jeden Fall werden wir uns jetzt einige subtilere Dinge ansehen, die das Schreiben dieser Testklasse sehr einfach gemacht haben.

Die Kraft der Abstraktion und Abhängigkeitsinversion

Es gibt zwei weitere wichtige Konzepte, die auf CalculatorControlLogic angewendet wurden, wodurch es trivial einfach zu testen ist. Erstens, wenn Sie sich jemals gefragt haben, welche Vorteile die Verwendung von Schnittstellen und abstrakten Klassen (zusammenfassend als Abstraktionen bezeichnet) in Java hat, ist der obige Code eine direkte Demonstration. Da die zu testende Klasse auf Abstraktionen statt auf konkrete Klassen verweist, konnten wir aus unserer Testklasse heraus Fake -Testdoubles für die Benutzeroberfläche und das Backend erstellen. Solange diese Testdoubles die entsprechenden Schnittstellen implementieren, ist es CalculatorControlLogic völlig egal, dass sie nicht echt sind.

Zweitens hat CalculatorControlLogic seine Abhängigkeiten über den Konstruktor erhalten (ja, das ist eine Form von Dependency Injection ), anstatt seine eigenen Abhängigkeiten zu erstellen. Daher muss es nicht neu geschrieben werden, wenn es in einer Produktions- oder Testumgebung verwendet wird, was ein Bonus für die Effizienz ist.

Dependency Injection ist eine Form der Inversion Of Control , die im Klartext schwer zu definieren ist. Unabhängig davon, ob Sie Dependency Injection oder ein Service Locator Pattern verwenden, erreichen beide das, was Martin Fowler (mein Lieblingslehrer für solche Themen) als „das Prinzip der Trennung von Konfiguration und Nutzung“ beschreibt. Dies führt zu Klassen, die einfacher zu testen und einfacher voneinander isoliert zu erstellen sind.

Testen der Rechenlogik

Schließlich kommen wir zur ComputationLogic -Klasse, die ein IO-Gerät wie einen Adapter an einen entfernten Server oder eine lokale Datenbank annähern soll. Da wir beides nicht für einen einfachen Taschenrechner benötigen, ist er nur dafür verantwortlich, die Logik zu kapseln, die zum Validieren und Auswerten der Ausdrücke erforderlich ist, die wir ihm geben:

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

Über diese Klasse gibt es nicht allzu viel zu sagen, da es normalerweise eine enge Kopplung zu einer bestimmten Back-End-Bibliothek geben würde, die ähnliche Probleme aufwerfen würde wie eine Klasse, die eng mit Android gekoppelt ist. Wir werden gleich diskutieren, was mit solchen Klassen zu tun ist, aber diese ist so einfach zu testen, dass wir es genauso gut versuchen können:

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

Die am einfachsten zu testenden Klassen sind diejenigen, denen einfach ein Wert oder Objekt gegeben wird und von denen erwartet wird, dass sie ein Ergebnis zurückgeben, ohne dass einige externe Abhängigkeiten aufgerufen werden müssen. In jedem Fall kommt ein Punkt, an dem Sie sich, egal wie viel Softwarearchitektur-Zauberei Sie anwenden, immer noch um Klassen kümmern müssen, die nicht von Plattformen und Frameworks entkoppelt werden können. Glücklicherweise gibt es immer noch einen Weg, wie wir Softwarearchitektur einsetzen können, um diese Klassen im schlimmsten Fall einfacher zu testen und bestenfalls so trivial einfach zu machen, dass das Testen auf einen Blick erledigt werden kann.

Bescheidene Objekte und passive Ansichten

Die beiden obigen Namen beziehen sich auf ein Muster, bei dem ein Objekt, das mit Abhängigkeiten auf niedriger Ebene kommunizieren muss, so stark vereinfacht wird, dass es wohl nicht getestet werden muss. Ich wurde zum ersten Mal mit diesem Muster über Martin Fowlers Blog über Variationen von Model-View-Presenter bekannt gemacht. Später wurde ich durch die Arbeiten von Robert C. Martin auf die Idee aufmerksam, bestimmte Klassen als Humble Objects zu behandeln, was impliziert, dass dieses Muster nicht auf Benutzeroberflächenklassen beschränkt sein muss (obwohl ich nicht sagen möchte, dass Fowler jemals impliziert eine solche Einschränkung).

Wie auch immer Sie dieses Muster nennen, es ist wunderbar einfach zu verstehen, und in gewissem Sinne glaube ich, dass es eigentlich nur das Ergebnis der rigorosen Anwendung von SOC auf Ihren Unterricht ist. Während dieses Muster auch für Back-End-Klassen gilt, werden wir unsere Benutzerschnittstellenklasse verwenden , um dieses Prinzip in Aktion zu demonstrieren. Die Trennung ist sehr einfach: Klassen, die mit Plattform- und Framework-Abhängigkeiten interagieren, denken nicht für sich selbst (daher die Spitznamen Humble und Passive ). Wenn ein Ereignis eintritt, leiten sie lediglich die Details dieses Ereignisses an die Logikklasse weiter, die gerade zuhört:

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

Die Logikklasse, die trivial einfach zu testen sein sollte, ist dann für die sehr feinkörnige Steuerung der Benutzeroberfläche zuständig. Anstatt eine einzelne generische updateUserInterface(...) Funktion in der user interface aufzurufen und ihr die Arbeit einer Massenaktualisierung zu überlassen, besitzt die user interface (oder eine andere solche Klasse) kleine und spezifische Funktionen, die einfach zu bedienen sein sollten Name und Ausführung:

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

Im Prinzip sollten Ihnen diese beiden Beispiele genügen, um zu verstehen, wie Sie dieses Muster implementieren können. Das Objekt, das die Logik besitzt, ist lose gekoppelt, und das Objekt, das eng an lästige Abhängigkeiten gekoppelt ist, wird fast ohne Logik.

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

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 .

Weiterführende Literatur zu SmashingMag:

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • CSS-Frameworks oder CSS-Grid: Was sollte ich für mein Projekt verwenden?
  • Verwenden von Flutter von Google für eine wirklich plattformübergreifende mobile Entwicklung