Jak napisać testy jednostkowe przed refaktoryzacją?

55

Przeczytałem kilka odpowiedzi na pytania o podobnej treści, na przykład „Jak utrzymać testy jednostkowe podczas refaktoryzacji?”. W moim przypadku scenariusz jest nieco inny, ponieważ otrzymałem projekt do przeglądu i dostosowania go do niektórych standardów, które obecnie posiadamy, obecnie nie ma żadnych testów dla tego projektu!

Zidentyfikowałem szereg rzeczy, które moim zdaniem można by zrobić lepiej, takich jak NIE mieszanie kodu typu DAO w warstwie usługowej.

Przed refaktoryzacją dobrym pomysłem było napisanie testów dla istniejącego kodu. Wydaje mi się, że problem polega na tym, że kiedy dokonam refaktoryzacji, testy te zostaną przerwane, ponieważ zmieniam miejsce, w którym wykonywana jest pewna logika, a testy będą pisane z myślą o poprzedniej strukturze (wyśmiewane zależności itp.)

W moim przypadku jaki byłby najlepszy sposób postępowania? Kusi mnie, aby napisać testy wokół refaktoryzowanego kodu, ale jestem świadomy, że istnieje ryzyko, że mogę niepoprawnie refaktoryzować rzeczy, które mogłyby zmienić pożądane zachowanie.

Niezależnie od tego, czy jest to refaktoryzacja, czy przeprojektowanie, cieszę się, że rozumiem te warunki, które mają zostać poprawione, obecnie pracuję nad następującą definicją refaktoryzacji „Dzięki refaktoryzacji z definicji nie zmieniasz tego, co robi twoje oprogramowanie, zmieniasz, jak to się robi. ”. Więc nie zmieniam tego, co robi oprogramowanie, zmieniam, jak / gdzie to robi.

Równie dobrze widzę argument, że jeśli zmieniam podpis metod, które można by uznać za przeprojektowanie.

Oto krótki przykład

MyDocumentService.java (obecny)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

MyDocumentService.java (przebudowane / przeprojektowane cokolwiek)

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      //Code dealing with DataResultSet moved back up to DAO
      //DAO now returns a List<Document> instead of a DataResultSet
      return documentDAO.findAllDocuments();
   }
}
PDStat
źródło
14
Czy to naprawdę planujesz dokonać refaktoryzacji , czy przeprojektować ? Ponieważ odpowiedź może być inna w obu przypadkach.
herby
4
Pracuję nad definicją „Dzięki refaktoryzacji z definicji nie zmieniasz tego, co robi twoje oprogramowanie, zmieniasz, jak to robi”. Więc wierzę, że w tym przypadku jest to refaktoryzacja, nie
wahaj
21
Nie pisz testów integracyjnych. Planowane „refaktoryzacja” jest powyżej poziomu testów jednostkowych. Testuj tylko nowe klasy (lub te, o których wiesz, że je przechowujesz).
Stop Harming Monica
2
Czy w odniesieniu do definicji refaktoryzacji oprogramowanie jasno określa, co robi? Innymi słowy, czy jest już „podzielony” na moduły z niezależnymi interfejsami API? Jeśli nie, to nie możesz go refaktoryzować, chyba że na najwyższym poziomie (zorientowanym na użytkownika). Na poziomie modułu nieuchronnie będziesz go przeprojektowywać. W takim przypadku nie marnuj czasu na pisanie testów jednostkowych, zanim będziesz mieć jednostki.
Kevin Krumwiede
4
Najprawdopodobniej będziesz musiał zrobić trochę refaktoryzacji bez siatki bezpieczeństwa testów, aby móc dostać się do uprzęży testowej. Najlepszą radą, jaką mogę ci dać, jest to, że jeśli twoje IDE lub narzędzie do refaktoryzacji nie zrobi tego za ciebie, nie rób tego ręcznie. Stosuj zautomatyzowane refaktoryzacje, aż będziesz mógł umieścić CUT w uprzęży. Na pewno będziesz chciał odebrać kopię „Efektywnej pracy ze starszym kodem” Michaela Feathera.
RubberDuck

