Testowanie w stylu "chłop żywemu nie przepuści"

Z przyjemnością przeczytałem ostatni wpis DHH, dowodzący, że w zasadzie nadal używa on TDD(*). Cieszę się, że doszedł wniosku, że TDD, nie jest w rzeczywistości martwy.

Ten wpis jest prostą odpowiedzią po to, żeby nie zgodzić się w kilku kwestiach. Ale muszę to powiedzieć: bardziej zgadzam się, niż nie zgadzam.

DHH przedstawia siedem punktów. Powtórzyłem je poniżej razem z moimi komentarzami. A ponieważ DHH nie uzasadnia swoich opinii, więc ja nie będę uzasadniał moich.


Poniższy tekst jest luźnym tłumaczeniem wpisu bloga Roberta Cecila "Wujka Boba" Martina ze strony :
http://blog.cleancoder.com/uncle-bob/2017/03/06/TestingLikeTheTSA.html
Proszę o komentarze, jeżeli ta luźność jest zbyt daleko posunięta.



1. Nie celuj w 100% pokrycia kodu testami.
Nie zgadzam się. Celuj najwyżej jak się da. Traktuj 100% jak asymptotyczny cel. Żadna inna liczba nie jest bardziej rozsądnym celem. Nie ma dobrego powodu, żeby przestać podbijać procent pokrycia wyżej.

2.Stosunek kodu produkcyjnego do kodu testowego powyżej 1:2 to zapaszek, a powyżej 1:3 to smród.
Nie zgadzam się. Stosunek 1:1 linii kodu jest na oko właściwy. Jeżeli masz 20 tys. linii kodu produkcyjnego, to prawdopodobnie powinieneś mieć około 20 tys. linii kodu testowego. (Tak na marginesie, nie rozumiem toku rozumowania DHH w zakresie tych stosunków. Myślę, że miał na myśli, w pierwszym przypadku coś około 2:1, a w drugim przypadku, to miało być coś w rodzaju 1,5:1. A może to ja jestem po prostu nieogarnięty.)

3. Prawdopodobnie robisz to źle, jeżeli testowanie zabiera Ci więcej niż 1/3 Twojego czasu. Zdecydowanie robisz to źle, jeżeli testowanie zabiera Ci więcej niż połowę czasu.
Zgadzam się. Testowanie nie powinno zabierać Ci żadnego czasu. Pisanie testów, szczególnie TDD, wymaga czasu ujemnego (co oszczędza ogromną ilość czasu). Każdy niewarty uwagi test, którego nie napiszesz, kosztuje Cię czas.

4. Nie testuj standardowych połączeń, walidacji, zakresów z Active Record.
Zgadzam się. Zasadniczo nie ma powodu, żeby testować swój framework; tak długo, jak długo mu ufasz. Jeżeli mu nie ufasz (i jest bardzo wiele frameworków, które nie są godne zaufania) wtedy pisanie testów sprawdzających framework może mieć sens. Traktuj to jako "Inspekcję z Sanepidu".

5. Zostaw testy integracji na przypadki integracji oddzielonych elementów (innymi słowy nie testuj testami integracji rzeczy, które mogą być testowane testami jednostkowymi).
Zgadzam się. Twoje testy muszą być skupione. Nie testuj przez graficzne interfejsy użytkownika. Nie testuj przez serwery webowe. Testuj najbliżej kodu, jak tylko się da.

6. Nie używaj Cucumbera, no chyba, że żyjesz w magicznym świecie nie-programistów-piszących-testy (i wyślij mi butelkę soku z gumijagód, gdy już tam będziesz!)
Zgadzam się i nie zgadzam się. Cucumber (i język Gherkin) jest wart uwagi tylko jeżeli masz ludzi biznesu i/lub testerów, którzy chcą czytać Twoje testy. Jeżeli Oni również chcą pisać testy akceptacyjne, wtedy: KONIECZNIE wyślij ten sok z gumijagód wszem i wobec; ponieważ jest wart swojej wagi w diamentach.

7. Nie zmuszaj się do testowania najpierw każdego kontrolera, modelu i widoku (mój stosunek jest zwykle 20% testów najpierw, 80% testów potem).
Zgadzam się... tak jakby. Niektóre kontrolery, modele i widoki są zbyt głupie, żeby je testować. Jeżeli są ewidentnie poprawne, ponieważ są jedną linią kodu, testowanie ich może okazać się zbędne. Ale bądź ostrożny. Czasami jedna linia kodu ma 20 linii znaczeń.
(*) Ktoś mi wskazał, że post "TSA" w zasadzie poprzedza post "TDD id Dead" o kilka lat. Z jakiegoś powodu miałem dostęp do dwóch poprzednich (ech).


Powyższy tekst jest luźnym tłumaczeniem wpisu bloga Roberta Cecila "Wujka Boba" Martina ze strony :
http://blog.cleancoder.com/uncle-bob/2017/03/06/TestingLikeTheTSA.html
Proszę o komentarze, jeżeli ta luźność jest zbyt daleko posunięta.




Dla programistów leniuszków. Travis CI.

Próbowałem dodać sobie do konta Githubowego automatyczne buildy przez serwis Travis CI.


Niestety mimo, że builduje mi się lokalnie przez polecenie rake, to zdalnie lecą błędy:

/home/travis/.rvm/rubies/ruby-1.9.3-p551/lib/ruby/site_ruby/1.9.1/rubygems/core_ext/kernel_require.rb:54:in `require': cannot load such file -- test/unit/testsuite (LoadError)
 from /home/travis/.rvm/rubies/ruby-1.9.3-p551/lib/ruby/site_ruby/1.9.1/rubygems/core_ext/kernel_require.rb:54:in `require'
 from ./test/all_tests.rb:1:in `<main>'
rake aborted!
Command failed with status (1): [/home/travis/.rvm/rubies/ruby-1.9.3-p551/b...]
/home/travis/build/coola/xp-simulator/Rakefile:4:in `block in <top (required)>'
/home/travis/.rvm/gems/ruby-1.9.3-p551/bin/ruby_executable_hooks:15:in `eval'
/home/travis/.rvm/gems/ruby-1.9.3-p551/bin/ruby_executable_hooks:15:in `<main>'
Tasks: TOP => default => test
(See full trace by running task with --trace)
The command "rake" exited with 1.
Done. Your build exited with 1.


