Jak napisać parser JSON z TDD?

7

Zgodnie z radą z innego tematu, zakładam temat, bo też mnie to ciekawi.

Dla osób, które nie chcą czytać ww. tematu, a chciałyby wiedzieć, o co chodzi:

Załóżmy, że piszesz parser JSONa. Uznałeś, że pierwszym przypadkiem (albo jednym z pierwszych) będzie sparsowanie prostego obiektu np. {"a": "3"}. Następnym krokiem będą zagnieżdżone obiekty tj. {"a": {"b": 3}}. Okazuje się, że pierwszy przypadek zaklepałeś jako prosty parser w stylu języka regularnego tj. jedzie od lewej do prawej i tyle, bo jest najprościej i najszybciej a testy przechodzą. Niestety do drugiego przypadku trzeba użyć parsera do gramatyki bezkontekstowej; potrzebujemy jakiegoś stosu lub rekurencji i okazuje się, że pierwotny kod musi pójść na śmietnik. Pytanie: czy napisałbyś taki kod wiedząc, że nie ma on nic wspólnego z ostateczną wersją

Tak, zrobiłbym tak. Błąd który popełniasz to nie zrozumienie zasady "minimal code pass the test". A minimum które wystarczy żeby przeszedł Twój przypadek to jest return singletonMap("a", 3);.

Moje podstawowe pytanie związane z tym podejściem: jakim cudem to nie powoduje ifologii stosowanej w kodzie? Jak mam return singletonMap("a", 3);, i dopiszę kolejny test, to najprostszy, najmniej zmieniający kod, który przechodzi testy… to if. I potem drugi if. I trzeci, i piętnasty, i pięćdziesięciotysięczny.

Im więcej mam już istniejących testów, tym więcej bym zmieniał przy dodawaniu kolejnego.

No, ale Ty tutaj niczego nie parsujesz i nie sprawdzasz. To tak jakbys w kalkulatorze zahardkodowal odpowiedzi na konkretne rownania, a nie je wyliczal.

Dlatego musisz napisać więcej testów, takie które wymuszą powstanie takiej implementacji jakiej oczekujesz. Na tym polega TDD.

Tyle że to nieskończenie wiele testów, bo istnieje nieskończenie wiele poprawnych JSON-ów (i nieskończenie wiele niepoprawnych). Na którymś etapie mi się pojawi regułka, która potrafi parsować {(dowolny string): (dowolny int)}, a która będzie miała, z konieczności, skończenie wiele testów sprawdzających skończenie wiele możliwości. Na którymś etapie uwierzyłem, że te testy stanowią wystarczające pokrycie kodu, że nie ma różnicy pomiędzy testowaniem {"foo": 666}, a {"bar": 42}.

Więc, moje pytanie — czemu to nie jest pierwszy etap? Czemu od razu nie napisać kodu sprawdzającego z pięć różnych intów do pięciu różnych etykiet, a potem napisać kod, który mapuje dowolnego inta do dowolnej etykiety?

„Bo w ten sposób wiesz, że testy są dobre — naprzemiennie sprawdzasz kod testami i testy kodem” — tyle że nie. Masz pokrycie możliwych wejść miary zero — sprawdzasz skończenie wiele przykładów w nieskończonej przestrzeni. Musisz zawierzyć, że testy mają sens. Co… no… nie wymaga wielkiej wiary, jak to trywialne, oczywiste asercje. One się mogą rypnąć, jak zrobić literówkę, ale robienie literówek wykryje „normalny” kod, a nie tylko „minimalny przechodzący” w postaci hardkodowanej odpowiedzi.

Ja to robię property testingiem — piszę sobie regułkę, piszę kod, który jest wystarczająco ogólny, żeby ją sprawdzić. Ale że nie patrzę w to, co mi ten property testing wygeneruje, jakie konkretne przykłady, to i nie jestem w stanie hardkodować. Piszę kod, ale minimalny dla regułki — która obejmuje nieskończenie wiele opcji (z których tylko, co oczywiste, skończenie wiele się wygeneruje podczas testów). Ale to też jest łamanie zasad TDD, bo testy nie są trywialnie reprodukowalne (każda znana mi biblioteka umożliwa łatwe manipulowanie seedem losowym, ale to wciąż testy losowe).

0
Althorion napisał(a):

Masz pokrycie możliwych wejść miary zero — sprawdzasz skończenie wiele przykładów w nieskończonej przestrzeni. Musisz zawierzyć, że testy mają sens.

Nie wiem, czy odczytuję intencje posta ...

Cała filozofia TDD zakłada (jednak z pozytywna wiarą w człowieka), że kod nie będzie złośliwie adoptowany do testów (np wyniki obliczeń zwracana zahardkorowane, zamiast ponoszenia kłopotu z obliczaniem *)
Wobec czego testujemy WYBRANE wartości. Dla pary "string": int by to było może coś ujemnego, zero, cos dodatniego i bardzo duża liczba.
podobnie jak nie zakładamy w testach, że nie będzie limitu 3ch właściwości, tz wyjątek powyżej 3, bo ktoś użył String[3].

testy zakładają, że kod ma podstawie przesłanki bycia racjonalnym i (w miarę) profesjonalnym - i w tym sensie masz (zmieniając środek ciężkości rację) - nie da się testami zabezpieczyć czegoś, co nie jest racjonalne i nie spełnia zawodowego rzemiosła.

*) choć sa blogi o tym, że hinduskie testy świecą na zielono na totalnie roz... systemie

1
ZrobieDobrze napisał(a):
Althorion napisał(a):

Masz pokrycie możliwych wejść miary zero — sprawdzasz skończenie wiele przykładów w nieskończonej przestrzeni. Musisz zawierzyć, że testy mają sens.

Nie wiem, czy odczytuję intencje posta ...

Cała filozofia TDD zakłada (jednak z pozytywna wiarą w człowieka), że kod nie będzie złośliwie adoptowany do testów (np wyniki obliczeń zwracana zahardkorowane, zamiast ponoszenia kłopotu z obliczaniem *)

Czyli przyklad z return singletonMap("a", 3); byl zly?

0
Seken napisał(a):

Czyli przyklad z return singletonMap("a", 3); byl zly?

Nie, nie był zły, zgodnie z ortodoksyjnym TDD taki kod jest wręcz oczekiwany, natomiast nie wiem dlaczego miałby z tego wyewoluować w pełni poprawny parser, a nie ifologia - to o czym pisze @Althorion.

Pozostaje czekać na @Riddle, żeby rozwiał te wątpliwości.

2
Althorion napisał(a):

Moje podstawowe pytanie związane z tym podejściem: jakim cudem to nie powoduje ifologii stosowanej w kodzie? Jak mam return singletonMap("a", 3);, i dopiszę kolejny test, to najprostszy, najmniej zmieniający kod, który przechodzi testy… to if. I potem drugi if. I trzeci, i piętnasty, i pięćdziesięciotysięczny.

Dlatego, że zawsze kiedy masz moment w którym są zielone testy (tzn jesteś w fazie "green") to robisz refaktor, czyścisz kod, uruchamiając testy cały czas. Jak Ci przechodzą wszystkie testy, to możesz połączyć dwa if'y w jeden, odpalając testy, jeśli przechodzą wszystko jest git. Jednak musisz uważać żeby nie dopisać żadnej nowej logiki, a jedynie zrefaktorować to co już jest. Jeśli chcesz dopisać coś nowego,musisz napierw napisać failujący test, który ma przejść kiedy dopiszesz ten kawałek logiki.

Poza tym, nawet gdybyś nie robił refaktoru - okej, masz ifologię na 1000 linijek, ale ponieważ masz też 1000 testów które powstały w TDD, to wiesz na pewno że każda z nich jest przetestowana, więc możesz bez obaw zrefaktorować taki na 10tą stronę. Co oczywiście powinieneś robić ciągle, a nie na końcu.

Tyle że to nieskończenie wiele testów, bo istnieje nieskończenie wiele poprawnych JSON-ów (i nieskończenie wiele niepoprawnych). Na którymś etapie mi się pojawi regułka, która potrafi parsować {(dowolny string): (dowolny int)}, a która będzie miała, z konieczności, skończenie wiele testów sprawdzających skończenie wiele możliwości. Na którymś etapie uwierzyłem, że te testy stanowią wystarczające pokrycie kodu, że nie ma różnicy pomiędzy testowaniem {"foo": 666}, a {"bar": 42}.

Jeśli twierdzisz że Twoja implementacja już jest dobra, i można ją nazwać pełnoprawnym parserem to skończyłeś. Przestajesz dopisywać testy i logikę.

