Łatwiejsze testowanie dzięki ramowemu minimalizmowi i architekturze oprogramowania

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ Podobnie jak w przypadku wielu innych tematów związanych z tworzeniem oprogramowania, testowanie i rozwój sterowany testami są często niepotrzebnie komplikowane w teorii i implementacji przez kładąc zbyt duży nacisk na naukę szerokiej gamy frameworków testowych. W tym artykule przyjrzymy się ponownie, co oznacza testowanie za pomocą prostej analogii, zbadamy koncepcje architektury oprogramowania, które bezpośrednio przełożą się na zmniejszenie zapotrzebowania na frameworki testowe, oraz kilka argumentów, dlaczego możesz skorzystać na postawie minimalizmu w procesie testowania .

Podobnie jak wielu innych programistów Androida, moja pierwsza wyprawa do testów na tej platformie doprowadziła mnie do natychmiastowej konfrontacji z demoralizującym stopniem żargonu. Co więcej, kilka przykładów, na które natknąłem się w tamtym czasie (około 2015 r.), nie przedstawiało praktycznych przypadków użycia, które mogły skłaniać mnie do myślenia, że ​​stosunek kosztów do korzyści uczenia się narzędzia takiego jak Espresso w celu sprawdzenia, czy TextView.setText( …) działał prawidłowo, była rozsądną inwestycją.

Co gorsza, nie miałem praktycznego zrozumienia architektury oprogramowania w teorii lub praktyce, co oznaczało, że nawet gdybym zadał sobie trud nauki tych frameworków, pisałbym testy dla aplikacji monolitycznych składających się z kilku god klas, napisanych w kodzie spaghetti . Najważniejsze jest to, że budowanie, testowanie i utrzymywanie takich aplikacji jest ćwiczeniem z samosabotażu, niezależnie od twojej wiedzy na temat frameworka; Jednak ta świadomość staje się jasna dopiero po zbudowaniu modułowej , luźno powiązanej i wysoce spójnej aplikacji.

Stąd dochodzimy do jednego z głównych punktów dyskusji w tym artykule, który podsumuję tutaj prostym językiem: Wśród głównych korzyści wynikających z zastosowania złotych zasad architektury oprogramowania (nie martw się, omówię je na prostych przykładach i język), jest to, że Twój kod może stać się łatwiejszy do przetestowania. Stosowanie takich zasad niesie ze sobą inne korzyści, ale ten artykuł skupia się na związku między architekturą oprogramowania a testowaniem.

Jednak dla tych, którzy chcą zrozumieć, dlaczego i jak testujemy nasz kod, najpierw zbadamy koncepcję testowania przez analogię; bez konieczności zapamiętywania żadnego żargonu. Zanim zagłębimy się w główny temat, przyjrzymy się również pytaniu, dlaczego istnieje tak wiele frameworków testowych, ponieważ badając to możemy zacząć dostrzegać ich zalety, ograniczenia, a być może nawet alternatywne rozwiązanie.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Testowanie: dlaczego i jak

Ta sekcja nie będzie nową informacją dla żadnego wytrawnego testera, ale być może mimo wszystko spodoba ci się ta analogia. Oczywiście jestem inżynierem oprogramowania, a nie inżynierem rakietowym, ale przez chwilę zapożyczę analogię, która dotyczy projektowania i budowania obiektów zarówno w przestrzeni fizycznej, jak iw przestrzeni pamięci komputera. Okazuje się, że podczas gdy medium się zmienia, proces jest w zasadzie taki sam.

Załóżmy przez chwilę, że jesteśmy inżynierami rakietowymi, a naszym zadaniem jest zbudowanie pierwszego stopnia* dopalacza rakietowego promu kosmicznego. Załóżmy również, że opracowaliśmy nadający się do użytku projekt dla pierwszego etapu, aby rozpocząć budowę i testowanie w różnych warunkach.

„Pierwszy etap” odnosi się do dopalaczy, które są wystrzeliwane podczas pierwszego startu rakiety

