Po co pisać testy kodu, który będę refaktoryzować?

15

Refaktoryzuję ogromną klasę kodu starszego typu. Refaktoryzacja (jak sądzę) zaleca:

  1. pisz testy dla starszych klas
  2. refaktorem do cholery z klasy

Problem: po ponownym złożeniu klasy moje testy w kroku 1 będą musiały zostać zmienione. Na przykład to, co kiedyś było starszą metodą, teraz może być osobną klasą. To, co było jedną metodą, może być teraz kilkoma metodami. Cały krajobraz klasy starszej może zostać zatarty w coś nowego, więc testy, które piszę w kroku 1, będą prawie nieważne. Zasadniczo dodam krok 3. obficie przepisz moje testy

Jaki jest zatem cel napisania testów przed refaktorem? To brzmi bardziej jak ćwiczenie akademickie polegające na tworzeniu większej ilości pracy dla siebie. Piszę teraz testy tej metody i dowiaduję się więcej o tym, jak testować rzeczy i jak działa starsza metoda. Można się tego nauczyć po prostu czytając sam starszy kod, ale pisanie testów jest prawie jak wcieranie w niego nosa, a także dokumentowanie tej tymczasowej wiedzy w osobnych testach. W ten sposób prawie nie mam wyboru, muszę się dowiedzieć, co robi kod. Powiedziałem tutaj tymczasowo, ponieważ zrefakturuję kod, a cała moja dokumentacja i testy będą znaczące i nieważne, z wyjątkiem mojej wiedzy, która pozostanie i pozwoli mi być świeższym w refaktoryzacji.

Czy to jest prawdziwy powód, aby pisać testy przed refaktoryzacją - aby pomóc mi lepiej zrozumieć kod? Musi być inny powód!

Proszę wytłumacz!

Uwaga:

Jest taki post: Czy warto pisać testy dla starszego kodu, gdy nie ma czasu na pełne refaktoryzowanie? ale mówi „napisz testy przed refaktorem”, ale nie mówi „dlaczego” ani co zrobić, jeśli „pisanie testów” wydaje się być „pracowitą pracą, która wkrótce zostanie zniszczona”

Dennis
źródło
1
Twoje założenie jest nieprawidłowe. Nie zmienisz swoich testów. Będziesz pisać nowe testy. Krok 3 będzie polegał na „usunięciu wszelkich niedziałających testów”.
pdr
1
W kroku 3 można następnie przeczytać „Napisz nowe testy. Usuń nieistniejące testy”. Myślę, że nadal sprowadza się to do zniszczenia oryginalnego dzieła
Dennis
3
Nie, chcesz napisać nowe testy podczas kroku 2. I tak, krok 1 jest zniszczony. Ale czy to strata czasu? Nie, ponieważ daje to mnóstwo pewności, że nic nie psujesz podczas kroku 2. Twoje nowe testy nie.
pdr
3
@Dennis - choć podzielam te same obawy co do sytuacji, możemy uznać, że większość działań związanych z refaktoryzacją to „zniszczenie oryginalnego dzieła”, ale gdybyśmy go nigdy nie zniszczyli, nigdy nie odeszlibyśmy od kodu spaghetti z 10 000 wierszy w jednym plik. To samo powinno prawdopodobnie dotyczyć testów jednostkowych, idą w parze z testowanym kodem. Gdy kod ewoluuje, a rzeczy są przenoszone i / lub usuwane, wraz z nim powinny ewoluować testy jednostkowe.
DXM,
„Zrozumienie kodu” jest niemałą zaletą. Jak spodziewasz się refaktoryzacji programu, którego nie rozumiesz? Jest to nieuniknione i nie ma lepszego sposobu na wykazanie prawdziwego zrozumienia programu niż napisanie dokładnego testu. Należy również powiedzieć, że im bardziej abstrakcyjne są testy, tym mniejsze jest prawdopodobieństwo, że będziesz musiał je później podrapać, więc jeśli już, to najpierw trzymaj się testów wysokiego poziomu.
Neil

Odpowiedzi:

46