Chyba brak odpowiednich bibliotek, z których korzystam w projekcie. Na przykład ruby-test.

Stworzyłem w katalogu głównym plik .travis.yml, który nadpisze domyślne ustawienia builda.
Spróbuję wymusić, żeby wersja Ruby była taka sama jak wersja, jaką mam na serwerze czyli 2.3.1

language: ruby
rvm:
  - 2.3.1

I rzeczywiście działa :) To nie były biblioteki. Chodziło o wersję języka Ruby. Musi być nowsza, żeby ogarniała nową składnię polecenia require.

Commit do tego TU:

Dla programistów leniuszków. Javascript

W czasie poświątecznego tygodnia, z racji braku poniedziałku, postanowiłem przetłumaczyć z Wujka Boba coś krótkiego. Ale jak sprawdzić, który wpis w blogu jest najkrótszy?

Spróbuję to sobie zautomatyzować.

Odpalam przeglądarkę Chrome.
Wchodzę na dowolny wpis w blogu:
Po lewej stronie mam menu z linkami do artykułów:



Odpalam sobie konsolę developerską przez klawisz F12

Klikam zakładkę "Console"



Skorzystam z bardzo szybkiego i popularnego frameworku Vanilla JS http://vanilla-js.com/ :)

Dumnie wyprodukowane przy pomocy:

Żeby wrzucić wszystkie linki do tablicy:

allLinks = document.getElementsByTagName('a');

Żeby wyświetlić wszystkie linki z tej tablicy:

for (i = 0; i < allLinks.length; i++) {
    console.log (allLinks[i].href);
}

Żeby otworzyć jakąś stronę w tle muszę:

Stworzyć obiekt AJAX:

var r = new XMLHttpRequest();

Skonfigurować go:

r.open("GET", allLinks[3], true);

Zdefiniować funkcję na wypadek sukcesu żądania (na razie wyświetlam rozmiar strony):

r.onreadystatechange = function () {
  if (r.readyState != 4 || r.status != 200) return;
  console.log(r.responseText.length);
};

i wypuścić psy:

r.send()


Oczom moim ukazał się rozmiar zdalnej strony:

32266


A teraz tylko zrobić to dla wszystkich linków, za każdym razem sprawdzając czy dany rozmiar jest najmniejszy:

allLinks = document.getElementsByTagName('a');

var currentSmalest = 99999999999;

for (i = 0; i < allLinks.length; i++) {  
 var r = new XMLHttpRequest();
 r.open("GET", allLinks[i], false);
 
 r.onreadystatechange = function () {
      if (r.readyState != 4 || r.status != 200) return;
 
   if (r.responseText.length < currentSmalest){
     currentSmalest = r.responseText.length;
     console.log("Current smalest: " + currentSmalest +", " + allLinks[i].href); 
   }
    };

 r.send();

}


No dobra mój fantastyczny skrypt znalazł mi stronę z powitaniem.

Current smalest: 17711, http://blog.cleancoder.com/


Nie jest to najdłuższa strona ;). Dodam kod omijający tę stronę w funkcji anonimowej podłączonej do r.onreadystatechange :

....
if(allLinks[i].href === "http://blog.cleancoder.com/") return;
....


Teraz wynik jest ciekawszy:

Current smalest: 18911, http://blog.cleancoder.com/uncle-bob/2013/05/27/TransformationPriorityAndSorting.html

Tam jest zamieszczony komiks:



 Na razie nie chcę tłumaczyć tego komiksu, a więc idę do wyniku wyżej:

Current smalest: 19157, http://blog.cleancoder.com/uncle-bob/2015/11/18/TheProgrammersOath.html


Tam jest przysięga programisty, już to przetłumaczyłem :) Idę dalej:

Current smalest: 21418, http://blog.cleancoder.com/uncle-bob/2017/03/06/TestingLikeTheTSA.html


Świetny wybór mój drogi skrypcie.
Krótkie, treściwe, niezbyt stare, o TDD i polemika z DHH.



Strzał w dziesiątkę.
Zabieram się za tłumaczenie :)

Kolorowanie Ruby w Vim

Hej, jakby ktoś był zainteresowany tym, jak włączyć kolorowanie kodu w vim to tak:

:syntax on

Autoformatowanie wcięć kodu w Ruby wymaga większego zachodu. najpierw trzeba upewnić się, że mamy wtyczkę vim-ruby załadowaną w vim:

Polecenie:

:scriptnames

