Tyle… że tutaj mi proponujecie coś, co ja nazywam zmianą logiki (dla nieskończenie wielu wejść zmienia się wyjście) do wykonania w ramach refaktoringu… Czyli dodaję coś nowego, kiedy jestem zielony. I że to niby jest TDD, a to, co ja robię, nie jest…
Więc pewnie inaczej niż TDD rozumiem, co to jest „nowa logika”.
Chodzi po prostu o to, że musisz w pewnym momencie sprawić że kod który piszesz stanie się bardziej ogólny, musisz sprawić że zacznie akceptować więcej wartości, niż wcześniej. Ale te nowe wartości, nie powinny być koncepcyjnie różne od tych, które już akceptuje. Jeśli program działa dla liczb 1-1123123, oraz dla powiedzmy Integer.MAX_VALUE
, to możesz go zmienić tak że działa dla 1
-Integer.MAX_VALUE
. I to jest okej w fazie "green".
To istota tych akceptowanych wartości jest kluczem, a nie ich absolutne, konkretne wartości.
Jak oceniasz, czy coś jest koncepcyjnie różne, czy może jednak nie? Bo ja, na przykład, korzystając ze swojego doświadczenia. I jestem w stanie to zrobić tak samo na początku, jak i w trakcie pisania kodu. W żaden sposób nie jest mi łatwiej zacząć od pisania testów małych wartości sprawdzających kod hardkodujący małe wartości, by potem przejść do pisania testów dużych wartości sprawdzających kod wykonujący faktyczne operacje na dostarczanych wartościach (małych i dużych); niż zacząć od napisania dokładnie takich samych testów, po czym napisania dokładnie takiego samego kodu.
Myślę że popełniasz błąd nazywając je "Testy małej wartości" lub "Dużej wartości". Test to test. Jeśli napiszesz test sprawdzający {"a":"3"}
-> a -> 3
, to ten test jest wartościowy, niezależnie od tego czy w początkowej fazie rozwoju kodu sprawiłeś że on przeszedł pisząc zahardkodzoną wartość czy prawdziwy parser.
Rozumiem, że wydaje Ci się że jeśli napiszesz test na taki przypadek {"a":"3"}
-> a -> 3
, i potem napiszesz return singletonMap("a", 3);
to to znaczy że ten test jest słaby, nieskiej wartości. Ale nie jest. Bo kiedy w przyszłości napiszesz prawdziwy parser, to wtedy ten test będzie testował to samo, i już będzie miał dużą wartość.
Nawet w Twoim przykładzie punkt pierwszy zajmuje 60 sekund, a punkt drugi — 62 sekundy. Nie kupuję tego, że „im krócej w red, tym wydajniej”, ale nawet gdybym to kupił, to przy pisaniu „po mojemu”… w red jestem krócej. Przy Twoim podejściu na czerwono jestem pomiędzy pierwszym testem a hardkodowaną implementacją, pomiędzy drugim testem a ifem, trzecim testem a ifem, czwartym testem a ifem, […] dziewiątym testem a ifem, oraz pomiędzy dziesiątym testem (tam wyżej pisałeś o dziesięciu testach, wszystko jedno jaka wartość) a faktycznie działającą implementacją. Przy moim podejściu jestem na czerwono pomiędzy naklepaniem testów a faktycznie działającą implementacją. Pisanie hardkodowanego kodu mi w niczym nie pomogło — ten kod wywaliłem. Nie pomogło mi nawet sprawdzić testów — są trywialne, jedyna możliwa pomyłka to literówka, więc jakbym miał po napisaniu kodu testy na czerwono, to zauważyłbym to tak samo szybko, jak po ifologii z testami na czerwono.
Podałeś przykład w którym dodajesz wartości koncepcyjnie podobne. Sam zauważyłeś że dodawanie kolejnych testów z natury {"a":"8"}
, {"a":"9"}
nic Ci nie dało. Praktykując TDD więc nie musiałbyś ich pisać, nie powinieneś. Nie powineneś dodawać piętnastu ifów, bo już przy trzecim powinieneś zmienić drabinkę ifów w coś bardziej ogólnego.
_gdybym_ to kupił, to przy pisaniu „po mojemu”… w red jestem krócej
, byłbyś dłużej, dlatego że napisanie prawdziwej implementacji potrwa dłużej, niż pisanie udawanej zahardkodzonej. Ale nawet pomijajac to, to są statystyki które pokazują, że pisząc kod i testy w stylu TDD, sumarycznie się programuje krócej niż pisząc najpierw kod potem testy, lub najpierw testy potem kod.
Był kiedyś jeden gość, który pisał przez 14 dni jeden i ten sam program. W parzyste dni pisał go w stylu TDD (dokładnie tak jak opisałem), a nieparzyste bez TDD (podobnie do tego jak Ty opisujesz). Całościowy trend był malejący (bo gość coraz lepiej poznawał program), ale w parzyste dni zawsze był szybszy niż w dzień następny i poprzedni. Mogę go wygrzebać jak bardzo chcesz.
Pisanie hardkodowanego kodu mi w niczym nie pomogło — ten kod wywaliłem
Kod wywaliłeś, ale pomógł Ci wytworzyć wiarygodne testy, dlatego że zaczęły przechodzić jeden po drugim. (Tzn, pomogłoby Ci, gdybyś pisał testy zgodnie z TDD).
Nie pomogło mi nawet sprawdzić testów — są trywialne
- stosując TDD nie powinieneś pisać takich testów, nie dlatego że są trywialne, ale dlatego że testują to co poprzednie testy już przetestowały. Pierwsze testy, {"a":"0"}
oraz {"a":"1"}
są dobre. Następny, {"a":"2"}
raczej nie przetestuje nic nowego, więc nie musisz go dodawać. Poza tym, najprawdopodobniej ten test i tak by od razu przeszedł. (No chyba że masz if
pod 0
oraz 1
, ale nie powinieneś go mieć, bo powinieneś to wcześniej uogólnić).
TDD Cię nie uchroni przed rzeczami o których nie wiesz.
Tak. Ale testy mogą mnie uchronić przed rzeczami, o których wiem… Więc bym chciał zacząć od wyrażania tej wiedzy — przez pisanie kodu, który, podejrzewam, może być poprawny — a nie tracenia czasu na pisanie kodu, co do którego wiem, że jest niepoprawny. Nawet jeśli mi ma zazielenić testy.
Nie ma Ci zazielenić testów, ma Ci pomóc wytworzyć testy które są rzetelne. Nie piszesz go "po to żeby testy były zielone", tylko po to żeby się upewnić że testy są wiarygodne.
Łatwo jest zrobić zielone testy - trudno jest zrobić wiarygodne testy.
Teraz jest Twoja rola, patrzysz na testy, i zastanawiasz się czy Twój parser ma wspierać długie stringi czy nie. Jeśli tak, to piszesz testy pod długie stringi, jeśli nie to zostawiasz (dopóki nie będzie takiego wymagania). Co jeśli nie pomyślisz o długich stringach? To samo, co jakbyś nie stosował TDD - po prostu tego nie zaimplementujesz. Ale to nie jest wada testów. Po prostu nie przewidziałeś takiego przypadku podczas pisania kodu.
Jak najbardziej się zgadzam z całością tutaj. Tylko popatrz — ta sama sytuacja przy „uogólnianiu podczas refaktoringu”. Nie przewidziałem takiego przypadku podczas pisania kodu. Mam zielono. Uogólniam. Źle. Dalej mam na zielono. Jestem… w dokładnie tym samym miejscu. W niczym mi pisanie hardkodowanego rozwiązania nie pomogło. Zabrało mi czas. Nie pomogło znaleźć, zauważyć, ani naprawić błędów. Nie dostarczyło nowej wiedzy… Zatem… co mi dało?
Mówiłem Ci już. Podajesz przypadek, że nie wiedziałeś że Twój parser ma wspierać długie stringi. Na to nic nie poradzisz z TDD czy bez.
Jeśli Ty nie wiedziałeś, że parser ma je wspierać, to po prostu ich nie zaimplementujesz nie ważne jaką metodykę testowania stosujesz.
Czyli chcesz najpierw napisać 20 testów które failują, a potem od razu 50 linijek prawdziwej implementacji które sprawią że od razu te 20 testów przejdzie?
Tak. Pewnie nie ze dwadzieścia, tylko bardziej z pięć, i nie pięćdziesiąt, tylko z dziesięć, ale tak.
Nie ma znaczenia ile. Jakakolwiek nadmiarowa implementacja ponad to co testy wykazują, jest narażona na to że może nie być przetestowana. Nawet jak
Nie czuję wstrętu do napisania dwóch testów sprawdzających dwie strony tego samego medalu, który testuję. Przykład idiotyczny, ale nie chce mi się już dzisiaj myśleć — jakbym pisał funkcję dodającą dwie liczby, to bym naklepał od razu assert 5 == 5 + 0
i assert 5 == 0 + 5
.
Noi te testy byłby useless, dlatego że napisanie return 5;
sprawiłoby że przejdą oba. Brakuje Ci takiego podejścia: "jakiby tu test napisać, który wykarze że implementacja jest zbugowana". To wygląda tak, jakbyś Ty chciał wyrazić testami swoje intencje dla czytelnika, licząc że to wystarczy do poprawnego przetestowania. Ale tak nie jest.
Dobrymi testami byłoby assert 5 == 5 + 0;
oraz assert 6 == 0 + 6;
.
A, co najważniejsze, po napisanu testów — ile by ich nie było — bym siadł do pisania „prawdziwej implementacji”. Kodu, który tworzę z myślą o tym, że ma działać w sposób oczekiwany, co się okaże, gdy przejdzie testy. Nie zaś takiego, który ma przejść testy, nawet jeśli wiem, że nie robi tego, co ma.
Im dłużej czytam Twoje wypowiedzi, tym bardziej widzę że nie rozumiesz o co w nich chodzi, a jednocześnie próbujesz argumentować że to nie ma sensu.
Po pierwsze, nie wiesz ile z tych 50 linijek faktycznie jest przetestowane (bo możliwe że kilka z nich nie jest). Może zajść sytuacja w których jak coś zepsuję w niektórych linijkach to testy tego nie wykryją.
W przypadku „uogólniania podczas refaktoringu” też nie wiem. Te testy mi się zazieleniły po raz pierwszy podczas pisania kodu, który potem wyrzuciłem. Sprawdzały linijki, których nie ma. Które pisałem z myślą o tym, że je wywalę. Tak samo jak nie mam gwarancji przy moim podejściu, tak samo nie mam gwarancji przy Twoim.
Ale nie piszesz testów żeby sprawdzać kod, tylko zachowanie. To że dla danego inputu dostaniesz taki output, i nic poza tym.
To jest tak samo nowy kod, który wziął się znikąd, a nie powstał przez przestawianie starego.
Ale masz testy, które wiesz że są rzetelne i mogą Cię zapewnić że ten nowy kod jest taki jaki ma być. Więc to jest bardziej wiarygodne, niż gdybyś od razu napisał ten kod i od razu napisał testy. Albo nawet gdybyś od razu napisał 20 testów, i potem kod.
Może tak powiem:
Metodyka #1 - Napisz cały parser, a potem napisz do niego wszystkie testy
Metodyka #2 - Napisz najpierw wszystkie testy, a potem napisz pod nie parser
Metodyka #3 - Napisz braindead, drabinkę ifów w stylu TDD, red, green, red green, wywal drabinkę ifów, napisz parser od nowa pod nie.
Metodyka #2 jest bardziej wiarygodna niż metodyka #1, a metodyka #3 jest bardziej wiarygodna niż metodyka #2.
Po drugie, nie wiesz które z tych 20 testów jest niezbędne (bo możliwe że jak usuniesz kilka, to test suite nadal będzie tak samo rzetelny). Łatwo jest dodać testy, które nie podnoszą wartości test suite'a.
W TDD też nie wiem.
Wiesz, dlatego że widziałeś każdy jeden test jak failuje, dopisałeś kawałek kodu, i wtedy już widziałeś że przechodzi. Więc wiesz, że ten test sprawdza coś, coś co Cię przybliża do prawdziwej funkcjonalności.
Pisząc od razu 20 testów od razu nie widzisz tego jak jeden test zaczyna przechodzić po drugim, więc nie wieszy, czy któryś z nich nie jest useless.
Na szczęście, to nie problem. Żadna metodologia, którą kojarzę, z TDD na czele, nie zakłada wyrzucania starych testów, które stają się niepotrzebne (bo na którymś etapie dodałem funkcjonalność, która uogólnia tamtą funkcjonalność, i nowy test sprawdza obie).
No nie zakłada wyrzucania, bo nie zakłada powstawania useless testów.
Ale jeśli już tak Ci się zdarzy że napiszesz useless test, to musisz go wyrzucić.