Więc, moje pytanie — czemu to nie jest pierwszy etap? Czemu od razu nie napisać kodu sprawdzającego z pięć różnych intów do pięciu różnych etykiet, a potem napisać kod, który mapuje dowolnego inta do dowolnej etykiety?

Teoretycznie mógłbys. Tylko wtedy nie wiesz na pewno które testy są konieczne, a które nie. Zeby się upewnić, musiałbyś stopniowo usuwać logikę i patrzeć czy któryś test się wywalił, a który nie. Tylko mógłbyś też też pominąć całkiem ten krok i zacząć od 0 kodu, i 0 testów, i pisać je one by one.

Lepiej jest pisać jeden po drugim. Lepiej mieć green, red, green, red, green, red, green, red, niż red, red, red, red, red, red, red, red, green.

„Bo w ten sposób wiesz, że testy są dobre — naprzemiennie sprawdzasz kod testami i testy kodem” — tyle że nie. Masz pokrycie możliwych wejść miary zero — sprawdzasz skończenie wiele przykładów w nieskończonej przestrzeni. Musisz zawierzyć, że testy mają sens. Co… no… nie wymaga wielkiej wiary, jak to trywialne, oczywiste asercje. One się mogą rypnąć, jak zrobić literówkę, ale robienie literówek wykryje „normalny” kod, a nie tylko „minimalny przechodzący” w postaci hardkodowanej odpowiedzi.

Używając TDD nie stworzysz testów które sprawdzą nieskończoność przypadków. Nie chodzi o to zeby były perfekcyjne. Ale testy wytworzone z TDD z reguły są jakoś 5-10x bardziej rzetelne niż takie pisane po fakcie. A to już coś.

Zdziwiłbyś się ile głupich, bezsensownych testów powstaje jak się je pisze w taki sposób. Wtedy powstają testy, takie że możesz usunąć 80% kodu,a testy nadal przechodzą.

0
Riddle napisał(a):

Jeśli twierdzisz że Twoja implementacja już jest dobra, i można ją nazwać pełnoprawnym parserem to skończyłeś. Przestajesz dopisywać testy i logikę.

Czyli zamiast na testach pokrywających wszystkie klasy abstrakcji opieramy się na wierze, że coś działa? A to nie testy mają udowodnić, że coś działa?

0

@Althorion: Takie pytanie poza konkursem, testy do tego parsera robisz w oparciu o instancje obiektów do sparsowania? Czy raczej w oparciu o gramtykę?

0
Saalin napisał(a):
Riddle napisał(a):

Jeśli twierdzisz że Twoja implementacja już jest dobra, i można ją nazwać pełnoprawnym parserem to skończyłeś. Przestajesz dopisywać testy i logikę.

Czyli zamiast na testach pokrywających wszystkie klasy abstrakcji opieramy się na wierze, że coś działa? A to nie testy mają udowodnić, że coś działa?

Nie ma żadnej wiary. Ty decydujesz jakie rozwiązanie chcesz dostarczyć. Jeśli twierdzisz że program robi to co ma robić, spełnia wymagania - to skończyłeś.

Jeśli jesteś zadowolony z implementacji która powstała, to git push.

Owszem, inputem tego programu może być teoretycznie niesokończa ilość permutacji danych, i wynikowo może wyjść teoretycznie nieskończona ilość permutacji danych. Ale po co miałbyś pisać testy pod nie wszystkie? Wystarczy że napiszesz minimalną ilość testów, która wymusi powstanie odpowiedniej implementacji. (Zakładając że zawsze dopisujesz minimum które wystarczy żeby testy przeszły).

Pytania i odpowiedzi:

  • Czy TDD zapewni że Twój kod nie ma bugów? Nie
  • Czy TDD zapewni testy pod każdy możliwy input i output? Nie
  • Czy TDD produkuje testy większej rzetelności, niż testy pisane pod koniec - Tak
  • Czy TDD produkuje testy ze zdrowszym coverage, niz testy pisane pod koniec - Tak
3

@ZrobieDobrze:

Cała filozofia TDD zakłada (jednak z pozytywna wiarą w człowieka), że kod nie będzie złośliwie adoptowany do testów (np wyniki obliczeń zwracana zahardkorowane, zamiast ponoszenia kłopotu z obliczaniem

Chciałbym w to wierzyć. Ale widziałem w życiu „kleryka TDD”, który uważa, chyba, podobnie jak Riddle, że to nie jest „złośliwa adaptacja kodu do testów”, tylko „istota TDD w postaci pisania minimalnego kodu”. Więc pytam, czy to jest ortodoksja, czy heterodoksja, czy w ogóle czyste szaleństwo.

Gdybym ja miał robić TDD, to tak, jak ja rozumiem TDD, robiłbym dokładnie tak, jak Ty: zacząłbym od naklepania {"a": 0}, {"żółć": -123456789}, {"測試": 17}, {"測試": 9999999999999999.123456}, i potem pod to klepał kod — z założeniem, że celem tego kodu jest przypisanie dowolnej liczby, inta lub floata, do dowolnej etykiety. Ale ten ww. człowiek, i Riddle z tego forum, mi mówią, że to nie jest TDD — OK, trudno, to bym nie robił TDD w takim razie — i że TDD jest od takiego podejścia lepsze. To próbuję zrozumieć w czym.

4

@Riddle: udzielił właściwej odpowiedzi.
To zresztą było moje guess why? z discorda.
Refaktoring jest kluczowy w TDD (i w zasadzie zawsze jeśli chcemy pisać utrzymywalny kod).

Złośliwe TDD (które zresztą często stosuję) ma takie zalety:
a) tworzymy więcej przypadków testowych (brzegowych)
b) mała szansa, że kod bez testów się prześliznie
c) rzadko, ale czasem, znajduje sie trywialny kod, który jednak spełnia wszystkie wymagania i jest oK
i ma wady:
a) czasem taki złośliwy kod ląduje na produkcji
b) tworzy się trochę nonsensownych testów,
c) jak się uprzeć to w złośliwość można bawić się dowolnie długo i zmarnować dużo czasu

Rozwiązaniem na wady jest refaktoring testów do property based (albo nawet od takich testów zaczynamy, ale to bywa trudne).

1
Althorion napisał(a):

@ZrobieDobrze:

Cała filozofia TDD zakłada (jednak z pozytywna wiarą w człowieka), że kod nie będzie złośliwie adoptowany do testów (np wyniki obliczeń zwracana zahardkorowane, zamiast ponoszenia kłopotu z obliczaniem

Chciałbym w to wierzyć. Ale widziałem w życiu „kleryka TDD”, który uważa, chyba, podobnie jak Riddle, że to nie jest „złośliwa adaptacja kodu do testów”, tylko „istota TDD w postaci pisania minimalnego kodu”. Więc pytam, czy to jest ortodoksja, czy heterodoksja, czy w ogóle czyste szaleństwo.

Jeśli robisz tak jak mówisz, to nie stosujesz TDD, przykro mi. Po prostu piszesz testy automatyczne. Nie wytwarzasz testów na podstawie kodu, i kodu na podstawie testu, więć to nie jest "Test driven development". To co praktykujesz to po prostu programowanie + pisanie testów.

TDD ma swoją bardzo konkretną definicję, i to co opisujesz po prostu się nie wpisuje w nią.

Gdybym ja miał robić TDD, to tak, jak ja rozumiem TDD, robiłbym dokładnie tak, jak Ty: zacząłbym od naklepania {"a": 0}, {"żółć": -123456789}, {"測試": 17}, {"測試": 9999999999999999.123456}, i potem pod to klepał kod — z założeniem, że celem tego kodu jest przypisanie dowolnej liczby, inta lub floata, do dowolnej etykiety. Ale ten ww. człowiek, i Riddle z tego forum, mi mówią, że to nie jest TDD — OK, trudno, to bym nie robił TDD w takim razie — i że TDD jest od takiego podejścia lepsze. To próbuję zrozumieć w czym.

Masz pełne prawo to robić. Ale to nie jest TDD.

Złośliwe TDD (które zresztą często stosuję) ma takie zalety:
a) tworzymy więcej przypadków testowych
b) mała szansa, że kod bez testów się prześliznie
c) rzadko, ale czasem, znajduje sie trywialny kod, który jednak spełnia wszystkie wymagania i jest oK
i ma wady:
a) czasem taki złośliwy kod ląduje na produkcji
b) tworzy się trochę nonsensownych testów,
c) jak się uprzeć to w złośliwość można bawić się dowolnie długo i zmarnować dużo czasu