Refaktoryzacja polega na usunięciu fragmentu kodu (np. Poprawie stylu, projektu lub algorytmów) bez zmiany (widocznego z zewnątrz) zachowania. Piszecie testy, aby upewnić się, że kod przed i po refaktoryzacji jest taki sam, zamiast tego piszecie testy jako wskaźnik tego, że wasza aplikacja przed i po refaktoryzacji zachowuje się tak samo: Nowy kod jest kompatybilny i nie wprowadzono żadnych nowych błędów.

Twoim głównym celem powinno być napisanie testów jednostkowych publicznego interfejsu oprogramowania. Ten interfejs nie powinien ulec zmianie, więc testy (które są automatycznym sprawdzaniem tego interfejsu) również nie powinny się zmienić.

Jednak testy są również przydatne do lokalizowania błędów, więc warto również napisać testy dla prywatnych części oprogramowania. Oczekuje się, że testy te zmienią się podczas refaktoryzacji. Jeśli chcesz zmienić szczegół implementacji (np. Nazewnictwo funkcji prywatnej), najpierw zaktualizuj testy, aby odzwierciedlić zmienione oczekiwania, a następnie upewnij się, że test się nie powiedzie (Twoje oczekiwania nie są spełnione), a następnie zmień rzeczywisty kod i sprawdź, czy wszystkie testy przeszły ponownie. W żadnym momencie testy interfejsu publicznego nie powinny zakończyć się niepowodzeniem.

Jest to trudniejsze, gdy wykonuje się zmiany na większą skalę, np. Przeprojektowując wiele współzależnych części. Ale będzie jakaś granica i na tej granicy będziesz mógł pisać testy.

amon
źródło
6
+1. Przeczytaj w myślach, napisałem odpowiedź. Ważna uwaga: może być konieczne napisanie testów jednostkowych, aby pokazać, że te same błędy nadal występują po refaktoryzacji!
david.pfx
Pytanie: dlaczego w przykładzie zmiany nazwy funkcji najpierw zmieniasz test, aby upewnić się, że się nie powiedzie? Chcę powiedzieć, że oczywiście to się nie powiedzie, kiedy go zmienisz - przerwałeś połączenie, którego łączą się, aby powiązać kod! Czy może spodziewasz się, że może istnieć inna prywatna funkcja o nazwie, którą właśnie wybrałeś, i musisz sprawdzić, czy tak nie jest w przypadku, gdy ją przegapiłeś? Widzę, że daje to pewną pewność graniczącą z OCD, ale w tym przypadku wydaje się, że to przesada. Czy jest jakiś możliwy powód, dla którego test w twoim przykładzie się nie powiedzie?
Dennis
^ cont: jako ogólna technika widzę, że dobrze jest sprawdzać poprawność kodu krok po kroku, aby jak najszybciej wykryć błędy. To tak, jakbyś nie zachorował, jeśli nie myjesz rąk za każdym razem, ale samo mycie rąk jako nawyk sprawi, że będziesz ogólnie zdrowszy, niezależnie od tego, czy wejdziesz w kontakt z zanieczyszczonymi rzeczami, czy nie. W tym miejscu możesz czasami niepotrzebnie myć ręce lub czasami niepotrzebnie testować kod, ale pomaga to zachować zdrowie twojego i twojego kodu. Czy o to ci chodziło?
Dennis
@Dennis w rzeczywistości nieświadomie opisywałem naukowo poprawny eksperyment: nie możemy powiedzieć, który parametr faktycznie wpłynął na wynik przy zmianie jednego parametru. Pamiętaj, że testy są kodami, a każdy kod zawiera błędy. Czy pójdziesz do piekła programisty za to, że nie uruchomiłeś testów przed dotknięciem kodu? Na pewno nie: podczas przeprowadzania testów byłoby idealnie, to Twoja profesjonalna ocena, czy jest to konieczne. Zauważ też, że test się nie powiódł, jeśli się nie skompiluje, i moja odpowiedź dotyczy również języków dynamicznych, a nie tylko języków statycznych z linkerem.
amon
2
Po naprawieniu różnych błędów podczas refaktoryzacji zdaję sobie sprawę, że nie przeprowadziłbym tak łatwo ruchów kodu bez testów. Testy ostrzegają mnie przed różnicami behawioralnymi / funkcjonalnymi, które wprowadzam poprzez zmianę kodu.
Dennis
7

