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 użytkowników online, w tym zalogowanych: 0, gości: 1