Automatické testování příkladů v dokumentaci pomocí JUnit 5 a JShell

23. 5. 2023 8:40 (aktualizováno) Jan Novotný

Dokumentace na stránkách evitaDB se stále rozšiřuje. Čím více příkladů přidáváme, tím více se obáváme, že budou zastaralé nebo nefunkční. Jak zkrotit tuto bestii čítající stovky příkladů?

Protože je evitaDB postavena na platformě Java, sdílí všechny její výhody i nevýhody. Java je staticky typovaný a kompilovaný jazyk. Než můžete spustit kus kódu, musíte jej zkompilovat a načíst do zavaděče tříd. Naše příklady kódu jsou roztroušeny v různých souborech MarkDown – někdy jsou přímo vloženy, jindy odkazují na samostatný soubor v úložišti. Zpočátku jsme si mysleli, že neexistuje žádný snadný způsob, jak ověřit platnost a konzistenci příkladů kódu v dokumentaci. Původní myšlenky byly napsat si vlastní plugin do buildovacího nástroje Maven, který by generoval testovací kód, zabalil příklady do Java šablony, zkompiloval je pomocí Javac a poté je spustil jako součást naší testovací sady.

Naštěstí existuje jednodušší a dynamičtější přístup. Java poskytuje JShell REPL (od verze 9), který umožňuje interaktivně zadávat, kompilovat a spouštět zdrojový kód jazyka Java. JShell lze spouštět i programově, i když to není jeho primární případ použití (proto jsou informace o tomto přístupu kusé a těžko se hledají). Tušili jsme, že JShell by mohl být způsob, jak překonat potíže s kompilací příkladů, a byli jsme odhodláni jej alespoň vyzkoušet.

Dalším dílem skládačky je JUnit 5 a jeho skvělá podpora dynamických testů, které jsme si po cestě vyzkoušeli.

Pojďme se tedy do problematiky ponořit.

Extrakce vzorků kódu z MarkDown

Extrakce kódu k ověření je nejjednodušší částí procesu. Spočívá v procházení složky obsahující dokumentační soubory do hloubky pomocí třídy Java File Walker:

try (final Stream<Path> walker = Files.walk(getRootDirectory().resolve(DOCS_ROOT))) {
    walker
        .filter(path -> path.toString().endsWith(".md"))
        .map(this::createTests)
        .toList();
}

… a načtení obsahu souboru do řetězce a extrakce bloků kódu buď přímo ze samotného souboru MarkDown, nebo z externě odkazovaného souboru (viz tělo metody createTests). Extrakce vyžaduje pouze trochu RegEx magie.

Generování dynamických testů JUnit 5

Dalším dílem skládačky je dynamické vytváření testů JUnit – v ideálním případě s jedním samostatným testem pro každý příklad bloku kódu. Naštěstí na to autoři frameworku JUnit 5 již mysleli a připravili podporu dynamických testů.

V podstatě potřebujeme jen lambdu, která provede samotný test, zabalit do třídy DynamicTest. Metoda DynamicTest.dynamicTest(String, URI, Executable) přijímá tři argumenty:

  1. název testu, který se má zobrazit (ekvivalent @DisplayName)
  2. lokátor souboru (URI), který umožňuje přejít ke zdroji po kliknutí na název testu v IDE
  3. lambda ve tvaru Executable, která představuje samotný test

Vytvoření jediného proudu všech fragmentů kódu není v našem případě praktické – je jich příliš mnoho a výpis testů v IDE se rychle stane nepřehledným. Proto používáme další vynález JUnit 5 – DynamicContainer, který je určen k agregaci více souvisejících testů do jednoho „uzlu“. Uzel v našem případě představuje konkrétní zdrojový soubor MarkDown, ze kterého testované bloky kódu pochází (buď jsou přímo vložené, nebo z něj odkazované). Tímto způsobem můžeme rychle identifikovat správný dokument, ke kterému testovaný příklad patří.

Provedené testy vypadají následovně:

Kompilace a provádění fragmentů kódu skrze JShell

Úryvky zdrojového kódu v jazyce Java je třeba zkompilovat a analyzovat, k čemuž se používá JShell REPL.

Inicializace

Nejprve musíme připravit instanci prostředí JShell pomocí funkce JShell.builder():

this.jShell = JShell.builder()
    // this is faster because JVM is not forked for each test
    .executionEngine(new LocalExecutionControlProvider(), Collections.emptyMap())
    .build();
// we copy the entire classpath of this test to the JShell instance
Arrays.stream(System.getProperty("java.class.path").split(":"))
    .forEach(jShell::addToClasspath);
