Monday, July 21, 2008

TDD: O co właściwie chodzi?



Szczerze mówiąc nie wiem jak jest w polskich firmach z TDD. Wiem, że testy się pisze, pokrycie się bada, ale jak z samym TDD sprawy się mają – pojęcie mam bliskie zeru. Wszak pisanie testów i TDD to nie to samo.

Testy jednostkowe


Wiadomo co to są testy jednostkowe i jak działają – w gruncie rzeczy, chodzi o to, aby rozpocząć testowanie możliwie wcześnie na najbardziej elementarnym poziomie – na poziomie obiektów i ich metod. Co i jak testować – na tym skupię się innym razem. Teraz chodzi mi raczej o wyszczególnienie sytuacji z jakimi można zetknąć się podczas pisania testów jednostkowych.
  1. Testy dopisywane są po zakończeniu implementacji – cóż, jeśli w projekcie do tej pory testów nie praktykowano, to nie ma innej rady. Trzeba pamiętać tylko o jednej rzeczy: jeśli istniejąca metoda zawiera buga, który jeszcze się nie objawił, to napisany do niej test traktuje go jako poprawne działanie metody. Jest tak właśnie dlatego, że test pisany jest do istniejącej metody, przy założeniu że działa ona poprawnie. Trzeba się więc przygotować na niespodzianki.
  2. Najpierw pisany jest cały test, a następnie cała implementacja – jest to kłopotliwe ponieważ: trudno jest od razu zaplanować kompletny test dla metody, po implementacji często okazuje się, że nawet jeśli jest ona poprawna i tak otrzymujemy green bar. Często z tego powodu, że pomyłka była w teście. I co wtedy, o zgrozo, się dzieje? Zmieniany jest test, a to przecież to on miał być naszą ostoją i gwarantem poprawności implementacji. Jak się za chwilę przekonamy, testy i implementację piszemy przyrostowo – po kawałku.
  3. Pisane są zbędne testy w celu podniesienia współczynnika pokrycia – to taki przejaw instynktu samozachowawczego. Narzędzia do badania pokrycia po części wykrywają takie sytuacje. Ocenę tych praktyk pozostawiam Czytelnikowi.
  4. Wykrycie błędów nie powoduje dodania nowego przypadku testowego – jeśli testy nie ewoluują wraz z kodem, to osłabiana jest tkwiąca w nich siła. Każdy błąd wykryty w kodzie powinien spowodować, że: zostanie dodane nowy przypadek testowy wykrywający dany błąd, a następnie implementacja zostanie poprawiona. Dodanie nowego testu zabezpiecza przed ponownym wystąpieniem danego błędu.
  5. Test odpowiada klasie tylko z nazwy – czasami, w magiczny sposób, implementacja znacznie oddala się od testów. Dzieje się to zwłaszcza wtedy, gdy programiści nie mają nawyku rozpoczynania programowania od testu, pozornie brak czasu na testowanie, a jednocześnie w projekcie nie istnieje kontrola kodu.
  6. Zapomina się, że testy również podlegają refaktoringowi oraz wypracowano dla nich stosowne wzorce projektowe – wiadomo, że entropia wzrasta z upływem czasu. Nic dziwnego zatem, jeśli w pewnym momencie kod klasy testującej jest tak obrzydliwy i ciężki, że wcale nie chce się go czytać. Wzorce i refaktoring testów to temat na osobny artykuł.


Filozofia TDD


Całe TDD można zamknąć w powiedzeniu: „Kapitanowi, który nie wie dokąd płynie, każdy wiatr jest na rękę”. TDD stwierdza wprost: najpierw postaw cel, a potem do niego zmierzaj – najpierw test, potem implementacja. Ma to dawać następujące korzyści: tworzony jest tylko niezbędny kod – ten który jest istotny dla osiągnięcia celu, rozwiązanie jest przemyślane, ze względu na konieczność rozpoczynania od testu, TDD wymusza dobry projekt obiektowy, gdyż testowanie kodu luźno traktującego inżynierię oprogramowania, to droga przez mękę. Dodatkowo posiadanie zestawu dobrych testów to rzecz absolutnie konieczna jeśli chce się myśleć o bezpiecznym refaktoringu kodu.
Zatem jeśli najpierw należy napisać test, to jak ma wyglądać cały proces?
Wspomniałem wcześniej, że pisanie kompletnego testu, a następnie kompletnej implementacji nie jest dobrą praktyką. Zatem jak? Otóż, przyrostowo, spiralnie – kawałek testu, kawałek implementacji.
  1. Napisz fragment testu
  2. Napisz najprostszy możliwy kod, który spełnia test
  3. Zrefaktoruj implementację do pożądanego stanu
  4. Czy implementacja wciąż spełnia test?
    1. Tak: Jeśli implementacja nie zakończona idź do 1
    2. Nie: Idź do 3


