Píšme testy pred kódom! [Šibenica stvorená trochu inak]

Heslo, ktoré dokola prevolávajú priaznivci agilného programovania je: Najprv píšte testy! Ukážme si, ako si vieme napísať jednoduchý softvér týmto spôsobom. Zdá sa to revolučné? Tak trochu.

Kde je revolúcia, vraj treba vešať. Namiesto biednych vývojárov môžeme vešať v rámci hry. V šibenici a.k.a. hangmanovi, čo je klasická hra o hádaní slov.

Nože, príklad:

  • Klementín: Myslím si slovo, má päť znakov.
  • Anastázia: má v sebe “A”?
  • Klementín: Výborne, slovo vyzerá: _ a _ _ a
  • Anastázia: má v sebe “C”?
  • Klementín: Nie, kreslím obesencovi hlavu.

  • Anastázia: Tak OK, má to v sebe “K”?
  • Klementín: Hej, na predposlednom mieste, vyzerá to: _ a _ k a
  • Anastázia: Tak OK, má to v sebe “P”?
  • Klementín: Haha, určite nie, kreslím mu telo!

Takto hra pokračuje, kým Anastázia neuhádne všetky písmená, alebo kým nevyčerpá všetkých šesť pokusov (hlavu, telo, dve ruky a dve nohy).

Ako implementovať?

Algoritmus vyzerá z pohľadu hráča (veľmi voľne) nasledovne:

  1. ukáž neuhádnuté (a uhádnuté) písmená
  2. v každom ťahu hádaj písmeno
  3. ak si uhádol písmeno, zvýrazni jeho miesta v slove

    3a. ak sú všetky písmená uhádnuté, hra je vyhratá.

  4. ak si neuhádol, nakresli časť tela obesenca

    4a. ak už je nakreslený celý obesenec, hra je prehratá

  5. choď na prvý krok.

Implementujmež triedu (a unit test)!

Unit test alias programujeme naopak

Základná predstava o algoritme by bola, môžeme skúsiť písať unit test v JUnite. Budeme ho písať naopak: pripravíme si kostru triedy a očakávané volania: a samotnú triedu napíšeme neskôr (“write test first!”).

Prvý test bude prechádzka ružovou záhradou: predpokladáme, že hra si myslí slovo troll a ihneď ho uhádneme.

Poznámka bokom: Náhodný výber, ktorý nebude celkom náhodný, zabezpečíme pevným seedom generátora náhodných čísiel. Trik (vrátane konštruktora s parametrom Random) je vysvetlený v samostatnom článku..

Ale teraz už test:

import java.util.Arrays;
import java.util.List;
import java.util.Random;

import org.junit.Assert;
import org.junit.Test;

public class HangmanTest {

    @Test
    public void testIdealRun() {
        List<String> dictionary = Arrays.asList("troll");

        HangmanGame game = new HangmanGame(new Random(1), dictionary);
        game.guess('t');
        game.guess('r');
        game.guess('o');
        game.guess('l');
        game.guess('l');
        Assert.assertTrue(game.isWon());
    }
}

Konštruktor zoberie dva parametre: zmienený Random a zoznam slov, spomedzi ktorých sa vyberie hádané slovo. Zatiaľ máme hlúpu šibenicu, ktorá vždy ponúkne na hádanie len jedno slovo (trolla).

Máme jasné základné schopnosti triedy HangmanGame: pomocou guess() hádame a pomocou isWon() zistíme, či sme vyhrali. Zatiaľ chýba ešte jedna hlavná vec: zistiť, či sme sa po hádaní písmena trafili alebo nie. Nesmieme na to zabudnúť, ale najprv dokončime nástrel prvej verzie triedy.

Samozrejme, toto sa ani neskompiluje, trieda HangmanGame ešte drieme v našich nápadoch, nehovoriac o jej konštruktore, či metódach. V Eclipse môžeme radostne používať Ctrl + 1, teda hinty.

Vytvorme triedu:

Následne nech sa zhmotní šablóna pre konštruktor:

A následne metódy:

Stav triedy

Keď už máme základné schopnosti, pomeditujme nad stavom hry. Čo si potrebuje objekt hry pamätať?

  • slovo, ktorá sa má hádať,
  • uhádnuté písmená,
  • a predvídavo stav šibenice

Dátové typy by mali byť zjavné: slovo, ktoré sa má hádať je String. Uhádnuté písmená zvádzajú k využitiu poľa charov (kde neuhádnuté písmená môžu byť napr. nahradené podtržníkom _), ale to potom spôsobí komplikácie: v používateľskom rozhraní totiž budeme musieť takmer určite prevádzať toto pole na obdivuhodný reťazec. Je tu kompromis: StringBuilder alias “meniteľný String“. Možno mu meniť znaky na jednotlivých pozíciách a prevod na reťazec je priamočiary.

Čo so stavom šibenice? V skutočnosti je to počet ostávajúcich pokusov: ak ostávajú štyri pokusy, znamená to, že je nakreslená hlava a jedna ruka. Jeden int bude musieť stačiť.

Vedľajším produktom budú ďalšie dve stavové premenné: jedna pre generátor náhodných čísiel Random.

Trieda verzia 0.1.alfa.

Hľa, prvotná verzia s prázdnymi metódami:

import java.util.List;
import java.util.Random;


public class HangmanGame {
    public static final int DEFAULT_ATTEMPTS_LEFT = 6;

    private static final char UNGUESSED_CHAR = '_';

    private String challengeWord;

    private StringBuilder guessedCharacters;

    private int attemptsLeft = DEFAULT_ATTEMPTS_LEFT;

    private Random random;

    public HangmanGame(Random random, List<String> dictionary) {
        // TODO Auto-generated constructor stub
    }

    public void guess(char character) {
        // TODO Auto-generated method stub
    }

    public boolean isWon() {
        // TODO Auto-generated method stub
        return false;
    }
}

Poďme doimplementovať obe metódy! (A konštruktor.)

Implementácia isWon()

Toto bude jednoduché: hra je vyhratá, ak hráč uhádol všetky písmená, čiže ak reťazec hádaných písmen sa zhoduje s reťazcom hádaného slova.

public boolean isWon() {
    return guessedCharacters.toString().equals(challengeWord);
}

Implementácia guess()

Mäso triedy, teda hádanie, pôjde v duchu slovného popisu algoritmu:

  • prejdeme písmeno po písmene hádaného slova
  • zistíme, či sa hádané písmeno zhoduje,
    • ak áno, na príslušnom mieste v hádaných písmenách nahradíme neuhádnuté písmeno uhádnutým
    • ak nie, skúšame ďalšie písmeno hádaného slova

Môže sa stať, že sa v slove vôbec nenašlo hádané písmeno: v takom prípade znížime počet pokusov o jedna. Vyriešime to boolean premennou, ktorá sa nastaví na true, ak písmeno v slove našlo.

public void guess(char character) {
    boolean letterIsFound = false;
    for (int i = 0; i < challengeWord.length(); i++) {
        if(challengeWord.charAt(i) == character) {
            letterIsFound = true;
            guessedCharacters.setCharAt(i, character);
        }
    }
    if(!letterIsFound) {
        attemptsLeft--;
    }
}

Ak sa pokúsime spustiť triedu, test zlyhá: a to na NullPointerException v druhom riadku metódy guess(). Hádané slovo sa totiž nikde nenainicializovalo!

Zroďme konštruktor

Napravme to: inicializácia triedy bude nasledovná:

  1. vyberme zo slovníka náhodné slovo (napr. troll)
  2. na jeho základe nainicializujme StringBuilder: pre troll potrebujeme nastaviť obsah na _____ (päť podtržníkov). StringBuilder je totiž na začiatku prázdny (zodpovedá prázdnemu reťazcu), a preto ho potrebujeme naplniť prázdnymi znakmi, ktoré potom budeme nahrádzať uhádnutými písmenami.