Odpowiedzi:

56

Szukasz testów sprawdzających regresje . tj. łamanie niektórych istniejących zachowań. Zacznę od ustalenia, na jakim poziomie to zachowanie pozostanie takie samo, a interfejs sterujący tym zachowaniem pozostanie taki sam, i zacznę testować w tym momencie.

Masz teraz kilka testów, które potwierdzą, że cokolwiek zrobisz poniżej tego poziomu, twoje zachowanie pozostanie takie samo.

Masz całkowitą rację, kwestionując sposób, w jaki testy i kod mogą być zsynchronizowane. Jeśli twój interfejs do komponentu pozostaje taki sam, możesz napisać wokół niego test i zapewnić te same warunki dla obu implementacji (podczas tworzenia nowej implementacji). Jeśli tak nie jest, musisz zaakceptować, że test nadmiarowego komponentu jest testem nadmiarowym.

Brian Agnew
źródło
1
Viz, prawdopodobnie przeprowadzasz testy integracyjne lub systemowe zamiast testów jednostkowych. Prawdopodobnie nadal będziesz używał do tego narzędzia „testowania jednostkowego”, ale przy każdym teście trafisz więcej niż jedną jednostkę kodu.
Móż
Tak. Tak właśnie jest. Twój test regresji może równie dobrze robić coś bardzo wysokiego, np. Żądania REST do serwera i być może kolejny test bazy danych (tj. Zdecydowanie nie jest to test jednostkowy !)
Brian Agnew
40

Zalecaną praktyką jest zacząć od napisania „szczegółowych testów”, które testują bieżące zachowanie kodu, być może włączając w to błędy, ale bez konieczności schodzenia w szaleństwo rozróżniania, czy dane zachowanie, które narusza wymagania dokumentów, jest błędem, obejście problemu, którego nie znasz, lub stanowi nieudokumentowaną zmianę wymagań.

Najbardziej sensowne jest, aby te szczegółowe testy były na wysokim poziomie, tj. Testy integracyjne, a nie jednostkowe, aby działały po rozpoczęciu refaktoryzacji.

Ale niektóre refaktoryzacje mogą być konieczne, aby kod był testowalny - po prostu uważaj, aby trzymać się „bezpiecznych” refaktoryzacji. Na przykład w prawie wszystkich przypadkach metody, które są prywatne, można upublicznić, nie niszcząc niczego.

Michael Borgwardt
źródło
+1 za testy integracyjne. W zależności od aplikacji możesz zacząć od poziomu wysyłania żądań do aplikacji internetowej. To, co aplikacja odsyła, nie powinno się zmienić tylko z powodu refaktoryzacji, chociaż jeśli odsyła HTML, jest to z pewnością mniej testowalne.
jpmc26
Podoba mi się test „pin-down”.
Brian Agnew
12

Sugeruję - jeśli jeszcze tego nie zrobiłeś - przeczytanie zarówno Efektywnej pracy ze starszym kodem, jak i Refaktoryzacja - Poprawa projektu istniejącego kodu .

[..] Wydaje mi się, że problem polega na tym, że kiedy dokonam refaktoryzacji, testy te zostaną przerwane, ponieważ zmieniam miejsce, w którym wykonywana jest pewna logika, a testy będą pisane z myślą o poprzedniej strukturze (wyśmiewane zależności itp.) [ ..]

Niekoniecznie postrzegam to jako problem: napisz testy, zmień strukturę kodu, a następnie dostosuj również strukturę testu . Dzięki temu uzyskasz bezpośrednią informację zwrotną, czy twoja nowa struktura jest rzeczywiście lepsza niż stara, ponieważ jeśli tak, to skorygowane testy będą łatwiejsze do napisania (a zatem zmiana testów powinna być stosunkowo prosta, zmniejszając ryzyko nowo wprowadzonego błąd zdał testy).

Ponadto, jak już napisali inni: Nie pisz zbyt szczegółowych testów (przynajmniej nie na początku). Staraj się pozostać na wysokim poziomie abstrakcji (dlatego twoje testy będą prawdopodobnie lepiej scharakteryzowane jako testy regresji, a nawet testy integracji).