Rozwiązaniem na wady jest refaktoring testów do property based (albo nawet od takich testów zaczynamy, ale to bywa trudne).

Odpowiem na to.

To co nazwałeś wadą, w mojej osobistej opinii, nie jest wadą - czemu.

czasem taki złośliwy kod ląduje na produkcji - Okej, jeśli nie pokryjesz wszystkich testów to faktycznie złośliwy kod ląduje na produkcji. I zastanówmy się teraz co to znaczy. To znaczy, że Twoje testy nie były w stanie wykryć tego kodu. A co z kolei to znaczy? Że jeśli ten złośliwy kod, nie jest napisany złośliwie przez Ciebie, tylko przez pomyłkę, jako bug, to testy też by go nie wykryły. A czy chcemy mieć testy które nie wykrywają bugów? Oczywiście że nie, byłby wtedy bezwartościowe. Testy są tylko tak dobre, jak ich zdolność do łapania błędów. A jaka jest różnica czy dodasz przez pomyłkę buga, czy czy napiszesz świadomie złośliwy kod? Testy go nie wykryły, bug uciekł. Musimy więc coś zrobić, żeby testy były na tyle rzetelne żeby nie pozwalały:

  • ani na przedostanie się złośliwego kodu na produkcję
  • co w konsekwencji oznacza też, że bugi dodane przez pomyłkę nie dostaną się na produkcję.

c) jak się uprzeć to w złośliwość można bawić się dowolnie długo i zmarnować dużo czasu

To już zależy od Twojej oceny, czy uważasz że parser jest już gotowy czy jeszcze nie. Mając TDD, możesz iteratywnie dodawać implementacje, i według własnego uznania kontynuować czy przestać. Nie stosując TDD, czyli po prostu pisząc implementacje jakby "jesteś z tym co masz".

Tak długo, jak jesteś w stanie napisać failujący test, tak długo wiesz że coś jeszcze jest do poprawny. Jak nie jesteś w stanie napisać nowego failującego testu (bo wszystkie które piszesz przechodzą od razu) - wiesz że skończyłeś.

2
Riddle napisał(a):

Więc co musisz zrobić żeby nie dopuścić do a) czasem taki złośliwy kod ląduje na produkcji? Przeczytać implementację, zastanowić się co tam jeszcze nie gra, i dopisać odpowiedni test. Robiąc tak, minimalizujemy szansę że prawdziwe bugi się przedostaną na produkcje.

Gdyby to tak działało, to nie trzeba by było pisać testów.
Wystarczy przeczytać implementację, zastanowić się co nie gra i ją poprawić, bez pisania testów.
Zobacz jaka oszczędność.

3

@Riddle:

Dlatego, że zawsze kiedy masz moment w którym są zielone testy (tzn jesteś w fazie "green") to robisz refaktor, czyścisz kod, uruchamiając testy cały czas. Jak Ci przechodzą wszystkie testy, to możesz połączyć dwa if'y w jeden, odpalając testy, jeśli przechodzą wszystko jest git. Jednak musisz uważać żeby nie dopisać żadnej nowej logiki, a jedynie zrefaktorować to co już jest. Jeśli chcesz dopisać coś nowego,musisz napierw napisać failujący test, który ma przejść kiedy dopiszesz ten kawałek logiki.

Przeskok pomiędzy „parser rozumie przypisywanie skończonej listy wyrażeń skończonej liczbie etykiet” do „parser rozumie przypisywanie dowolnym etykietom dowolnego floata” to jest nowa logika. Zatem jeśli mam test który proponowałeś, czyli asercję, że dla {"a": 3} będę miał a -> 3, to muszę sobie doklepać nowy test. Powiedzmy, doklepałem asercję, że {"b": 42} daje b -> 42. I teraz mogę tę logikę zakodzić? Bo to nie jest refaktoryzacja — refaktoryzacja to by było spinanie ifów razem, zmiana ich kolejności, nazewnictwa, tego typu sprawy; a tutaj muszę napisać coś całkiem nowego.

I teraz moje pytanie — kiedy mogę tak zrobić, po ilu takich testach przechodzących pierwotnie przez ifologię, i czemu nie mogę od tego zacząć? Jest oczywistym, że same ify nie pozwolą napisać dobrego parsera. Jest oczywistym, że te ify nie będą nigdy częścią dobrego parsera. Jest oczywistym, że jeśli zrobię literówkę w teście, i np. zamiast oczekiwać, że {"b": 42} mi się sparsuje do b -> 42, będę oczekiwał b -> 24, to będę miał „false red” przy pisaniu kodu „od razu”. Gdzie, zatem, tkwi przewaga w nie pisaniu tego od razu?

Poza tym, nawet gdybyś nie robił refaktoru - okej, masz ifologię na 1000 linijek, ale ponieważ masz też 1000 testów które powstały w TDD, to wiesz na pewno że każda z nich jest przetestowana, więc możesz bez obaw zrefaktorować taki na 10tą stronę. Co oczywiście powinieneś robić ciągle, a nie na końcu.

Ciągle, ale nie na początku? Dlaczego nie na początku?

Teoretycznie mógłbys. Tylko wtedy nie wiesz na pewno które testy są konieczne, a które nie.

Wszystkie są konieczne. Wszystkie muszą przejść, żeby móc uznać, że parser jest dobry. Mogą być — i są — nadmiarowe, ale nie widzę, w jaki sposób mi to przeszkadza, oraz w jaki sposób TDD by mnie przed tym chroniło.

Używając TDD nie stworzysz testów które sprawdzą nieskończoność przypadków. Nie chodzi o to zeby były perfekcyjne. Ale testy wytworzone z TDD z reguły są jakoś 5-10x bardziej rzetelne niż takie pisane po fakcie. A to już coś.

Fałszywa dychotomia. Jak najbardziej można pisać testy przed — ja też piszę przed — i nie pisać tak jak Ty piszesz, kodu o którym wiadomo z góry, że jest bezużyteczny.

Ale nie załapałeś, o co mi w tym chodziło — o to, że jest przeskok jakościowy pomiędzy testowaniem przypadków, a testowaniem reguł. Testy jednostkowe, z definicji, testują przypadki. I jeśli mają nam coś dać, musimy im zawierzyć, że odpowiednia lista przypadków testuje nam regułę. Ty piszesz, o ile dobrze Cię rozumiem, że jeden test, dwa testy, pięć testów, nie testują reguły, więc nie możemy ich używać do testowania reguł, więc jak mamy pierwszy test, {"a": 3} dający a -> 3, to jedyny kod, który możemy do niego naklepać, to hardkodowanie tej wartości. Ale jakby to nie był pierwszy test? Jakbym zaczął od {"x": 7}, potem miał coś jeszcze, potem jeszcze, i jeszcze, i wreszcie doszedł do momentu, w którym chcę mieć faktycznie parser, a nie drabinkę ifów, więc sobie dodaję {"a": 3}, teraz jest to nowy przypadek, nie mam go w ifologii, test się wysypuję, więc teraz piszę faktycznie parsowanie, i teraz mi to sprawdza… Ale czemu nie sprawdzał tego w poprzedniej historii?

Zdziwiłbyś się ile głupich, bezsensownych testów powstaje jak się je pisze w taki sposób. Wtedy powstają testy, takie że możesz usunąć 80% kodu,a testy nadal przechodzą.

Nie zdziwiłbym się, widziałem to na własne oczy. W jednym projekcie zaczęliśmy nawet testów mutacyjnych używać, żeby z tym walczyć. Nie będę nigdy bronił pisania testów po kodzie. Ale od tego daleka droga do tego, jak Ty testujesz aplikacje.

Owszem, inputem tego programu może być teoretycznie niesokończa ilość permutacji danych, i wynikowo może wyjść teoretycznie nieskończona ilość permutacji danych. Ale po co miałbyś pisać testy pod nie wszystkie? Wystarczy że napiszesz minimalną ilość testów, która wymusi powstanie odpowiedniej implementacji. (Zakładając że zawsze dopisujesz minimum które wystarczy żeby testy przeszły).

