Въведение в JUnit 4

Въведение

Тестването на нашите програми е една от не-особено любимите практики на повечето програмисти. Повечето от нас казват – за какво са ни тези QA-и, ако не изтестват това, което сме сътворили. Аз обаче мисля по друг начин – не бих се доверил на QA-те да проверяват най-общите възможности на моя development преди аз да съм го направил. Дори не бих имал самочувствието да дам кода си за тестване на когото и да било, без да съм сигурен, че той работи.

Най-лесният начин да свърша тази много важна задача е т.нар. unit testing. В тази статия ще представя четвъртата версия на един от най-популярните Java unit testing frameworks – JUnit. Ще наблегна повече на разликите с JUnit 3, отколкото на фундаментите в тази сфера. За последните съм отделил няколко реда в първата част, така че тези от вас, за които JUnit не е нещо ново, може да преминат директно на “Новости в JUnit 4” секцията. Ако обаче никога не сте се сблъскали с тази материя, бих ви препоръчал да се запознаете с някакъв уводен материал преди да продължите нататък. Едно просто търсене в Google с “JUnit tutorial” изкарва доста полезни сайтове, които могат да послужат за старт.

Кратко описание на Unit testing-а

Според Wikipedia unit testing-ът е метод за софтуерна проверка, при който програмистът проверява дали дадени парчета (unit-и) от source кода работят правилно. В езикът Java методите са тези въпросни unit-и. Идеята е те по възможност да се тестват индивидуално и в изолация от цялата останала част от нашия код.

Unit test-овете се групират от своя страна в класове наречени test case-ове. В идеалния случай един тестов клас (или test case) отговаря на един клас от продуктивния код, а за всеки негов публичен метод има поне един тестов метод. Отново в този идеален случай тестовите методи се пускат в изолация един от друг, т.е. проверката на поведението на даден продуктивен метод не зависи пряко от изпълнението на други методи. Реалността показва обаче, че самите класове не живеят самостоятелен живот, а имат зависимости един от друг (клас А използва в своя метод B методът C на класът D). При това положение, за да се постигне така желаното тестване в изолация, се използват различни техники като stubbing, mocking и пр. С тяхна помощ нещата “се нагаждат”, така че при тестването на операцията B на класа A извикването на операцията C на класът D да връща точно определен резултат без да се налага нейното реално извикване и вобще инициализирането на самия клас D. Тези техники обаче сами по себе си са цяла една наука, така че ще оставим тяхното разглеждане за някоя от следващите серии.

В Java светът библиотеката, която се ползва при писане и пускане на unit тестове е JUnit. Във версиите й преди 4 тест класовете наследяват junit.framework.TestCase. Тестовите методи са публични и не връщат резултат (void), а името им започва с test. Има два специални метода: setUp и tearDown. Първият се вика преди да се изпълни всеки тестов метод и обикновено се използва за подготовка на тестовите ресурси. Вторият пък се изпълнява веднага след изпълнението на тестовия метод и предназначението му е да изчиства създадените по време на теста артефакти (ако е необходимо естествено).

Основната задача на тестовите методи е да изпълнят тестваните операциите при дадени условия и с дадени входни параметри и да проверят дали резултатът отговаря на предварително очакваното. Тези проверки се осъществяват с помощта на някой от многото assertXxx() методи дефинирани в базовия клас. Тестовете се считат за успешно изпълнени, ако не хвърлят exception, който не се обработва в тестовия метод и ако всички assert-и са удовлетворени.

Ето един примерен клас заедно с един негов JUnit 3 test case:

public class OsFile extends File {

    public OsFile(String pathname) {
        super(pathname);
    }

    public void copyToFile(File toFile) throws IOException {
        if (!toFile.canWrite()) {
            throw new IOException("Can't write target file");
        }

        // Implementation goes here
    }

    public void moveToFile(File toFile) {

    }
}
import junit.framework.TestCase;

public class OldOsFileTest extends TestCase {

    OsFile target = new OsFile("resources/target.txt");
    OsFile source = new OsFile("resources/source.txt");

    protected void setUp() throws Exception {
        source.createNewFile();
    }

    public void testCopyFile() throws IOException {
        source.copyToFile(target);
        assertTrue(target.exists());
        assertTrue(source.exists());
    }