Daniel Jour
źródło
1
To. Testy będą wyglądały okropnie , ale będą obejmować istniejące zachowanie. Następnie, gdy kod jest refaktoryzowany, wykonaj testy w kroku blokady. Powtarzaj, aż będziesz mieć coś, z czego możesz być dumny. ++
RubberDuck
1
Popieram oba te rekomendacje książek - zawsze mam je pod ręką, gdy mam do czynienia z kodem bez testowania.
Toby Speight
5

Nie pisz ścisłych testów jednostkowych, w których kpisz ze wszystkich zależności. Niektórzy powiedzą, że to nie są prawdziwe testy jednostkowe. Ignoruj ​​ich. Te testy są przydatne i to jest ważne.

Spójrzmy na twój przykład:

public class MyDocumentService {
   ...
   public List<Document> findAllDocuments() {
      DataResultSet rs = documentDAO.findAllDocuments();
      List<Document> documents = new ArrayList<>();
      for(DataObject do: rs.getRows()) {
         //get row data create new document add it to 
         //documents list
      }

      return documents;
   }
}

Twój test prawdopodobnie wygląda mniej więcej tak:

DocumentDao documentDao = Mock.create(DocumentDao.class);
Mock.when(documentDao.findAllDocuments())
    .thenReturn(DataResultSet.create(...))
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Zamiast kpić z Documentexe, kpić z jego zależności:

DocumentDao documentDao = new DocumentDao(db);
Mock.when(db...)
    .thenReturn(...)
assertEquals(..., new MyDocumentService(documentDao).findAllDocuments());

Teraz można przejść od logiki MyDocumentServicedo DocumentDaobez łamania testów. Testy pokażą, że funkcjonalność jest taka sama (o ile ją przetestowałeś).

Winston Ewert
źródło
Jeśli testujesz usługę DocumentService i nie wyśmiewasz DAO, to wcale nie jest test jednostkowy. Jest to coś pomiędzy testem jednolitym a testem integracyjnym. Czyż nie?
LAIV
7
@Laiv, istnieje duża różnorodność sposobu, w jaki ludzie używają terminu test jednostkowy. Niektórzy używają go w znaczeniu tylko ściśle izolowanych testów. Inne obejmują każdy test, który uruchamia się szybko. Niektóre zawierają wszystko, co działa w środowisku testowym. Ale ostatecznie nie ma znaczenia, jak chcesz zdefiniować termin test jednostkowy. Pytanie brzmi, jakie testy są przydatne, więc nie powinniśmy się rozpraszać tym, jak dokładnie definiujemy test jednostkowy.
Winston Ewert
Znakomity punkt, który pokazuje, że najważniejsza jest użyteczność. Ekstrawaganckie testy jednostkowe dla najbardziej trywialnych algorytmów tylko po to, aby testy jednostkowe przyniosły więcej szkody niż pożytku, jeśli nie tylko ogromną stratę czasu i cennych zasobów. Można to zastosować do niemal wszystkiego i jest to coś, o czym chciałbym wiedzieć wcześniej.
Lee
3

Jak mówisz, jeśli zmienisz zachowanie, będzie to transformacja, a nie refaktor. Na jakim poziomie zmieniasz zachowanie, to robi różnicę.

Jeśli nie ma formalnych testów na najwyższym poziomie, spróbuj znaleźć zestaw wymagań, które klienci (wywołujący kod lub ludzie) muszą pozostać bez zmian po przeprojektowaniu kodu, aby mógł zostać uznany za działający. To jest lista przypadków testowych, które musisz zaimplementować.

Aby odpowiedzieć na twoje pytanie dotyczące zmiany implementacji wymagających zmiany przypadków testowych, proponuję spojrzeć na TDD Detroit (klasyczne) vs Londyn (mockist). Martin Fowler mówi o tym w swoim świetnym artykule. Drwiny nie są skrótami, ale wiele osób ma opinie. Jeśli zaczniesz od najwyższego poziomu, na którym twoje efekty zewnętrzne nie mogą się zmienić, i zejdziesz na dół, wymagania powinny pozostać dość stabilne, dopóki nie dojdziesz do poziomu, który naprawdę musi się zmienić.

