Tworzenie wykonalnego jar’a z testami Cucumber

W zależności od tego, jak wygląda proces integracji ciągłej i testowanie regresji w projekcie, może zaistnieć potrzeba wielokrotnego uruchamiania tych samych testów – bez wprowadzania zmian w frameworku testowym lub zestawach testów.

W takich przypadkach często staram się stworzyć rozwiązanie, które nie będzie wymagało ponownego budowania frameworku testowego za każdym razem, gdy chcę uruchomić testy. Takie podejście ma kilka zalet. Główną zaletą jest oszczędność czasu potrzebnego na budowanie projektu, a także możliwość zbudowania jar’a z wszystkimi zależnościami. Umożliwia to uruchamianie testów na maszynie z zainstalowanym jedynie JRE, co zwiększa przenośność naszego frameworku testowego. W tym wpisie chciałbym opisać, jak zbudować pojedynczy jar zawierający framework testowy z testami Cucumber oraz konfigurację Mavena. Zakładam, że mamy już utworzony projekt Maven z działającymi testami Cucumber.

Konfiguracja Maven

Tworzenie uruchamialnego jar’a jest dość standardowe dla wszystkich aplikacji Java. Osobiście używam wtyczki Maven o nazwie maven-assembly-plugin, aby skonfigurować to z poziomu pliku pom.xml. Za każdym razem, gdy używam polecenia mvn clean package, jar jest automatycznie tworzony. Wtyczka używa deskryptorów do określenia, co ma być zawarte w jar’ze – można użyć wstępnie zdefiniowanych deskryptorów (opisanych tutaj) oraz niestandardowych deskryptorów XML. Aby zbudować najprostszy jar ze wszystkimi zależnościami, wystarczy dodać następujący wpis do pom.xml:

<plugin >
    <artifactId>maven-assembly-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
    </configuration>
</plugin>

Deskryptor jar-with-dependencies tworzy oddzielny jar w katalogu target projektu – zawiera on wszystkie zależności Maven uwzględnione w konfiguracji. W moim przypadku jednak często to nie wystarcza.

Załóżmy, że chciałbym dołączyć do jar’a niektóre pliki spoza katalogu głównego projektu. W takim przypadku muszę stworzyć własny deskryptor – robi się to przez odwołanie się do pliku deskryptora XML w konfiguracji wtyczki:

    <configuration>
        ...
        <descriptors>
            <descriptor>src/assembly/descriptor.xml</descriptor>
        </descriptors>
    </configuration>

Należy utworzyć dodatkowy plik deskryptora XML – zawiera on konfigurację specyficzną dla działania jar’a. W poniższym przykładzie dodałem pojedynczy plik konfiguracyjny XML spoza projektu do katalogu głównego wyjściowego jar’a, aby mógł być użyty podczas uruchamiania testów:

<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.0.0 http://maven.apache.org/xsd/assembly-2.0.0.xsd">
    <id>0.1</id>
    <formats>
        <format>jar</format>
    </formats>
    <includeBaseDirectory>false</includeBaseDirectory>
    <dependencySets>
        <dependencySet>
            <outputDirectory>/</outputDirectory>
            <useProjectArtifact>true</useProjectArtifact>
            <unpack>true</unpack>
            <scope>runtime</scope>
        </dependencySet>
    </dependencySets>
    <files>
        <file>
            <source>${basedir}/../configs/main_config.xml</source>
            <outputDirectory>/</outputDirectory>
        </file>
    </files>
</assembly>

Ostatnią rzeczą, którą należy skonfigurować w naszej wtyczce, jest odwołanie się do klasy uruchamialnej z metodą main – konfigurację należy również dodać to do konfiguracji wtyczki. W celu stworzenia uruchamialnego pliku jar musi mieć on klasę główną odwołaną w pliku MANIFEST – jest to automatycznie realizowane przez maven-assembly-plugin, gdy jest odpowiednio skonfigurowana w pom.xml:

    <configuration>
	 ...
        <archive>
            <manifest>
                <mainClass>
                    runners.TestRunner
                </mainClass>
            </manifest>
        </archive>
    </configuration>

Po uruchomieniu mvn clean package, nasza wtyczka zbuduje jar zawierający wszystkie testy i wymagane zależności. Jeszcze nie wspomnieliśmy o Cucumber, ponieważ tworzenie jar’a z zależnościami nie zależy od bibliotek testowych, więc można to zrobić z dowolnym projektem Maven. Aby był on w pełni uruchamialny, musimy jeszcze odpowiednio skonfigurować klasę uruchamiania testów.

Wykonywalna klasa uruchamiająca testy Cucumber

Kiedy używamy Cucumber w klasyczny sposób, musi istnieć klasa służąca do uruchamiania testów z odpowiednio skonfigurowanymi CucumberOptions. Zakładając, że używamy JUnit:

@RunWith(Cucumber.class)
@CucumberOptions(
    features = "src/test/resources/feature ")