Ach, utrzymanie starszych systemów.

Idealnie, jeśli twoje testy traktują klasę tylko poprzez interfejs z resztą bazy kodu, innymi systemami i / lub interfejsem użytkownika. Interfejsy Nie można dokonać refaktoryzacji interfejsu bez wpływu na te komponenty. Jeśli wszystko jest jednym ściśle powiązanym bałaganem, równie dobrze możesz rozważyć wysiłek raczej niż ponowne napisanie, a nie refaktoryzację, ale w dużej mierze jest to semantyka.

Edytować: Powiedzmy, że część twojego kodu coś mierzy i ma funkcję, która po prostu zwraca wartość. Jedynym interfejsem jest wywołanie funkcji / metody / whatnot i otrzymanie zwróconej wartości. Jest to luźne sprzęgło i łatwe do przetestowania w jednostce. Jeśli twój program główny ma podskładnik, który zarządza buforem, a wszystkie wywołania do niego zależą od samego bufora, niektórych zmiennych kontrolnych i odczytuje komunikaty o błędach przez inną sekcję kodu, możesz powiedzieć, że jest ściśle powiązany i jest trudny do testowania jednostkowego. Nadal możesz to zrobić za pomocą wystarczającej liczby próbnych obiektów i tym podobnych rzeczy, ale robi się bałagan. Zwłaszcza w c. Każda zmiana sposobu działania bufora spowoduje uszkodzenie podskładnika.
Zakończ edycję

Jeśli testujesz klasę za pomocą interfejsów, które pozostają stabilne, wówczas testy powinny być ważne przed refaktoryzacją i po niej. Dzięki temu możesz dokonywać zmian z pewnością, że go nie złamałeś. Przynajmniej więcej pewności siebie.

Umożliwia także wprowadzanie zmian przyrostowych. Jeśli jest to duży projekt, nie sądzę, że będziesz chciał po prostu to wszystko zburzyć, zbudować zupełnie nowy system, a następnie rozpocząć opracowywanie testów. Możesz zmienić jedną jego część, przetestować i upewnić się, że zmiana nie sprowadzi reszty systemu. A jeśli tak, to możesz zobaczyć, jak rozwija się gigantyczny splątany bałagan, zamiast być zaskoczonym jego uwolnieniem.

Chociaż możesz podzielić metodę na trzy, nadal będą robić to samo, co poprzednia metoda, więc możesz wykonać test starej metody i podzielić ją na trzy. Wysiłek związany z pisaniem pierwszego testu nie jest marnowany.

Traktowanie wiedzy o starszym systemie jako „wiedzy tymczasowej” nie pójdzie dobrze. Wiedza o tym, jak to poprzednio było, jest niezwykle ważna, jeśli chodzi o starsze systemy. Niezwykle przydatne dla odwiecznego pytania „dlaczego, do diabła, to robi?”

Philip
źródło
Myślę, że rozumiem, ale straciłeś mnie na interfejsach. tzn. testy, które piszę teraz, sprawdzają, czy niektóre zmienne zostały poprawnie wypełnione, po wywołaniu testowanej metody. Jeśli zmienne te zostaną zmienione lub zrefaktoryzowane, podobnie będą moje testy. Istniejąca starsza klasa, z którą pracuję, nie ma interfejsów / getterów / seterów na seh, co powodowałoby zmienne zmiany lub takie mniej pracochłonne. Ale znowu nie jestem pewien, co rozumiesz przez interfejsy, jeśli chodzi o starszy kod. Może uda mi się coś stworzyć? Ale to będzie refaktoryzacja.
Dennis
1
Tak, jeśli masz jedną boską klasę, która robi wszystko, to naprawdę nie ma żadnych interfejsów. Ale jeśli wywoła inną klasę, najwyższa klasa oczekuje, że zachowa się w określony sposób, a testy jednostkowe mogą to sprawdzić. Mimo to nie udawałbym, że nie będziesz musiał aktualizować testów jednostkowych podczas refaktoryzacji.
Philip
4

