Trudności z TDD i refaktoryzacją (lub - dlaczego jest to bardziej bolesne niż powinno być?)

20

Chciałem nauczyć się korzystać z podejścia TDD i miałem projekt, nad którym chciałem pracować od dłuższego czasu. To nie był duży projekt, więc pomyślałem, że będzie dobrym kandydatem do TDD. Czuję jednak, że coś poszło nie tak. Podam przykład:

Na wysokim poziomie mój projekt jest dodatkiem do Microsoft OneNote, który pozwoli mi łatwiej śledzić i zarządzać projektami. Teraz chciałem zachować logikę biznesową w tym zakresie możliwie jak najbardziej oddzieloną od OneNote, na wypadek, gdyby któregoś dnia postanowiłem zbudować własną pamięć masową i zaplecze.

Najpierw zacząłem od podstawowego testu akceptacji prostych słów, który nakreślił, co chciałbym, aby moja pierwsza funkcja była zrobiona. Wygląda to mniej więcej tak:

  1. Kliknięcia użytkownika tworzą projekt
  2. Typy użytkowników w tytule projektu
  3. Sprawdź, czy projekt został poprawnie utworzony

Pomijam interfejs użytkownika i trochę planowania pośredniego, przechodzę do pierwszego testu jednostkowego:

[TestMethod]
public void CreateProject_BasicParameters_ProjectIsValid()
{
    var testController = new Controller();
    Project newProject = testController(A.Dummy<String>());
    Assert.IsNotNull(newProject);
}

Jak na razie dobrze. Czerwony, zielony, refaktor itp. W porządku, teraz potrzebuje rzeczy. Wycinając tutaj kilka kroków, kończę z tym.

[TestMethod]
public void CreateProject_BasicParameters_ProjectMatchesExpected()
{
    var fakeDataStore = A.Fake<IDataStore>();
    var testController = new Controller(fakeDataStore);
    String expectedTitle = fixture.Create<String>("Title");
    Project newProject = testController(expectedTitle);

    Assert.AreEqual(expectedTitle, newProject.Title);
}

W tym momencie nadal czuję się dobrze. Nie mam jeszcze konkretnego magazynu danych, ale stworzyłem interfejs tak, jak się spodziewałem.

Pominę tutaj kilka kroków, ponieważ ten post jest wystarczająco długi, ale postępowałem zgodnie z podobnymi procesami i ostatecznie przechodzę do tego testu dla mojego magazynu danych:

[TestMethod]
public void SaveNewProject_BasicParameters_RequestsNewPage()
{
    /* snip init code */
    testDataStore.SaveNewProject(A.Dummy<IProject>());
    A.CallTo(() => oneNoteInterop.SavePage()).MustHaveHappened();
}

To było dobre, dopóki nie spróbowałem go zaimplementować:

public String SaveNewProject(IProject project)
{
    Page projectPage = oneNoteInterop.CreatePage(...);
}

I jest problem właśnie tam, gdzie jest „...”. W tym momencie zdaję sobie sprawę, że CreatePage wymaga identyfikatora sekcji. Nie zdawałem sobie z tego sprawy, kiedy myślałem na poziomie kontrolera, ponieważ byłem zainteresowany jedynie testowaniem bitów istotnych dla kontrolera. Jednak przez całą drogę zdaję sobie sprawę, że muszę poprosić użytkownika o miejsce do przechowywania projektu. Teraz muszę dodać identyfikator lokalizacji do magazynu danych, następnie dodać jeden do projektu, następnie dodać jeden do kontrolera i dodać go do WSZYSTKICH testów, które są już napisane dla wszystkich tych rzeczy. Szybko stało się to żmudne i nie mogę się powstrzymać, ale czuję, że złapałbym to szybciej, gdyby naszkicowałem projekt z wyprzedzeniem, zamiast pozwolić mu zaprojektować go podczas procesu TDD.

Czy ktoś może mi wyjaśnić, czy w tym procesie zrobiłem coś złego? Czy można zrezygnować z tego rodzaju refaktoryzacji? Czy to jest powszechne? Jeśli jest to powszechne, czy są jakieś sposoby na uczynienie go bardziej bezbolesnym?

Dziękuje wszystkim!

Landon
źródło
Otrzymasz bardzo wnikliwe komentarze, jeśli opublikujesz ten temat na tym forum dyskusyjnym: groups.google.com/forum /#!forum/..., który jest specjalnie dla tematów TDD.
Chuck Krutsinger
1
Jeśli musisz dodać coś do wszystkich testów, brzmi to tak, jakby twoje testy były źle napisane. Powinieneś refaktoryzować swoje testy i rozważyć użycie rozsądnego urządzenia.
Dave Hillier

Odpowiedzi:

19

Chociaż TDD jest (słusznie) reklamowane jako sposób projektowania i rozwijania oprogramowania, nadal dobrze jest wcześniej pomyśleć o projekcie i architekturze. IMO „szkicowanie projektu z wyprzedzeniem” to uczciwa gra. Często jednak będzie to na wyższym poziomie niż decyzje projektowe, do których doprowadzi Cię TDD.