Tyle że właśnie ta „minimalna liczba testów, która wymusi powstanie odpowiedniej implementacji” to nieskończoność, jeśli faktycznie „dopisujesz minimum, które wystarczy, żeby testy przeszły”. Jeśli zaczynam od takiego kodu, który Ty proponujesz — return singletonMap("a", 3) — to minimum kodu, który mi potrzeba, żeby mi przeszło {"b": 42} to if. I tak z kolejnym takim testem. Muszę albo zrobić nie minimum, czyli faktycznie naklepać jakąś „nadmiarową” logikę; albo naklepać tę logikę w ramach „refaktoryzacji”, która nie będzie refaktoryzacją, bo istotnie zmienia działanie kodu… albo zrobić tak, jak ja bym zrobił — czyli zaczął właśnie od tego. Od pierwszej, najprostszej implementacji, o której jestem w stanie pomyśleć, która mi sprawdza regułę. Nie przypadek, regułę. Testy jednostkowe mam do przypadków. Kod piszę do reguły. Kod jest minimalny względem tej reguły. Nie jest minimalny względem przypadku. Zatem kod return singletonMap("a", 3) nigdy w testowanym parserze nie wyląduje, bo nie spełnia żadnej reguły.

Pytania i odpowiedzi:
Czy TDD zapewni że Twój kod nie ma bugów? Nie
Czy TDD zapewni testy pod każdy możliwy input i output? Nie
Czy TDD produkuje testy większej rzetelności, niż testy pisane pod koniec - Tak
Czy TDD produkuje testy ze zdrowszym coverage, niz testy pisane pod koniec - Tak

Zgadzam się jak najbardziej z każdym z tych punktów, tylko jak sam piszesz — to jeszcze nie jest TDD. W szczegolności, nie są to też odpowiedzi na moje pytania:

  1. Jak zapewnić, że powstanie nam sensowny algorytm, a nie ifologia, skoro wydaje się, że przeskok z ifów do algorytmu jest niezgodny z zasadami TDD?
  2. Jak zapewnić, że nie będziemy „symulować naturalnej ewolucji” i nie skończymy jak żyrafa z nerwem krtaniowym wstecznym idącym radośnie od mózgu wdół szyi, koło aorty, po czym z powrotem w górę szyi do krtani, bo za każdą iteracją to właśnie były „minimalne zmiany przechodzące testy”?
2
Althorion napisał(a):
  1. Jak zapewnić, że nie będziemy „symulować naturalnej ewolucji” i nie skończymy jak żyrafa z nerwem krtaniowym wstecznym idącym radośnie od mózgu wdół szyi, koło aorty, po czym z powrotem w górę szyi do krtani, bo za każdą iteracją to właśnie były „minimalne zmiany przechodzące testy”?

Bo tu jest błąd. (niestety, tak to w oryginale chyba było sformułowane) Nie szukamy minimalnych zmian przechodzących testy tylko jak najmniejszego kodu (całość) przechodzącego testy. (W kroku refaktoring)
Oczywiście, mimo że są różne metryki, tej minimalności to nigdy nie oceniałem tego inaczej niż "na oko".

0
Althorion napisał(a):

Dlatego, że zawsze kiedy masz moment w którym są zielone testy (tzn jesteś w fazie "green") to robisz refaktor, czyścisz kod, uruchamiając testy cały czas. Jak Ci przechodzą wszystkie testy, to możesz połączyć dwa if'y w jeden, odpalając testy, jeśli przechodzą wszystko jest git. Jednak musisz uważać żeby nie dopisać żadnej nowej logiki, a jedynie zrefaktorować to co już jest. Jeśli chcesz dopisać coś nowego,musisz napierw napisać failujący test, który ma przejść kiedy dopiszesz ten kawałek logiki.

Przeskok pomiędzy „parser rozumie przypisywanie skończonej listy wyrażeń skończonej liczbie etykiet” do „parser rozumie przypisywanie dowolnym etykietom dowolnego floata” to jest nowa logika. Zatem jeśli mam test który proponowałeś, czyli asercję, że dla {"a": 3} będę miał a -> 3, to muszę sobie doklepać nowy test. Powiedzmy, doklepałem asercję, że {"b": 42} daje b -> 42. I teraz mogę tę logikę zakodzić?

W rozumieniu TDD, to nie koniecznie jest nowa logika. Owszem, test testuje coś innego, ale test nie musi się wcale zmienić. Całkiem możliwe jest że napiszesz inny nowy test, który testuje coś co wydaje Ci się zupełnie czymś innym, ale ten test od razu przechodzi bez żadnych zmian w kodzie.

Althorion napisał(a):

I teraz moje pytanie — kiedy mogę tak zrobić, po ilu takich testach przechodzących pierwotnie przez ifologię, i czemu nie mogę od tego zacząć? Jest oczywistym, że same ify nie pozwolą napisać dobrego parsera. Jest oczywistym, że te ify nie będą nigdy częścią dobrego parsera. Jest oczywistym, że jeśli zrobię literówkę w teście, i np. zamiast oczekiwać, że {"b": 42} mi się sparsuje do b -> 42, będę oczekiwał b -> 24, to będę miał „false red” przy pisaniu kodu „od razu”. Gdzie, zatem, tkwi przewaga w nie pisaniu tego od razu?

Powinno być tak:

  1. Masz 0 testów i 0 kodu.
  2. Zaczynasz pisać pierwszy test
  3. Jak tylko ten test zacznie być czerwony, idziesz do kodu i zaczynasz pisać kod
  4. Jak tylko test który zaczyna przechodzić, musisz kończyć pisanie nowej logiki/nowej funkcjonalności, nowych rzeczy których wiesz że kod będzie potrzebował.
  5. Możesz teraz zrobić refactor, ale uważaj żeby nie dodać nic co jest konieczne dla działania programu, ale nie konieczne dla przejścia testu - zwykły refaktor. Rename, odwrócenie ifa, wydzielenie funkcji prywatnej, etc.
  6. Napisz kolejny test, wróć do punkt 2.
Althorion napisał(a):

Poza tym, nawet gdybyś nie robił refaktoru - okej, masz ifologię na 1000 linijek, ale ponieważ masz też 1000 testów które powstały w TDD, to wiesz na pewno że każda z nich jest przetestowana, więc możesz bez obaw zrefaktorować taki na 10tą stronę. Co oczywiście powinieneś robić ciągle, a nie na końcu.

Ciągle, ale nie na początku? Dlaczego nie na początku?

Dlaczego nie miałbyś napisać całej implementacji na początku, a potem napisać 1000% testów?

Bo wtedy te 1000 testów przejdzie, i nie wiesz czy one testują cokolwiek czy nie. + Nie wiesz ile kodu testują. Coverage też nie pomoże, bo nawet jak te 1000 testów stuknie 95% coverage, to to wcale nie oznacza że testy wykryją bugi.

Althorion napisał(a):

Teoretycznie mógłbys. Tylko wtedy nie wiesz na pewno które testy są konieczne, a które nie.

Wszystkie są konieczne. Wszystkie muszą przejść, żeby móc uznać, że parser jest dobry. Mogą być — i są — nadmiarowe, ale nie widzę, w jaki sposób mi to przeszkadza, oraz w jaki sposób TDD by mnie przed tym chroniło.

Nie wszystkie, niektóre są bezwartościowe - takie które nic nie sprawdzają, albo sprawdzają to samo co poprzednicy. Mogą mieć inne dane wejściowe, ale w żaden sposób nie zwiększają rzetelności test suite'a. Bardzo łatwo jest napisać takie testy.

Jak odpalisz dowolny projekt, to zawsze znajdziesz w nim takie dwa testy które albo przechodzą oba, albo failują oba. Nigdy nie failuje jeden z nich. Wtedy wiesz że jeden z nich jest niepotrzebny, bo nie sprawdza nic nowego. Taki projekt nie był napisany w TDD.

Niektóre testy są po prostu useless.

Althorion napisał(a):

Ale nie załapałeś, o co mi w tym chodziło — o to, że jest przeskok jakościowy pomiędzy testowaniem przypadków, a testowaniem reguł. Testy jednostkowe, z definicji, testują przypadki. I jeśli mają nam coś dać, musimy im zawierzyć, że odpowiednia lista przypadków testuje nam regułę. Ty piszesz, o ile dobrze Cię rozumiem, że jeden test, dwa testy, pięć testów, nie testują reguły, więc nie możemy ich używać do testowania reguł, więc jak mamy pierwszy test, {"a": 3} dający a -> 3, to jedyny kod, który możemy do niego naklepać, to hardkodowanie tej wartości. Ale jakby to nie był pierwszy test? Jakbym zaczął od {"x": 7}, potem miał coś jeszcze, potem jeszcze, i jeszcze, i wreszcie doszedł do momentu, w którym chcę mieć faktycznie parser, a nie drabinkę ifów, więc sobie dodaję {"a": 3}, teraz jest to nowy przypadek, nie mam go w ifologii, test się wysypuję, więc teraz piszę faktycznie parsowanie, i teraz mi to sprawdza… Ale czemu nie sprawdzał tego w poprzedniej historii?