Bez testów będzie to trudne i możesz rozważyć uruchomienie klientów za pomocą podwójnych ścieżek kodu (i rejestrowanie różnic), dopóki nie upewnisz się, że nowy kod robi dokładnie to, co powinien.

Encaitar
źródło
3

Oto moje podejście. Ma koszt pod względem czasu, ponieważ jest to test refaktorski w 4 fazach.

To, co zamierzam ujawnić, może lepiej pasować do komponentów o większej złożoności niż ta przedstawiona w przykładzie pytania.

W każdym razie strategia jest ważna dla dowolnego kandydata na komponent, który ma zostać znormalizowany przez interfejs (DAO, usługi, kontrolery, ...).

1. Interfejs

Zbierzmy wszystkie publiczne metody z MyDocumentService i połączmy je wszystkie w jeden interfejs. Na przykład. Jeśli już istnieje, użyj tego zamiast ustawiania nowego .

public interface DocumentService {

   List<Document> getAllDocuments();

   //more methods here...
}

Następnie zmuszamy MyDocumentService do wdrożenia tego nowego interfejsu.

Na razie w porządku. Nie wprowadzono większych zmian, dotrzymaliśmy aktualnego kontraktu, a behaivos pozostaje nietknięte.

public class MyDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //legacy code here as it is.
        // with no changes ...
  }
}

2. Test jednostkowy starszego kodu

Tutaj mamy ciężką pracę. Aby skonfigurować pakiet testowy. Powinniśmy ustawić jak najwięcej przypadków: przypadki udane, a także przypadki błędów. Te ostatnie służą dobrej jakości wyniku.

Teraz zamiast testować MyDocumentService , będziemy używać interfejsu jako testowanej umowy.

Nie będę wchodził w szczegóły, więc wybacz mi, jeśli mój kod wygląda na zbyt prosty lub zbyt agnostyczny

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

    //... More mocks

   DocumentService service;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
      //this is purposed way to inject 
      //dependencies. Replace it with one you like more.  
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> result = service.getAllDocuments();

          Assert.assertX(result);
          Assert.assertY(result);
           //... As many you think appropiate
    } 
 }

Ten etap trwa dłużej niż jakikolwiek inny w tym podejściu. I to jest najważniejsze, ponieważ wyznaczy punkt odniesienia dla przyszłych porównań.

Uwaga: Z powodu braku większych zmian, zachowanie nie zostało zmienione. Proponuję zrobić tag tutaj w SCM. Tag lub gałąź nie ma znaczenia. Po prostu zrób wersję.

Chcemy go do wycofywania, porównywania wersji i może być do równoległego wykonywania starego kodu i nowego.

3. Refaktoryzacja

Refactor zostanie wdrożony w nowym komponencie. Nie dokonamy żadnych zmian w istniejącym kodzie. Pierwszy krok jest tak prosty, jak skopiowanie i wklejenie MyDocumentService i zmiana nazwy na CustomDocumentService (na przykład).

Nowa klasa nadal wdraża usługę DocumentService . Następnie przejdź i refaktoryzuj getAllDocuments () . (Zacznijmy od jednego. Refaktory)

Może to wymagać pewnych zmian w interfejsie / metodach DAO. Jeśli tak, nie zmieniaj istniejącego kodu. Zaimplementuj własną metodę w interfejsie DAO. Oznacz stary kod jako Przestarzały, a później dowiesz się, co należy usunąć.

Ważne jest, aby nie przerywać / zmieniać istniejącej implementacji. Chcemy wykonywać obie usługi równolegle, a następnie porównywać wyniki.

public class CustomDocumentService implements DocumentService {

 @Override
 public List<Document> getAllDocuments(){
         //new code here ...
         //due to im refactoring service 
         //I do the less changes possible on its dependencies (DAO).
         //these changes will come later 
         //and they will have their own tests
  }
 }

4. Aktualizacja DocumentServiceTestSuite

Ok, teraz łatwiejsza część. Aby dodać testy nowego komponentu.