    public void testMoveFile() {
        source.moveToFile(target);
        assertTrue(target.exists());
        assertFalse(source.exists());
    }

    protected void tearDown() throws Exception {
        target.delete();
    }
}

Новости в JUnit 4

С навлизането на Java 5 feature-ите и станалите модерни POJOs, ограниченията на JUnit 3 станаха все по-явни. Освен това новите изисквания към най-използвания тестов framework и излизането на TestNG наложиха написването на версия 4 на продукта, която да се съобрази с всички горепосочени аспекти.

След бърз преглед на новостите в новата версия първото нещо, което се набива на очи е, че вече се използват основно анотации. Отпада условието тестовият клас да наследява framework-specific базов клас, а имената на тестовите методи да започват задължително с test. Отново чрез анотации може да се зададе очакването въпросния тестов метод да предизвика хвърляне на даден конкретен exception от тествания код. Отпада и условието за имена на setUp и tearDown методите – вече и те се обозначават със специални анотации и може да се именоват по произволен начин. Може да има и методи, които се изпълняват веднъж преди първия тест в класа и след това след края на последния. По съвсем лесен начин даден тест може да бъде конфигуриран да изпълнява дадена операция многократно с различни входни данни (без да се налага да пишем отделни тестови методи за целта). Може да конфигурираме дадени тестове да не се изпълняват, както и да указваме, че определен тест трябва да мине за по-малко от зададено от нас време.

Повечето от тези новости ще бъдат разгледани в подробности във следващите няколко секции.

Анотации и static imports

Както стана вече дума, в JUnit 3 тестовите класове трябваше да наследяват junit.framework.TestCase, а имената на тестовите методи трябваше да започват с test. В JUnit 4 тестовите класове са Plain Old Java Objects (POJOs). Те вече няма нужда да наследяват каквото и да било от JUnit framework-а.

Все пак, за да кажете на JUnit кои са вашите тестове, трябва да анотирате методите с @Test. Остава изискването те да са публични и да не връщат резултат, но пък вече няма нужда името на тестовият метод да започва с test. И ето нашият тестов клас вече като част от JUnit 4:

import org.junit.Test;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;

public class OsFileTest {

    OsFile target = new OsFile("resources/target.txt");
    OsFile source = new OsFile("resources/source.txt");

    @Test
    public void copyFileTest() throws IOException {
        source.copyToFile(target);
        assertTrue(target.exists());
        assertTrue(source.exists());
    }

    @Test
    public void moveFile() {
        source.moveToFile(target);
        assertTrue(target.exists());
        assertFalse(source.exists());
    }

}

Както виждате, това е абсолютно същият тест като в 3 (нарочно съм пропуснал setUp и tearDown – тях оставих за по-нататък). С малките разлики, че OsFileTest не наследява нищо, тестовите методи са анотирани с @Test, а към имената им няма никакви рестрикции.

Доста е интересна съдбата на assertXxx методите. Досега ги “получавахме” от нашия базов клас (TestCase), или по-точно от неговия parent – Assert. Сега отново ги получаваме от Assert, но не чрез наследяване, а чрез поредната нова техника в Java 5 – static imports. Вторият и третият ред от горният пример ни позволяват да викаме съответните assertXxx методи както сме го правили в JUnit 3.

Инициализация и почистване

В JUnit 3 можеше чрез методите setUp() и tearDown() да кажем на framework-а да изпълни определен код преди и след всеки тестов метод. Тази възможност я има и в JUnit 4, като отново се задава с анотации и отпада задължението към името на методите. setUp() е заместено от метод с произволно име и с анотация @Before, а tearDown() – от също толкова свободно наименован метод, но с анотация @After:

import org.junit.After;
import org.junit.Before;

public class OsFileTest {
...

    @Before
    public void initialize() throws IOException {
        source.createNewFile();
    }

    @After
    public void cleanup() {
        target.delete();
    }
...
}

В JUnit 4 имаме допълнителни възможности за по-добра инициализация на тестовете ни. С цел по-голяма ефективност можем да подготвим ресурсите, които ще ползваме преди да се изпълни първия тестов метод (и съответно неговият @Before). Аналогично – можем да изчистим всичко след последния тест (или по-конкретно след неговия @After). Това става чрез задаване на сътветната логика в методи, които са (изненада!) специално анотирани. Методът, който се вика преди всички останали е конфигуриран с @BeforeClass, а последният – с @AfterClass.

