Jak utrzymujesz działanie testów jednostkowych podczas refaktoryzacji?

29

W innym pytaniu ujawniono, że jednym z problemów związanych z TDD jest utrzymywanie pakietu testowego w synchronizacji z bazą kodu podczas i po refaktoryzacji.

Teraz jestem wielkim fanem refaktoryzacji. Nie zamierzam rezygnować z TDD. Ale doświadczyłem również problemów z testami napisanymi w taki sposób, że niewielkie refaktoryzowanie prowadzi do wielu niepowodzeń testów.

Jak uniknąć przełamywania testów podczas refaktoryzacji?

  • Czy piszesz testy „lepiej”? Jeśli tak, czego powinieneś szukać?
  • Czy unikasz pewnych rodzajów refaktoryzacji?
  • Czy istnieją narzędzia do refaktoryzacji testów?

Edycja: Napisałem nowe pytanie, w którym zadałem pytanie , co chciałem zadać (ale zachowałem to jako ciekawy wariant).

Alex Feinman
źródło
7
Myślałem, że w TDD pierwszym krokiem w refaktoryzacji jest napisanie testu, który się nie powiedzie, a następnie refaktoryzacja kodu, aby działał.
Matt Ellen,
Czy twoje IDE nie może również dowiedzieć się, jak refaktoryzować testy?
@ Thorbjørn Ravn Andersen, tak, i napisałem nowe pytanie, które zadało to, co chciałem zadać (ale zachowałem ten jako interesujący wariant; zobacz odpowiedź azheglova, która jest zasadniczo tym, co mówisz)
Alex Feinman
Czy rozważałeś dodanie Thar Info do tego pytania?

Odpowiedzi:

35

To, co próbujesz zrobić, nie jest tak naprawdę refaktoryzacją. Dzięki refaktoryzacji z definicji nie zmieniasz tego, co robi twoje oprogramowanie, zmieniasz, jak to robi.

Rozpocznij od wszystkich zielonych testów (wszystkie zaliczenia), a następnie dokonaj modyfikacji „pod maską” (np. Przenieś metodę z klasy pochodnej do bazy, wyodrębnij metodę lub obuduj kompozyt za pomocą Konstruktora itp.). Twoje testy wciąż powinny przejść.

To, co opisujesz, wydaje się nie refaktoryzować, ale przeprojektować, co również zwiększa funkcjonalność testowanego oprogramowania. TDD i refaktoryzacja (jak próbowałem to tutaj zdefiniować) nie są w konflikcie. Nadal możesz refaktoryzować (zielono-zielono) i zastosować TDD (czerwono-zielony), aby rozwinąć funkcjonalność „delta”.

azheglov
źródło
7
Ten sam kod X skopiował 15 miejsc. Dostosowane w każdym miejscu. Stajesz się wspólną biblioteką i parametryzujesz X lub używasz wzorca strategii, aby uwzględnić te różnice. Gwarantuję, że testy jednostkowe dla X nie powiodą się. Klienci X nie będą działać, ponieważ interfejs publiczny nieznacznie się zmieni. Przeprojektowanie czy refaktoryzacja? Nazywam to refaktorem, ale tak czy inaczej psuje różne rzeczy. Najważniejsze jest to, że nie można refaktoryzować, jeśli nie wiesz dokładnie, jak to wszystko pasuje do siebie. Następnie ustalanie testów jest żmudne, ale ostatecznie trywialne.
Kevin
3
Jeśli testy wymagają ciągłego dostosowywania, prawdopodobnie jest to wskazówka posiadania zbyt szczegółowych testów. Załóżmy na przykład, że fragment kodu musi wyzwalać zdarzenia A, B i C w pewnych okolicznościach, bez określonej kolejności. Stary kod robi to w kolejności ABC i testy oczekują zdarzeń w tej kolejności. Jeśli refaktoryzowany kod wyrzuca zdarzenia w kolejności ACB, nadal działa zgodnie ze specyfikacją, ale test się nie powiedzie.
otto
3
@Kevin: Uważam, że to, co opisujesz, to przeprojektowanie, ponieważ zmienia się interfejs publiczny. Definicja refaktoryzacji Fowlera („zmiana struktury wewnętrznej [kodu] bez zmiany jej zachowania zewnętrznego”) jest dość jasna.
azheglov
3
@azheglov: może, ale z mojego doświadczenia wynika, że ​​jeśli implementacja jest zła, to jest też interfejs
Kevin
2
Idealnie jasne i jasne pytanie kończy się dyskusją na temat „znaczenia słowa”. Kogo to obchodzi, jak to nazwiesz, porozmawiajmy gdzie indziej. Tymczasem w tej odpowiedzi całkowicie pomija się jakąkolwiek prawdziwą odpowiedź, ale jak dotąd ma najwięcej głosów pozytywnych. Rozumiem, dlaczego ludzie nazywają TDD religią.
Dirk Boer
21

