Teste unitare, cum se scrie un cod testabil și de ce este important
Publicat: 2022-03-11Testarea unitară este un instrument esențial în cutia de instrumente a oricărui dezvoltator de software serios. Cu toate acestea, uneori poate fi destul de dificil să scrieți un test unitar bun pentru o anumită bucată de cod. Având dificultăți în testarea codului propriu sau al altcuiva, dezvoltatorii cred adesea că luptele lor sunt cauzate de lipsa unor cunoștințe fundamentale de testare sau a unor tehnici secrete de testare unitară.
În acest tutorial de testare unitară, intenționez să demonstrez că testele unitare sunt destul de ușoare; problemele reale care complică testarea unitară și introduc o complexitate costisitoare sunt rezultatul unui cod prost proiectat, netestabil . Vom discuta despre ce face codul greu de testat, ce anti-modele și practicile proaste ar trebui să le evităm pentru a îmbunătăți testabilitatea și ce alte beneficii putem obține prin scrierea codului testabil. Vom vedea că scrierea testelor unitare și generarea de cod testabil nu înseamnă doar a face testarea mai puțin supărătoare, ci și a face codul în sine mai robust și mai ușor de întreținut.
Ce este testarea unitară?
În esență, un test unitar este o metodă care instanțiază o mică parte a aplicației noastre și verifică comportamentul acesteia independent de alte părți . Un test unitar tipic conține 3 faze: În primul rând, inițializează o mică parte dintr-o aplicație pe care dorește să o testeze (cunoscută și sub denumirea de sistem testat sau SUT), apoi aplică un anumit stimul sistemului testat (de obicei, apelând un metoda pe ea) și, în final, observă comportamentul rezultat. Dacă comportamentul observat este în concordanță cu așteptările, testul unitar trece, în caz contrar, eșuează, indicând că există o problemă undeva în sistemul testat. Aceste trei faze de testare unitară sunt cunoscute și sub denumirea de Aranjare, Acțiune și Afirmare sau pur și simplu AAA.
Un test unitar poate verifica diferite aspecte comportamentale ale sistemului testat, dar cel mai probabil se va încadra în una dintre următoarele două categorii: bazat pe stare sau bazat pe interacțiune . Verificarea faptului că sistemul testat produce rezultate corecte sau că starea sa rezultată este corectă se numește testare unitară bazată pe stare , în timp ce verificarea că invocă în mod corespunzător anumite metode se numește testare unitară bazată pe interacțiune .
Ca o metaforă pentru testarea corectă a unităților de software, imaginați-vă un om de știință nebun care vrea să construiască o himeră supranaturală, cu picioare de broaște, tentacule de caracatiță, aripi de pasăre și cap de câine. (Această metaforă este destul de aproape de ceea ce fac programatorii la locul de muncă). Cum s-ar asigura acel om de știință că fiecare parte (sau unitate) pe care a ales-o funcționează cu adevărat? Ei bine, el poate lua, să zicem, un singur picior de broască, îi poate aplica un stimul electric și poate verifica contracția musculară corectă. Ceea ce face el este în esență aceiași pași Aranjare-Acționare-Afirmare ai testului unitar; singura diferență este că, în acest caz, unitatea se referă la un obiect fizic, nu la un obiect abstract din care ne construim programele.
Voi folosi C# pentru toate exemplele din acest articol, dar conceptele descrise se aplică tuturor limbajelor de programare orientate pe obiecte.
Un exemplu de test unitar simplu ar putea arăta astfel:
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
Test unitar vs. Test de integrare
Un alt lucru important de luat în considerare este diferența dintre testarea unitară și testarea de integrare.
Scopul unui test unitar în inginerie software este de a verifica comportamentul unei piese relativ mici de software, independent de alte părți. Testele unitare sunt înguste și ne permit să acoperim toate cazurile, asigurându-ne că fiecare piesă funcționează corect.
Pe de altă parte, testele de integrare demonstrează că diferite părți ale unui sistem lucrează împreună în mediul real . Ele validează scenarii complexe (ne putem gândi la testele de integrare ca la un utilizator care efectuează o operațiune de nivel înalt în sistemul nostru) și necesită, de obicei, resurse externe, cum ar fi baze de date sau servere web, să fie prezente.
Să ne întoarcem la metafora noastră de om de știință nebun și să presupunem că a combinat cu succes toate părțile himerei. El vrea să efectueze un test de integrare a creaturii rezultate, asigurându-se că poate, să zicem, să meargă pe diferite tipuri de teren. În primul rând, omul de știință trebuie să emuleze un mediu pe care creatura să meargă. Apoi, el aruncă creatura în acel mediu și o împinge cu un băț, observând dacă merge și se mișcă așa cum a fost proiectat. După ce a terminat un test, omul de știință nebun curăță toată murdăria, nisipul și pietrele care sunt acum împrăștiate în minunatul său laborator.
Observați diferența semnificativă dintre testele unitare și cele de integrare: Un test unitar verifică comportamentul unei părți mici a aplicației, izolat de mediu și alte părți, și este destul de ușor de implementat, în timp ce un test de integrare acoperă interacțiunile dintre diferite componente, în mediu apropiat de viața reală și necesită mai mult efort, inclusiv faze suplimentare de configurare și demontare.
O combinație rezonabilă de teste de unitate și de integrare asigură că fiecare unitate funcționează corect, independent de celelalte și că toate aceste unități joacă frumos atunci când sunt integrate, oferindu-ne un nivel ridicat de încredere că întregul sistem funcționează conform așteptărilor.
Cu toate acestea, trebuie să ne amintim să identificăm întotdeauna ce fel de test implementăm: un test unitar sau de integrare. Diferența poate fi uneori înșelătoare. Dacă credem că scriem un test unitar pentru a verifica unele cazuri marginale subtile într-o clasă de logică de afaceri și ne dăm seama că necesită resurse externe, cum ar fi serviciile web sau bazele de date pentru a fi prezente, ceva nu este în regulă - în esență, folosim un baros pentru a sparge o nucă. Și asta înseamnă un design prost.
Ce face un test unitar bun?
Înainte de a aborda partea principală a acestui tutorial și de a scrie teste unitare, să discutăm rapid despre proprietățile unui test unitar bun. Principiile testării unitare cer ca un test bun să fie:
Usor de scris. De obicei, dezvoltatorii scriu o mulțime de teste unitare pentru a acoperi diferite cazuri și aspecte ale comportamentului aplicației, așa că ar trebui să fie ușor să codificați toate aceste rutine de testare fără efort enorm.
Citibil. Intenția unui test unitar ar trebui să fie clară. Un test unitar bun spune o poveste despre un anumit aspect comportamental al aplicației noastre, așa că ar trebui să fie ușor de înțeles care scenariu este testat și, dacă testul eșuează, ușor de detectat cum să rezolvi problema. Cu un test unitar bun, putem remedia o eroare fără a depana codul!
De încredere. Testele unitare ar trebui să eșueze numai dacă există o eroare în sistemul testat. Acest lucru pare destul de evident, dar programatorii se confruntă adesea cu o problemă atunci când testele lor eșuează chiar și atunci când nu au fost introduse erori. De exemplu, testele pot trece atunci când rulează unul câte unul, dar eșuează când rulează întreaga suită de teste sau pot trece pe mașina noastră de dezvoltare și eșuează pe serverul de integrare continuă. Aceste situații indică un defect de proiectare. Testele unitare bune ar trebui să fie reproductibile și independente de factori externi, cum ar fi mediul sau ordinea de funcționare.
Rapid. Dezvoltatorii scriu teste unitare astfel încât să le poată rula în mod repetat și să verifice dacă nu au fost introduse erori. Dacă testele unitare sunt lente, dezvoltatorii sunt mai susceptibili de a omite să le ruleze pe propriile computere. Un test lent nu va face o diferență semnificativă; mai adaugă o mie și cu siguranță rămânem blocați să așteptăm o vreme. Testele unitare lente pot indica, de asemenea, că fie sistemul testat, fie testul însuși, interacționează cu sistemele externe, făcându-l dependent de mediu.
Într-adevăr unitate, nu integrare. După cum am discutat deja, testele unitare și de integrare au scopuri diferite. Atât testul unitar, cât și sistemul testat nu ar trebui să acceseze resursele rețelei, bazele de date, sistemul de fișiere etc., pentru a elimina influența factorilor externi.
Asta este — nu există secrete pentru scrierea testelor unitare . Cu toate acestea, există câteva tehnici care ne permit să scriem cod testabil .
Cod testabil și netestabil
Unele coduri sunt scrise în așa fel încât este greu, sau chiar imposibil, să scrieți un test unitar bun pentru el. Deci, ce face codul greu de testat? Să trecem în revistă câteva anti-modele, mirosuri de cod și practici proaste pe care ar trebui să le evităm atunci când scriem cod testabil.
Otrăvirea bazei de cod cu factori nedeterminiști
Să începem cu un exemplu simplu. Imaginați-vă că scriem un program pentru un microcontroler pentru casă inteligentă, iar una dintre cerințe este să aprindem automat lumina din curtea din spate dacă se detectează o mișcare acolo seara sau noaptea. Am început de jos în sus prin implementarea unei metode care returnează o reprezentare în șir a orei aproximative a zilei („Noapte”, „Dimineață”, „După-amiază” sau „Seara”):
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
În esență, această metodă citește ora curentă a sistemului și returnează un rezultat bazat pe acea valoare. Deci, ce este în neregulă cu acest cod?
Dacă ne gândim la asta din perspectiva testării unitare, vom vedea că nu este posibil să scriem un test unitar adecvat bazat pe stare pentru această metodă. DateTime.Now
este, în esență, o intrare ascunsă, care probabil se va schimba în timpul execuției programului sau între rulările de testare. Astfel, apelurile ulterioare la acesta vor produce rezultate diferite.
Un astfel de comportament nedeterminist face imposibilă testarea logicii interne a GetTimeOfDay()
fără a schimba de fapt data și ora sistemului. Să vedem cum ar trebui implementat un astfel de test:
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
Teste ca acesta ar încălca multe dintre regulile discutate mai devreme. Ar fi costisitor de scris (din cauza setării netriviale și a logicii de demontare), nefiabil (poate eșua chiar dacă nu există erori în sistemul testat, din cauza problemelor de permisiuni ale sistemului, de exemplu) și nu este garantat aleargă repede. Și, în sfârșit, acest test nu ar fi de fapt un test unitar - ar fi ceva între un test unitar și cel de integrare, deoarece pretinde că testează un caz marginal simplu, dar necesită configurarea unui mediu într-un anumit mod. Rezultatul nu merită efortul, nu?
Se pare că toate aceste probleme de testare sunt cauzate de API-ul GetTimeOfDay()
de calitate scăzută. În forma sa actuală, această metodă suferă de mai multe probleme:
Este strâns legat de sursa de date concretă. Nu este posibilă reutilizarea acestei metode pentru procesarea datei și orei preluate din alte surse sau transmise ca argument; metoda funcționează numai cu data și ora anumitor mașini care execută codul. Cuplarea strânsă este rădăcina principală a majorității problemelor de testare.
Încalcă Principiul responsabilității unice (SRP). Metoda are responsabilități multiple; consumă informația și, de asemenea, o prelucrează. Un alt indicator al încălcării SRP este atunci când o singură clasă sau metodă are mai multe motive de schimbare . Din această perspectivă, metoda
GetTimeOfDay()
ar putea fi schimbată fie din cauza ajustărilor logice interne, fie pentru că sursa de dată și oră ar trebui modificată.Se minte despre informațiile necesare pentru a-și îndeplini treaba. Dezvoltatorii trebuie să citească fiecare rând din codul sursă real pentru a înțelege ce intrări ascunse sunt folosite și de unde provin. Numai semnătura metodei nu este suficientă pentru a înțelege comportamentul metodei.
Este greu de anticipat și de menținut. Comportamentul unei metode care depinde de o stare globală mutabilă nu poate fi prezis prin simpla citire a codului sursă; este necesar să se țină cont de valoarea sa actuală, alături de întreaga succesiune de evenimente care ar fi putut-o schimba mai devreme. Într-o aplicație din lumea reală, încercarea de a dezlega toate aceste lucruri devine o adevărată durere de cap.
După ce examinăm API-ul, să reparăm în sfârșit! Din fericire, acest lucru este mult mai ușor decât să discutăm toate defectele sale - trebuie doar să eliminăm preocupările strâns legate.
Remedierea API-ului: introducerea unui argument de metodă
Cea mai evidentă și ușoară modalitate de a remedia API-ul este prin introducerea unui argument de metodă:
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
Acum, metoda cere apelantului să furnizeze un argument DateTime
, în loc să caute în secret aceste informații de la sine. Din perspectiva testării unitare, acest lucru este grozav; metoda este acum deterministă (adică valoarea sa returnată depinde în totalitate de intrare), astfel încât testarea bazată pe stare este la fel de ușoară ca trecerea unei valori DateTime
și verificarea rezultatului:
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
Observați că acest simplu refactor a rezolvat, de asemenea, toate problemele API discutate mai devreme (cuplarea strânsă, încălcarea SRP, API neclară și greu de înțeles) prin introducerea unei cusături clare între ce date ar trebui procesate și cum ar trebui făcute.
Excelent - metoda este testabilă, dar cum rămâne cu clienții săi ? Acum este responsabilitatea apelantului să furnizeze data și ora GetTimeOfDay(DateTime dateTime)
, ceea ce înseamnă că ar putea deveni netestabile dacă nu acordăm suficientă atenție. Să vedem cum ne putem descurca cu asta.
Remedierea API-ului client: Injecție de dependență
Să presupunem că continuăm să lucrăm la sistemul de casă inteligentă și să implementăm următorul client al GetTimeOfDay(DateTime dateTime)
- codul de microcontroler pentru casă inteligentă menționat mai sus, responsabil pentru aprinderea sau stingerea luminii, în funcție de ora din zi și de detectarea mișcării :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
Ai! Avem același tip de problemă ascunsă de intrare DateTime.Now
- singura diferență este că este situată la un nivel puțin mai ridicat de abstracție. Pentru a rezolva această problemă, putem introduce un alt argument, delegând din nou responsabilitatea furnizării unei valori DateTime
apelantului unei noi metode cu semnătură ActuateLights(bool motionDetected, DateTime dateTime)
. Dar, în loc să mutăm încă o dată problema cu un nivel mai sus în stiva de apeluri, să folosim o altă tehnică care ne va permite să păstrăm testabile atât ActuateLights(bool motionDetected)
, cât și clienții săi: Inversion of Control sau IoC.
Inversion of Control este o tehnică simplă, dar extrem de utilă, pentru decuplarea codului și, în special, pentru testarea unitară. (La urma urmei, păstrarea lucrurilor cuplate liber este esențială pentru a le putea analiza independent unul de celălalt.) Punctul cheie al IoC este separarea codului de luare a deciziilor ( când să faci ceva) de codul de acțiune ( ce să faci când se întâmplă ceva). ). Această tehnică crește flexibilitatea, face codul nostru mai modular și reduce cuplarea între componente.
Inversarea controlului poate fi implementată în mai multe moduri; să aruncăm o privire la un exemplu anume — Injecția de dependență folosind un constructor — și cum poate ajuta la construirea unui API SmartHomeController
testabil.
Mai întâi, să creăm o interfață IDateTimeProvider
, care să conțină o semnătură a metodei pentru obținerea unei date și ore:
public interface IDateTimeProvider { DateTime GetDateTime(); }
Apoi, faceți ca SmartHomeController
să facă referire la o implementare IDateTimeProvider
și delegați-i responsabilitatea de a obține data și ora:
public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
Acum putem vedea de ce se numește inversarea controlului: controlul mecanismului de utilizat pentru citirea datei și orei a fost inversat și acum aparține clientului SmartHomeController
, nu SmartHomeController
în sine. Prin urmare, execuția ActuateLights(bool motionDetected)
depinde pe deplin de două lucruri care pot fi gestionate cu ușurință din exterior: argumentul motionDetected
și o implementare concretă a IDateTimeProvider
, transmisă într-un constructor SmartHomeController
.

De ce este acest lucru semnificativ pentru testarea unitară? Înseamnă că diferite implementări IDateTimeProvider
pot fi utilizate în codul de producție și codul de test unitar. În mediul de producție, o implementare reală va fi injectată (de exemplu, una care citește ora reală a sistemului). În testul unitar, totuși, putem injecta o implementare „falsă” care returnează o valoare DateTime
constantă sau predefinită, potrivită pentru testarea unui anumit scenariu.
O implementare falsă a IDateTimeProvider
ar putea arăta astfel:
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
Cu ajutorul acestei clase, este posibil să izolați SmartHomeController
de factorii nedeterminiști și să efectuați un test unitar bazat pe stare. Să verificăm că, dacă a fost detectată mișcare, ora acelei mișcări este înregistrată în proprietatea LastMotionTime
:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
Grozav! Un astfel de test nu a fost posibil înainte de refactorizare. Acum că am eliminat factorii nedeterminiști și am verificat scenariul bazat pe stare, credeți că SmartHomeController
este pe deplin testabil?
Otrăvirea bazei de cod cu efecte secundare
În ciuda faptului că am rezolvat problemele cauzate de intrarea ascunsă nedeterministă și am putut testa anumite funcționalități, codul (sau, cel puțin, o parte din el) este încă netestabil!
Să revedem următoarea parte a ActuateLights(bool motionDetected)
responsabilă pentru aprinderea sau stingerea luminii:
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
După cum putem vedea, SmartHomeController
deleagă responsabilitatea de a aprinde sau stinge lumina unui obiect BackyardLightSwitcher
, care implementează un model Singleton. Ce este în neregulă cu acest design?
Pentru a testa complet metoda ActuateLights(bool motionDetected)
, ar trebui să efectuăm testarea bazată pe interacțiune în plus față de testarea bazată pe stare; adică ar trebui să ne asigurăm că metodele de aprindere sau stingere a luminii sunt apelate dacă și numai dacă sunt îndeplinite condițiile adecvate. Din păcate, designul actual nu ne permite să facem asta: TurnOn()
și TurnOff()
ale BackyardLightSwitcher
declanșează unele schimbări de stare în sistem sau, cu alte cuvinte, produc efecte secundare . Singura modalitate de a verifica dacă aceste metode au fost apelate este de a verifica dacă efectele secundare corespunzătoare au avut loc sau nu, ceea ce ar putea fi dureros.
Într-adevăr, să presupunem că senzorul de mișcare, felinarul din curte și microcontrolerul pentru casă inteligentă sunt conectate la o rețea Internet of Things și comunică folosind un protocol wireless. În acest caz, un test unitar poate încerca să recepționeze și să analizeze acel trafic de rețea. Sau, dacă componentele hardware sunt conectate cu un fir, testul unității poate verifica dacă tensiunea a fost aplicată circuitului electric corespunzător. Sau, la urma urmei, poate verifica dacă lumina s-a aprins sau stins cu ajutorul unui senzor de lumină suplimentar.
După cum putem vedea, metodele de testare unitară cu efecte secundare ar putea fi la fel de grele ca cele nedeterministe de testare unitară și pot fi chiar imposibile. Orice încercare va duce la probleme similare celor pe care le-am văzut deja. Testul rezultat va fi greu de implementat, nefiabil, potențial lent și nu este cu adevărat unitar. Și, după toate acestea, clipirea luminii de fiecare dată când rulăm suita de teste ne va înnebuni în cele din urmă!
Din nou, toate aceste probleme de testare sunt cauzate de API-ul prost, nu de capacitatea dezvoltatorului de a scrie teste unitare. Indiferent cât de exact este implementat controlul luminii, API-ul SmartHomeController
suferă de aceste probleme deja familiare:
Este strâns legată de implementarea concretă. API-ul se bazează pe instanța concretă, codificată, a
BackyardLightSwitcher
. Nu este posibil să reutilizați metodaActuateLights(bool motionDetected)
pentru a comuta orice altă lumină decât cea din curtea din spate.Încalcă principiul responsabilității unice. API-ul are două motive de schimbare: în primul rând, modificări ale logicii interne (cum ar fi alegerea de a face lumina să se aprindă doar noaptea, dar nu seara) și în al doilea rând, dacă mecanismul de comutare a luminii este înlocuit cu altul.
Se minte cu privire la dependențele sale. Dezvoltatorii nu au nicio modalitate de a ști că
SmartHomeController
depinde de componentaBackyardLightSwitcher
codificată, în afară de săparea în codul sursă.Este greu de înțeles și de întreținut. Ce se întâmplă dacă lumina refuză să se aprindă atunci când condițiile sunt potrivite? Am putea petrece mult timp încercând să reparăm
SmartHomeController
fără niciun rezultat, doar pentru a realiza că problema a fost cauzată de o eroare înBackyardLightSwitcher
(sau, și mai amuzant, un bec ars!).
Soluția atât a problemelor de testare, cât și a problemelor API de calitate scăzută este, deloc surprinzător, de a rupe componentele strâns cuplate unele de altele. Ca și în exemplul anterior, folosirea Dependency Injection ar rezolva aceste probleme; trebuie doar să adăugați o dependență ILightSwitcher
la SmartHomeController
, să îi delegeți responsabilitatea de a comuta întrerupătorul luminii și să treceți o implementare ILightSwitcher
falsă, doar de testare, care va înregistra dacă metodele adecvate au fost apelate în condițiile potrivite. Cu toate acestea, în loc să folosim din nou Dependency Injection, să trecem în revistă o abordare alternativă interesantă pentru decuplarea responsabilităților.
Remedierea API-ului: Funcții de ordin superior
Această abordare este o opțiune în orice limbaj orientat pe obiecte care acceptă funcții de primă clasă . Să profităm de caracteristicile funcționale ale C# și să facem ca metoda ActuateLights(bool motionDetected)
să accepte încă două argumente: o pereche de delegați de Action
, indicând metodele care ar trebui apelate pentru a aprinde și stinge lumina. Această soluție va converti metoda într-o funcție de ordin superior :
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
Aceasta este o soluție mai funcțională decât abordarea clasică de dependență orientată pe obiecte pe care am mai văzut-o; cu toate acestea, ne permite să obținem același rezultat cu mai puțin cod și mai multă expresivitate decât Dependency Injection. Nu mai este necesar să se implementeze o clasă conformă cu o interfață pentru a furniza SmartHomeController
funcționalitatea necesară; în schimb, putem transmite doar o definiție a funcției. Funcțiile de ordin superior pot fi gândite ca o altă modalitate de implementare a inversării controlului.
Acum, pentru a efectua un test unitar bazat pe interacțiune al metodei rezultate, putem trece în ea acțiuni false ușor verificabile:
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
În cele din urmă, am făcut API-ul SmartHomeController
complet testabil și suntem capabili să efectuăm atât teste unitare bazate pe stare, cât și pe interacțiune. Din nou, observați că, pe lângă testabilitatea îmbunătățită, introducerea unei cusături între luarea deciziilor și codul de acțiune a ajutat la rezolvarea problemei de cuplare strânsă și a condus la un API mai curat și reutilizabil.
Acum, pentru a obține o acoperire completă a testelor unitare, putem implementa pur și simplu o grămadă de teste similare pentru a valida toate cazurile posibile - nu este mare lucru, deoarece testele unitare sunt acum destul de ușor de implementat.
Impuritatea și testabilitatea
Non-determinismul necontrolat și efectele secundare sunt similare în efectele lor distructive asupra bazei de cod. Când sunt utilizate cu neglijență, ele duc la un cod înșelător, greu de înțeles și de întreținut, strâns cuplat, nereutilizabil și netestabil.
Pe de altă parte, metodele care sunt atât deterministe, cât și fără efecte secundare sunt mult mai ușor de testat, raționat și reutilizat pentru a construi programe mai mari. În ceea ce privește programarea funcțională, astfel de metode sunt numite funcții pure . Rareori vom avea o unitate cu probleme care testează o funcție pură; tot ce trebuie să facem este să transmitem câteva argumente și să verificăm rezultatul pentru corectitudine. Ceea ce face ca codul să nu fie testat sunt factorii impuri, codificați greu, care nu pot fi înlocuiți, suprasolicitați sau abstrași în alt mod.
Impuritatea este toxică: dacă metoda Foo()
depinde de metoda nedeterministă sau cu efecte secundare Bar()
, atunci Foo()
devine și nedeterministă sau cu efecte secundare. În cele din urmă, putem ajunge să otrăvim întreaga bază de cod. Înmulțiți toate aceste probleme cu dimensiunea unei aplicații complexe din viața reală și ne vom găsi grevați de o bază de cod greu de întreținut plină de mirosuri, anti-modele, dependențe secrete și tot felul de lucruri urâte și neplăcute.
Cu toate acestea, impuritatea este inevitabilă; orice aplicație din viața reală trebuie, la un moment dat, să citească și să manipuleze starea interacționând cu mediul, bazele de date, fișierele de configurare, serviciile web sau alte sisteme externe. Deci, în loc să urmărim eliminarea totală a impurităților, este o idee bună să limitați acești factori, să evitați să-i lăsați să vă otrăvească baza de cod și să spargeți pe cât posibil dependențele hard-coded, pentru a putea analiza și testa lucrurile în mod independent.
Semne de avertizare comune ale codului greu de testat
În cele din urmă, să analizăm câteva semne de avertizare comune care indică faptul că codul nostru ar putea fi dificil de testat.
Proprietăți și câmpuri statice
Proprietățile și câmpurile statice sau, pur și simplu, starea globală, pot complica înțelegerea codului și testabilitatea, prin ascunderea informațiilor necesare unei metode pentru a-și îndeplini treaba, prin introducerea non-determinismului sau prin promovarea utilizării extinse a efectelor secundare. Funcțiile care citesc sau modifică starea globală mutabilă sunt în mod inerent impure.
De exemplu, este greu să raționezi cu privire la următorul cod, care depinde de o proprietate accesibilă la nivel global:
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
Ce se întâmplă dacă metoda HeatWater()
nu este apelată când suntem siguri că ar fi trebuit să fie? Deoarece orice parte a aplicației ar fi putut modifica valoarea CostSavingEnabled
, trebuie să găsim și să analizăm toate locurile care modifică acea valoare pentru a afla ce este în neregulă. De asemenea, după cum am văzut deja, nu este posibilă setarea unor proprietăți statice în scopuri de testare (de exemplu, DateTime.Now
sau Environment.MachineName
; acestea sunt doar pentru citire, dar încă nedeterministe).
Pe de altă parte, starea globală imuabilă și deterministă este total OK. De fapt, există un nume mai familiar pentru asta - o constantă. Valorile constante precum Math.PI
nu introduc niciun non-determinism și, deoarece valorile lor nu pot fi modificate, nu permit efecte secundare:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
Singletons
În esență, modelul Singleton este doar o altă formă a statului global. Singleton-urile promovează API-uri obscure care mint despre dependențe reale și introduc o cuplare strânsă inutil între componente. De asemenea, ei încalcă Principiul responsabilității unice deoarece, pe lângă sarcinile lor primare, își controlează propria inițializare și ciclu de viață.
Singleton-urile pot face cu ușurință testele unitare dependente de ordine, deoarece poartă starea pe toată durata de viață a întregii aplicații sau a suitei de teste unitare. Aruncă o privire la următorul exemplu:
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
Static Methods
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. De exemplu:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.