Owszem, inputem tego programu może być teoretycznie niesokończa ilość permutacji danych, i wynikowo może wyjść teoretycznie nieskończona ilość permutacji danych. Ale po co miałbyś pisać testy pod nie wszystkie? Wystarczy że napiszesz minimalną ilość testów, która wymusi powstanie odpowiedniej implementacji. (Zakładając że zawsze dopisujesz minimum które wystarczy żeby testy przeszły).

Tyle że właśnie ta „minimalna liczba testów, która wymusi powstanie odpowiedniej implementacji” to nieskończoność, jeśli faktycznie „dopisujesz minimum, które wystarczy, żeby testy przeszły”. Jeśli zaczynam od takiego kodu, który Ty proponujesz — return singletonMap("a", 3) — to minimum kodu, który mi potrzeba, żeby mi przeszło {"b": 42} to if. I tak z kolejnym takim testem. Muszę albo zrobić nie minimum, czyli faktycznie naklepać jakąś „nadmiarową” logikę; albo naklepać tę logikę w ramach „refaktoryzacji”, która nie będzie refaktoryzacją, bo istotnie zmienia działanie kodu… albo zrobić tak, jak ja bym zrobił — czyli zaczął właśnie od tego. Od pierwszej, najprostszej implementacji, o której jestem w stanie pomyśleć, która mi sprawdza regułę. Nie przypadek, regułę. Testy jednostkowe mam do przypadków. Kod piszę do reguły. Kod jest minimalny względem tej reguły. Nie jest minimalny względem przypadku. Zatem kod return singletonMap("a", 3) nigdy w testowanym parserze nie wyląduje, bo nie spełnia żadnej reguły.

Już chyba wiem o co Ci chodzi. Pozwól że się odniosę.

Po prostu pisz tyle testów, ile wystarczy żebyś był bardzo pewien Twojej implementacji. Jeśli napiszesz 10 testów, {"a":1},{"a":2},{"a":3},{"a":4},{"a":5},{"a":6}, to ja byłbym całkiem pewny że taki algorytm jest już w miarę okej, i mógłbym zrefaktorować if na praser. Jeśli nie jestem confident, to dodam więcej przypadków testowych.

Nie musisz wyczerpać wszystkich możliwych przypadków, żeby móc zamienić kod z mniej generycznego na bardziej generyczny (przynajmniej zgodnie z TDD).

Jeśli Twoje dane to bool, to dobrze napisać 2 test case'y - jeden pod false, jeden pod true. Jeśli testujesz enuma, to napisz tyle testów ile jest wartości enuma. Jeśli testujesz str lub int, napisz tyle przypadków, ile uznasz za wystarczające do upewnienia się że implementacja jest poprawna (że prawdopodobieństwo jej poprawności jest bardzo duże). Dla intów polecam 0, 1, -1, 2, -2, i może jakiś dwucyfrowy/trzycyfrowy. Jeśli uważasz że dodanie więcej przypadków testowych zwiększy wiarygodność testów - dodaj je. Jeśli nie, nie dodawaj. Dla stringów polecam "" oraz jakiś nie pusty.

  1. Jak zapewnić, że powstanie nam sensowny algorytm, a nie ifologia, skoro wydaje się, że przeskok z ifów do algorytmu jest niezgodny z zasadami TDD?
  2. Jak zapewnić, że nie będziemy „symulować naturalnej ewolucji” i nie skończymy jak żyrafa z nerwem krtaniowym wstecznym idącym radośnie od mózgu wdół szyi, koło aorty, po czym z powrotem w górę szyi do krtani, bo za każdą iteracją to właśnie były „minimalne zmiany przechodzące testy”?

Nie jest niezgodny. Jeśli jesteś w fazie "green" (to znaczy w aktualnym momencie wszystkie testy przechodzą) to możesz normalnie zrefaktorować ifologię na "prawdziwy parser". Zakładając że nie dodasz żadnej "nowej logiki".

Jasne, 15 ifów sprawdzi 15 przypadków, a "prawdziwy parser" sprawdzi nieskońoczność, jasne. Ale to się nie liczy jako nowa logika (z punktu widzenia TDD przynajmniej). Nazywaj to jak chcesz, ale wprowadzenie takiej zmiany jest dozwolone w fazie "green". Możesz to robić w TDD.

Innymi słowy zmiana takiego kodu:

if (a == "15") {
  return {"a": "15"}
}
if (a == "16") {
  return {"a": "16"}
}

na taki

return {"a": a}

Jest dozwolna w TDD i nie uznaje się jej za dodatkową logikę. Taka operacja jest okej. Możesz ją robić.

3

@jarekr000000:

Nie szukamy minimalnych zmian przechodzących testy tylko jak najmniejszego kodu (całość) przechodzącego testy. (W kroku refaktoring)

Refaktoring, przynajmniej tak, jak ja go rozumiem, i jak rozumie go Wikipedia, wymaga nie wprowadzania zmian w funkcjonalności. Jeśli zaczynamy od return singletonMap("a", 3), to odejście od takiego hardkodowania zmienia funkcjonalność — tworzy funkcjonalność — więc nie jest, tym samym, refaktoringiem.

Zatem jeśli uważamy za podstawowy cykl TDD (znowu, za Wikipedią):

  1. Dodaj test, który nie przechodzi.
  2. Napisz minimalny kod, który spełnia wszystkie testy (włącznie z tym nowym z punktu wyżej)
  3. Refaktoryzuj.

To nie widzę, gdzie logika „parser przyjmuje dowolną etykietę i dowolnego floata i zwraca mapowanie tej etykiety do tego floata” mogłaby się pojawić, jeśli przyjmiemy, że powinno się zaczynać od return singletonMap("a", 3). Nie może się pojawić w punkcie pierwszym, bo to nie test. Nie może się pojawić w punkcie drugim, bo to nie jest minimalny kod do nowego przypadku. Nie może się pojawić w punkcie trzecim, bo to nie refaktoring.

No chyba, że uznajemy, że w punkcie drugim jednak można. Tylko skoro uznaliśmy, że można… to czemu nie zrobić tego w pierwszej iteracji tego cyklu? Mam pierwszy test, i zamiast pisać tego sztywnego returna, piszę logikę parsowania floatów.

1
Althorion napisał(a):

@jarekr000000:

Nie szukamy minimalnych zmian przechodzących testy tylko jak najmniejszego kodu (całość) przechodzącego testy. (W kroku refaktoring)

Refaktoring, przynajmniej tak, jak ja go rozumiem, i jak rozumie go Wikipedia, wymaga nie wprowadzania zmian w funkcjonalności. Jeśli zaczynamy od return singletonMap("a", 3), to odejście od takiego hardkodowania zmienia funkcjonalność — tworzy funkcjonalność — więc nie jest, tym samym, refaktoringiem.

Zatem jeśli uważamy za podstawowy cykl TDD (znowu, za Wikipedią):

  1. Dodaj test, który nie przechodzi.
  2. Napisz minimalny kod, który spełnia wszystkie testy (włącznie z tym nowym z punktu wyżej)
  3. Refaktoryzuj.

To nie widzę, gdzie logika „parser przyjmuje dowolną etykietę i dowolnego floata i zwraca mapowanie tej etykiety do tego floata” mogłaby się pojawić, jeśli przyjmiemy, że powinno się zaczynać od return singletonMap("a", 3). Nie może się pojawić w punkcie pierwszym, bo to nie test. Nie może się pojawić w punkcie drugim, bo to nie jest minimalny kod do nowego przypadku. Nie może się pojawić w punkcie trzecim, bo to nie refaktoring.

No chyba, że uznajemy, że w punkcie drugim jednak można. Tylko skoro uznaliśmy, że można… to czemu nie zrobić tego w pierwszej iteracji tego cyklu? Mam pierwszy test, i zamiast pisać tego sztywnego returna, piszę logikę parsowania floatów.

Okej, możliwe że użyłem złego słowa mówiąc "refaktor".

Masz rację, zmiana z implementacji n wartości, do potencjalnie wszystkich wartości, zmieni implementację, więc nie można jej nazwać refaktorem. Złego słowa użyłem.