Wyświetla coś takiego:

  1: /home/coola/.vimrc                                                                                                                                                                                                 
  2: /usr/share/vim-7.3/share/vim/vim73/syntax/syntax.vim                                                                                                                                                               
  3: /usr/share/vim-7.3/share/vim/vim73/syntax/synload.vim                                                                                                                                                              
  4: /usr/share/vim-7.3/share/vim/vim73/syntax/syncolor.vim                                                                                                                                                             
  5: /usr/share/vim-7.3/share/vim/vim73/filetype.vim                                                                                                                                                                    
  6: /usr/share/vim-7.3/share/vim/vim73/indent.vim                                                                                                                                                                      
  7: /usr/share/vim-7.3/share/vim/vim73/ftplugin.vim                                                                                                                                                                    
  8: /usr/share/vim-7.3/share/vim/vim73/plugin/getscriptPlugin.vim                                                                                                                                                      
  9: /usr/share/vim-7.3/share/vim/vim73/plugin/gzip.vim                                                                                                                                                                 
 10: /usr/share/vim-7.3/share/vim/vim73/plugin/matchparen.vim                                                                                                                                                           
 11: /usr/share/vim-7.3/share/vim/vim73/plugin/netrwPlugin.vim                                                                                                                                                          
 12: /usr/share/vim-7.3/share/vim/vim73/plugin/rrhelper.vim                                                                                                                                                             
 13: /usr/share/vim-7.3/share/vim/vim73/plugin/spellfile.vim                                                                                                                                                            
 14: /usr/share/vim-7.3/share/vim/vim73/plugin/tarPlugin.vim                                                                                                                                                            
 15: /usr/share/vim-7.3/share/vim/vim73/plugin/tohtml.vim                                                                                                                                                               
 16: /usr/share/vim-7.3/share/vim/vim73/plugin/vimballPlugin.vim                                                                                                                                                        
 17: /usr/share/vim-7.3/share/vim/vim73/plugin/zipPlugin.vim                                                                                                                                                            
 18: /usr/share/vim-7.3/share/vim/vim73/syntax/ruby.vim                                                                                                                                                                 
 19: /usr/share/vim-7.3/share/vim/vim73/indent/ruby.vim                                                                                                                                                                 
 20: /usr/share/vim-7.3/share/vim/vim73/ftplugin/ruby.vim 


No to mam to. Tylko teraz trzeba wyedytować plik ~/.vimrc i upewnić się, że zawiera linie:

set nocompatible " We're running Vim, not Vi!
syntax on " Enable syntax highlighting
filetype on " Enable filetype detection
filetype indent on " Enable filetype-specific indenting
filetype plugin on " Enable filetype-specific plugins


No i teraz w Vim kombinacja klawiszy ggVG= co tłumaczy się na
  • gg - idż na początek pliku
  • V - włącz zaznaczanie
  • G - idź na koniec pliku
  • = - poformatuj wcięcia
Co daje dosyć zabawny efekt:

require "test-unit"

class FirstSmokeTestClass < Test::Unit::TestCase

        def test_if_this_test_framework_works

                assert_equal(2, 1 + 1)                                                                                                                                                                                  

        end

end

~                                                                                                                                                                                                                       
11 wierszy wciętych


Te wcięcia jak dla mnie są za duże. Wystarczą tylko dwie spacje. Jak to zmienić?

Internet mówi, że wystarczy dodać do pliku ~/.vimrc taką linię:

autocmd Filetype ruby setlocal ts=2 sts=2 sw=2

Po otwarciu pliku w vim od razu moim oczom ukazał się poprawnie wcięty kod. WOW:

require "test-unit"                                                                                                                                                                                                     
                                                                                                                                                                                                                        
class FirstSmokeTestClass < Test::Unit::TestCase                                                                                                                                                                        
                                                                                                                                                                                                                        
  def test_if_this_test_framework_works                                                                                                                                                                                 
                                                                                                                                                                                                                        
    assert_equal(2, 1 + 1)                                                                                                                                                                                              
                                                                                                                                                                                                                        
  end                                                                                                                                                                                                                   
                                                                                                                                                                                                                        
end

Oczywiście zmiana wcięć i operacja ggVG= działa tak samo dobrze :) 

Commit do tego TU

Ale, ale - podejrzałem kommit i okazało się, że w rzeczywistości w pliku są tabulatory. Czuję się oszukany :(


Ktoś tu sobie leci w kulki! Edytor sobie, a kod sobie. Nie wiem. Może Ty wiesz?



Po publikacji tego posta koledzy i koleżanki na Slack-u odezwali się do mnie i podsunęli mi rozwiązanie problemu z tabulatorami. Dzięki wielkie! :)


Kommit z tego TU.

Zasada Kolejności Przekształceń

Ten wpis proponuje raczej radykalne przesłanki. Sugeruje, że Refaktoringi mają odpowiedniki nazywane Przekształceniami. Refaktoringi są prostymi operacjami, które zmieniają strukturę kodu bez zmiany jego zachowania. Przekształcenia są prostymi operacjami, które zmieniają zachowanie kodu. Przekształcenia mogą być użyte jako jedyny sposób spełniania aktualnie nieprzechodzącego testu w cyklu red/green/refactor. Przekształcenia mają priorytet, preferowaną kolejność, która zapobiega blokadom lub długim przerwom w pracy w cyklu red/green/refactor.

"Gdy testy stają się bardziej konkretne, kod staje się bardziej ogólny."

Ostatnio ta mantra nabrała dla mnie nowego znaczenia.



Poniższy tekst jest luźnym tłumaczeniem wpisu bloga Roberta Cecila "Wujka Boba" Martina ze strony :
https://8thlight.com/blog/uncle-bob/2013/05/27/TheTransformationPriorityPremise.html
Proszę o komentarze, jeżeli ta luźność jest zbyt daleko posunięta.




Wynalazłem tę zasadę, aby uchronić moich uczniów przed paskudnym nawykiem pisania kodu produkcyjnego odzwierciedlającego kod testowy:


@Test
public void primeFactorsOfFour() {
  assertEquals(asList(),    PrimeFactors.of(1));
  assertEquals(asList(2),   PrimeFactors.of(2));
  assertEquals(asList(3),   PrimeFactors.of(3));
  assertEquals(asList(2,2), PrimeFactors.of(4));
  ...
}