public class DocumentServiceTestSuite {

   @Mock
   MyDependencyA mockDepA;

   @Mock
   MyDependencyB mockDepB;

   DocumentService service;
   DocumentService customService;

  @Before
   public void initService(){
       service = MyDocumentService(mockDepA, mockDepB);
        customService = CustomDocumentService(mockDepA, mockDepB);
       // this is purposed way to inject 
       //dependencies. Replace it with the one you like more
   }

   @Test
   public void getAllDocumentsOK(){
         // here I mock depA and depB
         // wanted behaivors...

         List<Document> oldResult = service.getAllDocuments();

          Assert.assertX(oldResult);
          Assert.assertY(oldResult);
           //... As many you think appropiate

          List<Document> newResult = customService.getAllDocuments();

          Assert.assertX(newResult);
          Assert.assertY(newResult);
           //... The very same made to oldResult

          //this is optional
Assert.assertEquals(oldResult,newResult);
    } 
 }

Teraz mamy zarówno oldResult, jak i newResult, które zostały sprawdzone niezależnie, ale możemy je również porównać ze sobą. Ta ostatnia walidacja jest opcjonalna i zależy od wyniku. Być może nie jest to porównywalne.

Może nie robić zbyt wiele sensu, aby porównywać dwie kolekcje w ten sposób, ale byłoby ważne dla każdego innego rodzaju obiektu (pojos, encje modelu danych, DTO, opakowania, typy rodzime ...)

Notatki

Nie odważyłbym się powiedzieć, jak przeprowadzać testy jednostkowe ani jak używać fałszywych bibliotek. Nie mam odwagi powiedzieć, jak trzeba zrobić refaktor. Chciałem zaproponować globalną strategię. To, jak pójść naprzód, zależy od Ciebie. Wiesz dokładnie, jaki jest kod, jego złożoność i czy taka strategia jest warta wypróbowania. Liczą się tu fakty, takie jak czas i zasoby. Ważne jest również to, czego oczekujesz od tych testów w przyszłości.

Zacząłem moje przykłady od usługi i podążałem za DAO i tak dalej. Wchodzenie głęboko w poziomy zależności. Mniej więcej to może być opisany jako góra-dolnym strategii. Jednak w przypadku drobnych zmian / refaktorów ( takich jak ten pokazany w przykładzie trasy ) oddolne wykonanie zadania byłoby łatwiejsze. Ponieważ zakres zmian jest niewielki.

Wreszcie, to do Ciebie należy usunięcie przestarzałego kodu i przekierowanie starych zależności na nowe.

Usuń również przestarzałe testy i zadanie zostanie wykonane. Jeśli wersjonujesz stare rozwiązanie z jego testami, możesz sprawdzić i porównać się w dowolnym momencie.

W wyniku tak dużej ilości pracy przetestowano, sprawdzono i zaktualizowano starszy kod. Nowy kod, przetestowany, sprawdzony i gotowy do wersji.

Laiv
źródło
3

tl; dr Nie pisz testów jednostkowych. Napisz testy na bardziej odpowiednim poziomie.


Biorąc pod uwagę roboczą definicję refaktoryzacji:

nie zmieniasz tego, co robi twoje oprogramowanie, zmieniasz, jak to robi

jest bardzo szerokie spektrum. Z jednej strony jest samodzielna zmiana konkretnej metody, być może wykorzystująca bardziej wydajny algorytm. Na drugim końcu jest portowanie na inny język.

Niezależnie od tego, jaki poziom refaktoryzacji / przeprojektowania jest wykonywany, ważne jest, aby mieć testy działające na tym poziomie lub wyższym.

Zautomatyzowane testy są często klasyfikowane według poziomu jako:

  • Testy jednostkowe - poszczególne elementy (klasy, metody)

  • Testy integracyjne - Interakcje między komponentami

  • Testy systemowe - kompletna aplikacja

Napisz poziom testu, który może przetrwać refaktoryzację w zasadzie nietknięty.

Myśleć:

Jakie istotne, publicznie widoczne zachowanie będzie miało zastosowanie zarówno przed refaktoryzacją, jak i po refaktoryzacji? Jak mogę sprawdzić, czy to nadal działa tak samo?