Ето новата версия на нашия тест:

import java.io.IOException;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;

public class OsFileTest {

    OsFile target = null;
    OsFile source = null;

    @BeforeClass
    public void createTestObjects() {
        target = new OsFile("resources/target.txt");
        source = new OsFile("resources/source.txt");
    }

    @Before
    public void initialize() throws IOException {
        source.createNewFile();
    }

    @Test
    public void copyFileTest() throws IOException {
        source.copyToFile(target);
        assertTrue(target.exists());
        assertTrue(source.exists());
    }

    @Test
    public void moveFile() {
        source.moveToFile(target);
        assertTrue(target.exists());
        assertFalse(source.exists());
    }

    @After
    public void cleanup() {
        target.delete();
    }

    @AfterClass
    public void deleteEverything() {
        target.delete();
        source.delete();
    }
}

Тестване за exception-и

Нашият copyToFile метод хвърля IOException, когато не може да копира въпросния файл поради това, че по някаква причина не може да създаде копието на диска. В JUnit 3 проверявахме тази функционалност по следния начин:

    public void testCopyToNonWritableFile() {
        try {
            target.createNewFile();
        } catch (IOException ioe) {
            ioe.printStackTrace();
            fail();
        }

        target.setWritable(false);

        try {
            source.copyToFile(target);
            fail("Copying to non-writable file should not be possible");
        } catch (IOException ioe) {
            // This is the correct behavior
        }
    }

Маркирани са шест реда код, които са написани просто, за да се уверим, че правилно обработваме даден exception. На пръв поглед няма проблем. Но в една голяма програма exception handling проверките са много. Така че тези 6 реда идват малко в повече като се умножат по всичките случаи в които ги правим. JUnit 4 идва с едно по-елегантно решение. @Test анотацията си има атрибут expected, който може да се използва, когато въпросният тестов метод се очаква да хвърли exception. Стойност на въпросния атрибут е Class обектът на очаквания exception. Ако методът мине без хвърляне на exception или хвърли нещо различно от зададеното, тогава тестът fail-ва автоматично. Ето я преработената версия на горният тест:

    @Test(expected=IOException.class)
    public void copyToWritableFileThrowsIOE() throws IOException {
        try {
            target.createNewFile();
        } catch (IOException ioe) {
            ioe.printStackTrace();
            fail();
        }

        target.setWritable(false);

        source.copyToFile(target);
    }

Тук в първият случай (target.createNewFile()) обработваме IOException и хвърляме грешка, ако се появи (това е наистина неочаквано поведение). Но накрая изпълняваме само тествания метод и нито ред повече. Методът няма как да не хвърля IOException – все пак трябва да го накараме да се компилира без да обработваме грешката при copyToFile.

Параметризирани тестове

Понякога ни се налага да пуснем даден тест много пъти с различни входни данни, за да проверим дали тестваният код се държи коректно в различни сценарии. В JUnit 3 за всеки един комплект входни данни трябваше да пишем или отделен тест, или да правим сложни еквилибристики с помощта на цикли и пр.

JUnit 4 ни улеснява много с въвеждането на параметризирани тестове. За да илюстрирам по-добре какво имам впредвид, нека да дам за пример един метод, който по зададено число връща съответния елемент от реда на Фибоначи:

public class Fibonacci {

    public int fib(int n) {
        if (n <= 1) return n;
        return fib(n - 1) + fib(n - 2);
    }
}

Би било добре да изтестваме нашия метод с възможно най-много входни данни (особено с граничните стойности). Можем да си спестим излишно писане като използваме споменатите вече параметризираните тестове. За да накараме JUnit да пусне един и същи тест няколко пъти с различни входни данни, трябва да направим следното:

1) Анотираме нашия тестов клас с @RunWith(Parameterized.class)
2) Дефинираме си публичен статичен метод, който връща Collection от масиви от даден обект (задължително поне Object) и е анотиран с @Parameters
3) Дефинираме конструктор, който приема няколко параметъра. Броят на параметрите отговаря на големината на масива, с който е инициализиран горния Collection.

За да станат нещата малко по-ясни, ето го теста на нашия Fibonacci клас

import static org.junit.Assert.assertEquals;