Może powinienem powiedzieć że w fazie "Green", kiedy wszystkie testy przechodzą można:

  • Albo zrobić refaktor
  • Albo zmienić kod z mniej generycznego, na bardziej generyczny, tak by nie dodać nowych funkcjonalności, poza zwiększeniem generycznego tej jednej cechy (np z wspierania n intów na nieskończoność intów).
1

@Althorion:
Istotą refaktoringu jest posiadanie testów, które definiują funkcjonalność. Koniec.

1
Althorion napisał(a):

No chyba, że uznajemy, że w punkcie drugim jednak można. Tylko skoro uznaliśmy, że można… to czemu nie zrobić tego w pierwszej iteracji tego cyklu? Mam pierwszy test, i zamiast pisać tego sztywnego returna, piszę logikę parsowania floatów.

Z kilku powodów.

  • Po pierwsze, nie sądzę żebyś był wstanie przejść z momentu "nie parsowania floatów" do "parsowania floatów" w ramach jednego cyklu, tzn że masz 1 failujący test, piszesz logikę, i test przechodzi. W sercu TDD leży dodawanie zmiany iteratywnie, czyli tak jakby testy prowadzą Cię poprzez dopisywanie kolejnych zmian, i kolejnych edycji kodu.

  • Po drugie, ważniejsze, szybkość. Dajmy na to że napisanie tego parsowania floatów zajmie Ci 60 sekund. Możesz wtedy zrobić tak:

    • Napisać failujący test, przez 60 sekund pisać kod podczas gdy test failuje, jak skończysz test przechodzi.
    • Vs. Napisać failujący test, napisać w 2 sekundy shit code, test przechodzi, pisać przez 60 sekund implementację kiedy testy przechodzą.

    Chodzi o to żeby czas kiedy jest się w fazie "red" był jaknajkrótszy, wtedy pracujesz najwydajniej.

Kolejną zasadą TDD oczywiście jest ciągłe uruchamianie testów kiedy programujesz.

Althorion napisał(a):

Tyle że właśnie ta „minimalna liczba testów, która wymusi powstanie odpowiedniej implementacji” to nieskończoność

Okej, rozumiem o co Ci chodzi.

W TDD nie chodzi o to, żeby napisać minimalna liczba testów, która wymusi powstanie odpowiedniej implementacji co do absolutnie każdego możliwego inputu, bo faktycznie tym minimum jest nieskończoność.

W TDD chodzi o napisanie minimalna liczba testów, która wymusi powstanie odpowiedniej implementacji, która zapewnia duże i rozsądne prawdopodobieństwo odpowiedniej implementacji.

Wiadomo że jak napiszę if (a === "12545425234") throw Exception(), to raczej nie mam testu który testuje taki przypadek. Tego testy by nie ogarnęły, masz rację. (Nie uważam jednak że to jest wadą TDD, bo żeby powstał taki if, to najpierw musiałby być test który go wymusza).

Co do property based testing, to jest spoko jeśli operujesz na inputach stringów, intów, boolów i innych prymitywów. Ale takie testowanie nie sprawdzi się jak np potrzebujesz napisać testy do funkcji, które reagują na np warningi w jakimś runtime'ie, albo lazy ładowanie modułów, takich rzeczy które ciężko reprezentować, i ciężko wygenerować ich arbitralne ilości. Nie wspominając o tym, że test który piszesz, wcale nie musi przekazać jednej wartości do klasy którą testujesz. Możesz napisać test, który jest uruchamian np 1000 razy z 1000 jakimiś tam zdefiniowanymi wartościami.

Powiedzmy, piszę klasę w Pythonie która ma spróbować załadować ponownie moduł, jeśli za pierwszym razem się go nie udało załadować. Jak takie coś przetestować property-based? Nie specjalnie się da, ale w TDD już jaknajbardziej.


Dodam też może od siebie, że częstym błędem też który ludzie popełniają, jest takie przekonanie że używając TDD, można przetestować założenia, typu właśnie "jedna liczba zawsze będzie większa od drugiej", albo coś takiego, a to zupełnie nie jest celem, funkcją ani sensem TDD. Tdd służy do wytworzenia testów implementacji, takich które zapewnią swobodny refaktor.

Mówić "TDD jest słabe, bo nie można nim sprawdzić czy funkcja jest kryptograificznie bezpieczna", to jakby powiedzieć "młotek jest słaby, bo nie można nim wiercić". No nie można, ale nie do tego jest to narzędzie.

2
jarekr000000 napisał(a):

Istotą refaktoringu jest posiadanie testów, które definiują funkcjonalność. Koniec.

To tu już kompletnie się pogubiłem. Jak mamy przykład jak tutaj: test sprawdzający, czy {"a": 3} się parsuje do a -> 3, i chcemy to „refaktoryzować” do kodu, który dowolnemu stringowi przypisuje dowolnego floata… To czy:

  1. Uznajemy, że ten test definiował tę funkcjonalność od początku — więc mogliśmy ją implementować od początku, co też i samemu postuluję.
  2. Ten test jednak nie definiuje tej funkcjonalności, więc to nie byłby refaktoring.
  3. Ten test definiuje tę funkcjonalność, ale tylko w punkcie trzecim (refaktoring) cyklu TDD, ale nigdy w punkcie drugim (pisanie nowej logiki) — a wtedy, kurde, co?
Riddle napisał(a):

W rozumieniu TDD, to nie koniecznie jest nowa logika.

OK, to co jest nową logiką w rozumieniu TDD? Ja to rozumiem tak: jak mamy funkcję f, którą chcemy zmienić do funkcji g, to jeśli dla wszystkich możliwych iksów, f(x) = g(x), to ta zmiana nie zmienia logiki (więc jest poprawnym refaktoringiem), a jak są takie iksy, dla których f(x) != g(x), to jest to zmiana logiki. Uważam też, po swojemu, że testy nam służą po to, żeby wyłapać sytuacje, w której chcieliśmy zrobić refaktoring, a zmieniliśmy logikę; oraz do tego, by sprawdzić, czy faktycznie zmiana logiki nam wyszła tak, jak chcieliśmy (tzn. dla już testowanych iksów nic się nie popsuło, a dla nowych testy zaczęły przechodzić). I tak ja robię refaktoring tylko na zielonym, a implementuję nową logikę tylko na czerwonym.

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”.

Dlaczego nie miałbyś napisać całej implementacji na początku, a potem napisać 1000% testów?

Zupełnie inna sytuacja, niż ta, o której piszę. U mnie masz najpierw nieprzechodzące testy — tyle, ile daje ci komfort psychiczny, że faktycznie sprawdzają funkcjonalność, którą chesz dopisać — a potem piszesz tę funkcjonalność. Skontrastowane z dodawaniem dokładnie jednego testu, który testuje przypadek, który potem na pewno będziemy wyrzucać z ostatecznego kodu. W którym będzie logika zamiast przypadkologii.

Albo zmienić kod z mniej generycznego, na bardziej generyczny, tak by nie dodać nowych funkcjonalności, poza zwiększeniem generycznego tej jednej cechy (np z wspierania n intów na nieskończoność intów)

To mi się wydaje skrajnie niebezpieczne — bo to jest dodanie nowej funkcjonalności, i to bardzo poważnej. Drastycznie zwiększasz zakres poprawnych wejść, albo zmieniasz wyjście dla praktycznie wszystkich wejść, zależnie jak patrzeć. Dla mnie to definicja nowej funkcjonalności. Jak miałem, na przykład, kod, który przyjmuje obrazek w jakimś formacie graficznym, i testy, które zawsze testowały dla PNG, do zmiana implementacji od tej hardkodującej PNG do tej nie hardkodującej PNG to dla mnie poważne rozszerzenie funkcjonalności, więc chciałbym to wykonywać na czerwonym. I tak samo, przejście od sytuacji, w której mi funkcja parsuje jedynie bardzo specyficzne wejście, do takiej w której parsuje wejście jakie chcę, jest znacznym rozszerzeniem funkcjonalności.

Nie uważam jednak że to jest wadą TDD, bo żeby powstał taki if, to najpierw musiałby być test który go wymusza

Też nie uważam, żeby to było wadą, żeby nie było.

Tak samo dla tych wszystkich późniejszych przypadków. Nie uważam testowania manualnego, czy property testingu, czy mutowalnego, czy jakiegokolwiek innego, za silver bullet. Nie o tym piszę.