Prawdą jest również to, że gdy coś się zmienia, zwykle będziesz musiał zaktualizować testy. Nie ma sposobu na całkowite wyeliminowanie tego, ale możesz zrobić kilka rzeczy, aby Twoje testy były mniej kruche i zminimalizować ból.

  1. W miarę możliwości trzymaj szczegóły implementacji poza testami. Oznacza to jedynie przeprowadzanie testów metodami publicznymi i, tam gdzie to możliwe, faworyzowanie weryfikacji opartej na stanie zamiast weryfikacji opartej na interakcji . Innymi słowy, jeśli testujesz wynik czegoś, a nie kroki, aby się tam dostać, twoje testy powinny być mniej kruche.

  2. Zminimalizuj duplikację w kodzie testowym, tak jak w kodzie produkcyjnym. Ten post jest dobrym źródłem informacji. W twoim przykładzie wydaje się, że dodanie IDwłaściwości do konstruktora było bolesne, ponieważ wywołałeś konstruktor bezpośrednio w kilku różnych testach. Zamiast tego spróbuj wyodrębnić utworzenie obiektu do metody lub zainicjować go raz dla każdego testu w metodzie inicjalizacji testu.

jhewlett
źródło
Przeczytałem zalety opartej na stanie vs opartej na interakcji i rozumiem ją przez większość czasu. Jednak nie widzę, jak to możliwe w każdym przypadku, bez WYRAŹNEGO pokazania właściwości w teście. Weź mój przykład powyżej. Nie jestem pewien, jak sprawdzić, czy magazyn danych rzeczywiście został wywołany bez użycia potwierdzenia dla „MustHaveBeenCalled”. Jeśli chodzi o punkt 2, masz całkowitą rację. Skończyło się na tym, że zrobiłem to po wszystkich zmianach, ale chciałem tylko upewnić się, że moje podejście jest ogólnie zgodne z przyjętymi praktykami TDD. Dzięki!
Landon
@Landon Istnieją przypadki, w których testowanie interakcji jest bardziej odpowiednie. Na przykład sprawdzenie, czy wykonano połączenie z bazą danych lub usługą internetową. Zasadniczo, ilekroć zachodzi potrzeba odizolowania testu, zwłaszcza od usługi zewnętrznej.
jhewlett
@Landon Jestem „przekonanym klasycyzmem”, więc nie mam zbyt dużego doświadczenia w testowaniu opartym na interakcjach ... Ale nie musisz robić twierdzeń na temat „MustHaveBeenCalled”. Jeśli testujesz wstawienie, możesz użyć zapytania, aby sprawdzić, czy zostało wstawione. PS: Używam kodów pośredniczących ze względu na wydajność podczas testowania wszystkiego oprócz warstwy bazy danych.
Hbas
@jhewlett Do tego też doszedłem. Dzięki!
Landon
@Hbas Nie ma bazy danych do zapytania. Zgadzam się, że byłby to najprostszy sposób, gdybym go miał, ale dodam to do notesu OneNote. Najlepsze, co mogę zrobić, to dodać metodę Get do mojej klasy pomocnika interop, aby spróbować pobrać stronę. MUSZĘ napisać test, aby to zrobić, ale czułem się, jakbym testował dwie rzeczy naraz: Czy to zapisałem? oraz Czy moja klasa pomocnicza poprawnie pobiera strony? Chociaż wydaje mi się, że w pewnym momencie twoje testy będą musiały polegać na innym kodzie testowanym gdzie indziej. Dzięki!
Landon
10

... nie mogę się powstrzymać, ale czuję, że złapałbym to szybciej, gdyby naszkicowałem projekt z wyprzedzeniem, zamiast pozwolić mu być zaprojektowanym podczas procesu TDD ...

Może, może nie

Z jednej strony TDD działało dobrze, dając automatyczne testy podczas budowania funkcjonalności i natychmiast przerywając, gdy trzeba było zmienić interfejs.

Z drugiej strony, być może, gdybyś zaczął od funkcji wysokiego poziomu (SaveProject) zamiast funkcji niższego poziomu (CreateProject), wcześniej zauważyłbyś brakujące parametry.

Z drugiej strony, być może nie miałbyś. To niepowtarzalny eksperyment.

Ale jeśli szukasz lekcji na następny raz: zacznij od góry. I najpierw pomyśl o projekcie, ile chcesz.

Steven A. Lowe
źródło
0

https://frontendmasters.com/courses/angularjs-and-code-testability/ Od około 2:22:00 do końca (około 1 godziny). Przepraszam, że film nie jest darmowy, ale nie znalazłem darmowego, który wyjaśnia go tak dobrze.

Jedna z najlepszych prezentacji pisania testowalnego kodu znajduje się w tej lekcji. Jest to klasa AngularJS, ale część testowa dotyczy całego kodu Java, przede wszystkim dlatego, że to, o czym mówi, nie ma nic wspólnego z językiem, a przede wszystkim z pisaniem dobrego testowalnego kodu.

Magią jest pisanie testowalnego kodu, a nie pisanie testów kodu. Nie chodzi o pisanie kodu, który udaje użytkownika.

Poświęca także trochę czasu na napisanie specyfikacji w formie twierdzeń testowych.

Boatcoder
źródło