Oba kroky vybavme volaním dvoch samostatných metód:

protected HangmanGame(Random random, List<String> dictionary) {
    this.random = random;

    chooseRandomWord(dictionary);
    initializeUnguessedWord();      
}

Metóda initializeRandomWord()

Z náhodného generátora si požiadajme číslo, ktoré použijeme ako index do slovníka slov.

private void chooseRandomWord(List<String> dictionary) {
    int randomIndex = random.nextInt(dictionary.size());

    challengeWord = dictionary.get(randomIndex);
}

Metóda initializeUnguessedWord()

Vytvoríme novú inštanciu StringBuildera, ktorú naplníme neuhádnutými znakmi.

private void initializeUnguessedWord() {
    guessedCharacters = new StringBuilder();
    for (int i = 0; i < challengeWord.length(); i++) {
        guessedCharacters.append(UNGUESSED_CHAR);
    }               
}

Prvý úspešný test

Máme všetko na to, aby sme mohli úspešne spustiť test!

Trieda 2.0 (a unit test)

Flashback do minulosti! Varovný hlas varoval: “Zatiaľ chýba ešte jedna hlavná vec: zistiť, či sme sa po hádaní písmena trafili alebo nie.”

Teraz je ten čas.

Potrebujeme zistiť, či sme písmeno uhádli a ak áno, tak vedieť, ktoré písmená su uhádnuté. Ak sme sa netrafili, musíme vedieť, koľko pokusov ešte ostáva.

Unit test by mohol vyzerať nasledovne. Je strašný,

@Test
public void testDetailedRun() {
    List<String> dictionary = Arrays.asList("troll");

    HangmanGame game = new HangmanGame(new Random(1), dictionary);
    char[] attemptedCharacters = { 't', 'r', 'o', 'l', 'l' };

    for (char c : attemptedCharacters) {
        if(game.guess(c)) {
            System.out.println(game.getGuessedCharacters());
        } else {
            System.out.println("FAIL!" + game.getAttemptsLeft());
        }
    }
    Assert.assertTrue(game.isWon());
}   

V premennej attemptedCharacters budeme mať znaky, ktoré budeme po jednom posielať do hry cez metódu guess().

Tú potom budeme musieť upraviť: nech vráti true, ak sme písmeno uhádli, a false, ak nie. Uhádnuté písmená zistíme pomocou metódy vracajúcej stav premennej guessedCharacters a počet zostávajúcich pokusov vybavíme ďalšou metódou vracajúcou stav premennej attemptsLeft.

Refaktor triedy (podľa unit testu)!

Poďme refaktorovať triedu!

Nech guess() vracia búlieny

Zistiť , či sa písmeno uhádlo je jednoduché: veď máme premennú letterIsFound. Vráťme ju z metódy a je to!

public boolean guess(char character) {
    boolean letterIsFound = false;
    for (int i = 0; i < challengeWord.length(); i++) {
        if(challengeWord.charAt(i) == character) {
            letterIsFound = true;
            guessedCharacters.setCharAt(i, character);
        }
    }
    if(!letterIsFound) {
        attemptsLeft--;
    }
    return letterIsFound;
}

Dva gettery

Ešte jednoduchšie je dopracovať dve metódy pre zistanie stavu premenných:

public CharSequence getGuessedCharacters() {
    return guessedCharacters;
}

public int getAttemptsLeft() {
    return attemptsLeft;
}

Spustenie testu

Ak spustíme test, všetko bude radostne zelené. Na výstupe uvidíme navyše:

t____
tr___
tro__
troll
troll

Refaktor testu!

Upratovanie vo viacerých testov

Keďže máme už dva testy (a pribudnú ďalšie), kde sa na začiatku inicializuje hra s rovnakým slovníkom, použime radšej metódu setUp() s anotáciou @Before, ktorá sa spustí pred každým testom.

Pôvodný kód:

public class HangmanTest {

    @Test
    public void testDetailedRun() {
        List<String> dictionary = Arrays.asList("troll");
        HangmanGame game = new HangmanGame(new Random(1), dictionary);
        ...
    }

    @Test
    public void testIdealRun() {
        List<String> dictionary = Arrays.asList("troll");
        HangmanGame game = new HangmanGame(new Random(1), dictionary);
        ...
    }
...
}

Upravme ho na:

public class HangmanTest {

    @Before
    public void setUp() {
        ...
    }

    @Test
    public void testIdealRun() {
        ...
    }
...
}

Namiesto oka automatika

Pre poriadok by sme však mali nahradiť veci, ktoré odsúhlasí používateľ pohľadom na výstup do konzoly, automatizovaním cez asserty.

Skúsme testovať, či pre každé zadané písmeno dostaneme očakávaný stav aktuálnych písmen. (Ak by sme chceli byť veľmi precízni, mali by sme kontrolovať, či sa počet pokusov náhodou omylom neznížil, ale nebudeme to robiť.)

Celý test vyzerá takto:

@Test
public void testDetailedRun() {
    char[] attemptedCharacters = { 't', 'r', 'o', 'l' };
    String[] expectedGuessCharacters = { "t____", "tr___", "tro__", "troll" };

    for (int i = 0; i < attemptedCharacters.length; i++) {
        char c = attemptedCharacters[i];
        if(game.guess(c)) {
            Assert.assertEquals(expectedGuessCharacters[i], game.getGuessedCharacters().toString());
        } else {
            Assert.fail("Letter " + c + " should be guessed correctly, but it is not.");
        }
    }
    Assert.assertTrue(game.isWon());
}   

Pesimistické testy

Predošlý test bol optimistický: samá prechádzka ružovou záhradou; všetko sme hádali správne, jednoducho radosť hrať.

Otestujme ale aj správanie v prípade neuhádnutých písmen.

Celé zle… ale tak to má byť

Začnime totálnym pesimizmom: nahádžme do testu šesť zlých písmen — na konci by sme mali mať nula voľných pokusov.

@Test
public void testCompletelyFailedGuessing() {
    for (int i = 0; i < 6; i++) {
        if(game.guess('w')) {
            Assert.fail("Letter 'w' is incorrectly guessed");
        }       
    }
    Assert.assertEquals(0, game.getAttemptsLeft());
}       

Chvíľu dobre, chvíľu zle

A čo takto test, kde začneme zle, zadaním dvoch zlých znakov, ale doklepneme to v poriadku? Test zbehne, ak nám ostanú ešte štyri pokusy, a hru vyhráme. (Trochu zmeníme zadávanie znakov: použijeme String v roli poľa znakov.)

public void testWithTwoFailures() {     
    for(char c : "xytroll".toCharArray()) {
        game.guess(c);
    }
    Assert.assertEquals(4, game.getAttemptsLeft());
    Assert.assertTrue(game.isWon());
}   

Pesimizmom k robustnosti

Čo sa však stane, ak sa pokúsime hádať písmená desaťkrát?

public void testFailTenTimes() {
    for(char c : "qwetzuipas".toCharArray()) {
        game.guess(c);
    }
    Assert.assertEquals(0, game.getAttemptsLeft());
}       

Ak test spustíme, uvidíme červený pás. Test zlyhal s výnimkou:

java.lang.AssertionError: expected:<0> but was:<-3>

Mínus tri? Áno, presne toľko pokusov nám ostáva po zadaní desiatich zlých písmen. Teoretickí matematici sa pobavia, ale v teste to nedáva vôbec zmysel.

Za normálnych okolností by nás po vyčerpaní pokusov mala metóda guess() odmietnuť: najlepšie vyhodením výnimky. Klient triedy totiž má možnosť ako zistiť, či mu ešte ostávajú pokusy: má predsa metódu getAttemptsLeft(). Ak si neoverí, či pokusy ostávajú, je to jeho problém (narušil kontrakt metódy) a zaslúži si za trest výnimku.

Refaktor metódy guess()

Dodajme na začiatok metódy guess() overenie základnej precondition, teda podmienky, ktorá musí byť splnená na to, aby zvyšok kódu v metóde fungoval korektne. Ak je počet pokusov nulový či záporný, žiadne hádanie sa nesmie konať:

public boolean guess(char character) {
    if(attemptsLeft <= 0) {
        throw new IllegalStateException("No more guessing attempts left");
    }
    /* ... zvyšok metódy ... */

… a refaktor testu

V tomto prípade test uspeje, ak metóda vyhodí výnimku IllegalStateException. Na prvý pohľad to vyzerá zvrátene: uspejeme, ak zlyháme, ale takúto situáciu presne potrebujeme odchytiť a otestovať.

Test upravíme jednoducho: anotácia @Test má parameter expected, kde uvedieme názov výnimky, ktorú očakávame.

@Test(expected=IllegalStateException.class)
public void testFailTenTimes() {
    ...
}

Test teraz uspeje v zelenej radosti.

Finálne úpravy

Trieda je skoro hotová. Hodí sa dotvoriť ešte jednu metódu: a to pre získanie celého slova, ktoré sa má hádať; budeme ju totiž potrebovať na konci prehratej hry, keď budeme chcieť ukázať používateľovi, čo to vlastne neuhádol.

public String getChallengeWord() {
    return challengeWord;
}

Testovať túto metódu zatiaľ nemusíme: v jednoriadkových getteroch sa nemá čo pokaziť.

A finálne myšlienky

Recyklovateľnosť inštancií

Možno sa pýtate, čo sa stane s inštanciou triedy HangmanGame, ak hra dobehne.Túto triedu sme navrhli v duchu “použi a zahoď”: ak potrebujete naštartovať novú hru, jednoducho si vytvoríte novú inštanciu a na starú zabudnete. Iní dizajnéri by možno navrhli recyklovateľné inštancie — dodali by napr. metódu reset(), ktorá by nastavila počet pokusov na pôvodnú hodnotu, vybrala nové slovo atď. Súvisí to s nedostatkom tejto verzie: nie je vylúčené, že sa dvakrát po sebe vygeneruje v dvoch inštanciách hry to isté slovo. (Hádate psa, potom psa, potom psa… jednoducho nuda.)

Filozofia na záver

Táto trieda je dostatočne robustná, testovateľná, radosť pozrieť a spustiť — a nič nebráni nad ňou napísať používateľské rozhranie. Príkazový riadok, servlet, applet, androidová aktivita — všetko môže využívať túto triedu. To je skvelý vedľajší dôsledok: oddelením mäsa (pardón, biznis logiky) od používateľského rozhrania tvoríme lepší (pardón, udržiavateľnejší) softvér.

Celý kód (fakt o dvoch triedach)

Stiahnite si monumentálny kód pozostávajúci z dvoch tried.

2 thoughts on “Píšme testy pred kódom! [Šibenica stvorená trochu inak]

  1. V praxi to často býva menej priamočiarejšie: mnohokrát mám predstavu o triede dostatočne jasnú, alebo ide o triedu, ktorá vznikla podľa návrhových vzorov, tak ju najprv vytvorím takmer celú a potom dopíšem testy. Alebo je to mixované, vytvorím triedu a metódy, dorobím test, zistím, že mi čosi chýba, prepíšem triedu a podobne.

    V tomto spôsobe je veľká výhoda: keď najprv píšeš test, definuješ API, čo znamená, že medzi verejnými metódami máš len tie, ktoré naozaj potrebuješ, s hlavičkami, ktoré ti vyhovujú. Triedu totiž píšeš na to, aby ju používali ostatní klienti (v zmysle kódu, ktorý na nej závisí) a test ti hlavne dá predstavu, ako bude trieda fungovať v spolupráci s inými.

Napísať odpoveď pre Róbert Novotný Zrušiť odpoveď

Vaša e-mailová adresa nebude zverejnená. Vyžadované polia sú označené *