Nie jest to już jednak konieczne, ponieważ JUnit nie będzie uruchamiał naszych testów – chcemy uruchamiać je bezpośrednio z linii komend, używając naszego zbudowanego jar’a z klasą uruchomieniową wewnątrz. Klasa uruchamiania musi zawierać metodę main, aby była odpowiednio rozpoznawana przez Javę jako klasa uruchomieniowa, podobnie jak standardowe aplikacje Java. Najprostsza działająca implementacja wygląda następująco:

public final class TestRunner {
    public static void main(String[] args) {
        cucumber.api.cli.Main.main(args );
    }
}

Metoda Cucumber cucumber.api.cli.Main.main(args) uruchamia cały silnik Cucumber i przyjmuje parametry bezpośrednio przekazane z linii komend naszego jar’a. Istnieje wiele parametrów – informacje na temat ich użycia można znaleźć tutaj. Tak więc w tym momencie mamy jar w pełni zdolny do uruchamiania naszych testów:

java -jar cucumber-test-jar-with-dependencies.jar –glue cucumber/stepdefinition –plugin pretty classpath:features/MyTestFeature.feature

Ważną rzeczą do zapamiętania jest to , że pom.xml Maven nie jest już odpowiedzialny za konfigurację i uruchamianie naszych testów – każda logika konfiguracji musi być obsługiwana przez klasę uruchamiania testów. Wszystko na razie wygląda dobrze, ale jest jeszcze kilka rzeczy do zrobienia, aby nasz jar był bardziej użyteczny.

Parametry linii komend

Nie ma sensu wprowadzanie tych samych parametrów za każdym razem – w moim przypadku parametr glue dla definicji kroków oraz konfiguracje wtyczek są zawsze identyczne i chciałem je ukryć, aby nie wprowadzać większego zamieszania przy użyciu jar’a. Aby to osiągnąć, po prostu zakodowałem na stałe parametry, które są wspólne dla każdego uruchomienia testu – inne, takie jak ścieżka do plików funkcji czy konfiguracja tagów, można nadal przekazywać w linii komend:

public final class TestRunner {
    private static String[] defaultOptions = {
            "--glue", "stepdefinitions",
            "--plugin", "pretty",
            "--plugin", "json:cucumber.json"
    };
 
    public static void main(String[] args) {
        Stream<String> cucumberOptions = Stream.concat(Stream.of(defaultOptions), Stream.of(args));
        cucumber.api.cli.Main.main(cucumberOptions.toArray(String[]::new));
    }
}

co zatem z parametrami, które wcześniej były używane w uruchomieniu Maven? W moim przypadku zazwyczaj konfiguruję framework tak, aby typ przeglądarki był parametrem linii komend – w przypadku Mavena był on przekazywany do testów w ten sposób: mvn clean test -Dbrowser=chrome. Ponieważ jest to obsługiwane jako właściwość systemu w kodzie Java. W przypadku uruchamiania jar’a można to nadal przekazać w linii komend, ale nieco inaczej niż parametry Cucumber – musi to być wpisane przed -jar:

java -Dbrowser=chrome -jar cucumber-test-jar-with-dependencies.jar classpath:features/MyTestFeature.feature

Obsługa System.exit w Cucumber

Jest jedno nietypowe zachowanie zaimplementowane wewnątrz metody Main Cucumber – wywołuje ona System.exit na końcu uruchamiania testu. Nie jestem pewien, dlaczego tak to działa – być może po prostu po to, aby wyrzucić specyficzny kod błędu, gdy testy się nie powiodą. Stanowi to jednak problem, gdy używamy tego rozwiązania jako API Java wewnątrz naszej klasy uruchamiania testów. Uniemożliwia to umieszczenie jakiejkolwiek logiki po wykonaniu testów oraz nie ma możliwości zignorowania błędów testów – wykonanie polecenia zakończy się niepowodzeniem.

Znalazłem sposób na zignorowanie tego, korzystając z menedżera systemu Java. Między innymi, pozwala on na nasłuchiwanie każdego wywołania System.exit wewnątrz aplikacji i wstrzyknięcie własnej logiki. Nadpisałem metodę checkExit(int status), więc kiedy wywoływane jest exit, rzucam wyjątek z menedżera systemu, który następnie mogę przechwycić w moim runnerze testów. W celu poprawnego działałania, musiałem również zastąpić domyślną implementację checkPermission(Permission perm), ponieważ wyrzucała ona niechciane wyjątki zabezpieczeń:

public class IgnoreExitCall extends SecurityManager {
    @Override
    public void checkExit(int status) {
        throw new SecurityException();
    }

    @Override
    public void checkPermission(Permission perm) {
        //Allow other activities by default
    }
}

Ostatnią rzeczą, którą należy zrobić jest przechwycenie SecurityException w klasie uruchamiania testów – jest to wyjątek niekontrolowany, ale musimy to zrobić, aby zapobiec wyjściu naszej aplikacji:

public final class TestRunner {
    public static void main(String[] args) {
        SecurityManager manager = new IgnoreExitCall();
        System.setSecurityManager(manager);
        try {
            cucumber.api.cli.Main.main(args);
        } catch (SecurityException) {
            System.out.println("Ignore exit");
        }
        //Do some other stuff like reporting logic
    }
}

Powodzenia w testowaniu!

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami