Testarea a fost mai ușoară prin minimalismul cadru și arhitectura software
Publicat: 2022-03-10La fel ca mulți alți dezvoltatori Android, incursiunea mea inițială în testarea pe platformă m-a determinat să mă confrunt imediat cu un grad demoralizator de jargon. Mai mult, puținele exemple pe care le-am întâlnit la acea vreme (circa 2015) nu prezentau cazuri practice de utilizare care ar fi putut să mă fi înclinat să cred că raportul cost-beneficiu al învățării unui instrument precum Espresso pentru a verifica dacă un TextView.setText( …) a funcționat corect, a fost o investiție rezonabilă.
Pentru a înrăutăți lucrurile, nu aveam o înțelegere de lucru a arhitecturii software în teorie sau practică, ceea ce însemna că, chiar dacă m-aș fi obosit să învăț aceste cadre, aș fi scris teste pentru aplicații monolitice compuse din câteva clase de god
, scrise. în cod spaghete . Punchline este că construirea, testarea și întreținerea unor astfel de aplicații este un exercițiu de auto-sabotare, indiferent de expertiza dvs. în cadrul; totuși, această realizare devine clară numai după ce cineva a construit o aplicație modulară , slab cuplată și foarte coezive .
De aici ajungem la unul dintre principalele puncte de discuție din acest articol, pe care îl voi rezuma într-un limbaj simplu aici: Printre beneficiile primare ale aplicării principiilor de aur ale arhitecturii software (nu vă faceți griji, le voi discuta cu exemple simple și limba), este că codul dvs. poate deveni mai ușor de testat. Există și alte beneficii în aplicarea unor astfel de principii, dar relația dintre arhitectura software și testare este punctul central al acestui articol.
Cu toate acestea, de dragul celor care doresc să înțeleagă de ce și cum testăm codul nostru, vom explora mai întâi conceptul de testare prin analogie; fără a fi nevoie să memorezi vreun jargon. Înainte de a aprofunda subiectul principal, ne vom uita, de asemenea, la întrebarea de ce există atât de multe cadre de testare, deoarece examinând acest lucru, putem începe să le vedem beneficiile, limitările și poate chiar o soluție alternativă.
Testare: de ce și cum
Această secțiune nu va fi informații noi pentru niciun tester experimentat, dar poate că vă puteți bucura totuși de această analogie. Desigur, sunt inginer de software, nu inginer de rachete, dar pentru o clipă voi împrumuta o analogie care se referă la proiectarea și construirea de obiecte atât în spațiul fizic, cât și în spațiul de memorie al unui computer. Se pare că, în timp ce mediul se schimbă, procesul este, în principiu, aproape același.
Să presupunem pentru o clipă că suntem ingineri de rachete, iar treaba noastră este să construim prima etapă* rachetă de amplificare a unei navete spațiale. Să presupunem, de asemenea, că am venit cu un design funcțional pentru prima etapă pentru a începe construirea și testarea în diferite condiții.
„Prima etapă” se referă la amplificatoarele care sunt trase la prima lansare a rachetei
Înainte de a trece la proces, aș dori să subliniez de ce prefer această analogie: nu ar trebui să aveți dificultăți în a răspunde la întrebarea de ce ne deranjam să ne testăm designul înainte de a-l pune în situații în care viețile umane sunt în joc. Deși nu voi încerca să vă conving că testarea aplicațiilor înainte de lansare ar putea salva vieți (deși este posibil în funcție de natura aplicației), ar putea salva evaluările, recenziile și munca dvs. În sensul cel mai larg, testarea este modul în care ne asigurăm că părțile individuale, mai multe componente și sistemele întregi funcționează înainte de a le folosi în situații în care este extrem de important să nu eșueze.
Revenind la aspectul cum al acestei analogii, voi introduce procesul prin care inginerii testează un anumit design: redundanța . Redundanța este simplă în principiu: creați copii ale componentei care urmează să fie testate după aceeași specificație de design ca ceea ce doriți să utilizați la momentul lansării. Testați aceste copii într-un mediu izolat care controlează strict condițiile preliminare și variabile. Deși acest lucru nu garantează că racheta booster va funcționa corect atunci când este integrat în întreaga navetă, se poate fi sigur că, dacă nu funcționează într-un mediu controlat, va fi foarte puțin probabil să funcționeze.
Să presupunem că dintre sutele, sau poate miile de variabile împotriva cărora au fost testate copiile designului rachetei, aceasta se reduce la temperaturile ambiante în care racheta de amplificare va fi declanșată. La testarea la 35° Celsius, vedem că totul funcționează fără eroare. Din nou, racheta este testată la aproximativ temperatura camerei fără eșec. Testul final va fi la cea mai scăzută temperatură înregistrată pentru locul de lansare, la -5° Celcius. În timpul acestui test final, racheta trage, dar după o perioadă scurtă, racheta explodă și la scurt timp după aceea explodează violent; dar din fericire într-un mediu controlat și sigur.
În acest moment, știm că schimbările de temperatură par să fie cel puțin implicate în testul eșuat, ceea ce ne conduce să luăm în considerare ce părți ale rachetei de amplificare pot fi afectate negativ de temperaturile scăzute. De-a lungul timpului, se descoperă că o componentă cheie, un inel O de cauciuc care servește la blocarea fluxului de combustibil de la un compartiment la altul, devine rigid și ineficient atunci când este expus la temperaturi care se apropie sau sub zero.
Este posibil să fi observat că analogia lui se bazează vag pe evenimentele tragice ale dezastrului navetei spațiale Challenger . Pentru cei care nu sunt familiarizați, adevărul trist (în măsura în care investigațiile au concluzionat) este că au existat o mulțime de teste și avertismente eșuate de la ingineri, și totuși preocupările administrative și politice au determinat lansarea să continue indiferent. În orice caz, indiferent dacă ați memorat sau nu termenul de redundanță , speranța mea este că ați înțeles procesul fundamental de testare a părților oricărui tip de sistem.
Referitor la Software
În timp ce analogia anterioară a explicat procesul fundamental de testare a rachetelor (în timp ce luăm multă libertate cu detaliile mai fine), acum voi rezuma într-o manieră care este probabil mai relevantă pentru tine și pentru mine. Deși este posibil să testezi software-ul doar lansând pe dispozitive odată ce se află în orice fel de stare implementabilă, presupun că putem aplica mai întâi principiul redundanței părților individuale ale aplicației.
Aceasta înseamnă că creăm copii ale părților mai mici ale întregii aplicații (denumite în mod obișnuit ca unități de software), creăm un mediu de testare izolat și vedem cum se comportă pe baza variabilelor, argumentelor, evenimentelor și răspunsurilor care pot apărea. în timpul rulării. Testarea este într-adevăr la fel de simplă ca și în teorie, dar cheia pentru a ajunge chiar la acest proces constă în construirea de aplicații care pot fi testate în mod fezabil. Acest lucru se reduce la două preocupări pe care le vom analiza în următoarele două secțiuni. Prima preocupare are de-a face cu mediul de testare , iar a doua preocupare are de-a face cu modul în care structurăm aplicațiile.
De ce avem nevoie de cadre?
Pentru a testa o bucată de software (denumită în continuare unitate , deși această definiție este în mod deliberat o simplificare excesivă), este necesar să aveți un fel de mediu de testare care să vă permită să interacționați cu software-ul dvs. în timpul execuției. Pentru ca acele aplicații care creează aplicații să fie executate exclusiv într-un mediu JVM ( Java Virtual Machine ), tot ceea ce este necesar pentru a scrie teste este un JRE ( Java Runtime Environment ). Luați de exemplu această clasă foarte simplă Calculator :
class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }
În absența oricăror cadre, atâta timp cât avem o clasă de testare care conține o funcție main
pentru a executa efectiv codul nostru, îl putem testa. După cum vă amintiți, funcția main
indică punctul de pornire al execuției pentru un program Java simplu. În ceea ce privește ceea ce testăm, pur și simplu introducem câteva date de testare în funcțiile Calculatorului și verificăm dacă efectuează corect aritmetica de bază:
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."); } }
Testarea unei aplicații Android este, desigur, o procedură complet diferită. Deși există o funcție main
îngropată adânc în sursa fișierului ZygoteInit.java (ale cărui detalii mai fine nu sunt importante aici), care este invocată înainte ca o aplicație Android să fie lansată pe JVM , chiar și un dezvoltator Android junior ar trebui să să știți că sistemul însuși este responsabil pentru apelarea acestei funcție; nu dezvoltatorul . În schimb, punctele de intrare pentru aplicațiile Android se întâmplă să fie clasa Application
și orice clase de Activity
către care sistemul poate fi indicat prin fișierul AndroidManifest.xml .
Toate acestea sunt doar o duc la faptul că testarea unităților într-o aplicație Android prezintă un nivel mai mare de complexitate, strict pentru că mediul nostru de testare trebuie acum să țină cont de platforma Android.
Îmblanzirea problemei cuplării strânse
Cuplarea strânsă este un termen care descrie o funcție, o clasă sau un modul de aplicație care depinde de anumite platforme, cadre, limbaje și biblioteci. Este un termen relativ, ceea ce înseamnă că exemplul nostru Calculator.java este strâns cuplat cu limbajul de programare Java și cu biblioteca standard, dar aceasta este măsura cuplării sale. În aceeași linie, problema testării claselor care sunt strâns cuplate cu platforma Android este că trebuie să găsiți o modalitate de a lucra cu sau în jurul platformei.
Pentru cursurile strâns cuplate la platforma Android, aveți două opțiuni. Primul este să vă implementați pur și simplu cursurile pe un dispozitiv Android (fizic sau virtual). Deși vă sugerez să testați implementarea codului aplicației înainte de a-l expedia în producție, aceasta este o abordare extrem de ineficientă în timpul etapelor incipiente și mijlocii ale procesului de dezvoltare în ceea ce privește timpul.
O unitate , oricât de tehnică ar fi o definiție pe care o preferați, este în general considerată o singură funcție într-o clasă (deși unii extind definiția pentru a include funcții auxiliare ulterioare care sunt apelate intern de apelul inițial al funcției unice). Oricum, unitățile sunt menite să fie mici; construirea, compilarea și desfășurarea unei întregi aplicații pentru a testa o singură unitate înseamnă să pierdeți punctul de a testa în întregime izolat.
O altă soluție la problema cuplării strânse este utilizarea cadrelor de testare pentru a interacționa cu sau pentru a simula (simula) dependențele platformei. Framework-uri precum Espresso și Robolectric oferă dezvoltatorilor mijloace mult mai eficiente pentru testarea unităților decât abordarea anterioară; prima fiind utilă pentru testele rulate pe un dispozitiv (cunoscute sub denumirea de „teste instrumentate” pentru că aparent numirea acestora teste de dispozitiv nu era suficient de ambiguă), iar cea de-a doua fiind capabilă să bată joc de framework-ul Android la nivel local pe un JVM.
Înainte de a trece la opoziție împotriva unor astfel de cadre în loc de alternativa pe care o voi discuta în scurt timp, vreau să fiu clar că nu vreau să insinuez că nu ar trebui să utilizați niciodată aceste opțiuni. Procesul pe care îl folosește un dezvoltator pentru a-și construi și testa aplicațiile ar trebui să se nască dintr-o combinație de preferințe personale și un ochi pentru eficiență.
Pentru cei cărora nu le place să construiască aplicații modulare și slab cuplate, nu veți avea de ales decât să vă familiarizați cu aceste cadre dacă doriți să aveți un nivel adecvat de acoperire a testelor. Multe aplicații minunate au fost construite în acest fel și nu de puține ori sunt acuzat că îmi fac aplicațiile prea modulare și abstracte. Indiferent dacă iei abordarea mea sau decizi să te bazezi mult pe cadre, te salut pentru că ai acordat timp și efort pentru a-ți testa aplicațiile.
Păstrați-vă cadrele la distanță
Pentru preambulul final al lecției de bază a acestui articol, merită să discutați de ce ați dori să aveți o atitudine de minimalism atunci când vine vorba de utilizarea cadrelor (și acest lucru se aplică mai mult decât doar testarea cadrelor). Subtitlul de mai sus este o parafrază a profesorului generos de bune practici software: Robert „Uncle Bob” C. Martin. Dintre multele pietre prețioase pe care mi le-a oferit de când i-am studiat pentru prima dată lucrările, aceasta a avut nevoie de câțiva ani de experiență directă pentru a o înțelege.
În măsura în care înțeleg despre ce este vorba în această declarație, costul utilizării cadrelor este în timpul investiției necesare pentru a le învăța și menține. Unele dintre ele se schimbă destul de frecvent, iar altele nu se schimbă suficient de frecvent. Funcțiile devin depreciate, cadrele nu mai sunt menținute și la fiecare 6-24 de luni sosește un nou cadru care îl înlocuiește pe ultimul. Prin urmare, dacă puteți găsi o soluție care poate fi implementată ca platformă sau caracteristică de limbă (care tinde să dureze mult mai mult), aceasta va tinde să fie mai rezistentă la schimbările de diferitele tipuri menționate mai sus.
Pe o notă mai tehnică, cadrele precum Espresso și, într-o măsură mai mică, Robolectric , nu pot rula niciodată la fel de eficient ca testele simple JUnit sau chiar testul fără cadre de mai devreme. În timp ce JUnit este într-adevăr un cadru, este strâns cuplat la JVM , care tinde să se schimbe într-un ritm mult mai lent decât platforma Android propriu-zisă. Mai puține cadre înseamnă aproape invariabil cod care este mai eficient în ceea ce privește timpul necesar pentru a executa și scrie unul sau mai multe teste.
Din aceasta, probabil că puteți ști că acum vom discuta despre o abordare care va folosi unele tehnici care ne permit să menținem platforma Android la distanță; în același timp, permițându-ne multă acoperire de cod, eficiență a testelor și posibilitatea de a folosi în continuare un cadru aici sau acolo când este nevoie.
Arta Arhitecturii
Pentru a folosi o analogie stupidă, s-ar putea crede că cadrele și platformele sunt ca niște colegi prevăzători care îți vor prelua procesul de dezvoltare, dacă nu stabilești limite adecvate cu ele. Principiile de aur ale arhitecturii software vă pot oferi conceptele generale și tehnicile specifice necesare atât pentru a crea, cât și pentru a aplica aceste limite. După cum vom vedea într-un moment, dacă v-ați întrebat vreodată care sunt cu adevărat beneficiile aplicării principiilor arhitecturii software în codul dvs., unele în mod direct și multe indirect fac codul mai ușor de testat.
Separarea preocupărilor
Separarea preocupărilor este, după estimarea mea, conceptul cel mai universal aplicabil și util în arhitectura software în ansamblu (fără a însemna să spun că alții ar trebui neglijați). Separarea preocupărilor (SOC) poate fi aplicată, sau complet ignorată, în orice perspectivă a dezvoltării software de care sunt conștient. Pentru a rezuma pe scurt conceptul, ne vom uita la SOC atunci când este aplicat la clase, dar să fim conștienți de faptul că SOC poate fi aplicat funcțiilor prin utilizarea extensivă a funcțiilor de ajutor și poate fi extrapolat la module întregi ale unei aplicații („module” utilizate în contextul Android/Gradle).
Dacă ați petrecut mult timp cercetând modele arhitecturale software pentru aplicațiile GUI, probabil că veți fi întâlnit cel puțin unul dintre: Model-View-Controller (MVC), Model-View-Presenter (MVP) sau Model-View- ViewModel (MVVM). După ce am construit aplicații în fiecare stil, voi spune dinainte că nu consider că niciuna dintre ele este cea mai bună opțiune pentru toate proiectele (sau chiar caracteristicile dintr-un singur proiect). În mod ironic, modelul pe care echipa Android l-a prezentat în urmă cu câțiva ani ca abordare recomandată, MVVM, pare a fi cel mai puțin testabil în absența cadrelor de testare specifice Android (presupunând că doriți să utilizați clasele ViewModel ale platformei Android, despre care, desigur, sunt un fan). de).
În orice caz, specificul acestor modele este mai puțin important decât generalitățile lor. Toate aceste modele sunt doar arome diferite de SOC care subliniază o separare fundamentală a trei tipuri de cod pe care le numesc: date , interfață utilizator , logică .
Deci, cum vă ajută exact separarea datelor , a interfeței cu utilizatorul și a logicii să vă testați aplicațiile? Răspunsul este că, prin scoaterea logicii din clasele care trebuie să se ocupe de dependențele platformei/cadru în clase care posedă puține sau deloc dependențe de platformă/cadru, testarea devine ușoară și cadrul minim . Pentru a fi clar, vorbesc în general despre clase care trebuie să redea interfața cu utilizatorul, să stocheze date într-un tabel SQL sau să se conecteze la un server la distanță. Pentru a demonstra cum funcționează acest lucru, să ne uităm la o arhitectură simplificată cu trei straturi a unei aplicații Android ipotetice.
Prima clasă va gestiona interfața noastră cu utilizatorul. Pentru a menține lucrurile simple, am folosit o activitate în acest scop, dar de obicei optez pentru Fragmente ca clase de interfață cu utilizatorul. În ambele cazuri, ambele clase prezintă o cuplare strânsă similară cu platforma 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(); } }
După cum puteți vedea, Activitatea are două sarcini: în primul rând, deoarece este punctul de intrare al unei anumite caracteristici a unei aplicații Android , acționează ca un fel de container pentru celelalte componente ale caracteristicii. În termeni simpli, un container poate fi gândit ca un fel de clasă rădăcină la care celelalte componente sunt în cele din urmă legate prin referințe (sau câmpuri membre private în acest caz). De asemenea, umflă, leagă referințe și adaugă ascultători la aspectul XML (interfața cu utilizatorul).
Testarea logicii de control
Mai degrabă decât ca Activitatea să aibă o referință la o clasă concretă în back-end, o facem să vorbească cu o interfață de tip CalculatorContract.IControlLogic.
Vom discuta de ce aceasta este o interfață în secțiunea următoare. Deocamdată, înțelegeți că orice se află de cealaltă parte a acelei interfețe ar trebui să fie ceva de genul Prezentator sau Controller . Deoarece această clasă va controla interacțiunile dintre Activitatea front-end și Calculatorul back-end , am ales să o numesc 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(); } }
Există multe lucruri subtile despre modul în care este proiectată această clasă care o fac mai ușor de testat. În primul rând, toate referințele sale sunt fie din biblioteca standard Java, fie interfețe care sunt definite în aplicație. Aceasta înseamnă că testarea acestei clase fără cadre este o simplă briză și ar putea fi făcută local pe un JVM . Un alt sfat mic, dar util, este că toate interacțiunile diferite ale acestei clase pot fi apelate printr-o singură funcție generică handleInput(...)
. Aceasta oferă un singur punct de intrare pentru a testa fiecare comportament al acestei clase.
De asemenea, rețineți că în funcția evaluateExpression()
, returnez o clasă de tip Optional<String>
din back-end. În mod normal, aș folosi ceea ce programatorii funcționali numesc un Either Monad sau, așa cum prefer să o numesc, un Result Wrapper . Orice nume prost folosiți, acesta este un obiect care este capabil să reprezinte mai multe stări diferite printr-un singur apel de funcție. Optional
este o construcție mai simplă care poate reprezenta fie o valoare nulă , fie o valoare a tipului generic furnizat. În orice caz, din moment ce backend-ului i se poate da o expresie invalidă, dorim să oferim clasei ControlLogic
câteva mijloace de a determina rezultatul operației backend; reprezentând atât succesul, cât și eșecul. În acest caz, null va reprezenta un eșec.
Mai jos este un exemplu de clasă de testare care a fost scrisă folosind JUnit și o clasă care în jargonul de testare se numește Fals :
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"); } } }
După cum puteți vedea, nu numai că această suită de teste poate fi executată foarte rapid, dar nu a durat deloc mult timp pentru a scrie. În orice caz, acum ne vom uita la câteva lucruri mai subtile care au făcut scrierea acestei clase de test foarte ușoară.
Puterea abstracției și inversării dependenței
Există alte două concepte importante care au fost aplicate la CalculatorControlLogic
care l-au făcut trivial de ușor de testat. În primul rând, dacă v-ați întrebat vreodată care sunt beneficiile utilizării interfețelor și claselor abstracte (denumite în mod colectiv abstracții ) în Java, codul de mai sus este o demonstrație directă. Deoarece clasa care urmează să fie testată face referire la abstracții în loc de clase concrete , am putut crea duble de testare false pentru interfața de utilizator și back-end din clasa noastră de testare. Atâta timp cât aceste duble de testare implementează interfețele adecvate, CalculatorControlLogic
nu i-ar păsa mai puțin că nu sunt adevărate.
În al doilea rând, CalculatorControlLogic
a primit dependențele sale prin intermediul constructorului (da, aceasta este o formă de Dependency Injection ), în loc să-și creeze propriile dependențe. Prin urmare, nu trebuie să fie rescris atunci când este utilizat într-un mediu de producție sau de testare, ceea ce este un bonus pentru eficiență.
Dependency Injection este o formă de inversare a controlului , care este un concept dificil de definit într-un limbaj simplu. Indiferent dacă utilizați Dependency Injection sau un Service Locator Pattern , ambele realizează ceea ce Martin Fowler (profesorul meu preferat pe astfel de subiecte) descrie „principiul separării configurației de utilizare”. Acest lucru are ca rezultat clase care sunt mai ușor de testat și mai ușor de construit izolat unul de celălalt.
Testarea logicii de calcul
În cele din urmă, ajungem la clasa ComputationLogic
, care ar trebui să aproximeze un dispozitiv IO, cum ar fi un adaptor la un server la distanță sau o bază de date locală. Deoarece nu avem nevoie de niciunul dintre ele pentru un calculator simplu, acesta va fi responsabil doar de încapsularea logicii necesare pentru validarea și evaluarea expresiilor pe care i le dăm:
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); } } }
Nu sunt prea multe de spus despre această clasă, deoarece de obicei ar exista o cuplare strânsă la o anumită bibliotecă back-end, care ar prezenta probleme similare cu o clasă strâns cuplată cu Android. Peste un moment vom discuta ce să facem cu astfel de cursuri, dar acesta este atât de ușor de testat încât ar putea la fel de bine să încercăm:
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()); } }
Clasele cel mai ușor de testat sunt cele cărora li se dă pur și simplu o valoare sau un obiect și se așteaptă să returneze un rezultat fără a fi nevoie de apelarea unor dependențe externe. În orice caz, vine un moment în care, indiferent cât de multă vrăjitorie ai arhitecturii software aplicați, va trebui să vă faceți griji cu privire la clasele care nu pot fi decuplate de platforme și cadre. Din fericire, există încă o modalitate prin care putem folosi arhitectura software pentru: în cel mai rău caz, să facem aceste clase mai ușor de testat și, în cel mai bun caz, atât de simplu încât testarea poate fi făcută dintr-o privire .
Obiecte umile și vederi pasive
Cele două nume de mai sus se referă la un model în care un obiect care trebuie să vorbească cu dependențe de nivel scăzut este simplificat atât de mult încât, probabil , nu trebuie testat. Am fost introdus pentru prima dată în acest model prin blogul lui Martin Fowler despre variațiile Model-View-Presenter. Mai târziu, prin lucrările lui Robert C. Martin, am fost introdusă în ideea de a trata anumite clase ca Humble Objects , ceea ce implică faptul că acest tipar nu trebuie să se limiteze la clasele de interfață cu utilizatorul (deși nu vreau să spun că Fowler vreodată implica o astfel de limitare).
Indiferent cum ai alege să numești acest model, este încântător de simplu de înțeles și, într-un anumit sens, cred că este de fapt doar rezultatul aplicării riguroase a SOC la cursurile tale. În timp ce acest model se aplică și claselor back-end, vom folosi clasa noastră de interfață cu utilizatorul pentru a demonstra acest principiu în acțiune. Separarea este foarte simplă: clasele care interacționează cu dependențele de platformă și cadru, nu gândesc de la sine (de unde și denumirile Umil și pasiv ). Când are loc un eveniment, singurul lucru pe care îl fac este să trimită detaliile acestui eveniment către orice clasă de logică care se întâmplă să asculte:
//from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });
Clasa logică, care ar trebui să fie trivial de ușor de testat, este apoi responsabilă pentru controlul interfeței cu utilizatorul într-un mod foarte fin. În loc să apelați o singură funcție generică updateUserInterface(...)
pe clasa user interface
cu utilizatorul și să o lăsați să facă munca unei actualizări în bloc, user interface
(sau o altă asemenea clasă) va avea funcții mici și specifice care ar trebui să fie ușor de utilizat. denumește și implementează:
//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. Ai.
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 .
Citiți suplimentare despre SmashingMag:
- Sliding In And Out Of Vue.js
- Designing And Building A Progressive Web Application Without A Framework
- Cadre CSS sau grilă CSS: ce ar trebui să folosesc pentru proiectul meu?
- Utilizarea Flutter de la Google pentru o dezvoltare mobilă cu adevărat multiplatformă