4it101»J Unit 3

J Unit 3

Testování - příklad s římskými číslicemi

V tomto příkladu se používají pokročilejší techniky testování:

  • testování výjimek,
  • vytváření pomocných struktur pro testování,
  • spouštění testů uvnitř cyklů,

1. Zadání příkladu

V této kapitole vytvoříme třídu, která bude převádět čísla na/z římská čísla. Římská čísla ze zapisují pomocí velkých písmen, které zastupují jednotlivá čísla:

     I = 1
     V = 5
     X = 10
     L = 50
     C = 100
     D = 500
     M = 1000

Pro skládání římských čísel se používají následující pravidla:

  1. Hodnoty písmen se až na výjimku popsanou v pravidle 2 sčítají, např. I je 1, II je 2, III jsou 3, VI je 6, VII je 7, VIII je 8.
  2. Písmena vyjadřující mocniny 10 (I, X, C, M) se mohou za sebou opakovat maximálně 3. Pokud by měly být za sebou čtyři, vyjádří se pomocí odečtení hodnoty od nejbližší vyšší číslice. Číslo 4 nelze vyjádřit jako IIII, ale jako IV, 40 se zapisuje jako XL (50 bez 10), 41 se zapíše jako XLI, 44 jako XLIV (50 bez 10 a poté 5 bez 1). Obdobně 9 se zapíše jako IX, 90 jako XL, 900 jako CM.
  3. Písmena vyjadřující 5, 50 a 500 se neopakují.
  4. Písmena římského čísla se zapisují od největšího k nejmenšímu, na pořadí záleží. DC označuje hodnotu 600, číslo CD vyjadřuje úplně odlišnou hodnotu (400). CI znamená 101, zápis IC není přípustný (nelze odečítat 1 od 100, hodnota 99 se zapisuje jako XCIX – 100 bez 10 a k tomu 10 bez 1).
  5. Pro každou hodnotu existuje pouze jeden správný zápis římskými číslicemi.
  6. Pokud je zápis římského čísla správný, vyjadřuje právě jednu hodnotu.
  7. Pomocí římských číslic lze zapsat pouze omezený rozsah hodnot – celá čísla od 1 do 3999

2. Vytvoření kostry třídy Roman

Prvním krokem je navržení rozhraní třídy - navrhnout metody a konstruktor. V našem případě vytvoříme třídu Roman, která bude mít dvě statické metody public static String toRoman(int arabInput) a public static int fromRoman(String romanInput).

  • metoda toRoman převede celočíselný parametr na řetězec (String), ve kterém bude číslo vyjádřeno pomocí římských číslic,
  • pokud vstupní parametr metody toRoman bude mimo přípustný rozsah (1 až 3999), vyvolá výjimku IllegalArgumentException,
  • metoda fromRoman převede hodnotu zadanou pomocí římských číslic ve vstupním parametru (String) na hodnotu typu int,
  • pokud vstupní parametr fromRoman nebude obsahovat přípustnou hodnotu, bude vyvolána výjimka NumberFormatException.

API třídy Roman bude vypadat následovně:

public class Roman {
    public static int fromRoman (String romanInput) throws NumberFormatException {
        return 0;
    }
    public static String toRoman (int arabInput) throws IllegalArgumentException {
        return "";
    }
}

3. Návrh testů

Po navržení API pokračujeme vytvořením testů, které můžeme rozdělit do tří skupin:

  • testy správných hodnot. Budeme ověřovat, zda metody toRoman a fromRoman správně převádějí vybrané hodnoty.
  • ověření, že metody toRoman a fromRoman správně ošetřují chybné hodnoty parametrů,
  • test konzistence ověří, že pokud číslo převedu na římské číslice a poté zpět, tak získáme původní číslo,