Jedną z zalet posiadania testów jednostkowych jest to, że możesz pewnie refaktoryzować.

Jeśli refaktoryzacja nie zmieni interfejsu publicznego, wówczas pozostawiasz testy jednostkowe w niezmienionej postaci i upewniasz się, że po refaktoryzacji wszystkie przejdą pomyślnie.

Jeśli refaktoryzacja zmieni interfejs publiczny, najpierw należy przepisać testy. Refaktoryzuj do momentu przejścia nowych testów.

Nigdy nie uniknęłbym żadnego refaktoryzacji, ponieważ to psuje testy. Pisanie testów jednostkowych może być kłopotem w tyłku, ale na dłuższą metę jest warte bólu.

Tim Murphy
źródło
7

W przeciwieństwie do innych odpowiedzi, należy zauważyć, że niektóre sposoby testowania mogą stać się kruche, gdy testowany system (SUT) zostanie zrefaktoryzowany, jeśli test jest whitebox.

Jeśli korzystam z frameworka, który weryfikuje kolejność metod wywoływanych w próbkach (gdy kolejność jest nieistotna, ponieważ wywołania są wolne od efektów ubocznych); wtedy jeśli mój kod jest czystszy z tymi wywołaniami metod w innej kolejności, a ja dokonam refaktoryzacji, mój test się zepsuje. Ogólnie, makiety mogą wprowadzać kruchość do testów.