Piszę o tym, że nie widzę sensu w pisaniu kodu, który nie prowadzi do celu — tzn. tego return singletonMap("a", 3). Nie widzę, jak to mi pomaga, nie widzę też, jak w zgodzie z zasadami TDD mogę to naprawić do działającego kodu. Podsuwasz mi razem z Jarkiem teraz ideę, żeby traktować refaktoring jako przyzwolenie na uogólnianie… ale takie uogólnianie to dla mnie istota rozszerzania funkcjonalności programu. Idę o zakład, że większość ticketów, jakie robiłem, były na modłę „program robi teraz X przy założeniu Y, osłab założenie Y tak, aby Z też pasowało” — „użytkownik chce móc zapisywać rezultat do JSON-a, nie tylko do CSV”, „użytkownik chce móc importować daty z przyszłości, nie tylko z przeszłości”, itd.

W tym hipotetycznym parserze, jak piszesz, dziesięć testów na modłę {"a":1},{"a":2},{"a":3},{"a":4},{"a":5},{"a":6} Cię przekonuje, że testy są spoko. Ale wyobraź sobie, że nie pisałeś pełnego parsera, tylko taki, który ma „przypisywać jednoliterowym etykietom liczby całkowite”. Testy spoko. Poszło na produkcję. Działa. I przychodzi ticket „użytkownik chce móc używać etykiet będących dowolnymi stringami UTF-8, nie tylko jednoliterowymi”. Więc co, piszesz testy, klepiesz kod, testujesz, działa. Ale… czym się różni ta sytuacja od tej pierwotnej? Dlaczego teraz piszesz nowe testy, a wtedy uznawałeś, że jest spoko? Skoro to ta sama funkcjonalność powinna być.

Jak masz hardkodowane odpowiedzi do hardkodowanych testów, to przy uogólnianiu — który proponujesz robić w refaktoryzacji — tysiąc jeden rzeczy może pójść nie tak. Może, tak jak tutaj, używałeś za krótkich stringów, i kod przejdzie te testy, ale nie będzie działał. Może miałeś za małe inty, żeby się posypało. Może dodanie niepotrzebnych białych znaków psuje parser. I tak dalej, i tak dalej.

Za każdym razem jest to zmiana logiki — uogólnienie jest, dla mnie, zmianą logiki. I to i poważną, i często spotykaną w praktyce. Zatem podchodzę do tego jak do zmiany logiki. Ale jak już do tego podejdę jak do zmiany logiki, i zauważę co mi do tego jest potrzebne — nowe testy, i zauważę co mam na starcie pisania tego parsera — nowe testy, to dochodzę do wniosku, że oszczędzę wszystkim czas, jak nie będę zaczynał od hardkodowania, które potem uogólnię, tylko zacznę od uogólnienia. Wydaje mi się też, że nic w ten sposób nie tracę — bo skoro czułbym się pewnie uogólniać mają te dziesięć testów, to czuję się pewnie pisać uogólnione mając te dziesięć testów. Dokładnie to samo.

0

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.

Althorion napisał(a):
jarekr000000 napisał(a):

Istotą refaktoringu jest posiadanie testów, które definiują funkcjonalność. Koniec.

To tu już kompletnie się pogubiłem. Jak mamy przykład jak tutaj: test sprawdzający, czy {"a": 3} się parsuje do a -> 3, i chcemy to „refaktoryzować” do kodu, który dowolnemu stringowi przypisuje dowolnego floata… To czy:

  1. Uznajemy, że ten test definiował tę funkcjonalność od początku — więc mogliśmy ją implementować od początku, co też i samemu postuluję.
  2. Ten test jednak nie definiuje tej funkcjonalności, więc to nie byłby refaktoring.
  3. Ten test definiuje tę funkcjonalność, ale tylko w punkcie trzecim (refaktoring) cyklu TDD, ale nigdy w punkcie drugim (pisanie nowej logiki) — a wtedy, kurde, co?

Myślę że 1. tylko wtedy odnieś się do mojego poprzedniego posta: Jak napisać parser JSON z TDD? i podpunktu drugiego Po drugie, ważniejsze, szybkość

1

Tak z ciekawosci. @Riddle @jarekr000000 w ilu firmach pracowaliscie gdzie faktycznie to TDD bylo stosowane z takimi intencjami jak Wy opisujecie?

0

Nie widzę, jak to mi pomaga, nie widzę też, jak w zgodzie z zasadami TDD mogę to naprawić do działającego kodu. Podsuwasz mi razem z Jarkiem teraz ideę, żeby traktować refaktoring jako przyzwolenie na uogólnianie… ale takie uogólnianie to dla mnie istota rozszerzania funkcjonalności programu.

Możesz uogólniać te rzeczy, które są koncepcyjnie tożsame.

Jak otwierasz port to porty localhost:8080, localhost:8081,localhost:8082,localhost:8083,localhost:8084, są różne (to są różne porty), ale koncepcyjnie to to samo. Nie musisz pisać różnych testów pod nie.

Ale już porty np localhost:80 i localhost:8080 to są koncepcyjnie różne porty, bo jeden jest systemowy a inny użytkownika, więc może zajść sytuacja że inna logika się odpalić, więc warto napisać różne testy.

W tym hipotetycznym parserze, jak piszesz, dziesięć testów na modłę {"a":1},{"a":2},{"a":3},{"a":4},{"a":5},{"a":6} Cię przekonuje, że testy są spoko. Ale wyobraź sobie, że nie pisałeś pełnego parsera, tylko taki, który ma „przypisywać jednoliterowym etykietom liczby całkowite”. Testy spoko. Poszło na produkcję. Działa. I przychodzi ticket „użytkownik chce móc używać etykiet będących dowolnymi stringami UTF-8, nie tylko jednoliterowymi”. Więc co, piszesz testy, klepiesz kod, testujesz, działa. Ale… czym się różni ta sytuacja od tej pierwotnej? Dlaczego teraz piszesz nowe testy, a wtedy uznawałeś, że jest spoko? Skoro to ta sama funkcjonalność powinna być.

Jak masz hardkodowane odpowiedzi do hardkodowanych testów, to przy uogólnianiu — który proponujesz robić w refaktoryzacji — tysiąc jeden rzeczy może pójść nie tak. Może, tak jak tutaj, używałeś za krótkich stringów, i kod przejdzie te testy, ale nie będzie działał. Może miałeś za małe inty, żeby się posypało. Może dodanie niepotrzebnych białych znaków psuje parser. I tak dalej, i tak dalej.

TDD Cię nie uchroni przed rzeczami o których nie wiesz.

używałeś za krótkich stringów, i kod przejdzie te testy, ale nie będzie działał - no dobra. Czyli napisałeś testy pod krótkie stringi, napisałeś implementację i jesteś w fazie "green", wszystkie testy przechodzą.

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. (Wadą testów byłoby gdybyś przewidział długie stringi, zaimplementował je poprawnie, ale nie było testu który sprawdza długie stringi, ktoś by potem przyszedł, zmienił kod, odpalił testy, testy przechodzą, ale program nie wspiera już długich stringów - to jest winda tego kto napisał te testy, bo nie upewnił się że testy sprawdzają jakąś funkcjonalność).

Testy nie uchronią Cię przed rzeczami o których nie wiesz. Jeśli nie wiesz o długich stringach, to nie napiszesz testów które sprawdzają długie stringi.

Ta postawa wygląda trochę tak, jakbyś chciał wziąć jakieś losowe dane, rzucić nimi w kod i zobaczyć czy "działa" czy nie "działa", ale to nie jest dobre programowanie i dobre testowanie. Jeśli nie znasz jakiegoś przypadku, to nie napiszesz nigdy odpowiedniej implementacji pod niego. A TDD pomagają wytwarzać implementację, ale taką którą znasz. Nie taką której jeszcze nie znasz.

Za każdym razem jest to zmiana logiki — uogólnienie jest, dla mnie, zmianą logiki. I to i poważną, i często spotykaną w praktyce. Zatem podchodzę do tego jak do zmiany logiki. Ale jak już do tego podejdę jak do zmiany logiki, i zauważę co mi do tego jest potrzebne — nowe testy, i zauważę co mam na starcie pisania tego parsera — nowe testy, to dochodzę do wniosku, że oszczędzę wszystkim czas, jak nie będę zaczynał od hardkodowania, które potem uogólnię, tylko zacznę od uogólnienia. Wydaje mi się też, że nic w ten sposób nie tracę — bo skoro czułbym się pewnie uogólniać mają te dziesięć testów, to czuję się pewnie pisać uogólnione mając te dziesięć testów. Dokładnie to samo.

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?

Tracisz dwie rzeczy:

  • 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ą.

  • 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.

  • Seken napisał(a):

Tak z ciekawosci. @Riddle @jarekr000000 w ilu firmach pracowaliscie gdzie faktycznie to TDD bylo stosowane z takimi intencjami jak Wy opisujecie?

W firmie? Współpracownicy nie posługiwali się tym w ogóle. Na 100 ludzi z którymi pracowałem, może 1-2 udało mi się przekonać żeby tak napisał kilka klas, ale nie wiele. Ja natomiast wszystkie klasy w pracy tak piszę.

W prywatnych projektach każdy mój kod tak powstaje.

2
Riddle napisał(a):

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 1. tylko wtedy odnieś się do mojego poprzedniego posta: Jak napisać parser JSON z TDD? i podpunktu drugiego Po drugie, ważniejsze, szybkość

O, dzięki, przegapiłem to.

Zatem, niech się odniosę:

Riddle napisał(a):
  • Po drugie, ważniejsze, szybkość. Dajmy na to że napisanie tego parsowania floatów zajmie Ci 60 sekund. Możesz wtedy zrobić tak:

    • Napisać failujący test, przez 60 sekund pisać kod podczas gdy test failuje, jak skończysz test przechodzi.
    • Vs. Napisać failujący test, napisać w 2 sekundy shit code, test przechodzi, pisać przez 60 sekund implementację kiedy testy przechodzą.

    Chodzi o to żeby czas kiedy jest się w fazie "red" był jaknajkrótszy, wtedy pracujesz najwydajniej.

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.

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.

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?

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 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 + 0assert 5 == 0 + 5. Jakbym testował ten parser, i miał już wcześniej sprawdzanie dla ujemnych i dodatnich liczb, a teraz bym dopisywał parsowanie bigintów, to bym zrobił i dla minus Bardzo Wiele, i plus Bardzo Wiele. I tak dalej, i tak dalej.

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.

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. To jest tak samo nowy kod, który wziął się znikąd, a nie powstał przez przestawianie starego.

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. 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).

0
Althorion napisał(a):
Riddle napisał(a):

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ść.

Riddle napisał(a):

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 + 0assert 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ć.

0
Althorion napisał(a):

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.

Tutaj masz relacje gościa praktykujący w co drugi dzień TDD, potem nie: http://codemanship.co.uk/parlezuml/blog/?postid=1019


Jest o tym też mowa w flmiku: (polecam całą serie, od Lesson 1, ale akurat w tym jest mowa o szybkości): od 45:06 do 47:55

3
Seken napisał(a):

Tak z ciekawosci. @Riddle @jarekr000000 w ilu firmach pracowaliscie gdzie faktycznie to TDD bylo stosowane z takimi intencjami jak Wy opisujecie?

Mniej więcej piszę tak od 2011 w różnych firmach. I kilka razy udało mi się wpuścić "fajny" kod na produkcję. (pisałem o wadach).
Natomiast uważam, że jest to po prostu styl, który mi pasuje - ze względu na osobiste przywary. Robię często różne eksperymenty - i inne podejście wychodziło mi jeszcze gorzej.

Ale nikomu nie narzucam. Był okres, że narzucałem TDD w ogóle, ale sobie odpuściłem (obecnie uważam, że projekt powinien być w stanie takim, że można w nim robić TDD (czyli powinna być infrastruktura do testów) i każdy w zespole powinien umieć TDD - ale nie musi robić).

Od dwóch lat coraz częściej stosuje property based testing, a to dużo zmienia, bo już po drugim teście na ogół nie da się zrobić zbyt trywialnego kodu, który go spełnia.(ale testy właściwości to haskell, scala i eksperymentalnie Kotlin)

1

Nie wiem czy dobrze rozumiem ideę TDD, próbuję ogarnąć to podejście praktycznie, ale zdecydowanie ekspertem nie jestem. W każdym razie, w moim rozumieniu, kluczowe jest to napędzanie pisania kodu testami, co przekładam sobie na własną być może błędną, a na 100% uproszczoną definicję, że jedynym powodem do napisania jakiegoś kodu, jest czerwony test, lub brak możliwości skompilowania tego testu.
Oczywiście, pisząc ten parser, da się zastosować takie podeście, że gdzieś tam wpiszemy return 3 i dla pierwszego przypadku testowego jest to nawet oczekiwane rozwiązanie, bo przecież mamy pisać najprostszy możliwy kod spełniający testy. Wiadomo też, że te proste implementacje w miarę dopisywania kolejnych przypadków testowych staną się bezużyteczne i zostaną wywalone. Tylko do napisania tej "logiki" nie potrzeba wiele.
To co się dzieje na początkowym etapie, to rozwijanie testów i przy okazji zewnętrznego interface'u modułu. Logika która znajduje się wewnątrz właściwie zawsze będzie do refaktoringu (wyrzucenia). Tylko ta zmiana będzie już bezpieczna, bo wymuszona testami. Jeżeli test jest jedynym powodem do napisania jakiegoś kodu, to w sytuacji idealnej, pokrycie będzie wynosiło 100% i prawidłowo wykonana zmiana nie spowoduje zmiany tego pokrycia.
Programista nie jest automatem, który tworzy kod wyłącznie pod testy, bo w przeciwnym wypadku wcale nie trzeba było by pisać tego kodu, a jedynie postawić po drugiej stronie jakieś AI, które po przepuszczeniu milion razy testów zacznie udzielać oczekiwanych odpowiedzi. Jak piszę np. metodę sum(a, b), to wiem co piszę i o ile po pierwszym przypadku assertEquals(4, sum(2, 2) wpiszę tam return 4, to przecież widzę, że to bzdura i wymyślę sobie przypadek testowy, który to udowodni. Jak już go napiszę, to poprawię implementację na najprostszą możliwą spełniającą warunki testu, czyli return a+b, bo wiem (i wiedziałem od początku), że takie będzie docelowe rozwiązanie, ale musiałem się upewnić, że testy będą jasno na nie wskazywać.

0
piotrpo napisał(a):

Nie wiem czy dobrze rozumiem ideę TDD, próbuję ogarnąć to podejście praktycznie, ale zdecydowanie ekspertem nie jestem. W każdym razie, w moim rozumieniu, kluczowe jest to napędzanie pisania kodu testami, co przekładam sobie na własną być może błędną, a na 100% uproszczoną definicję, że jedynym powodem do napisania jakiegoś kodu, jest czerwony test, lub brak możliwości skompilowania tego testu.

No dokładnie tak jest.

Oczywiście, pisząc ten parser, da się zastosować takie podeście, że gdzieś tam wpiszemy return 3 i dla pierwszego przypadku testowego jest to nawet oczekiwane rozwiązanie, bo przecież mamy pisać najprostszy możliwy kod spełniający testy. Wiadomo też, że te proste implementacje w miarę dopisywania kolejnych przypadków testowych staną się bezużyteczne i zostaną wywalone. Tylko do napisania tej "logiki" nie potrzeba wiele.

True.

To co się dzieje na początkowym etapie, to rozwijanie testów i przy okazji zewnętrznego interface'u modułu. Logika która znajduje się wewnątrz właściwie zawsze będzie do refaktoringu (wyrzucenia). Tylko ta zmiana będzie już bezpieczna, bo wymuszona testami. Jeżeli test jest jedynym powodem do napisania jakiegoś kodu, to w sytuacji idealnej, pokrycie będzie wynosiło 100% i prawidłowo wykonana zmiana nie spowoduje zmiany tego pokrycia.

Exactly.

Programista nie jest automatem, który tworzy kod wyłącznie pod testy, bo w przeciwnym wypadku wcale nie trzeba było by pisać tego kodu, a jedynie postawić po drugiej stronie jakieś AI, które po przepuszczeniu milion razy testów zacznie udzielać oczekiwanych odpowiedzi. Jak piszę np. metodę sum(a, b), to wiem co piszę i o ile po pierwszym przypadku assertEquals(4, sum(2, 2) wpiszę tam return 4, to przecież widzę, że to bzdura i wymyślę sobie przypadek testowy, który to udowodni. Jak już go napiszę, to poprawię implementację na najprostszą możliwą spełniającą warunki testu, czyli return a+b, bo wiem (i wiedziałem od początku), że takie będzie docelowe rozwiązanie, ale musiałem się upewnić, że testy będą jasno na nie wskazywać.

You nailed it. Nie jest to co prawda całe TDD; ale 80% case'ów załatwiłeś.

0

Tutaj może pomóc Zasada Kolejności Przekształceń: https://michalkulinski.blogspot.com/2017/04/zasada-kolejnosci-przeksztacen.html?m=1

1 użytkowników online, w tym zalogowanych: 0, gości: 1