Powyżej znajduje się ramowy algorytm tworzenia oprogramowanie poprzez TDD. Warto podkreślić istotność punktu 2. Dlaczego piszemy najprostszą możliwą implementację spełniającą test? Aby przekonać się czy test jest poprawny. Dlatego właśnie pisanie kompletnego testu od razu jest niewskazane – trudno jest zweryfikować jego poprawność.

Red-Green-Refactor: przykład Eclipse'a wzięty


Załóżmy, że tworzony jest sklep internetowy. Pierwszą funkcjonalnością, którą warto się zająć jest koszyk, z którego będzie korzystał użytkownik. Zaczniemy od odnajdywania produktów w koszyku.
  1. Tworzę szkielet klas:
    public class CartManager {
    public Product findProduct( String name ) {
    return null;
    }
    }
    
    public class Product {
    private String name;
    //...
    }
    

  2. Tworzę fragment testu jednostkowego jednostkowego:
    public class CartManagerTest extends TestCase {
    public void testFindProduct() {
    CartManager cartManager = new CartManager();
    
    Product product = cartManager
    .findProduct( "myProduct" );
    
    assertNotNull( product );
    }    
    }
    

  3. Uruchamiam test: Red Bar
  4. Refaktoruję implementację metody (najprostsza możliwa implmentacja!):
    public Product findProduct( String name ) {
    return new Product();
    }
    

  5. Uruchamiam test: Green Bar
  6. Rozbudowuję test:
    public void testFindProduct() {
    CartManager cartManager = new CartManager();
    Product product = cartManager.findProduct( "myProduct" );
    
    assertNotNull( product );
    assertEquals( "myProduct" , product.getName() );
    }
    

  7. Uruchamiam test: Red Bar
  8. Refaktoruję implementację metody (najprostsza możliwa implmentacja!):
    public Product findProduct( String name ) {
    Product product = new Product();
    product.setName( "myProduct" );
    return product;
    }
    

  9. Uruchamiam test: Green Bar
  10. Refaktoruję implementację klasy:
    public class CartManager {
    private Map<String, Product> cartMap
    = new HashMap<String, Product>();
    
    public Product findProduct( String name ) {
    Product product = new Product();
    product.setName( "myProduct" );
    
    cartMap.put( "myProduct" , product );
    return cartMap.get( "myProduct" );
    }    
    }
    

  11. Uruchamiam test: wciąż Green Bar
  12. 1.Refaktoruję test:
    public void testFindProduct() {
    CartManager cartManager = new CartManager();
    final String PRODUCT_NAME = "myProduct"; 
    
    Product putProduct = new Product();
    putProduct.setName( PRODUCT_NAME );
    
    Map<String, Product> cartMap
    = new HashMap<String, Product>();
    cartMap.put( PRODUCT_NAME , putProduct );
    
    cartManager.setCartMap( cartMap );
    
    Product product = cartManager.findProduct( PRODUCT_NAME );
    
    assertNotNull( product );
    assertEquals( PRODUCT_NAME , product.getName() );
    }
    

  13. Uruchamiam test: wciąż Green Bar
  14. Dodaję do testu nową asercję:
    public void testFindProduct() {    
    //...    
    assertNotNull( product );
    assertEquals( PRODUCT_NAME , product.getName() );
    assertSame( putProduct , product );
    }
    

  15. Uruchamiam test: Red Bar
  16. Refaktoruję implementację metody:
    public Product findProduct( String name ) {
    return cartMap.get( "myProduct" );
    }
    

  17. Uruchamiam test: Green Bar
  18. 1.Refaktoruję implementację metody:
    public Product findProduct( String name ) {
    return cartMap.get( name );
    }
    

  19. Uruchamiam test: Green Bar

To wszystko jeśli chodzi o zasadniczą funkcjonalność wyszukiwania w koszyku. Zadanie domowe brzmi następująco: jeśli w koszuku nie ma produktu o danej nazwie metoda findProduct powinna rzucić wyjątek. Dodaj tę funcjonalność pracując zgodnie z TDD. Podpowiedź: aby wymusić red bar użyj metody fail().

Czyli...


Gdybym chciał wybrać jedną rzecz do zapamiętania z tego artykułu to powiedziałbym: kawałek testu, kawałek implementacji...