Moja własna odpowiedź / realizacja:

Po naprawieniu różnych błędów podczas refaktoryzacji zdaję sobie sprawę, że nie przeprowadziłbym tak łatwo ruchów kodu bez testów. Testy ostrzegają mnie przed różnicami behawioralnymi / funkcjonalnymi, które wprowadzam poprzez zmianę kodu.

Nie musisz być bardzo świadomy, gdy masz dobre testy. Możesz edytować kod w bardziej swobodny sposób. Testy przeprowadzają dla Ciebie weryfikację i kontrolę poczytalności.

Poza tym moje testy pozostały prawie takie same, jak w przypadku przebudowy i nie zostały zniszczone. Zauważyłem dodatkowe możliwości dodawania asercji do moich testów, gdy zagłębiałem się w kod.

AKTUALIZACJA

Cóż, teraz bardzo zmieniam swoje testy: / Ponieważ zrefakturowałem pierwotną funkcję na zewnątrz (usunąłem funkcję i zamiast tego utworzyłem nową czystszą klasę, przenosząc puch, który kiedyś był wewnątrz funkcji poza nową klasę), więc teraz testowany kod, który uruchomiłem wcześniej, przyjmuje różne parametry pod inną nazwą klasy i daje różne wyniki (oryginalny kod z puchem miał więcej wyników do przetestowania). A zatem moje testy muszą odzwierciedlać te zmiany i zasadniczo przepisuję moje testy w coś nowego.

Sądzę, że są inne rozwiązania, które mogę zrobić, aby uniknąć przepisywania testów. tzn. zachowaj starą nazwę funkcji z nowym kodem i zawartym w niej puchem ... ale nie wiem, czy to najlepszy pomysł i nie mam jeszcze zbyt dużego doświadczenia, aby dokonać oceny, co należy zrobić.

Dennis
źródło
Brzmi bardziej jak przeprojektowana aplikacja wraz z refaktoryzacją.
JeffO
Kiedy jest refaktoryzowany, a kiedy przeprojektowany? tzn. podczas refaktoryzacji trudno jest nie rozbijać większych nieporęcznych klas na mniejsze, a także przenosić je. Więc tak, nie jestem do końca pewien tego rozróżnienia, ale być może robię oba.
Dennis
3

Skorzystaj z testów, aby sterować kodem. W starszym kodzie oznacza to pisanie testów dla kodu, który zamierzasz zmienić. W ten sposób nie są osobnym artefaktem. Testy powinny dotyczyć tego, co kod musi osiągnąć, a nie wewnętrznych odwrotności tego, jak to robi.

Ogólnie rzecz biorąc, chcesz dodać testy kodu, który go nie ma) dla kodu idziesz do refaktoryzacji, aby upewnić się, że zachowanie kodu będzie działać zgodnie z oczekiwaniami. Zatem ciągłe uruchamianie zestawu testów podczas refaktoryzacji jest fantastyczną siatką bezpieczeństwa. Myśl o zmianie kodu bez zestawu testów w celu potwierdzenia, że ​​zmiany nie wpływają na coś nieoczekiwanego, jest przerażająca.

Jeśli chodzi o drobiazgową aktualizację starych testów, pisanie nowych testów, usuwanie starych testów itp. Po prostu widzę to jako część kosztów nowoczesnego profesjonalnego oprogramowania.

Michael Durrant
źródło
Twój pierwszy akapit wydaje się zalecać ignorowanie kroku 1 i pisanie testów w miarę jego przechodzenia; twój drugi akapit wydaje się temu zaprzeczać.
pdr
Zaktualizowałem moją odpowiedź.
Michael Durrant
2

Jaki jest cel refaktoryzacji w konkretnym przypadku?

Załóżmy, że w celu pogodzenia się z moją odpowiedzią wszyscy wierzymy (do pewnego stopnia) w TDD (rozwój oparty na testach).