Zanim przejdziemy do procesu, chciałbym wskazać, dlaczego wolę tę analogię: Nie powinieneś mieć żadnych trudności z odpowiedzią na pytanie, dlaczego zawracamy sobie głowę testowaniem naszego projektu przed umieszczeniem go w sytuacjach, w których stawką jest ludzkie życie. Chociaż nie będę Cię przekonywać, że testowanie aplikacji przed uruchomieniem może uratować życie (choć jest to możliwe w zależności od charakteru aplikacji), może uratować oceny, recenzje i Twoją pracę. W najszerszym znaczeniu testowanie to sposób, w jaki upewniamy się, że pojedyncze części, kilka komponentów i całe systemy działają, zanim zastosujemy je w sytuacjach, w których niezwykle ważne jest, aby nie zawiodły.

Wracając do aspektu „jak” tej analogii, przedstawię proces, w którym inżynierowie przechodzą do testowania konkretnego projektu: redundancja . Redundancja jest w zasadzie prosta: Twórz kopie testowanego komponentu zgodnie z tą samą specyfikacją projektową, która ma być używana w czasie uruchamiania. Przetestuj te kopie w odizolowanym środowisku, które ściśle kontroluje warunki wstępne i zmienne. Chociaż nie gwarantuje to, że rakieta będzie działać prawidłowo po zintegrowaniu z całym promem, można być pewnym, że jeśli nie będzie działać w kontrolowanym środowisku, będzie bardzo mało prawdopodobne, aby w ogóle zadziałała.

Załóżmy, że spośród setek, a może tysięcy zmiennych, z którymi przetestowano kopie projektu rakiety, sprowadza się ona do temperatury otoczenia, w której rakieta zostanie odpalona. Po testach w temperaturze 35° Celsjusza widzimy, że wszystko działa bezbłędnie. Ponownie rakieta jest bezawaryjnie testowana w temperaturze zbliżonej do pokojowej. Ostateczny test odbędzie się w najniższej zarejestrowanej temperaturze miejsca startu, czyli -5°C. Podczas tego ostatniego testu rakieta odpala, ale po krótkim czasie rakieta wybucha i wkrótce potem gwałtownie eksploduje; ale na szczęście w kontrolowanym i bezpiecznym środowisku.

W tym momencie wiemy, że zmiany temperatury wydają się być przynajmniej związane z nieudanym testem, co prowadzi nas do rozważenia, na które części rakietowego wzmacniacza mogą mieć negatywny wpływ niskie temperatury. Odkryto, że z biegiem czasu jeden kluczowy element, gumowy pierścień uszczelniający, który służy do zatrzymywania przepływu paliwa z jednego przedziału do drugiego, staje się sztywny i nieskuteczny, gdy jest wystawiony na temperatury zbliżające się lub poniżej zera.

Możliwe, że zauważyłeś, że jego analogia jest luźno oparta na tragicznych wydarzeniach katastrofy promu kosmicznego Challenger . Dla tych, którzy nie są zaznajomieni, smutna prawda (o ile dochodzenia zakończyły się) jest taka, że ​​było wiele nieudanych testów i ostrzeżeń ze strony inżynierów, a mimo to obawy administracyjne i polityczne zachęciły uruchomienie do kontynuowania. W każdym razie, niezależnie od tego, czy zapamiętałeś termin redundancja , czy nie, mam nadzieję, że zrozumiełeś fundamentalny proces testowania części dowolnego rodzaju systemu.

Dotyczące oprogramowania

Podczas gdy poprzednia analogia wyjaśniała fundamentalny proces testowania rakiet (przy zachowaniu dużej swobody przy drobniejszych szczegółach), teraz podsumuję w sposób, który prawdopodobnie będzie bardziej odpowiedni dla ciebie i dla mnie. Chociaż możliwe jest testowanie oprogramowania tylko poprzez uruchomienie do urządzeń, gdy znajdzie się w jakimkolwiek stanie możliwym do wdrożenia, przypuszczam, że zamiast tego możemy najpierw zastosować zasadę nadmiarowości do poszczególnych części aplikacji.