public class PrimeFactors {
  public static of(int n) {
    if (n == 1)
      return asList();
    else if (n == 2)
      return asList(2);
    else if (n == 3)
      return asList(3);
    else if (n == 4)
      return asList(2,2);
    ...

Nowicjusze w TDD często pytają dlaczego TDD nie prowadzi do tego typu kodu. Odpowiadam, że z powodu powyższej zasady. Wyjaśnienie zwykle zaspokaja uczniów, zwłaszcza, kiedy pokazuje im ten pomysł na przykładzie Kata Liczb Pierwszych.

Liczby Pierwsze 

Wymyśliłem to Kata dziesięć lat temu, kiedy mój syn Justin przyszedł do domu z pracą domową. Miał za zadanie policzyć liczby pierwsze kilku liczb całkowitych. Powiedziałem mu żeby zrobił tę pracę domową, a ja napiszę program, który pozwoli mu sprawdzić jego pracę. (On wpisze odpowiedź, a program po prostu powie mu czy miał rację, czy się pomylił).

Usiadłem w kuchni przy stole i używając nowej dyscypliny o nazwie TDD, napisałem algorytm w Ruby. To było dla mnie jedno z tych wydarzeń otwierających oczy. Jak przechodziłem od testu do testu algorytm tworzył się w kompletnie nieoczekiwany sposób. Byłem zdumiony, że mogę sprawić, żeby test numer 3 przechodził tylko przez zmianę jednego znaku z '2' na 'n'. Byłem wstrząśnięty kiedy przypadek numer 8 przechodził przez zmianę słowa 'if' na słowo 'while'. Czułem, że w tym jest coś głębszego, ale nie mogłem wskazać co dokładnie. Ale teraz myślę, że wiem.

Durnota

Przez te wszystkie lata napisałem mnóstwo testów. Co więcej, wykonałem jeszcze więcej różnych Kata. Od czasu do czasu dokonuję drobnych poprawek w Kata. Udoskonalam testy i kod, żeby były prostsze, bardziej eleganckie. Z każdym takim powtórzeniem i udoskonaleniem coś zauważam. Ma to związek z jednym z zarzutów, jaki mają ludzie do TDD: a mianowicie durnoty.

Zobacz jak Kata Kręgli zaczyna się testem dla najgorszej możliwej gry:

@Test
public void gutterGame() {
  for (int i=0; i<20; i++)
    game.roll(0);
  assertEquals(0, game.score());
}

Kiedy uczymy TDD zwykle pytamy: "Jak zrobić żeby ten test przeszedł?". Nowicjusze są często zakłopotani tym pytaniem, bo oczekują, że będą musieli  do tego napisać cały algorytm gry w kręgle. Ale zaskakujemy ich pokazując, że test przechodzi dla kodu napisanego w taki sposób:

public int score() {
  return 0;
}

W tym momencie programiści w klasie wywracają oczy i jęczą. Najwyraźniej sądzą, że to durne i są rozbici tym, że każę im pisać kod w sposób ewidentnie zły.

Zwykle wtedy stosuję unik. Zgadzam się z nimi, ze to było durne, i że po prostu odwlekaliśmy decyzję, aż do momentu, gdy będziemy mieć więcej informacji. Mówię im, że to był także świetny sposób na testowanie testów, no bo, jeżeli metoda zwróciła zero, jasno widzimy, że test przechodzi i oznacza to, że test jest prawidłowy.

Porządek Przekształceń.

To, co odkryłem, to to, że zwracanie zera nie jest tak durne jak wygląda. Nie, jeżeli umieścisz je we właściwym otoczeniu.

Kiedy używamy TDD, nasz produkcyjny kod podlega kolejnym przekształceniom. Zwykle myślę o nich jako o przekształceniach od głupich do sprytnych. Ale zacząłem się przekonywać, że to nie o to chodzi. Raczej kod przechodzi przez zestaw przekształceń od bardziej konkretnych do bardziej ogólnych.

Zwracanie zera z funkcji score to przypadek konkretny. Sedno sprawy leży w poprawnej formie. To jest liczba całkowita i ma poprawną wartość. W związku z tym kształt algorytmu jest poprawny, po prostu nie został jeszcze uogólniony.

Następny test w grze w kręgle to:

@Test
public void allOnes() {
  for (int i=0; i<20; i++)
    game.roll(1);
  assertEquals(20, game.score());
}

Sprawiamy żeby przechodził przez dodanie wszystkich punktów za strącone pachołki w funkcji roll i przechowujemy ich sumę w zmiennej o nazwie score. Potem zmieniamy funkcję score, żeby zwracała wartość tej zmiennej:

public int score() {
  return score;
}

Zauważ, że przekształciliśmy stałą 0 w zmienną score. Algorytm ma taki sam kształt, jak wcześniej (na przykład zwraca inta), ale teraz ma bardziej ogólną implementację. Dlaczego ma bardziej ogólną implementację? Ponieważ zmienna jest uogólnieniem stałej.

Innymi słowy, przekształcenie, które miało miejsce, jest prostą zmianą jednej części rozwiązania z formy bardziej specyficznej w formę bardziej ogólną!

Zacząłem myśleć, że to jest całkiem interesujące. Byłem podniecony faktem, że czasami mogłem dokonać pewnych przekształceń z bardziej konkretnych na ogólne. Potem zacząłem podejrzewać, że to jest zasada, że każda zmiana kodu jest albo zmieniającym przekształceniem od konkretnego do ogólnego, albo refaktoringiem. Rzeczywiście, myślę, że te zasada może pokierować wyborem co do następnego testu do napisania, może też wpłynąć na kod produkcyjny który powinien być napisany aby spełnić ten test.

Ale nie uprzedzajmy faktów. Co z następnym testem w Grze w Kręgle?

@Test
public void oneSpare() {
  game.roll(5);
  game.roll(5); // spare
  game.roll(3);
  rollMany(20,0);
  assertEquals(16, g.score());
}

Ten test zmusza nas, aby porzucić prostą implementację metody score na rzecz dużo bardziej skomplikowanej. Zmienna obiektowa o nazwie score uaktualniana w funkcji roll jest usunięta i funkcja score oblicza wynik z tablicy rzutów kulą bilardową.

Po raz kolejny przekształciliśmy konkretną implementację (zmienna, która zawiera wstępnie obliczony wynik) na bardziej ogólną postać (pętla, która oblicza wynik z tablicy).

Inne częste przekształcenie można zauważyć w kata liczb pierwszych, gdzie przy spełnieniu drugiego przypadku, wstawiamy instrukcję if do implementacji. Kod przekształca się z:

List factors = new ArrayList();
return factors;

do:

List factors = new ArrayList();
if (n>1)
  factors.add(2);
return factors;

W tym przypadku tworzymy kod bardziej ogólny przez warunkowe podzielenie wykonania na dwie ścieżki. Jedna ścieżka sprawia, że stary kod przechodzi i druga ścieżka sprawia, że nowy kod przechodzi.

Kata liczb pierwszych jest pod tym względem interesująca, bo przekształcenie dzieje się znowu w kroku numer 4, kiedy to instrukcja if jest dodawana dla przypadku, kiedy wejściowa zmienna jest podzielna przez 2.

List factors = new ArrayList();
if (n>1) {
  if (n%2 == 0) {
    factors.add(2);
    n %= 2;
  }
  if (n>1)
    factors.add(n);
}
return factors;

Nowa ścieżka radzi sobie z krokiem numer 4 poprzez odkrycie, że liczba 4 jest podzielna przez 2, potem jest dodanie 2 do factors i modyfikacja n tak, żeby ścieżki te mogły się połączyć ponownie.

Jeszcze bardziej interesująco jest w kroku numer 8, gdzie wewnętrzna instrukcja if jest przekształcana w instrukcję while.  I potem dla kroku 9 zewnętrzna if jest przekształcana w while. Jasne, że while jest ogólną postacią if.

Przekształcenia

A więc jakie to są przekształcenia? Być może możemy zrobić z nich listę:
  • ({} -> nil) brak kodu w ogóle -> kod, który zwraca nila (nulla) 
  • (nil -> stała)
  • (stała -> stała+) prosta stała w bardziej skomplikowaną stałą
  • (stała -> skalar) zamiana stałej w zmienną, albo argument
  • (instrukcja -> instrukcje) dodawanie więcej niewarunkowych instrukcji
  • (niewarunkowe -> if) rozbijanie ścieżek wykonania
  • (skalar -> tablica)
  • (tablica -> kontener)
  • (instrukcja -> rekurencja)
  • (if -> while)
  • (wyrażenie -> funkcja) zamiana wyrażenia na funkcję lub algorytm 
  • (zmienna -> przypisanie) zmiana wartości zmiennej
Zapewne są też inne. 

Zapewne zauważyłeś podobieństwo tych przekształceń do refaktoringów. Jakkolwiek refaktoringi używane są do przekształcania konstrukcji kodu, bez zmiany jego zachowania. Te przekształcenia, z kolei, są używane do zmiany zachowania kodu. W szczególności, używamy tych przekształceń do sprawiania, żeby przechodziły testy, które nie przechodzą.

Powinno być jasne, że każde przekształcenie posiada kierunek. One wszystkie przekształcają zachowanie kodu z czegoś konkretnego na coś bardziej ogólnego. W niektórych przypadkach to stała jest przekształcana w zmienną lub zmienna jest przekształcana w tablicę. W innych to instrukcja if jest przekształcana w pętlę while, albo prosta sekwencja jest przekształcana w rekurencję.

Powinno też być jasne, że z grubsza posortowałem tę listę po złożoności jej elementów. Tak jest, przekształcenia na górze listy są prostsze i mniej ryzykowne, niż przekształcenia na dole listy.

Zasada kolejności

To, co jest ostatnio przedmiotem mojego zainteresowania, to pomysł, że przekształcenia na szczycie listy powinny być chętniej wybierane niż te na dole listy. Jest lepiej (lub jak kto woli łatwiej) zmienić stałą w zmienną, niż dodać warunek if. A więc kiedy sprawiasz, żeby test przechodził - robisz to przy użyciu przekształceń, które są prostsze (wyżej na liście) niż bardziej złożone.

Co więcej, gdy tworzysz test, starasz się, aby pozwalał on na łatwiejsze przekształcenia bardziej, niż na złożone przekształcenia; ponieważ im większa złożoność jest wymagana przez test tym większe ryzyko, że nie sprawisz, żeby ten test przechodził.

Problem blokady. 

To Kata Zawijania Wierszy sprawiła, że zacząłem o tym myśleć. Kata zaczyna się dość prosto, ale szybko stajemy przed zagwozdką. Jest tam jedna kolejność testów i decyzji co do implementacji, które stawiają Cię w martwym punkcie, w którym nie ma innej drogi, aby następny test przechodził jak przez napisanie całego algorytmu. Inna kolejność testów pozwala stworzyć algorytm krok po kroku w sposób preferowany przez TDDersów. Jak wybrać właściwe następstwo testów?

To jest stosunkowo często spotykany problem przez TDDersów. Tworzymy test tylko po to, żeby dowiedzieć się, że nie wiemy jak to rozwiązać bez zmiany ogromnej ilości kodu. A im więcej kodu będziemy zmieniać, tym dłużej nam zajmie powrót do koloru zielonego; i cykl red/green/refactor załamie się.

Moja propozycja jest taka, że jeżeli wybierzesz testy i implementacje, które będą wykorzystywać przekształcenia z góry listy, to unikniesz blokady.

Konkretny przypadek :  Zawijanie Wierszy.

Pójdziemy po kolei tokiem rozumowania. Najpierw odpalimy katę zawijania wierszy i wybierzemy ścieżkę, która prowadzi do ślepego zaułka. Potem zrobimy to znów, ale nie pójdziemy tą ścieżką. W każdym przypadku pokażemy przekształcenia.

Pierwszy test w kata zawijania wierszy jest całkiem oczywistym przypadkiem zdegenerowanym. Zauważ, że stosuję pierwsze przekształcenie ({} -> nil):

@Test
public void WrapNullReturnsEmptyString() throws Exception {
  assertThat(wrap(null, 10), is(""));
}


Po napisaniu tego testu, piszemy także nieprzechodzącą implementację, która także korzysta z ({} -> nil) 

public static String wrap(String s, int length) {
  return null;
}


Możemy sprawić, żeby ten test przechodził przez (nil -> stała)

public static String wrap(String s, int length) {
  return "";
}


Następnym testem będzie przypadek pustego stringa. Zauważ, że to proste (nil -> stała) zastosowane do pierwszego testu. Test przechodzi bez zmiany w implementacji. Zwykle uważam to za znak, że wszystko idzie dobrze.

@Test
public void WrapEmptyStringReturnsEmptyString() throws Exception {
  assertThat(wrap("", 10), is(""));
}


Następny test stosuje (stała -> stała+)

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}


Aby ten test przechodził jesteśmy zmuszeni użyć (niewarunkowe -> if) jak i (stała -> skalar)

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s;
}