Paul Draper
źródło
2

Nie trać czasu na pisanie testów, które podpinają się w punktach, w których można spodziewać się, że interfejs zmieni się w niebanalny sposób. Jest to często znak, że próbujesz przetestować jednostki, które mają charakter „oparty na współpracy” - których wartość nie polega na tym, co robią sami, ale na tym, jak wchodzą w interakcję z wieloma blisko spokrewnionymi klasami, aby uzyskać cenne zachowanie . To , że zachowanie, które chcesz przetestować, co oznacza, że chcesz być testowanie na wyższym poziomie. Testowanie poniżej tego poziomu często wymaga dużo brzydkiego drwiny, a powstałe testy mogą bardziej hamować rozwój niż pomagać w obronie zachowania.

Nie przejmuj się zbytnio, czy robisz remont, przeprojektowanie, czy cokolwiek innego. Możesz wprowadzać zmiany, które na niższym poziomie stanowią przeprojektowanie wielu komponentów, ale na wyższym poziomie integracji po prostu stanowią refaktor. Chodzi o to, aby wyjaśnić, jakie zachowanie jest dla ciebie wartościowe, i bronić tego zachowania w miarę upływu czasu.

Podczas pisania testów warto zastanowić się - czy mógłbym łatwo opisać QA, właścicielowi produktu lub użytkownikowi, co ten test faktycznie testuje? Jeśli wydaje się, że opisanie testu byłoby zbyt ezoteryczne i techniczne, być może testujesz na niewłaściwym poziomie. Testuj w punktach / poziomach, które „mają sens”, i nie uszkadzaj kodu testami na każdym poziomie.

Topo Morto
źródło
Zawsze zainteresowany przyczynami głosów negatywnych!
topo morto
1

Twoim pierwszym zadaniem jest próba wymyślenia „idealnej sygnatury metody” do swoich testów. Staraj się, aby była to czysta funkcja . Powinno to być niezależne od testowanego kodu; jest to mała warstwa adaptera. Napisz swój kod do tej warstwy adaptera. Teraz, gdy refaktoryzujesz kod, musisz tylko zmienić warstwę adaptera. Oto prosty przykład:

[TestMethod]
public void simple_addition()
{
    Assert.AreEqual(7, Eval("3 + 4"));
}

[TestMethod]
public void order_of_operations()
{
    Assert.AreEqual(52, Eval("2 + 5 * 10"));
}

[TestMethod]
public void absolute_value()
{
    Assert.AreEqual(9, Eval("abs(-9)"));
    Assert.AreEqual(5, Eval("abs(5)"));
    Assert.AreEqual(0, Eval("abs(0)"));
}

static object Eval(string expression)
{
    // This is the code under test.
    // I can refactor this as much as I want without changing the tests.
    var settings = new EvaluatorSettings();
    Evaluator.Settings = settings;
    Evaluator.Evaluate(expression);
    return Evaluator.LastResult;
}

Testy są dobre, ale testowany kod ma zły interfejs API. Mogę zmienić to bez zmiany testów, po prostu aktualizując warstwę adaptera:

static object Eval(string expression)
{
    // After refactoring...
    var settings = new EvaluatorSettings();
    var evaluator = new Evaluator(settings);
    return evaluator.Evaluate(expression);
}

Ten przykład wydaje się dość oczywistą rzeczą do zrobienia zgodnie z zasadą „Nie powtarzaj się”, ale w innych przypadkach może nie być tak oczywisty. Korzyść wykracza poza DRY - prawdziwą zaletą jest oddzielenie testów od testowanego kodu.

Oczywiście ta technika może nie być zalecana we wszystkich sytuacjach. Na przykład nie byłoby powodu pisać adapterów dla POCO / POJO, ponieważ tak naprawdę nie mają one interfejsu API, który mógłby się zmieniać niezależnie od kodu testowego. Również jeśli piszesz niewielką liczbę testów, stosunkowo duża warstwa adaptera byłaby prawdopodobnie zmarnowanym wysiłkiem.

default.kramer
źródło