Oznacza to, że tworzymy kopie mniejszych części całej aplikacji (powszechnie określanych jako Jednostki oprogramowania), tworzymy izolowane środowisko testowe i widzimy, jak się zachowują w oparciu o dowolne zmienne, argumenty, zdarzenia i odpowiedzi, które mogą wystąpić W czasie wykonywania. Testowanie jest naprawdę tak proste, jak to w teorii, ale kluczem do nawet dostania się do tego procesu jest budowanie aplikacji, które można przetestować. Sprowadza się to do dwóch obaw, którym przyjrzymy się w kolejnych dwóch sekcjach. Pierwsza kwestia dotyczy środowiska testowego , a druga dotyczy sposobu, w jaki organizujemy aplikacje.

Dlaczego potrzebujemy frameworków?

Aby przetestować oprogramowanie (odtąd określane jako Jednostka , chociaż ta definicja jest celowo nadmiernym uproszczeniem), konieczne jest posiadanie pewnego rodzaju środowiska testowego, które pozwala na interakcję z oprogramowaniem w czasie wykonywania. Dla tych, którzy tworzą aplikacje, które mają być wykonywane wyłącznie w środowisku JVM ( Java Virtual Machine ), wszystko, co jest wymagane do pisania testów, to JRE ( Java Runtime Environment ). Weźmy na przykład tę bardzo prostą klasę Kalkulatora :

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

W przypadku braku frameworków, o ile mamy klasę testową, która zawiera main funkcję do faktycznego wykonania naszego kodu, możemy ją przetestować. Jak zapewne pamiętasz, funkcja main oznacza punkt początkowy wykonania prostego programu w Javie. Jeśli chodzi o to, co testujemy, po prostu wprowadzamy dane testowe do funkcji Kalkulatora i sprawdzamy, czy poprawnie wykonuje podstawowe operacje arytmetyczne:

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

Testowanie aplikacji na Androida to oczywiście zupełnie inna procedura. Chociaż istnieje main funkcja ukryta głęboko w źródle pliku ZygoteInit.java (którego drobniejsze szczegóły nie są tutaj ważne), która jest wywoływana przed uruchomieniem aplikacji Androida na JVM , nawet młodszy programista Android powinien wiedzieć, że sam system jest odpowiedzialny za wywołanie tej funkcji; nie deweloper . Zamiast tego punktami wejścia dla aplikacji na Androida są klasa Application i wszelkie klasy Activity , do których system może być wskazywany za pośrednictwem pliku AndroidManifest.xml .

Wszystko to tylko prowadzi do tego, że testowanie jednostek w aplikacji na Androida jest bardziej skomplikowane, ponieważ nasze środowisko testowe musi teraz uwzględniać platformę Android.

Poskromienie problemu ścisłego sprzężenia

Ścisłe sprzężenie to termin opisujący funkcję, klasę lub moduł aplikacji, który jest zależny od konkretnych platform, frameworków, języków i bibliotek. Jest to termin względny, co oznacza, że ​​nasz przykład Calculator.java jest ściśle powiązany z językiem programowania Java i biblioteką standardową, ale to jest zakres tego połączenia. W podobny sposób problem testowania klas, które są ściśle powiązane z platformą Android, polega na tym, że musisz znaleźć sposób na pracę z platformą lub wokół niej.

W przypadku klas ściśle powiązanych z platformą Android masz dwie możliwości. Pierwszym z nich jest po prostu wdrożenie klas na urządzeniu z Androidem (fizycznym lub wirtualnym). Chociaż sugeruję, aby przetestować wdrożenie kodu aplikacji przed wysłaniem go do produkcji, jest to wysoce nieefektywne podejście na wczesnych i środkowych etapach procesu rozwoju w odniesieniu do czasu.

Jednostka , niezależnie od preferowanej technicznej definicji, jest ogólnie uważana za pojedynczą funkcję w klasie (chociaż niektórzy rozszerzają definicję o kolejne funkcje pomocnicze, które są wywoływane wewnętrznie przez początkowe wywołanie pojedynczej funkcji). Tak czy inaczej, Jednostki mają być małe; budowanie, kompilowanie i wdrażanie całej aplikacji w celu przetestowania pojedynczej jednostki oznacza całkowite pominięcie punktu testowania w izolacji .