Teoreticky můžeme napsat testy pro všech 3999 hodnot. Ale nedokážit si přestavit, že by někdo prakticky naprogramoval těchto 2x3999 testů (jeden pro každou metodu). Proto vybereme jenom několik hodnot, na kterých ověříme správnost převodu v obou směrech:

     1 -> I          31 -> XXXI           1485 -> MCDLXXXV           3743 -> MMMDCCXLIII
     2 -> II        148 -> CXLVIII        1509 -> MDIX               3844 -> MMMDCCCXLIV
     3 -> III       294 -> CCXCIV         1607 -> MDCVII             3888 -> MMMDCCCLXXXVIII
     4 -> IV        312 -> CCCXII         2499 -> MMCDXCIX           3940 -> MMMCMXL
     5 -> V         421 -> CDXXI          2574 -> MMDLXXIV           3999 -> MMMCMXCIX
     6 -> VI        528 -> DXXVIII        2646 -> MMDCXLVI
     7 -> VII       621 -> DCXXI          2723 -> MMDCCXXIII
     8 -> VIII      782 -> DCCLXXXII      2892 -> MMDCCCXCII
     9 -> IX        870 -> DCCCLXX        2975 -> MMCMLXXV
    10 -> X         941 -> CMXLI          3051 -> MMMLI
    50 -> L        1043 -> MXLIII         3313 -> MMMCCCXIII
   100 -> C        1110 -> MCX            3408 -> MMMCDVIII
   500 -> D        1226 -> MCCXXVI        3501 -> MMMDI
  1000 -> M        1301 -> MCCCI          3610 -> MMMDCX

Následující tabulka obsahuje skupiny chybných vstupních hodnot.

metodatyp chybychybné vstupní hodnoty
toRomanvstupní parametr mimo rozsah 1-3999-1, 0, 4000
fromRomannepřípustné znaky v parametru"I ", "i", "a", "mm", "d", "MCi"
fromRomanpříliš mnoho opakování římských číslic"MMMM", "VV", "LL", "CCCC", "DD", "IIII"
fromRomanopakující se dvojice"CMCM", "CDCD", "IVIV", "IXIX", "XLXL"
fromRomanchybné "odčítání" římských číslic"IIMMCC", "VX", "DCM", "CMM", "CMD", "IXIV", "MCMC", "XCX", "IVI", "LM", "LD", "LC"

4. Jak naprogramovat testy?

4.1 Pro každé porovnání samostatná testovací metoda

První možnost je pro každou hodnotu vytvořit samostatnou testovací metodu (v případě správných hodnot dvě metody), např.

    @Test
    public void testToRoman621() {
        assertEquals("DCXXI", Roman.toRoman(621));
    }

    @Test
    public void testFromRoman621() {
        assertEquals(621, Roman.fromRoman("DCXXI"));
    }

Výhody:

  • název metody popisuje test, v assertEquals není potřeba psát komentáře,
  • vyzkouší se všechny testy (porovnejte s následujícími přístupy pro zápis testů),

Nevýhody:

  • velmi pracné napsat více než 100 testovacích metod,
  • více kódu - větší pravděpodobnost chyb, testovací třída je nepřehledná,
4.2 Více porovnání v jedné testovací metodě

Druhá možnost, sloučit podobné testy do jedné testovací metody:

    @Test
    public void testToRomanZname() {
        assertEquals("I", Roman.toRoman(1));
        assertEquals("II", Roman.toRoman(2));
        assertEquals("III", Roman.toRoman(3));
        assertEquals("IV", Roman.toRoman(4));
        assertEquals("V", Roman.toRoman(5));
        ......
    }

Výhody:

  • méně kódu, než v předchozím přístupu,
  • stejné testy seskupeny k sobě,
  • lze ověřit, že počáteční stav odpovídá předpokladům či že kroky k nastavení testované situace proběhnou správně. Toto je ukázáno na

Nevýhody:

  • když se zjistí chybný výsledek, tak se ukončí testovací metoda, ostatní porovnání v metodě se již nezkouší,
  • ve výpisu s popisem chyby se obtížněji orientuje - není přímo zobrazeno, při kterém porovnání vznikla chyba. V BlueJ je možné si zobrazit řádek, na kterém došlo k chybě (tlačítko "Zobrazit zdrojový kód").

Následuje ukázka testu metody celkemBoduUtoku třídy Hra z domácího úkolu Hra?. První porovnání ověří počáteční stav - že metoda na začátku správně vrací body útoku hráče. Další dvě porovnání slouží k ověření, že se podařilo nasadit zbraň a brnění. Poslední porovnání ověří, že se správně sečtou body útoku.

    @Test
    public void testCelkovePoctyBodu() {
        Hrac hrac = new Hrac ("trpaslík", 5, 2, 3);
        Brneni brneni = new Brneni ("ocelové brnění", 3, 2, 1);
        Zbran zbran = new Zbran ("meč", 3, 2, 1);

        // otestování počáteční stavu před nasazením brnění a zbraně
        assertEquals(5, hrac.celkemBoduUtoku());

        // ověřím, zda se podaří vzít zbraň a brnění
        assertEquals(true, hrac.vemZbran(zbran));
        assertEquals(true, hrac.vemBrneni(brneni));                

        // ověřím celkový počet bodů po nasazení brnění a zbraně,
        assertEquals(11, hrac.celkemBoduUtoku());
    }
4.3 Hodnoty k testování v datových strukturách

Třetí možnost je připravit si datové struktury (např. pole) s jednotlivými hodnotami/dvojicemi a tyto struktury procházet v testovacích metodách. Pro uložení dvojice arabské-římské číslo si vytvoříme pomocnou třídu Dvojice, která bude uvnitř testovací třídy RomanTest:

private static class Dvojice {
    final int arab;
    final String roman;
    Dvojice (int arab, String roman) {
        this.arab=arab;
        this.roman=roman;
    }
};
 

Poté vytvoříme pole, které bude obsahovat instance této třídy, každá s arabským a římským číslem:

    private Dvojice znameHodnoty [] = {
        new Dvojice(1,"I"),
        new Dvojice(2,"II"),
        new Dvojice(3,"III"),
        new Dvojice(4,"IV"),
        new Dvojice(5,"V"),
        //......
 

Po nadefinování hodnot v této tabulce je snadné napsat obě metody pro kontrolu správnosti výsledků. Vzhledem k tomu, že v rámci metody probíhá velké množství testů v cyklu, je potřeba věnovat velkou pozornost komentáři, který se zobrazí v případě chyby.

    @Test
    public void testToRomanZnameHodnoty () {
        for (Dvojice dvojice : znameHodnoty) {
            String vysl = Roman.toRoman(dvojice.arab);
            String komentar = "toRoman(" + (dvojice.arab) + ") :  ";
            assertEquals(komentar, dvojice.roman, vysl);
        }
    }

    @Test
    public void testFromRomanZnameHodnoty () {
        for (Dvojice dvojice : znameHodnoty) {
            int vysl = Roman.fromRoman(dvojice.roman);
            String komentar = "fromRoman(\""+ dvojice.roman + "\") : ";
            assertEquals(komentar, dvojice.arab, vysl);
        }
    }

Výhody:

  • méně kódu pokud testujeme větší množství rozdílných vstupních hodnot,
  • pole s hodnotami pro porovnání je přehlednější než předchozí varianty,
  • při rozšiřování vkládá programátor jenom hodnoty, nemusí psát kód s větší pravděpodobností udělat chybu,

Nevýhody:

  • vytvářet pomocné datové struktury má smysl v případě, že mám větší množství stejných testů s odlišnými parametry,
  • když se zjistí chybný výsledek, tak se ukončí testovací metoda, ostatní porovnání v metodě se již nezkouší,
  • ve výpisu s popisem chyby není přímo zobrazeno, při kterém porovnání vznikla chyba. Nepomůže ani zobrazení zdrojového řádku, neboť všechny porovnání probíhají na stejném místě uvnitř cyklu. Pro přehlednost je nutné do metody s porovnáním doplnit výstižný komentář.
  • je náročnější vymyslet potřebné struktury a naprogramovat testovací metody. Není to pro začátečníky.

Spuštění testů skončí chybou, neboť jsme metody ve třídě Roman nenaprogramovali. Můžeme se ale podívat, zda komentář k testu je dostatečné výstižný:


5. Testy chybných hodnot (testy výjimek)

Vedle testů správnosti výsledků je potřeba též napsat testy, zda v případě chybných vstupních parametrů zahlásí metody chybu.

5.1 Test jedné výjimky v metodě

Pokud chceme ověřit, že při chybném vstupu vznikne výjimka, tak nejjednodušší je do anotace doplnit element expected s očekávanou výjimkou. Pokud výjimka nevznikne či vznikne jiná výjimka, tak test skončí chybou:

    @Test(expected=IllegalArgumentException.class)
    public void testToRomanException0() {
        Roman.toRoman(0);
    }

    @Test(expected=IllegalArgumentException.class)
    public void testToRomanException_1() {
        Roman.toRoman(-1);
    }

    @Test(expected=IllegalArgumentException.class)
    public void testToRomanException4000() {
        Roman.toRoman(4000);
    }

Element expected v anotaci vede k jednoduchým testovacím metodám na výjimky, neboť je možné testovat pouze jednu výjimku. Za řádek, na kterém má vzniknout výjimka, nemá smysl uvádět další kód, neboť se neprovede. Nelze též testovat obsah zprávy předané výjimkou.

5.2 Ošetření chybných arabských čísel

Pro ověření vzniku výjimky lze též použít konstrukci try ... catch pro odchytávání výjimek. Pokud výjimka nevznikne, tak se pomocí metody fail(String textChyby) ohlásí chyba.

Následuje test ošetření chybných vstupních arabských čísel v metodě toRoman - v cyklu se projde seznam chybných hodnot a pro každou se zavolá metoda toRoman.

    @Test
    public void testToRomanException () {
        int chybneArabske [] = { 0, -1, 4000 };
        for (int hodnota : chybneArabske) {
            try {
                String vysl=Roman.toRoman(hodnota);
                fail ("Očekávána výjimka IllegalArgumentException pro Roman.toRoman("
                    + hodnota + ")");
            }
            catch (IllegalArgumentException e) {
                continue;
            }
        }
    }

Otázky:

  • jak skončí test, pokud by v metodě toRoman() vznikla jiná výjimka než IllegalArgumentException?
  • jsou přehlednější tři samostatné metody (5.1) nebo toto řešení s cykly?
  • jak byste ověřili text zprávy (message) předané výjimkou?
5.3 Ošetření chybných římských čísel

Pro test ošetření chybných vstupních parametrů metody fromRoman (chybných římských čísel) se vytvoří pomocná mapa, která se naplní v konstruktoru:

    private Map<String, String []> chybneRimske;

    public RomanTest() {
        chybneRimske = new TreeMap<>();
        chybneRimske.put("nepřípustné znaky v římském čísle",
                            new String[] { "I ", "i", "a", "mm", "d", "MCi" });
        chybneRimske.put("přiliš mnoho opakování znaků",
                            new String[] { "MMMM", "VV", "LL", "CCCC", "DD", "IIII" });
        chybneRimske.put("nepřípustně se opakující dvojice",
                            new String[] { "CMCM", "CDCD", "IVIV", "IXIX", "XLXL" });
        chybneRimske.put("chybné \"odčítání\"",
                            new String[] { "IIMMCC", "VX", "DCM", "CMM", "CMD", "IXIV", "MCMC",
                "XCX", "IVI", "LM", "LD", "LC" });
    }

Metoda pro testování ošetření chybných vstupů obsahuje dva vnořené cykly:

    @Test
    public void testFromRomanException () {              
        for (String typ : chybneRimske.keySet()) {
            for (String hodnota : chybneRimske.get(typ)) {
                try {
                    int vysl=Roman.fromRoman(hodnota);
                }
                catch (NumberFormatException e) {
                    continue;
                }
                fail("Očekávána výjimka NumberFormatException pro Roman.fromRoman(\""
                    + hodnota + "\"); typ chyby: " + typ);
            }
        };
    }
 

6. Test konzistence

V testech správných převodů se neověřili všechny přípustné hodnoty. Částečně to lze dohnat následujícím testem, ve kterém všechna čísla od 1 do 3999 převedu na římské a zpět. Poté zkontroluji, zda výsledek odpovídá vstupní hodnotě, ověřím vztah:

    i = fromRoman(toRoman(i));

pro hodnoty i v rozsahu od 1 do 3999.

    @Test  
    public void testKonzistence () {
        for (int i=1; i < 4000; i++) {
            int vysl = Roman.fromRoman(Roman.toRoman(i));
            assertEquals(i, vysl);
        };
    }

7. Metoda toRoman

Nyní je čas začít psát vlastní třídu (v praxi se většinou střídá psaní testů s psaním vlastního kódu, náš případ je poměrně jednoduchý a tudíž i z pedagogických důvodů jsme popsali nejdříve všechny testy). Začneme třídu toRoman, která je jednodušší (je rychle vidět pokrok v počtu úspěšných testů): Nejdříve doplníme test přípustného intervalu hodnot:

    public static String toRoman (int arabInput) throws IllegalArgumentException {
        if ((arabInput > 3999) || (arabInput <= 0)) {
            throw new IllegalArgumentException("Přípustná jsou čísla z intervalu 1-3999");
        }
        else {
            return "";
        }

    }

Program přeložíme a spustíme test – zjistíme, že test testToRomanException úspěšně prošel.

Nyní do metody toRoman doplníme konverzi na římské číslice. Nejdříve si vytvoříme pomocné pole, které bude obsahovat dvojice hodnot, ze kterých lze skládat římské číslice. Nejsou zde uvedeny pouze jednotlivá písmena, ale i dvojice znaků, které mají speciální význam (4, 9, 40 ...). Pro uložení dvojic si nadefinujeme vnitřní třídu Dvojice (stejná třída, jakou používáme v testovací třídě).

    private static class Dvojice {
        final int arab;
        final String roman;
        Dvojice (int arab, String roman) {
            this.arab=arab;
            this.roman=roman;
        }
    }

    private static Dvojice tabulka [] = {
            new Dvojice (1000, "M" ),
            new Dvojice (900, "CM" ),
            new Dvojice (500, "D" ),
            new Dvojice (400, "CD" ),
            new Dvojice (100, "C" ),
            new Dvojice ( 90, "XC" ),
            new Dvojice ( 50, "L" ),
            new Dvojice ( 40, "XL" ),
            new Dvojice ( 10, "X" ),
            new Dvojice ( 9, "IX" ),
            new Dvojice ( 5, "V" ),
            new Dvojice ( 4, "IV" ),
            new Dvojice ( 1, "I" )
        };

Čísle v tabulce jsou uvedena sestupně. Při převodu se vyhledá první řádek v tabulce, kde arabská číslice je větší či stejná jako převáděné číslo.

TODO: obrázek s výpočtem např. pro číslo 146.

Nyní je již poměrně snadné napsat celou metodu toRoman:

    public static String toRoman (int arabInput) throws IllegalArgumentException {
        if ((arabInput > 3999) || (arabInput <= 0)) {
            throw new IllegalArgumentException("Přípustná jsou čísla z intervalu 1-3999");
        }
        else {
            int cislo = arabInput;
            String vysl="";
            while (cislo > 0) {
                for (int j=0; j < tabulka.length; j++) {
                    if (cislo >= tabulka[j].arab) {
                        vysl=vysl+tabulka[j].roman;
                        cislo=cislo-tabulka[j].arab;
                        break;
                    };
                };
            };
            return vysl;
        }
    }

Nyní budou úspěšné dva testy - testToRomanZnameHodnoty a testToRomanException.


8. Úkoly

  • Napište metodu fromRoman.
  • Při psaní římských číslic se někdy připouští možnost psaní čtyř písmen M za sebou, tj je možno pomocí římských číslic zapsat čísla od 1 do 4999. Upravte testy a třídu Roman tak, aby toto umožňovala.