import java.util.Arrays;
import java.util.Collection;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;

@RunWith(Parameterized.class)
public class FibonacciTest {

    private int number;
    private int expected;

    private Fibonacci fib = new Fibonacci();

    public FibonacciTest(int number, int expected) {
        this.number = number;
        this.expected = expected;
    }

    @Parameters
    public static Collection<Integer[]> data() {
        return Arrays.asList(new Integer [][] {{0, 0}, {1, 1}, {2, 1},
		    {3, 2}, {4, 3}, {5, 5}});
    }

    @Test
    public void checkNumber() {
        int calculated = fib.fib(number);
        assertEquals(expected, calculated);
    }
}

И така, виждаме, че нашият клас е анотиран, че ще се третира като параметризиран: @RunWith(Parameterized.class).

След това, конструкторът ни приема два параметъра. Това автоматично означава, че масивите в инициализационната колекция (връщана от data() метода ни) трябва да имат два елемента. Първият ще инициализира number, а вторият – expected.

Колекция с данните се връща от data() метода. Когато пуснем теста, всъщност JUnit пуска толкова тестове, колкото елемента има инициализацонната колекция. В горния пример ще имаме общо 6 теста, защото data() връща списък от 6 масива. За всеки един от тях се създава нов клас, в чийто конструктор се подават тестовите данни от съответния масив. При това положение в нашия случай, ако добавим втори тестов метод, ще имаме 12 теста. Затова е добре, ако този метод не е параметризиран (т.е. тества нещо друго), да се премести в друг тестов клас.

Ето някои интересни неща, които ми направиха впечатление, докато подкарвах теста:
1) Типът на масивите тук не може да е примитивен, затова използваме Integer
2) Ако искаме да имаме @BeforeClass и @AfterClass методи, те задължително трябва да са статични

Други благинки

С версия 4 на JUnit с много малко усилия можем да пишем и съвсем прости performance тестове. Това става с timeout параметъра на @Test анотацията. Неговата стойност показва за колко максимум милисекунди трябва да е свършил тестът. След като изтече това време, тестът fail-ва.

В нашият пример с OsFile можем да зададем очакване, че тестовият файл ще бъде копиран за максимум една секунда:

    @Test(timeout=1000)
    public void copyFileTest() throws IOException {
        source.copyToFile(target);
        assertTrue(target.exists());
        assertTrue(source.exists());
    }

Понякога, особено в Test driven development-а в най-чистата му форма, можем да пишем тестове, които не минават поради липсваща функционалност (все пак как беше: test first). В други случаи някои тестове (например такива, които достъпват мрежови ресурси или обработват огромно количество данни), може да продължават прекалено дълго време и да не ни се иска да ги пускаме всеки път. Вместо обаче да ги коментираме и да забравим за тях, може да ги анотираме с @Ignore. По този начин JUnit наистина няма да изпълни този тест, но пък в summary-то накрая ще изкара съобщение, че има пропуснати тестове. В крайна сметка тези тестове не са написани само за красота, а за да бъдат пускани, за да валидират определени аспекти от продуктовния ни код.

Ако например решим да напишем тест, проверяващ дали нашата copy операция се справя със symbolic links под UNIX, но все още ни е трудно да преценим как да ги implement-ираме в нашия OsFile (един колега казва, че в C се чудят как да го направят откакто съществува), то въпросният тест може да бъде изключен по този начин.

Заключение

В настоящата статия се опитах да обобщя какви са новостите в JUnit 4 спрямо предишната версия на този най-използван Java unit testing framework (да ме извиняват феновете на TestNG). Има някои неща, за които не ми остана време – test suit-ове, test runner-и, интеграция с ant и maven, интеграция с Eclipse, новости в JUnit 4.7. Но и на тях ще им дойде времето…

Използвани материали

Java Power Tools, by John Fergusson Smart
http://www.devx.com/Java/Article/31983/0/
http://www.ibm.com/developerworks/java/library/j-junit4.html

Responses

  1. […] https://ivko3.wordpress.com/%D0%B2%D1%8A%D0%B2%D0%B5%D0%B4%D0%B5%D0%BD%D0%B8%D0%B5-%D0%B2-junit-4/ […]

  2. […] седмица ви разказах за новостите в JUnit 4. Една от тях беше писането на […]


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: