Daniel Kolman

Jak v javě strukturovat testy velkých tříd

| 0 comments |

Pro každou třídu typicky existuje jedna testovací třída. Tento klasický pattern má ale nevýhodu, když testujete velkou třídu, která se používá v různých scénářích. Testovací třída pak obsahuje mnoho testů, setup různých situací vyžaduje ještě další metody navíc. Pokud mají vaše testovací třídy mnoho metod, měli byste se zamyslet, jak je lépe strukturovat.

Samozřejmě, můžete namítnout, že pokud má třída mnoho metod nebo se používá v různých scénářích, je to jasné porušení Single Responsibility Principle a to hlavní o co byste se měli snažit, je definovat zodpovědnost a extrahovat vše nesouvisející do jiných tříd. Jenže jsou minimálně dvě situace, kdy to dost dobře nejde:

  1. Controller třídy v běžných MVC frameworcích (ASP.NET MVC, Spring MVC, Grails...), které mají pro každou obsluhovanou URL a HTTP verb (GET, POST) jednu metodu.
  2. Píšete regresní test, který má zafixovat chování části aplikace. Typicky se nejedná o čistý unit test (kde testujeme jednu třídu v izolaci), ale o integrační test, který má fungovat jako záchranná síť pro refaktoring.

Abychom si ukázali o co jde na konkrétním příkladu, vypůjčíme si příklad použitý v diskusi na stejné téma ve světě C# (přeložený do javy):

public class Titleizer {

    public String titleize(String name) {
        if (name == null || name.length() == 0)
            return "Your name is now Daniel the Foolish";
        return name + " the awesome hearted";
    }

    public String knightify(String name, boolean male) {
        if (name == null || name.length() == 0)
            return "Your name is now Sir Jester";
        return (male ? "Sir" : "Dame") + " " + name;
    }
}
Co konkrétně tato třída dělá není důležité, hlavní je, že obsahuje dvě velmi nezávislé metody. Každá z nich ale bude potřebovat několik testů a my bychom rádi, aby byly testy pro každou z nich nějak seskupené.

Trik který na to ve zmiňovaném článku použili, je vytvořit pro každou metodu vnořenou třídu a seskupit do ní její testy. Funguje to v xUnit i v NUnit a takhle se to dá napsat v JUnit:

import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;

@RunWith(Enclosed.class)
public class TitleizerTest {

    public static class TheTitleizeMethod {

        @Test
        public void returnsDefaultTitleForNullName() {
            // Test code
        }

        @Test
        public void appendsTitleToName() {
            // Test code
        }
    }

    public static class TheKnightifyMethod {

        @Test
        public void returnsDefaultTitleForNullName() {
            // Test code
        }

        @Test
        public void appendsSirToMaleNames() {
            // Test code
        }

        @Test
        public void appendsDameToFemaleNames() {
            // Test code
        }
    }
}
Všimněte si anotace @RunWith(Enclosed.class). Bez té vám test nepoběží (ba dokonce spadne kvůli legendární hlášce "No runnable methods"). Naopak s touto anotací bude i v IntelliJ krásně vidět struktura testovacích metod:

A teď si představte, že vaše třída potřebuje nějaký netriviální setup, který je potřeba pro každou z testovaných metod nějak modifikovat. Takže bychom chtěli sdílenou @Before metodu, která by provedla setup společný pro všechny scénáře, a pak další @Before metodu pro každou vnořenou třídu. V C# to jde vyřešit tak, že vnořené třídy dědí z hlavní test třídy. To v JUnitu nejde (věřte mi, zkoušel jsem to), ale můžeme si pomoci abstraktní base třídou a malým trikem:

@RunWith(EnclosedDoneRight.class)
public class TitleizerTest {

    public static abstract class TitleizerScenarioBase {

        protected Titleizer titleizer;

        public void setUp() {
            titleizer = new Titleizer();
        }
    }

    public static class TheTitleizeScenario extends TitleizerScenarioBase {

        private String defaultTitle;

        @Before
        @Override
        public void setUp() {
            super.setUp();
            // let's pretend that defaultTitle is required by all tests of titleize()
            // and it takes more effort to create it
            defaultTitle = "Your name is now Daniel the Foolish";
        }

        @Test
        public void returnsDefaultTitleForNullName() {
            String title = titleizer.titleize(null);
            assertThat(title).isEqualTo(defaultTitle);
        }
    }
}
Je to samozřejmě triviální příklad, ale představte si že obě metody setUp() mají kolem dvaceti řádků a vytvářejí několik objektů nutných pro test. Pak má smysl jednotlivé vnořené třídy považovat za scénáře, které definují určitou výchozí situaci, která vyžaduje několik objektů (collaborators) v určitém stavu.

Všimli jste si malého háčku? Ano, už ke spuštění testů nemůžeme použít runner Enclosed! Test by spadnul, protože by se pokoušel spustit i abstraktní třídu TitleizerScenarioBase, která nemá žádné testovací metody (a jsme zpět u "No runnable methods").

Musíme si bohužel napsat runner sami. Naštěstí je to velmi jednoduché:

import java.lang.reflect.Modifier;
import java.util.ArrayList;

import org.junit.runners.Suite;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;

public class EnclosedDoneRight extends Suite {

    public EnclosedDoneRight(Class<?> klass, RunnerBuilder builder) throws InitializationError {
        super(builder, klass, getRunnableInnerClasses(klass));
    }

    private static Class<?>[] getRunnableInnerClasses(Class<?> klass) {
        ArrayList<Class<?>> classes = new ArrayList<Class<?>>();
        for (Class<?> inner : klass.getClasses()) {
            if (!Modifier.isAbstract(inner.getModifiers()))
                classes.add(inner);
        }
        return classes.toArray(new Class<?>[classes.size()]);
    }
}
Mimochodem stejně je udělaný runner Enclosed, ale ten prostě vezme vše co vrátí klass.getClasses().

A je to! Můžeme psát složité setupy, vyloučit duplicitu a mít strukturované testy. Co vy na to komunito?

(0) Comments

Leave a Response