Testarea unitară .NET: cheltuiți în avans pentru a economisi mai târziu
Publicat: 2022-03-11Există adesea multe confuzii și îndoieli cu privire la testarea unitară atunci când discutăm despre aceasta cu părțile interesate și clienții. Testarea unitară sună uneori la fel cum folosește ața dentară pentru un copil: „Deja mă spăl pe dinți, de ce trebuie să fac asta?”
Sugerarea testării unitare sună adesea ca o cheltuială inutilă pentru persoanele care consideră că metodele lor de testare și testarea de acceptare a utilizatorilor sunt suficient de puternice.
Dar testele unitare sunt un instrument foarte puternic și sunt mai simple decât ați putea crede. În acest articol, vom arunca o privire asupra testării unitare și a instrumentelor disponibile în DotNet, cum ar fi Microsoft.VisualStudio.TestTools și Moq .
Vom încerca să construim o bibliotecă de clase simplă care să calculeze al n-lea termen din șirul Fibonacci. Pentru a face asta, vom dori să creăm o clasă pentru calcularea secvențelor Fibonacci care depinde de o clasă de matematică personalizată care adună numerele împreună. Apoi, putem folosi .NET Testing Framework pentru a ne asigura că programul nostru rulează conform așteptărilor.
Ce este testarea unitară?
Testarea unitară descompune programul în cel mai mic bit de cod, de obicei la nivel de funcție, și asigură că funcția returnează valoarea așteptată. Prin utilizarea unui cadru de testare unitară, testele unitare devin o entitate separată care poate rula apoi teste automate pe program pe măsură ce acesta este construit.
[TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }
Un test unitar simplu folosind testarea metodologiei Arrange, Act, Assert pe care biblioteca noastră de matematică poate adăuga corect 2 + 2.
Odată ce testele unitare sunt configurate, dacă se face o modificare a codului, pentru a ține cont de o condiție suplimentară care nu era cunoscută când a fost dezvoltat pentru prima dată programul, de exemplu, testele unitare vor arăta dacă toate cazurile se potrivesc cu valorile așteptate. iesit de functie.
Testarea unitară nu este testarea integrării. Nu este testare end-to-end. Deși ambele sunt metodologii puternice, ele ar trebui să funcționeze împreună cu testarea unitară, nu ca înlocuitoare.
Beneficiile și scopul testării unitare
Cel mai greu beneficiu al testării unitare de înțeles, dar cel mai important, este capacitatea de a retesta codul modificat din mers. Motivul pentru care poate fi atât de greu de înțeles este că atât de mulți dezvoltatori se gândesc pentru ei înșiși: „Nu voi mai atinge niciodată această funcție” sau „O voi retesta când termin.” Iar părțile interesate se gândesc în termeni: „Dacă acea bucată este deja scrisă, de ce trebuie să o testez din nou?”
Ca cineva care a fost de ambele părți ale spectrului de dezvoltare, am spus ambele lucruri. Dezvoltatorul din mine știe de ce trebuie să-l retestăm.
Schimbările pe care le facem de zi cu zi pot avea un impact uriaș. De exemplu:
- Comutatorul dvs. ține cont în mod corespunzător de o nouă valoare pe care o introduceți?
- Știi de câte ori ai folosit acel comutator?
- Ați luat în considerare în mod corespunzător comparațiile de șiruri care nu țin cont de majuscule și minuscule?
- Verificați în mod corespunzător valorile nule?
- O excepție de aruncare este gestionată așa cum vă așteptați?
Testarea unitară preia aceste întrebări și le memorează în cod și într-un proces pentru a se asigura că se răspunde întotdeauna la aceste întrebări. Testele unitare pot fi executate înaintea unei versiuni pentru a vă asigura că nu ați introdus erori noi. Deoarece testele unitare sunt concepute pentru a fi atomice, acestea sunt executate foarte rapid, de obicei mai puțin de 10 milisecunde per test. Chiar și într-o aplicație foarte mare, o suită completă de testare poate fi efectuată în mai puțin de o oră. Poate procesul dumneavoastră UAT să se potrivească cu asta?
Fibonacci_GetNthTerm_Input2_AssertResult1
, care este prima rulare și include timpul de configurare, toate testele unitare rulează sub 5 ms. Convenția mea de denumire aici este configurată pentru a căuta cu ușurință o clasă sau o metodă într-o clasă pe care vreau să o testez
Totuși, în calitate de dezvoltator, poate că acest lucru sună a mai multă muncă pentru tine. Da, aveți liniște sufletească că codul pe care îl lansați este bun. Dar testarea unitară vă oferă și oportunitatea de a vedea unde designul dvs. este slab. Scrieți aceleași teste unitare pentru două bucăți de cod? Ar trebui să fie pe o singură bucată de cod în schimb?
Faptul ca codul să fie testabil în unitate este o modalitate prin care vă puteți îmbunătăți designul. Iar pentru majoritatea dezvoltatorilor care nu au testat niciodată unitatea sau nu au nevoie de atât de mult timp pentru a lua în considerare designul înainte de codare, vă puteți da seama cât de mult se îmbunătățește designul dvs. pregătindu-l pentru testarea unitară.
Unitatea dvs. de cod poate fi testată?
Pe lângă DRY, avem și alte considerații.
Metodele sau funcțiile tale încearcă să facă prea multe?
Dacă trebuie să scrieți teste unitare prea complexe, care rulează mai mult decât vă așteptați, metoda dvs. poate fi prea complicată și mai potrivită ca metode multiple.
Utilizați corect injecția de dependență?
Dacă metoda dvs. testată necesită o altă clasă sau funcție, numim aceasta o dependență. În testarea unitară, nu ne interesează ce face dependența sub capotă; în scopul metodei testate, este o cutie neagră. Dependența are propriul set de teste unitare care vor determina dacă comportamentul său funcționează corect.
În calitate de tester, doriți să simulați acea dependență și să îi spuneți ce valori să returneze în cazuri specifice. Acest lucru vă va oferi un control mai mare asupra cazurilor dvs. de testare. Pentru a face acest lucru, va trebui să injectați o versiune inactivă (sau, după cum vom vedea mai târziu, batjocorită) a acestei dependențe.
Componentele dvs. interacționează între ele cum vă așteptați?
Odată ce ați stabilit dependențele și injecția de dependență, este posibil să descoperiți că ați introdus dependențe ciclice în cod. Dacă clasa A depinde de clasa B, care la rândul său depinde de clasa A, ar trebui să vă reconsiderați designul.
Frumusețea injecției dependenței
Să luăm în considerare exemplul nostru Fibonacci. Șeful tău îți spune că au o nouă clasă care este mai eficientă și mai precisă decât operatorul de adăugare actual disponibil în C#.
Deși acest exemplu particular nu este foarte probabil în lumea reală, vedem exemple similare în alte componente, cum ar fi autentificarea, maparea obiectelor și aproape orice proces algoritmic. În scopul acestui articol, să presupunem că noua funcție de adăugare a clientului dvs. este cea mai recentă și cea mai bună de când au fost inventate computerele.
Ca atare, șeful tău îți oferă o bibliotecă cutie neagră cu o singură clasă Math
, iar în acea clasă, o singură funcție Add
. Sarcina ta de a implementa un calculator Fibonacci este probabil să arate cam așa:
public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }
Acest lucru nu este îngrozitor. Instanțiați o nouă clasă de Math
și utilizați-o pentru a adăuga cei doi termeni anteriori pentru a obține următorul. Executați această metodă prin bateria dvs. obișnuită de teste, calculând până la 100 de termeni, calculând termenul 1000, termenul 10.000 și așa mai departe până când vă simțiți mulțumit că metodologia dvs. funcționează bine. Apoi, cândva, în viitor, un utilizator se plânge că termenul 501 nu funcționează conform așteptărilor. Îți petreci seara uitându-te prin codul tău și încercând să-ți dai seama de ce această carcasă de colț nu funcționează. Începi să te bănuiești că cea mai recentă și mai bună clasă de Math
nu este chiar atât de grozavă pe cât crede șeful tău. Dar este o cutie neagră și nu poți dovedi asta cu adevărat – ajungi într-un impas intern.
Problema aici este că dependența Math
nu este injectată în calculatorul tău Fibonacci. Prin urmare, în testele dvs., vă bazați întotdeauna pe rezultatele existente, netestate și necunoscute de la Math
pentru a testa Fibonacci. Dacă există o problemă cu Math
, atunci Fibonacci va fi întotdeauna greșit (fără a codifica un caz special pentru al 501-lea termen).
Ideea de a corecta această problemă este să injectați clasa Math
în calculatorul dumneavoastră Fibonacci. Dar și mai bine, este să creăm o interfață pentru clasa Math
care să definească metodele publice (în cazul nostru, Add
) și să implementăm interfața pe clasa noastră Math
.
public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }
În loc să injectăm clasa Math
în Fibonacci, putem injecta interfața IMath
în Fibonacci. Avantajul aici este că am putea defini propria noastră clasă OurMath
despre care știm că este exactă și să ne testăm calculatorul în funcție de aceasta. Și mai bine, folosind Moq putem defini pur și simplu ce returnează Math.Add
. Putem defini un număr de sume sau putem spune doar Math.Add
să returneze x + y.

private IMath _math; public Fibonacci(IMath math) { _math = math; }
Injectați interfața IMath în clasa Fibonacci
//setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);
Folosind Moq pentru a defini ce returnează Math.Add
.
Acum avem o metodă încercată și adevărată (bine, dacă acel operator + este greșit în C#, avem probleme mai mari) pentru a adăuga două numere. Folosind noul nostru Mocked IMath
, putem codifica un test unitar pentru cel de-al 501-lea termen al nostru și să vedem dacă ne-am prost implementat sau dacă clasa personalizată Math
are nevoie de puțină mai multă muncă.
Nu lăsați o metodă să încerce să facă prea multe
Acest exemplu indică, de asemenea, ideea unei metode care face prea mult. Sigur, adăugarea este o operație destul de simplă, fără a fi nevoie să-și abstragă funcționalitatea de la metoda noastră GetNthTerm
. Dar dacă operația a fost puțin mai complicată? În loc de adăugare, poate a fost validarea modelului, apelarea unei fabrici pentru a obține un obiect pe care să-l opereze sau colectarea datelor suplimentare necesare dintr-un depozit.
Majoritatea dezvoltatorilor vor încerca să rămână la ideea că o metodă are un singur scop. În testarea unitară, încercăm să rămânem la principiul conform căruia testele unitare ar trebui aplicate metodelor atomice și, introducând prea multe operații într-o metodă, o facem imposibil de testat. De multe ori putem crea o problemă în care trebuie să scriem atât de multe teste pentru a ne testa corect funcția.
Fiecare parametru pe care îl adăugăm la o metodă mărește numărul de teste pe care trebuie să le scriem exponențial în conformitate cu complexitatea parametrului. Dacă adăugați un boolean la logica dvs., trebuie să dublați numărul de teste de scris, deoarece acum trebuie să verificați cazurile adevărate și false împreună cu testele curente. În cazul validării modelului, complexitatea testelor noastre unitare poate crește foarte rapid.
Cu toții suntem vinovați că am adăugat un pic în plus unei metode. Dar aceste metode mai mari și mai complexe creează nevoia de prea multe teste unitare. Și devine rapid evident când scrieți testele unitare că metoda încearcă să facă prea mult. Dacă simțiți că încercați să testați prea multe rezultate posibile din parametrii dvs. de intrare, luați în considerare faptul că metoda dvs. trebuie împărțită într-o serie de altele mai mici.
Nu te repeta
Unul dintre locatarii noștri preferați de programare. Acesta ar trebui să fie destul de simplu. Dacă te trezești că scrii aceleași teste de mai multe ori, ai introdus cod de mai multe ori. Vă poate fi de folos să refactorizați acestea într-o clasă comună care este accesibilă ambelor instanțe în care încercați să o utilizați.
Ce instrumente de testare unitară sunt disponibile?
DotNet ne oferă o platformă de testare unitară foarte puternică. Folosind aceasta, puteți implementa ceea ce este cunoscut sub numele de metodologia Arrange, Act, Assert. Îți aranjezi considerentele inițiale, acționezi în acele condiții cu metoda ta testată, apoi afirmi că s-a întâmplat ceva. Puteți afirma orice, făcând acest instrument și mai puternic. Puteți afirma că o metodă a fost apelată de un anumit număr de ori, că metoda a returnat o anumită valoare, că a fost lansat un anumit tip de excepție sau orice altceva la care vă puteți gândi. Pentru cei care caută un cadru mai avansat, NUnit și omologul său Java JUnit sunt opțiuni viabile.
[TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }
Testăm că metoda noastră Fibonacci tratează numerele negative prin aruncarea unei excepții. Testele unitare pot verifica dacă excepția a fost aruncată.
Pentru a gestiona injecția de dependență, atât Ninject, cât și Unity există pe platforma DotNet. Există foarte puțină diferență între cele două și devine o problemă dacă doriți să gestionați configurațiile cu Fluent Syntax sau XML Configuration.
Pentru simularea dependențelor, recomand Moq. Moq poate fi dificil să vă puneți mâna, dar esenția este că creați o versiune batjocorită a dependențelor dvs. Apoi, îi spuneți dependenței ce să returneze în anumite condiții. De exemplu, dacă ai avut o metodă numită Square(int x)
care a pătrat întregul, ai putea să-i spui când x = 2, returnează 4. De asemenea, i-ai putea spune să returneze x^2 pentru orice număr întreg. Sau i-ați putea spune să returneze 5 când x = 2. De ce ați efectua ultimul caz? În cazul în care metoda din rolul testului este de a valida răspunsul din dependență, poate doriți să forțați răspunsurile nevalide să revină pentru a vă asigura că prindeți corect eroarea.
[TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }
Folosind Moq pentru a spune interfeței IMath
batjocorită cum să gestioneze Add
sub testare. Puteți seta cazuri explicite cu It.Is
sau un interval cu It.IsInRange
.
Cadre de testare unitară pentru DotNet
Cadrul de testare unitară Microsoft
Microsoft Unit Testing Framework este soluția de testare unitară de la Microsoft și inclusă cu Visual Studio. Pentru că vine cu VS, se integrează frumos cu el. Când începeți un proiect, Visual Studio vă va întreba dacă doriți să creați o bibliotecă de test unitar alături de aplicația dvs.
Microsoft Unit Testing Framework vine, de asemenea, cu o serie de instrumente care vă ajută să vă analizați mai bine procedurile de testare. De asemenea, deoarece este deținut și scris de Microsoft, există un sentiment de stabilitate în existența sa în viitor.
Dar când lucrezi cu instrumente Microsoft, primești ceea ce îți oferă. Microsoft Unit Testing Framework poate fi greoi de integrat.
NUnit
Cel mai mare avantaj pentru mine în utilizarea NUnit sunt testele parametrizate. În exemplul nostru Fibonacci de mai sus, putem introduce un număr de cazuri de testare și ne putem asigura că aceste rezultate sunt adevărate. Și în cazul problemei noastre 501, putem adăuga întotdeauna un nou set de parametri pentru a ne asigura că testul este întotdeauna rulat fără a fi nevoie de o nouă metodă de testare.
Dezavantajul major al NUnit este integrarea acestuia în Visual Studio. Îi lipsesc clopotele și fluierele care vin cu versiunea Microsoft și înseamnă că va trebui să descărcați propriul set de instrumente.
xUnit.Net
xUnit este foarte popular în C# deoarece se integrează foarte bine cu ecosistemul .NET existent. Nuget are multe extensii de xUnit disponibile. De asemenea, se integrează frumos cu Team Foundation Server, deși nu sunt sigur câți dezvoltatori .NET mai folosesc TFS peste diferite implementări Git.
În dezavantaj, mulți utilizatori se plâng că documentația xUnit este puțin lipsită. Pentru utilizatorii noi care testează unitar, acest lucru poate provoca o durere de cap masivă. În plus, extensibilitatea și adaptabilitatea xUnit fac, de asemenea, curba de învățare un pic mai abruptă decât NUnit sau cadrul de testare unitară Microsoft.
Proiectare/dezvoltare bazată pe teste
Proiectarea/dezvoltarea bazată pe teste (TDD) este un subiect puțin mai avansat care merită propria postare. Totuși, am vrut să ofer o introducere.
Ideea este să începeți cu testele unitare și să spuneți testelor unitare ce este corect. Apoi, puteți scrie codul în jurul acelor teste. În teorie, conceptul sună simplu, dar în practică, este foarte dificil să-ți antrenezi creierul să gândească înapoi la aplicație. Dar abordarea are avantajul încorporat de a nu fi obligat să scrieți testele unitare după fapt. Acest lucru duce la mai puțină refactorizare, rescriere și confuzie de clasă.
TDD a fost un cuvânt la modă în ultimii ani, dar adoptarea a fost lentă. Natura sa conceptuală este confuză pentru părțile interesate, ceea ce face dificilă obținerea aprobării. Dar, ca dezvoltator, vă încurajez să scrieți chiar și o aplicație mică folosind abordarea TDD pentru a vă obișnui cu acest proces.
De ce nu poți avea prea multe teste unitare
Testarea unitară este unul dintre cele mai puternice instrumente de testare pe care dezvoltatorii le au la dispoziție. Nu este în niciun caz suficient pentru un test complet al aplicației dvs., dar beneficiile sale în testarea regresiei, proiectarea codului și documentarea scopului sunt de neegalat.
Nu există așa ceva ca să scrieți prea multe teste unitare. Fiecare carcasă marginală poate propune probleme mari în continuare în software-ul dumneavoastră. Memorializarea erorilor găsite ca teste unitare poate asigura că aceste erori nu găsesc modalități de a se strecura înapoi în software-ul dvs. în timpul modificărilor ulterioare de cod. Deși puteți adăuga 10-20% la bugetul inițial al proiectului dvs., puteți economisi mult mai mult decât atât în instruire, remedieri de erori și documentare.
Puteți găsi depozitul Bitbucket folosit în acest articol aici.