// and now pre initialize all necessary imports
executeJShellCommands(jShell, toJavaSnippets(jShell, STATIC_IMPORT));

Všimněte si, že explicitně injektujeme LocalExecutionControlProvider. Ve výchozím nastavení vytvoří JVM samostatný proces (fork), ve kterém vyhodnocuje výrazy jazyka Java. To je sice dobré z hlediska izolace a bezpečnosti, ale pro nás je to nepohodlné, protože nemůžeme nastavit debug breakpointy na kódu prováděném blokem skriptu. Dostupnost debuggingu nám umožňuje mnohem rychleji a snadněji opravovat chyby v našich příkladech zdrojového kódu.

Dále inicializujeme classpath pro instanci JShell zkopírováním úplné classpath JVM, ve kterém běží testovací sada JUnit, a nakonec inicializujeme všechny importy, které naše příklady v jazyce Java potřebují, pomocí tohoto souboru: imports. Pro inicializaci importů používáme stejnou logiku jako pro spuštění samotného zdrojového kódu.

Příprava a spouštění zdrojového kódu

Zdrojový kód je třeba rozdělit na samostatné příkazy, které lze spustit v instanci JShell. JShell nepřijímá celý zdrojový kód, ale podobá se dialogovému oknu IDE pro vyhodnocení jednoho výrazu. Proto požádáme samotnou instanci JShell, aby kód vhodně rozdělila na separátní výrazy:

@Nonnull
static List<String> toJavaSnippets(@Nonnull JShell jShell, @Nonnull String sourceCode) {
    final SourceCodeAnalysis sca = jShell.sourceCodeAnalysis();
    final List<String> snippets = new LinkedList<>();
    String str = sourceCode;
    do {
        CompletionInfo info = sca.analyzeCompletion(str);
        snippets.add(info.source());
        str = info.remaining();
    } while (str.length() > 0);
    return snippets;
}

Metoda vrací seznam samostatných výrazů jazyka Java, které dohromady tvoří původní příklad v dokumentaci. Dalším krokem je jejich vyhodnocení/vykonání v instanci JShell:

@Nonnull
static InvocationResult executeJShellCommands(@Nonnull JShell jShell, @Nonnull List<String> snippets) {
    final List<RuntimeException> exceptions = new LinkedList<>();
    final ArrayList<Snippet> executedSnippets = new ArrayList<>(snippets.size() << 1);

    // iterate over snippets and execute them
    for (String snippet : snippets) {
        final List<SnippetEvent> events = jShell.eval(snippet);
        // verify the output events triggered by the execution
        for (SnippetEvent event : events) {
            // if the snippet is not active
            if (!event.status().isActive()) {
                // collect the compilation error and the problematic position and register exception
                exceptions.add(
                    new JavaCompilationException(
                        jShell.diagnostics(event.snippet())
                            .map(it ->
                                "\n- [" + it.getStartPosition() + "-" + it.getEndPosition() + "] " +
                                    it.getMessage(Locale.ENGLISH)
                            )
                            .collect(Collectors.joining()),
                        event.snippet().source()
                    )
                );
            // it the event contains exception
            } else if (event.exception() != null) {
                // it means, that code was successfully compiled, but threw exception upon evaluation
                exceptions.add(
                    new JavaExecutionException(event.exception())
                );
                // add the snippet to the list of executed ones
                if (event.status() == Status.VALID) {
                    executedSnippets.add(event.snippet());
                }
            } else {
                // it means, that code was successfully compiled and executed without exception
                executedSnippets.add(event.snippet());
            }
        }
        // if the exception is not null, fail fast and report the exception
        if (!exceptions.isEmpty()) {
            break;
        }
    }

    // return all snippets that has been executed and report exception if occurred
    return new InvocationResult(
        executedSnippets,
        exceptions.isEmpty() ? null : exceptions.get(0)
    );
}

Pro každý vyhodnocený úryvek jazyka Java (výraz) generuje JShell seznam událostí popisujících, co se stalo. Nejdůležitější informací pro nás je, zda byl snippet úspěšně aplikován a je součástí stavu JShellu – to lze ověřit voláním event.status().isActive(). Pokud událost není aktivní, došlo k závažné chybě a my pomocí diagnostiky JShellu odhalíme hlavní příčinu problému.

Dále zkontrolujeme, zda během vyhodnocování výrazu nebyla vyhozena výjimka, a to kontrolou metody event.exception(). Pokud je výjimka nalezena a stav události je VALID, přidáme ji do seznamu fragmentů, které byly použity prostředím JShell a jsou součástí jeho stavu.

Stejná logika platí i pro případ, kdy nedošlo k chybě kompilace ani k výjimce vyhodnocení a fragment byl úspěšně vyhodnocen. Fragment je přidán do seznamu fragmentů, které ovlivnily stav JShellu a jsou vráceny jako výsledek této metody.

Vyčištění stavu po ukončení testu

Inicializace a příprava instance prostředí JShell je nákladná, proto pro všechny příklady ve stejném souboru dokumentace opakovaně používáme jednu instanci. Nechceme sdílet stejnou instanci JShellu pro více dokumentačních souborů, protože naším konečným cílem je, aby bylo možné paralelně spouštět naše dokumentační testy. To aktuálně není možné kvůli nedostatku v API řešeném v JUnit 5 #2497. Opakované použití stejné instance JShellu pro více testů v jednom dokumentačním souboru vyvolává otázku správného čištění stavové paměti, aby pozůstatky jednoho příkladu neovlivnily ostatní příklady, které se spustí po něm.

Po dokončení testu musíme tedy vyčistit instanci JShellu, která se znovu používá pro další příklad ve stejném dokumentačním souboru. Naštěstí JShell poskytuje způsob, jak zahodit vyhodnocený příkaz, který účinně eliminuje jeho vliv na stav instance JShellu.

Čištění probíhá ve dvou fázích:

// clean up - we travel from the most recent (last) snippet to the first
final List<Snippet> snippets = result.snippets();
for (int i = snippets.size() - 1; i >= 0; i--) {
    final Snippet snippet = snippets.get(i);
    // if the snippet declared an AutoCloseable variable, we need to close it
    if (snippet instanceof VarSnippet varSnippet) {
        // there is no way how to get the reference of the variable - so the clean up
        // must be performed by another snippet
        executeJShellCommands(
            jShell,
            Arrays.asList(
                // instanceof / cast throws a compiler exception, so that we need to
                // work around it by runtime evaluation
                "if (AutoCloseable.class.isInstance(" + varSnippet.name() + ")) {\n\t" +
                    "AutoCloseable.class.cast(" + varSnippet.name() + ").close();\n" +
                    "}\n"
            )
        )
            .snippets()
            .forEach(jShell::drop);
    }
    // each snippet is "dropped" by the JShell instance (undone)
    jShell.drop(snippet);
}

První fáze iteruje všechny fragmenty a najde všechny VarSnippets – tj. výrazy, které deklarují proměnné v kontextu JShell, a pokusí se zjistit, zda tyto proměnné implementují rozhraní java.lang.AutoCloseable a je třeba je správně uzavřít. Má to však háček, JShell neposkytuje možnost přistupovat k proměnným pomocí odkazu a pracovat s nimi přímo v kódu, který tyto snippety vyvolal. JShell může pouze vracet výstupy toString těchto proměnných. Myslíme si, že důvodem je fakt, že interpret JShellu může běžet (a obvykle běží) jako separátní proces a objekty Java žijí v kontextu (classpath, paměť procesu atd.) jiné instance JVM. Proto musíme logiku zavírání spustit jako další výraz JShellu.

Proč používáme metody isInstance / cast třídy AutoCloseable.class namísto metod instanceof nebo direct cast v Javě?
Zajímavé je, že pokud se pokusíte nahradit AutoCloseable.class.isInstance(" + varSnippet.name() + „) za varSnippet.name() + " instanceof AutoCloseable“, skončíte s výjimkou kompilace pro proměnné, které nejsou instancemi AutoCloseable. Ačkoli se jedná o správný zdrojový kód pro běžný kód Javy, interpret/kompilátor JShellu se chová jinak a my musíme tento problém obejít pomocí metod na rozhraní třídy. Viz problém JDK #8211697.

Nakonec je třeba odstranit výrazy zajišťující uzavření AutoCloseable proměnných i všechny ukázkové úryvky zdrojového kódu, které byly vyhodnoceny a ovlivnily stav instance prostředí JShell. Operace drop se chová podobně jako operace rollback u relačních databází a vrací zpět všechny operace, které ovlivnily instanci JShell (nikoli však vedlejší efekty spojené se síťovým voláním nebo zápisem na souborový systém).

Předpoklady pro testování, řetězení příkladů

Některé příklady navazují na kontext jiných příkladů v tomtéž dokumentu – přirozeně tak, jak se popisovaný případ použití vyvíjí. Rozhodně nechceme kód příkladu komplikovat a znepřehledňovat opakováním příkazů, které vytvářejí prostředí pro jeho spouštění – příklady by měly být co nejkratší.

Proto jsme pro naši komponentu SourceCodeTabs zavedli atribut requires, který umožňuje určit jeden nebo více dalších souborů, které se musí spustit před spuštěním kódu v samotném příkladu.

Překlady příkladů do dalších jazyků

Další speciální funkcí naší dokumentace je možnost přepínat mezi různými jejími variantami a dokonce i obsahem dokumentace výběrem jiného preferovaného jazyka pro zobrazení dokumentace:

Příprava příkladů pro 4–5 různých jazyků je zdlouhavý a časově náročný úkol, kterému se chceme vyhnout. Proto jsme implementovali speciální režim, který nám umožňuje generovat příklady pro různé jazyky (Java, REST, GraphQL) ze základního příkazu dotazu EvitaQL.

Podobně můžeme automaticky generovat úryvky MarkDown dokumentující očekávaný výstup pro dotazy aplikované na naši ukázkovou datovou sadu. Tyto MarkDown snippety hrají další důležitou roli, jak se dočtete v následující kapitole.

Vytvoření překladů do dalších jazyků je ruční operace, která neprobíhá na naší CI pipeline. Vygenerované soubory musí zkontrolovat člověk a spolu s dokumentací je commitnout do verzovacího systému Git. Jakmile jsou však příklady/soubory vygenerovány, jsou spouštěny a kontrolovány výše popsaným způsobem, takže jejich korektnost je již ověřována automatizovaňě. Podívejte se sami, jak vygenerované úryvky vypadají.

Ověřování výstupů příkladů

Příklady, které se dotazují na naši ukázkovou sadu dat, obvykle obsahují náhled výsledku, který může čtenář očekávat, pokud dotaz spustí sám. Vzhledem k tomu, že naše datová sada není statická, ale v průběhu času se vyvíjí (přibývají nová data, mění se struktura atd.), mohou se výsledky v čase lišit. Pracujeme také na dotazovacím enginu evitaDB a výsledky se mohou lišit, i proto že změníme vnitřní logiku enginu. V každém případě si chceme být vědomi toho, že k tomu došlo, a prozkoumat rozdíly mezi předchozími výsledky a výsledky vytvořenými nad aktuální verzí datové sady a enginem evitaDB. Musíme buď opravit dokumentaci (i když poloautomaticky opětovným spuštěním překladu příkladu), nebo opravit náš engine evitaDB, či zdokumentovat zpětně nekompatibilní chování.

Proto automaticky porovnáváme očekávané výsledky MarkDown snippets se skutečnými výsledky dotazu evitaDB uvedenými v příkladu, a pokud se tyto výsledky liší, označíme testy jako neúspěšné a vynutíme tím lidskou intervenci.

S ověřováním dokumentace je spojen speciální workflow ci-dev-documentation na GitHubu. Testy, které kontrolují spustitelnost našich příkladů zdrojového kódu a porovnávají jejich výstup s posledním ověřeným výstupem, se spouštějí automaticky vždy, když dojde ke změně ve složce s uživatelskou dokumentací. Spouštějí se také každou neděli večer, abychom mohli odhalit případné změny v ukázkové sadě dat, i když se samotná dokumentace nezměnila.

Závěrem

Doposud jsme napsali stovky stránek dokumentace a máme okolo 150 spustitelných příkladů. Dokumentace se časem stále rozrůstá a mít proces, který nám kryje záda a sleduje nefunkční příklady nebo odchylky v deklarovaných odpovědích, je dost důležité. Odhadujeme, že počet příkladů se v době vydání první verze evitaDB může blížit číslu 1.000 a v tomto počtu by už manuální kontrola nebyla myslitelná. Není nic horšího než zastaralá nebo nesprávná dokumentace.

Příjemným vedlejším efektem popsaného procesu je, že ušetří značné množství zdlouhavé práce při definování příkladů pro všechny jazyky, které podporujeme, a také přidává další vrstvu integračního testování, která odhalila některé chyby v jádře evitaDB / parseru dotazů.

Dotazy se spouštějí proti naší demo datové sadě a ověřují, zda API našeho demo serveru funguje správně a zda jsme ho nějakou aktualizací nerozbili. Chceme, aby náš demo server byl k dispozici každému, kdo si s ním chce hrát.

Práce vynaložená na automatizaci podpory pro generování a kontrolu ukázkových příkladů se jich vrátila a jsme si jisti, že se v dlouhodobém horizontu vrátí ještě mnohokrát.

Poznámka: článek je překladem původního článku na blogu projektu evitaDB

Sdílet