Impas

W tym miejscu, jeżeli przyglądaliśmy się uważnie zasadzie kolejności, możemy się zastanawiać czy to był mądry krok. W końcu, (niewarunkowe -> if) leży całkiem nisko na liście. Ale w tym przypadku ominę zasadę kolejności, żeby móc zaprezentować blokadę.

Następny test stosuje znowu (stała -> stała+)

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
}


Możemy sprawić, żeby przechodził przez użycie (wyrażenie -> funkcja)

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s.replaceAll(" ", "\n");
}

To jest jeden z tych ruchów, który wydaje się sprytny. Uzasadniamy to mówiąc, że robimy najprostszą rzecz, która działa. Ale mając zasadę kolejności, to nie jest już takie proste. Przekształcenie (wyrażenie -> funkcja) jest bardzo nisko na dole listy.

Następny test stosuje (stała -> stała+)

@Test
public void ThreeWordsJustOverTheLimitShouldWrapAtSecondWord() throws Exception {
  assertThat(wrap("word word word", 9), is("word word\nword"));
}

Ale jak zrobić, żeby to przechodziło? Aktualne rozwiązanie w kodzie nie wydaje się być łatwo przekształcalne na coś, co sprawi, żeby test przechodził. Gdybyśmy mieli funkcję w rodzaju replaceLast(" ",  "\n") to byłoby może łatwe; ale to nie pomogłoby nam w następnym przypadku "word word word word".

Jesteśmy w ślepym zaułku. W tym przypadku, to jest prosty problem i nie jest tak naprawdę trudno znaleźć rozwiązanie. Ale nie o to chodzi. Aktualna sytuacja zmusza nas byśmy zrobili dłuższy krok niż chcemy. Postawiliśmy się na pozycji, w której musimy teraz rozwiązać dużą część problemu zamiast małej, przyrostowej części problemu. Musimy postawić krok, który jest niewygodnie duży.

Przełamanie Impasu

A więc wróćmy teraz do punktu, w którym po raz pierwszy pominęliśmy kolejność przekształceń. Stworzyliśmy wtedy następujący test:

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}

Nie ma w tym teście nic niezwykłego, co mogłoby wskazywać, że ten test jest niepełnosprytny. Oczywiście nie możemy napisać lepszego testu. Jednak implementacja zmusza nas byśmy użyli przekształcenia (niewarunkowy -> if), które ma całkiem niski priorytet.

public static String wrap(String s, int length) {
  if (s == null)
    return "";
  return s;
}

Więc teraz powinniśmy zapytać siebie samych, jakie inne testy możemy stworzyć, które mogą przechodzić dla przekształceń z wyższym priorytetem. W tym momencie implementacją jest po prostu return ""; więc czy są inne wejścia, które powodują zwrócenie pustego stringa?

Długość kolumny, poniżej pewnej wartości jest bez sensu. Możemy dla tego przypadku zwrócić pustego stringa, ale możemy też wyrzucić wyjątek. Myślę, że wyjątek jest prawdopodobnie bardziej właściwy. Ale testy dla tego przypadku wykorzystywałyby także przekształcenie (niewarunkowy -> if). Nadal, to prawdopodobnie dobry pomysł zacząć od przypadków niepoprawnego wejścia.

@Test(expected = WordWrapper.InvalidArgument.class)
public void LengthLessThanOneShouldThrowInvalidArgument() throws Exception {
  wrap("xxx", 0);
}


Który przechodzi przez:

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  return "";
}


Ale to ustawia nas w sytuacji, która była już wcześniej. Więc zgaduję, że nie ma lepszego testu do napisania:

@Test
public void OneShortWordDoesNotWrap() throws Exception {
  assertThat(wrap("word", 5), is("word"));
}


I po (niewarunkowy -> if) i po (stała -> skalar) implementacja wygląda tak:

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  if (s == null)
    return "";

  return s;
}

No więc teraz znowu tworzymy test word word. Tak jak wcześniej to jest zwykłe przekształcenie (stała -> stała+).

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
}

Ostatni raz, kiedy widzieliśmy ten test, sprawiliśmy, żeby działał przy użyciu (wyrażenie -> funkcja). Czy on może być rozwiązany przy użyciu przekształcenia o wyższym priorytecie? Nie wydaje mi się. Każde rozwiązanie, które biorę pod uwagę wymaga zastosowania jakiejś formy algorytmu.

Czy jest inny test, który możemy stworzyć, który może być rozwiązany przy użyciu przekształcenia wyższego priorytetu? Tak, jest taki! Więc zignorujmy (@Ignore) aktualny test i napiszmy taki, który używa prostszego przekształcenia.

@Test
public void WordLongerThanLengthBreaksAtLength() throws Exception {
  assertThat(wrap("longword", 4), is("long\nword"));
}


Ten test może przechodzić dzięki przekształceniu (niewarunkowy -> if).

public static String wrap(String s, int length) {
  if (length < 1)
    throw new InvalidArgument();
  if (s == null)
    return "";

  if (s.length() <= length)
    return s;
  else {
    return "long\nword";
  }
}