Innym rozwiązaniem problemu ścisłego sprzężenia jest wykorzystanie frameworków testowych do interakcji lub symulowania (symulowania) zależności platform. Struktury, takie jak Espresso i Robolectric , dają programistom znacznie skuteczniejsze sposoby testowania jednostek niż poprzednie podejście; pierwszy jest przydatny do testów uruchamianych na urządzeniu (znanych jako „testy instrumentalne”, ponieważ najwyraźniej nazywanie ich testami urządzeń nie było wystarczająco niejednoznaczne), a drugi jest zdolny do lokalnego podszycia frameworka Androida na JVM.

Zanim przejdę do pomstowania na takie frameworki zamiast alternatywy, którą omówię krótko, chcę jasno powiedzieć, że nie mam na myśli sugerowania, że ​​nigdy nie należy korzystać z tych opcji. Proces, który programista wykorzystuje do tworzenia i testowania swoich aplikacji, powinien zrodzić się z połączenia osobistych preferencji i dbałości o wydajność.

Ci, którzy nie lubią budować modułowych i luźno powiązanych aplikacji, nie będą mieli innego wyjścia, jak tylko zapoznać się z tymi frameworkami, jeśli chcesz mieć odpowiedni poziom pokrycia testami. W ten sposób zbudowano wiele wspaniałych aplikacji i nierzadko jestem oskarżany o to, że moje aplikacje są zbyt modularne i abstrakcyjne. Niezależnie od tego, czy przyjmiesz moje podejście, czy zdecydujesz się mocno oprzeć na frameworkach, gratuluję Ci poświęcenia czasu i wysiłku na przetestowanie swoich aplikacji.

Trzymaj swoje ramy na wyciągnięcie ręki

Jako ostatnią preambułę do głównej lekcji tego artykułu, warto omówić, dlaczego możesz chcieć zachować postawę minimalizmu, jeśli chodzi o używanie frameworków (i dotyczy to nie tylko frameworków testowych). Powyższy podtytuł jest parafrazą wspaniałomyślnego nauczyciela najlepszych praktyk w zakresie oprogramowania: Roberta „Wujka Boba” C. Martina. Spośród wielu klejnotów, które podarował mi, odkąd po raz pierwszy studiowałem jego prace, ten wymagał kilku lat bezpośredniego doświadczenia, aby pojąć.

O ile rozumiem, o czym jest to stwierdzenie, koszt korzystania z frameworków to czas poświęcony na ich naukę i utrzymanie. Niektóre z nich zmieniają się dość często, a niektóre niezbyt często. Funkcje stają się przestarzałe, frameworki przestają być utrzymywane, a co 6-24 miesiące pojawia się nowy framework, który zastępuje ostatni. Dlatego, jeśli znajdziesz rozwiązanie, które można zaimplementować jako platformę lub funkcję językową (które zwykle działają znacznie dłużej), będzie ono bardziej odporne na zmiany różnego rodzaju wymienionych powyżej.

Mówiąc bardziej technicznie, frameworki takie jak Espresso i, w mniejszym stopniu, Robolectric , nigdy nie mogą działać tak wydajnie, jak proste testy JUnit , a nawet wcześniejsze testy bez frameworków. Chociaż JUnit jest rzeczywiście frameworkiem, jest ściśle powiązany z JVM , który zmienia się znacznie wolniej niż właściwa platforma Android. Mniejsza liczba frameworków prawie zawsze oznacza kod, który jest bardziej wydajny pod względem czasu potrzebnego na wykonanie i napisanie jednego lub więcej testów.

Z tego prawdopodobnie możesz wywnioskować, że będziemy teraz omawiać podejście, które będzie wykorzystywać niektóre techniki, które pozwolą nam utrzymać platformę Android na wyciągnięcie ręki; cały czas dając nam dużo pokrycia kodu, wydajność testów i możliwość dalszego korzystania z frameworka tu i tam, gdy zajdzie taka potrzeba.

Sztuka Architektury

Używając głupiej analogii, można by pomyśleć, że frameworki i platformy są jak apodyktyczni koledzy, którzy przejmą kontrolę nad procesem rozwoju, chyba że ustalisz z nimi odpowiednie granice. Złote zasady architektury oprogramowania mogą dostarczyć ogólnych koncepcji i konkretnych technik niezbędnych do tworzenia i egzekwowania tych granic. Jak zobaczymy za chwilę, jeśli kiedykolwiek zastanawiałeś się, jakie naprawdę są korzyści z zastosowania zasad architektury oprogramowania w swoim kodzie, niektóre bezpośrednio, a wiele pośrednio ułatwiają testowanie kodu.

Separacja obaw

Separacja obaw jest według mojej oceny najbardziej uniwersalną i użyteczną koncepcją w architekturze oprogramowania jako całości (bez znaczenia, że ​​należy pominąć inne). Separacja obaw (SOC) może być stosowana lub całkowicie ignorowana w każdej znanej mi perspektywy tworzenia oprogramowania. Krótko podsumowując koncepcję, przyjrzymy się SOC w zastosowaniu do klas, ale pamiętaj, że SOC można zastosować do funkcji poprzez szerokie użycie funkcji pomocniczych i można go ekstrapolować na całe moduły aplikacji („moduły” używane w kontekst Androida/Gradle).

Jeśli spędziłeś dużo czasu na badaniu wzorców architektonicznych oprogramowania dla aplikacji GUI, prawdopodobnie natkniesz się na co najmniej jeden z: Model-View-Controller (MVC), Model-View-Presenter (MVP) lub Model-View- ViewModel (MVVM). Po zbudowaniu aplikacji w każdym stylu powiem z góry, że nie uważam żadnej z nich za najlepszą opcję dla wszystkich projektów (a nawet funkcji w ramach jednego projektu). Jak na ironię, wzorzec, który zespół Androida przedstawił kilka lat temu jako zalecane podejście, MVVM, wydaje się najmniej testowalny w przypadku braku specyficznych frameworków testowych dla Androida (zakładając, że chcesz używać klas ViewModel platformy Android, których jestem wprawdzie fanem z).

W każdym razie specyfika tych wzorców jest mniej ważna niż ich ogólniki. Wszystkie te wzorce są po prostu różnymi smakami SOC, które podkreślają fundamentalne oddzielenie trzech rodzajów kodu, które nazywam: Dane , Interfejs użytkownika , Logika .

W jaki więc dokładnie sposób oddzielenie danych , interfejsu użytkownika i logiki pomaga w testowaniu aplikacji? Odpowiedź jest taka, że ​​przeciągając logikę z klas, które muszą radzić sobie z zależnościami platformy/frameworku, do klas, które mają niewiele lub wcale nie mają zależności platforma/framework, testowanie staje się łatwe, a framework minimalny . Żeby było jasne, mówię ogólnie o klasach, które muszą renderować interfejs użytkownika, przechowywać dane w tabeli SQL lub łączyć się ze zdalnym serwerem. Aby zademonstrować, jak to działa, przyjrzyjmy się uproszczonej, trójwarstwowej architekturze hipotetycznej aplikacji na Androida.

Pierwsza klasa będzie zarządzać naszym interfejsem użytkownika. Aby uprościć sprawę, użyłem do tego celu Activity , ale zazwyczaj wybieram fragmenty jako klasy interfejsu użytkownika. W obu przypadkach obie klasy prezentują podobne ścisłe sprzężenie z platformą Android :

 public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }

Jak widać, Aktywność ma dwa zadania: Po pierwsze, ponieważ jest punktem wejścia danej funkcji aplikacji na Androida , działa jako rodzaj kontenera dla innych składników funkcji. Mówiąc prościej, kontener można traktować jako rodzaj klasy głównej, z którą inne komponenty są ostatecznie powiązane za pośrednictwem referencji (lub w tym przypadku prywatnych pól składowych). Rozdmuchuje również, wiąże referencje i dodaje detektory do układu XML (interfejsu użytkownika).

Testowanie logiki sterowania