Jeśli celem refaktoryzacji jest wyczyszczenie istniejącego kodu bez zmiany istniejącego zachowania, wówczas pisanie testów przed refaktoryzacją jest sposobem upewnienia się, że nie zmieniłeś zachowania kodu, jeśli ci się powiedzie, testy powiodą się zarówno przed, jak i po refaktoryzujesz.

  • Testy pomogą ci upewnić się, że nowa praca rzeczywiście działa.

  • Testy prawdopodobnie odkryją również przypadki, w których oryginalne dzieło nie działa.

Ale w jaki sposób naprawdę dokonujesz znaczącego refaktoryzacji bez wpływu na zachowanie niektórych osób stopniu ?

Oto krótka lista kilku rzeczy, które mogą się zdarzyć podczas refaktoryzacji:

  • zmień nazwę zmiennej
  • funkcja zmiany nazwy
  • dodaj funkcję
  • funkcja usuwania
  • funkcja podzielona na dwie lub więcej funkcji
  • połączyć dwie lub więcej funkcji w jedną funkcję
  • klasa podzielona
  • łączyć klasy
  • zmień nazwę klasy

Będę argumentować, że każde z wymienionych działań w pewien sposób zmienia zachowanie .

I będę argumentować, że jeśli twoje refaktoryzacja zmieni zachowanie, twoje testy nadal będą w taki sposób, jak upewnisz się, że nic nie zepsułeś.

Być może zachowanie nie zmienia się na poziomie makr, ale celem testów jednostkowych nie jest zapewnienie zachowania makr. To testy integracyjne . Celem testów jednostkowych jest upewnienie się, że poszczególne części, z których zbudujesz swój produkt, nie są zepsute. Łańcuch, najsłabsze ogniwo itp.

Co z tym scenariuszem:

  • Załóżmy, że masz function bar()

  • function foo() wykonuje połączenie z bar()

  • function flee() wywołuje również funkcję bar()

  • Tylko dla odmiany, flam()dzwoni dofoo()

  • Wszystko działa wspaniale (przynajmniej najwyraźniej).

  • Refaktoryzujesz ...

  • bar() zostaje przemianowany na barista()

  • flee() zmienia się na połączenie barista()

  • foo()się nie zmieniło na wezwaniebarista()

Oczywiście, twoje testy dla obu foo()i flam()teraz nie.

Może w ogóle nie zdawałeś sobie sprawy z foo()powołania bar(). Z pewnością nie zdawał sobie sprawy, że flam()była zależna bar()drodze foo().

Cokolwiek. Chodzi o to, że twoje testy ujawnią nowo złamane zachowanie obu foo()i flam(), stopniowo, podczas pracy nad refaktoryzacją.

Testy pomagają ci dobrze refaktoryzować.

Chyba że nie masz żadnych testów.

To trochę wymyślony przykład. Są tacy, którzy twierdzą, że jeśli zmienia się bar()przerwy foo(), to foo()był zbyt skomplikowany, aby zacząć i powinien zostać zepsuty. Ale procedury mogą wywoływać inne procedury z konkretnego powodu i nie można wyeliminować całej złożoności, prawda? Naszym zadaniem jest zarządzanie rozsądne złożonością.

Rozważ inny scenariusz.

Budujesz budynek.

Zbudujesz rusztowanie, aby upewnić się, że budynek jest poprawnie zbudowany.

Rusztowanie pomaga między innymi zbudować szyb windy. Następnie burzysz rusztowanie, ale szyb windy pozostaje. Zniszczyłeś „oryginalne dzieło”, niszcząc rusztowanie.

Analogia jest niepewna, ale chodzi o to, że nie jest niespotykane budowanie narzędzi, które pomogą Ci zbudować produkt. Nawet jeśli narzędzia nie są trwałe, są przydatne (nawet konieczne). Stolarze cały czas robią przyrządy, czasem tylko do jednej pracy. Następnie rozrywają przyrządy, czasem używając części do budowy innych przyrządów do innych zadań, a czasem nie. Ale to nie sprawia, że ​​przyrządy są bezużyteczne lub zmarnowane.

Craig
źródło