To może wyglądać jak oszukaństwo; ale nim nie jest. Rozbiliśmy ścieżki wykonania i nowa ścieżka może być początkowo widziana jako zupełnie pusta, a potem może być przekształcone przez ({}->nil) i (null -> stała). Moglibyśmy napisać te przekształcenia i zobaczyć, że przechodzą; ale po co?

Następny test jest całkowicie oczywisty. Musimy pozbyć się tej stałej. Możemy to zrobić poprzez dodanie nowej instrukcji do istniejącego testu przy użyciu przekształcenia (instrukcja -> instrukcje).

@Test
public void WordLongerThanLengthBreaksAtLength() throws Exception {
  assertThat(wrap("longword", 4), is("long\nword"));
  assertThat(wrap("longerword", 6), is("longer\nword"));
}


To będzie wymagać zastosowania (wyrażenie -> funkcja) do tego, aby przechodziło. Nie ma prostszego przekształcenia i prostszego testu.

else {
    return s.substring(0, length) + "\n" + s.substring(length);
  }


Następny test jest liczbą mnogą dla poprzedniego:

@Test
public void WordLongerThanTwiceLengthShouldBreakTwice() throws Exception {
  assertThat(wrap("verylongword", 4), is("very\nlong\nword"));
}


Możemy sprawić, żeby przechodził poprzez (instrukcja -> rekurencja).

return s.substring(0, length) + "\n" + wrap (s.substring(length), length);


Jest także możliwe spełnienie tego testu przez (if -> while). W rzeczywistości mógłbyś zapytać dlaczego umieściłem (instrukcja -> rekurencja) ponad (if -> while). Więc trochę dalej w tym artykule zbadamy iteracyjne rozwiązanie. Porównanie tych dwóch może przekonać Cię, że rekurencja jest w rzeczywistości prostsza niż iteracja.

Więc teraz wróćmy do pominiętego testu (@Ignore) i włączmy go z powrotem. Jak teraz sprawić, żeby przechodził?

if (s.length() <= length)
    return s;
  else {
    int space = s.indexOf(" ");
    if (space >= 0)
      return "word\nword";
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);
  }

Przekształcenie (niewarunkowy -> if), a następnie przekształcenie (nil -> stała) robią robotę. Co więcej nie można stworzyć prostszego testu, który przechodził, ani użyć prostszego przekształcenia.

Pozbycie się stałej będzie wymagało dodatkowego testu:

@Test
public void TwoWordsLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word", 6), is("word\nword"));
  assertThat(wrap("wrap here", 6), is("wrap\nhere"));
}

Który przechodzi przy użyciu przekształcenia (wyrażenie -> funkcja). Znowu, nie ma prostszego testu czy przekształcenia. (Ze względu na zwięzłość, i aby ten artykuł nie brzmiał jak zdarta płyta przestaję powtarzać to zdanie. Przyjmij to za pewnik dla wszystkich testów i przekształceń poniżej.)

int space = s.indexOf(" ");
    if (space >= 0)
      return s.substring(0, space) + "\n" + s.substring(space+1);
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);


Teraz widzimy, że nowy warunek potrzebuje przekształcenia (instrukcja -> rekurencja). Więc piszemy test, który to wymusza:

@Test
public void ThreeWordsEachLongerThanLimitShouldWrap() throws Exception {
  assertThat(wrap("word word word", 6), is("word\nword\nword"));
}


Zrobienie tego, żeby przechodziło jest proste:

if (space >= 0)
      return s.substring(0, space) + "\n" + wrap(s.substring(space+1), length);
    else
      return s.substring(0, length) + "\n" + wrap(s.substring(length), length);


Teraz możemy zrefaktorować kod, aby usunąć powtórzenia.

public class WordWrapper {
  private int length;

  public WordWrapper(int length) {
    this.length = length;
  }

  public static String wrap(String s, int length) {
    return new WordWrapper(length).wrap(s);
  }

  public String wrap(String s) {
    if (length < 1)
      throw new InvalidArgument();
    if (s == null)
      return "";

    if (s.length() <= length)
      return s;
    else {
      int space = s.indexOf(" ");
      if (space >= 0) 
        return breakBetween(s, space, space + 1);
      else
        return breakBetween(s, length, length);
    }
  }

  private String breakBetween(String s, int start, int end) {
    return s.substring(0, start) + 
      "\n" + 
      wrap(s.substring(end), length);
  }

  public static class InvalidArgument extends RuntimeException {
  }
}


Następny test upewnia nas, że łamiemy linię na ostatniej spacji przed limitem.

@Test
public void ThreeWordsJustOverTheLimitShouldBreakAtSecond() throws Exception {
  assertThat(wrap("word word word", 11), is("word word\nword"));
}


To wymaga przekształcenia (wyrażenie -> funkcja), ale jest tak proste, że wydaje się oczywiste.

int space = s.lastIndexOf(" ");


Chociaż to spełnia nowy test, to łamie poprzedni test; ale możemy zrobić jeszcze jedno przekształcenie (wyrażenie -> funkcja) żeby to naprawić.

int space = s.substring(0, length).lastIndexOf(" ");


Gdziekolwiek limity są w użyciu, musi by rozważona relacja trychotomiczna. Wszystkie długości użyte w testach jednoznacznie wykraczały poza zawijającą spację. A co jeżeli będziemy łamać tekst dokładnie na pozycji spacji?

@Test
public void TwoWordsTheFirstEndingAtTheLimit() throws Exception {
  assertThat(wrap("word word", 4), is("word\nword"));
}


To nie przechodzi, ale można zrobić żeby przechodziło przez użycie przekształcenia (instrukcja -> funkcja).

int space = s.substring(0, length+1).lastIndexOf(" ");


To może nie wyglądać jak przekształcenie (instrukcja -> funkcja), ale jest nim w rzeczywistości. Dodawanie jest funkcją. Równie dobrze moglibyśmy napisać add(length, 1).

Iteracja zamiast Rekurencji