Zamiast posiadania odniesienia do konkretnej klasy na zapleczu, mamy do czynienia z interfejsem typu CalculatorContract.IControlLogic. Omówimy, dlaczego jest to interfejs w następnej sekcji. Na razie po prostu zrozum, że wszystko, co znajduje się po drugiej stronie tego interfejsu, powinno być czymś w rodzaju prezentera lub kontrolera . Ponieważ ta klasa będzie sterować interakcjami między aktywnością front-endową a kalkulatorem back-endowym , zdecydowałem się nazwać ją CalculatorControlLogic :

 public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } } public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }

Jest wiele subtelnych rzeczy w sposobie, w jaki ta klasa jest zaprojektowana, które ułatwiają testowanie. Po pierwsze, wszystkie jego odniesienia pochodzą albo ze standardowej biblioteki Java, albo z interfejsów zdefiniowanych w aplikacji. Oznacza to, że testowanie tej klasy bez żadnych frameworków jest absolutną bryzą i można to zrobić lokalnie na JVM . Inną małą, ale użyteczną wskazówką jest to, że wszystkie różne interakcje tej klasy mogą być wywoływane za pomocą jednej ogólnej handleInput(...) . Zapewnia to pojedynczy punkt wejścia do testowania każdego zachowania tej klasy.

Należy również zauważyć, że w funkcji evaluateExpression() zwracam klasę typu Optional<String> z zaplecza. Normalnie używałbym tego, co programiści funkcjonalni nazywają Either Monad , lub jak ja wolę to nazywać, Result Wrapper . Bez względu na to, jaką głupią nazwę użyjesz, jest to obiekt, który jest w stanie reprezentować wiele różnych stanów za pomocą pojedynczego wywołania funkcji. Optional to prostsza konstrukcja, która może reprezentować null lub pewną wartość dostarczonego typu ogólnego. W każdym razie, ponieważ zaplecze może otrzymać nieprawidłowe wyrażenie, chcemy dać klasie ControlLogic pewne sposoby określania wyniku operacji zaplecza; rozliczanie zarówno sukcesu, jak i porażki. W takim przypadku null będzie oznaczać niepowodzenie.

Poniżej znajduje się przykładowa klasa testowa, która została napisana przy użyciu JUnit , oraz klasa, która w żargonie testowym nazywa się Fake :

 public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } } public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }

Jak widać, ten zestaw testów nie tylko może być wykonywany bardzo szybko, ale jego napisanie nie zajęło wiele czasu. W każdym razie, przyjrzymy się teraz kilku bardziej subtelnym rzeczom, które sprawiły, że pisanie tej klasy testowej było bardzo łatwe.

Moc abstrakcji i odwrócenia zależności

Istnieją dwie inne ważne koncepcje, które zostały zastosowane w CalculatorControlLogic , co uczyniło go trywialnie łatwym do testowania. Po pierwsze, jeśli kiedykolwiek zastanawiałeś się, jakie są korzyści z używania interfejsów i klas abstrakcyjnych (łącznie zwanych abstrakcjami ) w Javie, powyższy kod jest bezpośrednią demonstracją. Ponieważ testowana klasa odwołuje się do abstrakcji zamiast do klas konkretnych , byliśmy w stanie stworzyć fałszywe dublety testowe dla interfejsu użytkownika i zaplecza z poziomu naszej klasy testowej. Tak długo, jak te podwoje testowe implementują odpowiednie interfejsy, CalculatorControlLogic nie dba o to, że nie są one prawdziwe.

Po drugie, CalculatorControlLogic otrzymał swoje zależności za pośrednictwem konstruktora (tak, jest to forma Dependency Injection ), zamiast tworzyć własne zależności. Dlatego nie trzeba go ponownie pisać, gdy jest używany w środowisku produkcyjnym lub testowym, co jest premią za wydajność.

Dependency Injection to forma Inversion Of Control , co jest trudnym pojęciem do zdefiniowania w prostym języku. Niezależnie od tego, czy używasz Dependency Injection , czy Service Locator Pattern , obydwa osiągają to, co Martin Fowler (mój ulubiony nauczyciel zajmujący się takimi tematami) opisuje jako „zasadę oddzielania konfiguracji od użycia”. Skutkuje to klasami, które są łatwiejsze do przetestowania i łatwiejsze do zbudowania w oderwaniu od siebie.