Jeśli sprawdzam wewnętrzny stan mojego SUT, ujawniając jego prywatnych lub chronionych członków (moglibyśmy użyć „przyjaciela” w Visual Basic lub eskalować poziom dostępu „wewnętrzny” i użyć „internalsvisibleto” w języku c #; w wielu językach OO, w tym c # mogłaby zostać użyta „ podklasa specyficzna dla testu ”), wtedy nagle stan wewnętrzny klasy będzie miał znaczenie - być może refaktoryzujesz klasę jako czarną skrzynkę, ale testy białej skrzynki zakończą się niepowodzeniem. Załóżmy, że jedno pole jest ponownie używane do oznaczania różnych rzeczy (nie jest to dobra praktyka!), Gdy stan SUT zmienia się - jeśli podzielimy je na dwa pola, może być konieczne przepisanie zepsutych testów.

Podklasy specyficzne dla testu można również wykorzystać do testowania metod chronionych - co może oznaczać, że refaktor z punktu widzenia kodu produkcyjnego jest przełomową zmianą z punktu widzenia kodu testowego. Przeniesienie kilku linii do lub z chronionej metody może nie wywoływać efektów ubocznych, ale przerwać test.

Jeśli użyjęhaków testowych ” lub jakiegokolwiek innego kodu kompilacyjnego specyficznego dla testu lub warunkowego, może być trudno zapewnić, że testy nie zostaną przerwane z powodu delikatnych zależności od wewnętrznej logiki.

Aby zapobiec sprzężeniu testów z intymnymi wewnętrznymi szczegółami SUT, pomocne może być:

  • W miarę możliwości używaj kodów pośredniczących zamiast próbnych. Aby uzyskać więcej informacji, zobacz blog Fabio Periera na temat testów tautologicznych oraz mój blog na temat testów tautologicznych .
  • Jeśli korzystasz z prób, unikaj sprawdzania kolejności wywoływanych metod, chyba że jest to ważne.
  • Staraj się unikać sprawdzania wewnętrznego stanu SUT - w miarę możliwości używaj zewnętrznego API.
  • Staraj się unikać logiki specyficznej dla testu w kodzie produkcyjnym
  • Staraj się unikać używania podklas specyficznych dla testu.

Wszystkie powyższe punkty są przykładami sprzężenia białych skrzynek zastosowanych w testach. Aby całkowicie uniknąć refaktoryzacji testów niszczenia, należy zastosować testowanie SUT w czarnej skrzynce.

Zastrzeżenie: W celu omówienia refaktoryzacji używam tego słowa nieco szerzej, aby uwzględnić zmianę implementacji wewnętrznej bez widocznych efektów zewnętrznych. Niektórzy puryści mogą się nie zgadzać i odnoszą się wyłącznie do książki Martina Fowlera i Kenta Becka Refaktoryzacja - która opisuje operacje refaktoryzacji atomowej.

W praktyce staramy się podejmować nieco większe niełamliwe kroki niż opisane tam operacje atomowe, aw szczególności zmiany, które powodują, że kod produkcyjny zachowuje się identycznie z zewnątrz, mogą nie pozostawić pozytywnych wyników testów. Ale myślę, że sprawiedliwym jest włączenie „algorytmu zastępczego do innego algorytmu, który ma identyczne zachowanie” jako refaktora, i myślę, że Fowler się z tym zgadza. Sam Martin Fowler mówi, że refaktoryzacja może przerwać testy:

Kiedy piszesz test mockist, testujesz połączenia wychodzące z SUT, aby upewnić się, że poprawnie rozmawia z dostawcami. Klasyczny test dba tylko o stan końcowy - nie o to, jak ten stan został uzyskany. Testy próbne są zatem bardziej powiązane z wdrożeniem metody. Zmiana charakteru połączeń do współpracowników zwykle powoduje przerwanie testu kpiny.

[...]

Sprzężenie z implementacją przeszkadza również w refaktoryzacji, ponieważ zmiany w implementacji znacznie częściej psują testy niż w przypadku testów klasycznych.

Fowler - Kpiny nie są stubami

perfekcjonista
źródło
Fowler dosłownie napisał książkę o refaktoryzacji; a najbardziej autorytatywna książka na temat testowania jednostek (xUnit Test Patterns autorstwa Gerarda Meszarosa) znajduje się w serii „podpisów” Fowlera, więc kiedy mówi, że refaktoryzacja może przerwać test, prawdopodobnie ma rację.
perfekcjonista
5

Jeśli twoje testy zostaną przerwane podczas refaktoryzacji, to z definicji nie jest to refaktoryzacja, która oznacza „zmianę struktury programu bez zmiany jego zachowania”.

Czasami musisz zmienić zachowanie swoich testów. Być może musisz połączyć ze sobą dwie metody (powiedzmy, bind () i listen () na nasłuchującej klasie gniazda TCP), więc inne części twojego kodu próbują i nie używają teraz zmienionego API. Ale to nie jest refaktoryzacja!

Frank Shearar
źródło
Co jeśli zmieni tylko nazwę metody testowanej przez testy? Testy zakończą się niepowodzeniem, chyba że zmienisz również ich nazwę w testach. Tutaj nie zmienia zachowania programu.
Oscar Mederos
2
W takim przypadku jego testy również są refaktoryzowane. Musisz jednak zachować ostrożność: najpierw zmień nazwę metody, a następnie uruchom test. Powinien zawieść z właściwych powodów (nie można go skompilować (C #), pojawia się wyjątek MessageNotUnderstood (Smalltalk), nic się nie dzieje (wzorzec zerowego jedzenia Objective-C)). Następnie zmieniasz test, wiedząc, że przypadkowo nie wprowadziłeś żadnego błędu. Innymi słowy, „jeśli testy się psują” oznacza „jeśli testy się psują po zakończeniu refaktoryzacji”. Staraj się, aby fragmenty zmian były małe!
Frank Shearar
1
Testy jednostkowe są nieodłącznie związane ze strukturą kodu. Na przykład Fowler ma wiele w refactoring.com/catalog, które miałyby wpływ na testy jednostkowe (np. Metoda ukrywania, metoda wbudowana, zamiana kodu błędu na wyjątek itp.).
Kristian H
fałszywe. Połączenie dwóch metod razem jest oczywiście refaktoryzacją, która ma oficjalne nazwy (np. Refaktoryzacja metody inline pasuje do definicji) i przerwie testy metody, która jest wbudowana - niektóre przypadki testowe powinny teraz zostać przepisane / przetestowane w inny sposób. Nie muszę zmieniać zachowania programu, aby przerwać testy jednostkowe, wystarczy zrestrukturyzować elementy wewnętrzne, które mają połączone testy jednostkowe. Tak długo, jak zachowanie programu nie uległo zmianie, nadal odpowiada definicji refaktoryzacji.
KolA
Napisałem powyższe, zakładając dobrze napisane testy: jeśli testujesz swoją implementację - jeśli struktura testu odzwierciedla elementy wewnętrzne testowanego kodu, na pewno. W takim przypadku sprawdź umowę jednostki, a nie jej wdrożenie.
Frank Shearar
4

Myślę, że problem z tym pytaniem polega na tym, że różni ludzie inaczej interpretują słowo „refaktoryzacja”. Myślę, że najlepiej jest dokładnie zdefiniować kilka rzeczy, które prawdopodobnie masz na myśli:

>  Keep the API the same, but change how the API is implemented internally
>  Change the API

Jak zauważyła już inna osoba, jeśli utrzymujesz interfejs API bez zmian, a wszystkie testy regresji działają na publicznym interfejsie API, nie powinieneś mieć problemów. Refaktoryzacja nie powinna powodować żadnych problemów. Wszelkie nieudane testy EITHER oznaczają, że Twój stary kod zawierał błąd, a test jest zły lub nowy kod zawiera błąd.

Ale to całkiem oczywiste. PRAWDOPODOBNIE rozumiesz przez refaktoryzację, że zmieniasz interfejs API.

Pozwól, że odpowiem, jak do tego podejść!

  • Najpierw utwórz NOWY interfejs API, który będzie działał tak, jak chcesz. Jeśli zdarzy się, że ten nowy interfejs API ma taką samą nazwę jak interfejs OLDER API, wówczas dołączam nazwę _NEW do nowej nazwy interfejsu API.

    int DoSomethingInterestingAPI ();

staje się:

int DoSomethingInterestingAPI_NEW( int takes_more_arguments );
int DoSomethingInterestingAPI_OLD();
int DoSomethingInterestingAPI() { DoSomethingInterestingAPI_NEW (whatever_default_mimics_the_old_API);

OK - na tym etapie - wszystkie twoje testy regresyjne przechodzą pomyślnie - używając nazwy DoSomethingInterestingAPI ().

NASTĘPNIE, przejrzyj kod i zmień wszystkie wywołania DoSomethingInterestingAPI () na odpowiedni wariant DoSomethingInterestingAPI_NEW (). Obejmuje to aktualizację / przepisywanie dowolnych części testów regresji, które należy zmienić, aby korzystać z nowego interfejsu API.

NASTĘPNY, oznacz DoSomethingInterestingAPI_OLD () jako [[przestarzałe ()]]. Trzymaj się przestarzałego interfejsu API tak długo, jak chcesz (dopóki nie zaktualizujesz bezpiecznie całego kodu, który może od niego zależeć).

Przy takim podejściu wszelkie niepowodzenia w testach regresji są po prostu błędami w teście regresji lub identyfikują błędy w kodzie - dokładnie tak, jak byś tego chciał. Ten etapowy proces przeglądu interfejsu API poprzez jawne tworzenie wersji API _NEW i _OLD pozwala na współdziałanie przez pewien czas nowego i starego kodu.

Lewis Pringle
źródło
Podoba mi się ta odpowiedź, ponieważ pokazuje, że testy jednostkowe dla SUT są takie same jak klienci zewnętrzni dla opublikowanego interfejsu API. To, co przepisujesz, jest bardzo podobne do protokołu SemVer do zarządzania publikowaną biblioteką / komponentem w celu uniknięcia „piekła zależności”. Jest to jednak kosztowne pod względem czasu i elastyczności, ekstrapolacja tego podejścia do interfejsu publicznego każdej mikro jednostki oznacza także ekstrapolację kosztów. Bardziej elastycznym podejściem jest jak największe oddzielenie testów od implementacji, tj. Testowanie integracji lub osobna DSL do opisywania wejść i wyjść
testowych
1

Zakładam, że twoje testy jednostkowe mają szczegółowość, którą nazwałbym „głupimi” :) tj. Testują absolutne minucje każdej klasy i funkcji. Odejdź od narzędzi do generowania kodu i pisz testy, które dotyczą większej powierzchni, a następnie możesz refaktoryzować elementy wewnętrzne tak, jak chcesz, wiedząc, że interfejsy do aplikacji nie uległy zmianie, a twoje testy nadal działają.

Jeśli chcesz mieć testy jednostkowe, które sprawdzają każdą metodę, spodziewaj się, że będziesz musiał je jednocześnie refaktoryzować.

gbjbaanb
źródło
1
Najbardziej użyteczna odpowiedź, która faktycznie odpowiada na pytanie - nie buduj pokrycia testowego na chwiejnych podstawach wewnętrznych ciekawostek, ani nie oczekuj, że będzie się ciągle rozpadać - ale najbardziej odrzucona, ponieważ TDD nakazuje robić dokładnie odwrotnie. To właśnie dostajesz za wskazanie niewygodnej prawdy na temat przesadnego podejścia.
KolA
1

utrzymywanie pakietu testowego w synchronizacji z bazą kodu podczas i po refaktoryzacji

Trudno jest sprzęgać . Wszelkie testy mają pewien stopień sprzężenia ze szczegółami implementacji, ale testy jednostkowe (niezależnie od tego, czy jest to TDD, czy nie) są szczególnie złe, ponieważ zakłócają wewnętrzne: więcej testów jednostkowych oznacza więcej kodów połączonych z jednostkami, tj. Podpisy metod / dowolny inny interfejs publiczny jednostek - przynajmniej.

„Jednostki” z definicji są szczegółami implementacji niskiego poziomu, interfejs jednostek może i powinien się zmieniać / dzielić / scalać i w inny sposób mutować w miarę ewolucji systemu. Mnóstwo testów jednostkowych może w rzeczywistości bardziej utrudnić tę ewolucję, niż to pomaga.

Jak uniknąć przełamywania testów podczas refaktoryzacji? Unikaj sprzężenia. W praktyce oznacza to unikanie jak największej liczby testów jednostkowych i preferowanie testów wyższego poziomu / integracji bardziej niezależnych od szczegółów implementacji. Pamiętaj jednak, że nie ma srebrnej kuli, testy nadal muszą być powiązane z czymś na pewnym poziomie, ale idealnie powinien to być interfejs, który jest jawnie wersjonowany przy użyciu Wersji semantycznej, tj. Zwykle na opublikowanym poziomie interfejsu API / aplikacji (nie chcesz robić SemVer dla każdej jednostki w twoim rozwiązaniu).

KolA
źródło
0

Twoje testy są zbyt ściśle powiązane z implementacją, a nie z wymaganiami.

zastanów się nad napisaniem testów z takimi komentarzami:

//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...

w ten sposób nie można refaktoryzować znaczenia poza testami.

mcintyre321
źródło