A teraz cofnijmy wskazówki zegara i sprawdźmy, jak rozwinie się podejście iteracyjne w porównaniu z podejściem rekurencyjnym. Pamiętasz, jak zastosowaliśmy (instrukcja -> rekurencja) kiedy  próbowaliśmy spełnić następujący test:

@Test
public void WordLongerThanTwiceLengthShouldBreakTwice() throws Exception {
  assertThat(wrap("verylongword", 4), is("very\nlong\nword"));
}

Kod, który powodował nieprzechodzenie testu wygląda tak:

if (s.length() <= length)
    return s;
  else {
    return s.substring(0, length) + "\n" + s.substring(length);
  }


Możemy sprawić, żeby to przechodziło używając przekształcenia (if -> while). Jeżeli chcemy użyć pętli while, to musimy odwrócić warunek instrukcji if. To jest prosty refaktoring, a nie przekształcenie:

if (s.length() > length) {
    return s.substring(0, length) + "\n" + s.substring(length);
  } else {
    return s;
  }


Następnie potrzebujemy stworzyć zmienną, która przechowuje stan iteracji. Znowu, to jest refaktoring, a nie przekształcenie.

String result = "";
  if (s.length() > length) {
    result = s.substring(0, length) + "\n" + s.substring(length);
  } else {
    result = s;
  }
  return result;
}


Pętle while nie mogą mieć instrukcji else, więc potrzebujemy usunąć ścieżkę else poprzez czystki w ścieżce if. Znowu, to tylko refaktoring.

String result = "";
  if (s.length() > length) {
    result = s.substring(0, length) + "\n";
    s = s.substring(length);
  }
  result += s;


I teraz możemy zastosować (if -> while) aby test przechodził.

String result = "";
  while (s.length() > length) {
    result += s.substring(0, length) + "\n";
    s = s.substring(length);
  }
  result += s;


Proces

Jeżeli przyjmiemy Zasadę Kolejności możemy zmodyfikować typowy proces TDD red-green refactor o następujące zasady:
  • Jeżeli dążysz do spełnienia testu wybieraj przekształcenia z wyższym priorytetem
  • Jeżeli tworzysz test wybieraj taki, który może być spełniony z użyciem przekształceń z wyższym priorytetem.
  • Kiedy implementacja wymaga przekształceń o niskim priorytecie, wróć, żeby zobaczyć czy jest gdzieś tam łatwiejszy test do przejścia.

Problemy

Jest też z tym kilka problemów.
  • Czy istnieją inne przekształcenia? (prawie na pewno)
  • Czy te przekształcenia są właściwe? (pewnie nie)
  • Czy istnieją lepsze nazwy dla tych przekształceń? (prawie na pewno)
  • Czy tu chodzi naprawdę o priorytet? (Tak myślę, ale to może być bardziej skomplikowane niż zwykła sekwencja porządkowa)
  • Jeżeli tak, to jaka jest zasada za tym priorytetem? (jakieś pojęcie "złożoności")
  • Czy można to określić ilościowo, kwantowo? (nie mam pojęcia)
  • Czy kolejność priorytetów zaprezentowana w tym artykule jest poprawna? (nie wydaje mi się)
  • Opisane tu przekształcenia opisane są w najlepszym razie w sposób nieformalny. Czy mogą być sformalizowane? (To byłby Święty Graal!)
Jak widzisz, po moich uwagach na marginesie, mam wątpliwości co do tych wszystkich pytań. Tego, czego jestem pewien, to to, że istnieje fundamentalna zasada, która gdzieś tam się ukrywa. Myślę, że istnieje zestaw ustalonych i prostych zasad, nawet jeżeli nie wymieniłem ich dobrze. Mam nadzieję, że mogą być sformalizowane. Myślę, że gdzieś tam istnieją zasady wybierania - które przekształcenia należy zastosować, nawet nie tak proste jak zwykła lista kolejności. 


Skutki

Jeżeli moje podejrzenia okażą się prawidłowe, kilka rzeczy może stać się możliwych:
  • Narzędzie wspierające przekształcenia podobne do narzędzia wspierającego refaktoringi.
  • Narzędzie wspierające podpowiadanie przekształceń, które zachowuje kolejność.
  • Zestaw testów, przekształceń i refaktoringów może być formalnym dowodem poprawności. 
Formalny Dowód Poprawności

Ostatni punkt wymaga podkreślenia. Jeśli możesz opisać żądane zachowanie programu przy użyciu zestawu testów; i możesz pokazać krok po kroku, jak każdy test przechodzi przy zastosowaniu formalnych przekształceń i refaktoringów, to właśnie stworzyłeś dowód. 

Co dziwne, dowód jest osiągnięty przez tworzenie algorytmu w sposób krok po kroku. To interesujące w porównaniu z podejściem Dijkstry dowodzenia poprawności wychodząc od algorytmu.


Podsumowanie

Podczas zwykłego procesu TDD red/green/refactor, wydaje się, że faza green może być osiągnięta przez zastosowanie ustalonego zestawu przekształceń zmieniających zachowanie kodu. Te zmiany zmieniają formę kodu z konkretnej na bardziej ogólną. Wydaje się także, że te przekształcenia mają ustalony porządek oparty na złożoności. Ta kolejność może być używana tak w fazie red jak i fazie green procesu TDD. Podczas fazy green wybieramy prostsze przekształcenia. Podczas fazy red tworzymy takie testy, które mogą być spełnione przy użyciu prostszych przekształceń. Przesłaniem tego wpisu jest to, że jeżeli testy będą wybierane i implementowane w ustalonej kolejności przekształceń to możliwe jest zredukowanie lub wyeliminowanie blokad, impasów w TDD.



Powyższy tekst jest luźnym tłumaczeniem wpisu bloga Roberta Cecila "Wujka Boba" Martina ze strony :
https://8thlight.com/blog/uncle-bob/2013/05/27/TheTransformationPriorityPremise.html
Proszę o komentarze, jeżeli ta luźność jest zbyt daleko posunięta.