Testowanie logiki obliczeniowej

Wreszcie dochodzimy do klasy ComputationLogic , która ma aproksymować urządzenie IO, takie jak adapter do zdalnego serwera lub lokalnej bazy danych. Ponieważ nie potrzebujemy żadnego z nich do prostego kalkulatora, będzie on po prostu odpowiedzialny za enkapsulację logiki wymaganej do walidacji i oceny wyrażeń, które mu podamy:

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

Nie ma zbyt wiele do powiedzenia na temat tej klasy, ponieważ zwykle istnieje pewne ścisłe sprzężenie z konkretną biblioteką zaplecza, co stwarzałoby podobne problemy, jak klasa ściśle powiązana z Androidem. Za chwilę porozmawiamy, co zrobić z takimi zajęciami, ale ta jest tak łatwa do przetestowania, że ​​równie dobrze możemy spróbować:

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

Najłatwiejsze do przetestowania klasy to takie, którym po prostu przypisuje się jakąś wartość lub obiekt i oczekuje się, że zwrócą wynik bez konieczności wywoływania zewnętrznych zależności. W każdym razie nadchodzi moment, w którym bez względu na to, jak wiele magii architektury oprogramowania zastosujesz, nadal będziesz musiał martwić się o klasy, których nie można oddzielić od platform i frameworków. Na szczęście wciąż istnieje sposób, w jaki możemy wykorzystać architekturę oprogramowania, aby: w najgorszym przypadku uczynić te klasy łatwiejszymi do testowania, aw najlepszym tak trywialnie prostymi, że testowanie można przeprowadzić na pierwszy rzut oka .

Skromne przedmioty i pasywne poglądy

Powyższe dwie nazwy odnoszą się do wzorca, w którym obiekt, który musi komunikować się z zależnościami niskiego poziomu, jest tak uproszczony, że prawdopodobnie nie trzeba go testować. Po raz pierwszy zapoznałem się z tym wzorcem za pośrednictwem bloga Martina Fowlera o odmianach Model-View-Presenter. Później, w pracach Roberta C. Martina, wpadłem na pomysł traktowania pewnych klas jako Humble Objects , co implikuje, że ten wzorzec nie musi ograniczać się do klas interfejsu użytkownika (chociaż nie mam na myśli, że Fowler kiedykolwiek dorozumiane takie ograniczenie).

Niezależnie od tego, jak nazwiesz ten wzorzec, jest on cudownie prosty do zrozumienia iw pewnym sensie uważam, że jest to po prostu wynik rygorystycznego stosowania SOC do twoich klas. Chociaż ten wzorzec dotyczy również klas zaplecza, użyjemy naszej klasy interfejsu użytkownika, aby zademonstrować tę zasadę w działaniu. Podział jest bardzo prosty: klasy, które wchodzą w interakcje z zależnościami platformy i frameworka, nie myślą same za siebie (stąd przydomki Humble i Passive ). Kiedy wystąpi zdarzenie, jedyne, co robią, to przesyłają szczegóły tego zdarzenia do dowolnej klasy logicznej , która akurat nasłuchuje:

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

Klasa logiczna, która powinna być banalnie łatwa do przetestowania, jest wtedy odpowiedzialna za kontrolowanie interfejsu użytkownika w bardzo precyzyjny sposób. Zamiast wywoływać pojedynczą ogólną updateUserInterface(...) w klasie user interface i pozostawić ją do wykonania zbiorczej aktualizacji, user interface (lub inna taka klasa) będzie posiadał małe i specyficzne funkcje, które powinny być łatwe do nazwać i wdrożyć:

 //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. Auć.

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 .

Dalsze czytanie na SmashingMag:

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • Struktura CSS lub siatka CSS: czego powinienem użyć w moim projekcie?
  • Korzystanie z narzędzia Google Flutter do prawdziwie wieloplatformowego programowania mobilnego