Experimentelles Test-Framework

Nachdem ich junit-quickcheck schon erfolgreich verwende, mich aber die mangelnde JUnit 5 Unterstützung (*) gestört hat, und es JUnit 5 viel einfacher macht, Erweiterungen zu schreiben, habe ich mal selbst etwas zusammengeklöppelt:

Wie üblich, alles sehr wackelig, dafür habe ich viel über JUnit 5 gelernt.

(*) praktisch kein großes Problem, es läuft auch im Vintage Modus

Hab’ auch kürzlich/vor einiger Zeit (und zum Teil immernoch) einiges mit ~„Datengenerierung“ gemacht, und war gerade mal neugierig, als ich das Beispielsnippet in der README gesehen habe.

Bewirkt https://github.com/DanielGronau/genjiTest/blob/cbedfd417177ea60a32fe6024b297f3391e71e30/src/main/java/org/genji/GenjiProvider.java#L26 nicht, dass der Test bei 10 Durchläufen im schlechtesten Fall 1 mal failt, und man dann nicht mehr weiß (bzw. reproduzieren kann) unter welchen Bedingungen das passiert ist?

Selbst wenn man das auf new Random(0) festpinnen würde, würde es zusammen mit https://github.com/DanielGronau/genjiTest/blob/cbedfd417177ea60a32fe6024b297f3391e71e30/src/main/java/org/genji/GenjiProvider.java#L51 ja (wenn man nicht explizit einen Seed für einen Test vorgibt) immernoch bewirken, dass das, was man testet, von der Reihenfolge der Tests abhängig ist.

(Vielleicht übersehe ich was, und ich hab’ den Code noch nicht im Detail nachvollzogen…)

Dann kommt natürlich noch das dazu, was bei streng getypten Sprachen eben etwas lästig sein kann: IntegerGen gibt es schon - jetzt fehlen nur noch BooleanGen, ByteGen, CharacterGen, ShortGen, LongGen, FloatGen, und DoubleGen

(Und nachher natürlich noch SetGen und MapGen - aber wie z.B. der Inhalt von ListGen konfiguriert wird, oder man sowas vorgeben kann wie: „Ich hätte gerne zufällige Map<Integer, String>, bei denen der Key im Bereich [0…10] liegt, alle Maps die Größe 5 (oder, ätsch: 12 (!)) haben, und die Strings aus einem Pool von 43 vorgegebenen Strings stammen“, ist mir sowieso nicht klar…)

Jedes mal neue Testdaten zu erhalten hat logischerweise Vor- und Nachteile. Mit welchen Daten das Ding fehlschlägt, sollte einem JUnit 5 eigentlich sagen.

Selbst wenn man das auf new Random(0) festpinnen würde, würde es zusammen mit https://github.com/DanielGronau/genjiTest/blob/cbedfd417177ea60a32fe6024b297f3391e71e30/src/main/java/org/genji/GenjiProvider.java#L51 ja (wenn man nicht explizit einen Seed für einen Test vorgibt) immernoch bewirken, dass das, was man testet, von der Reihenfolge der Tests abhängig ist.

Es ist vorgesehen, dass man einen Seed festlegen kann. Die Reihenfolge der Tests lässt sich glaube ich in JUnit festlegen. Falls das nicht geht, kann ich immer noch jeder Methode einen neuen Random mit dem gleichen Seed zurückgeben.

Dann kommt natürlich noch das dazu, was bei streng getypten Sprachen eben etwas lästig sein kann: IntegerGen gibt es schon - jetzt fehlen nur noch BooleanGen , ByteGen , CharacterGen , ShortGen , LongGen , FloatGen , und DoubleGen

Exakt

(Und nachher natürlich noch SetGen und MapGen - aber wie z.B. der Inhalt von ListGen konfiguriert wird, oder man sowas vorgeben kann wie: „Ich hätte gerne zufällige Map<Integer, String> , bei denen der Key im Bereich [0…10] liegt, alle Maps die Größe 5 (oder, ätsch: 12 (!)) haben, und die Strings aus einem Pool von 43 vorgegebenen Strings stammen“, ist mir sowieso nicht klar…)

Also es soll schon so sein, dass du für deine Teststrings einfache Sachen wie Zeichenvorrat und Länge festlegen kannst. Die Konfiguration soll über Annotations erfolgen, aber da muss ich erst mal sehen, wie weit ich das (inssbesondere bei Generics) treiben kann.

Danke für dein Feedback!

Hallo,

habe in die Test extension reingeschaut und mal einen PR gemacht wg. POM aufräumen… bzgl. params/api Abhängigkeiten.

Gruß
Karl Heinz

Ja in JUnit Jupiter gibt es

@TestMethodOrder(OrderAnnotation.class)
..
@Order(..)

Siehe https://junit.org/junit5/docs/current/user-guide/#writing-tests-test-execution-order

Gruß
Karl Heinz

Danke!

Genau, sowas hatte ich in Erinnerung. Aber nach einigem Nachdenken würde ich wohl doch einfach jeder Methode ein eigenes Random mit dem gegebenen Seed spendieren, so dass

@Seed(42)
public class TestFoo {
       @GenjiTest
       void test1(String a) { ... }

       @GenjiTest
       void test2(int b) { ... }
}

als

public class TestFoo {
       @Seed(42)
       @GenjiTest
       void test1(String a) { ... }

       @Seed(42)
       @GenjiTest
       void test2(int b) { ... }
}

interpretiert wird.

Wobei geplant ist, den Seed auch auf Methoden-Parameter-Level angeben zu können.

Ich bin auf eine interessante Frage gestoßen: Angenommen, man hat einen unendlichen Stream<Character>, gibt es eine elegante Lösung, daraus einen Stream<String> (mit zufälligen Längen) zu machen? Ich habe nichts gutes gefunden, am Ende habe ich mit dem Iterator vom Character-Stream hantiert. Fällt euch was besseres ein?

Hab’ kurz (bzw. ziemlich lange :roll_eyes: ) überlegt und rumprobiert. Ich glaube, das ideomatisch-streamig zu machen, ist nicht möglich. Es scheitert zuerst natürlich daran, das ganze „Zustandsfrei“ zu machen. Als zweites scheitert es daran, dass man ja (vermutlich!?) zwei Streams hat, nämlich einmal den Stream<Character> und einmal den IntStream für die Längen - und zwei Streams zu verarbeiten, die irgendeine Abhängigkeit zueinander haben, ist immer schwierig.

Nicht zuletzt muss man aber auch annehmen, dass der Stream<Character> wirklich unendlich ist. Andernfalls müßte man sich überlegen, was passieren soll, wenn aus 10 Zeichen zwei Strings mit den Längen 5 und 6 erstellt werden sollen…

Eine andere Möglichkeit, als den Iterator zu holen, habe ich demnach auch nicht gefunden. Aber das ist in vieler Hinsicht nicht schön. Ein Supplier<Character>, der einfach zufällige Characters liefert, würde etliches da vieeel einfacher machen. Muss es denn unbedingt ein Stream<Character> sein?

import java.util.Iterator;
import java.util.Random;
import java.util.function.IntFunction;
import java.util.stream.Collector;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class StringStreamFromCharacterStream
{
    public static void main(String[] args)
    {
        Random random = new Random(0);
        Stream<Character> chars = create(random);

        int minLength = 5;
        int maxLength = 10;
        int numStrings = 10;
        IntStream lengths =
            random.ints(minLength, maxLength + 1).limit(numStrings);

        Iterator<Character> it = chars.iterator();
        IntFunction<String> lengthsToStrings =
            len -> lengthToStream(len, it).collect(
                characterStreamToStringCollector());
        Stream<String> strings = lengths.mapToObj(lengthsToStrings);

        strings.forEach(System.out::println);
    }

    private static <T> Stream<T> lengthToStream(
        int length, Iterator<? extends T> it)
    {
        return IntStream.range(0, length).mapToObj(i -> it.next());
    }

    private static Collector<Character, StringBuilder, String>
        characterStreamToStringCollector()
    {
        return Collector.of(StringBuilder::new, StringBuilder::append,
            StringBuilder::append, StringBuilder::toString);
    }

    private static Stream<Character> create(Random random)
    {
        //return Stream.generate(() -> (char) ('a' + random.nextInt('z' - 'a')));
        // Whatever...
        return random.ints('a', 'z' + 1).mapToObj(i -> Character.valueOf((char)i));
    }
}

Dass ist das, was meine Generatoren in der Bibliothek liefern. Und da wollte ich mir die Code-Dopplung für den Character- und den String-Generator sparen.

Ich hatte auch verschiedene Möglichkeiten in Betracht gezogen, wie man den Stream<Character> erstellen könnte…

… aber da es nur um einen Test ging, hab’ ich dann mit dem lapidaten „Whatever…“ aufgehört.

Mit dem, was in https://github.com/DanielGronau/genjiTest/blob/master/src/main/java/org/genji/defaultgenerators/CharGen.java#L32 steht, kommt noch eine weitere Möglichkeit dazu. Ein wichtiger Unterschied ist eben, dass der „charSet“ vorgegeben ist. Aber oft/meistens (d.h. auch in diesem Fall) kann man streams an der Quelle ja auf Supplier runterbrechen…

package bytewelt;

import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Stream;

public class CharacterStreamsTest
{
    public static void main(String[] args)
    {
        String charSet = " \0\t\n\r\\\"'²³?!#+*/;.-_<>|§$%&1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ[]{}";

        Random random0 = new Random(0);
        Stream<Character> stream0 = random0.ints(0, charSet.length()).mapToObj(charSet::charAt);
        stream0.limit(10).forEach(c -> System.out.print(c+", "));
        System.out.println();

        Random random1 = new Random(0);
        Supplier<Character> supplier = () -> charSet.charAt(random1.nextInt(charSet.length()));
        Stream<Character> stream1 = Stream.generate(supplier);
        stream1.limit(10).forEach(c -> System.out.print(c+", "));
        System.out.println();

    }
}

Das „fühlt sich vielleicht komisch an, weil da die Struktur irgendwie anders wäre, als bei allen anderen Generatoren“, aber … war ja nur ein Gedanke.

(EDIT: Stream#generate wird ja schon mehrfach verwendet. Tatsächlich könnten die Generatoren ja auch meistens Supplier liefern. Mir kam gerade noch ein möglicher Stolperstein mit parallelen Streams und einem nicht-Threadsicheren Random in den Sinn. Aber das nur nebenbei…)

Ich wollte jetzt keine größeren Umbauten machen, und bin bei Streams geblieben. Ich denke, das so eien Situation wie bei chars und Strings nicht so oft vorkommt.

Die ganzen Primitive (und ein bisschen mehr) sind abgedeckt.

Heute habe ich eine Annotation für selbstgebaute Generatoren implementiert, wie üblich auf Klassen-, Methoden- und Paramter-Ebene, und natürlich auch repeatable. Sieht so aus:

@Custom(target = Integer.class, generator = MeinTollerIntGenerator.class)

Typsicher geht es leider nicht, wenn ich bei Annotations bleiben will, und nicht noch eine Indirektion wie GeneratorFactory dazwischenschalten will.

Jetzt wäre meine Frage, wie ihr die globale Registrierung von selbstgestrickten Generatoren angehen würdet. Eine direkte Aufruf an meinem Singleton halte ich für eine denkbar schlechte Idee, weil das in Reihenfolgeabhängigkeit laufen kann (wenn das z.B. jemand einfach in @Before packt), und würde auch alles kaputt machen, wenn mein naiver Singleton-Ansatz einmal geändert werden müsste. Client-Code soll möglichst nicht direkt an meine Klassen gekoppelt werden (mal mit Ausnahme der Annotations)

Eine ganz einfache Variante für mich wäre, fremde Generatoren über SPI vorgesetzt zu bekommen. Ich frage mich nur, ob das nicht für die Nutzer zu unbequem wird. Was haltet ihr davon?

In aller Tiefe nachvollziehen kann ich das (auf die Schnelle) nicht mehr - zumindest nicht, ohne mal in einer IDE drüberzubrowsen. (Wenn ich bei GitHub drüberbrowse denke ich mir nur zu Details irgendwas - z.B. dass mir hier ein bißchen Blut aus den Augen schießt, oder was dich so sicher macht, dass irgendein assetTrue immer passt

Details, Details...

Das hier…

package bytewelt.genji;

import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;

public class GenjiTestTestTest
{
    public static void main(String[] args)
    {
        long seed = 0;
        while (true)
        {
            Random random = new Random(seed);
            int from = 3;
            int to = 15;
            long n = 20;
            List<Integer> list =
                random.ints(from, to).boxed().limit(n)
                .collect(Collectors.toList());
            if (!list.stream().anyMatch(i -> 5 < i && i < 12))
            {
                System.out.println("Gotcha: " + seed + ", " + list);
                return;
            }
            else
            {
                System.out.println("Fine for " + seed);
            }
            seed++;
        }
    }
}

gibt irgendwann

Gotcha: 1799024, [14, 5, 14, 14, 14, 4, 14, 4, 3, 4, 12, 5, 5, 12, 4, 5, 3, 12, 5, 3]

aus - aber zugegeben: Bei 50 dürfte das noch „aktzeptabel unwahrscheinlich“ sein :smiley:

… aber das ist nicht so wichtig).

Zu der Frage mit den Custom-Generatoren: Falls ich das richtig verstanden habe, ist da die Frage wichtig, wer die wie verwenden soll. Man könnte seinen Custom-Generator doch einfach mit ins eigene test-Verzeichnis packen, oder? Ansonsten … meinst du mit SPI den „rudimentären“ java.util.ServiceLoader, oder irgendwas DI-mäßiges, womit du dir eine weitere Dependency reinziehen würdest? Sowas wie „classpath scanning“ mit reflections ist manchmal auch eine Option, aber ich bin nicht sicher, ob ich den Punkt überhaupt richtig verstanden habe…

Den einfachen ServiceLoader (der ganze Mechanismus heißt ja offiziell „Java Service Provider Interface“).

In die Richtung zielte meine Frage ab. Im Prinzip ist die Auswahl entweder einen bestehenden, schnellen und für den Nutzer etwas unbequemen Mechanismus zu nutzen (SPI), oder zusätzliche dependencies zu haben, die zwar bequem, aber eventuell recht teuer sind (classpath scanning).

Eventuell könnte man das in extra Projekte abspalten, die dann auch auf verschiedene vorhandene Systeme (wie Spring oder Dagger) aufsetzten können. Je länger ich darüber nachdenke, umso besser gefällt mir die Idee: Erst mal einfach mit „Bordmitteln“, und dann über Ereweiterungsmodule andere Mechanismen unterstützen. Und wer weiß, ob überhaupt jemals jemand die Bibliothek verwendet, da muss ich nicht gleich mit Kanonen auf Spatzen…

Bezüglich der Code-Qualität muss ich dir recht geben, da muss ein bisschen Ordnung rein. Es wäre ein guter Anfang, den Reflection-Krams von der eigentlichen Logik zu trennen. Für die zufallsabhängigen Tests habe ich noch keine gute Idee.

Auch auf den ServiceLoader kann man eine bequemere Schicht oben drauf setzen - da gibt’s dann sowas wie https://github.com/google/auto/tree/master/service … (oder beißt sich da die Katze in den Schwanz? :smiley: )

Die Frage nach der geeigneten Aufteilung oder dem Rausziehen von (in deinem Beispiel) dem „Reflection-Krams“ in eigene Module stellt sich mir auch immer. Wenn man sich ~„mit irgendeinem Teilbereich“ beschäftigt (wie etwa Reflection) entstehen immer Dinge, die man generalisiert und rauszieht, und wenn es zu viele sind, fasst man sie weider zusammen … und am Ende hat man Libs wie https://github.com/javagl/Reflection oder https://github.com/javagl/Types , wissend, dass es das von Apache oder in Guava in vieeel „besserer“ Form gibt :frowning:

Aber wie gesagt, solange ich nicht ganz sicher bin, wie das verwendet werden soll, kann ich kaum was sinnvolles dazu sagen. Die Frage

Man könnte seinen Custom-Generator doch einfach mit ins eigene test -Verzeichnis packen, oder?

stellt sich aber nach wie vor…

Stimmt nur so halb. Mein Annotations-Kram ist schon recht speziell, die Aufgabe besteht im Wesentlichen darin, die entsprechende Annotation im kleinsten Scope (Parameter, Methode, Klasse) zu finden. Wahrscheinlich kann man das ziemlich zusammendampfen, wenn man es erst einmal rausoperiert hat. Werde mir aber trotzdem mal Guava anschauen.

Da fällt mir gerade ein, dass man ja auch Package-Level annotations haben kann. Nutzt zwar selten jemand, wäre aber in dem Anwendungsfall durchaus praktisch, und ist kein Problem, die zu unterstützen. Wenn Java jetzt noch Application-Level Annotations zulassen würde…

Sicher, aber mein Framework muss ihn auch finden, das ist das Problem.

Bei der Bibliothen junit-quickcheck war eine Variante, dass sie für eine Klasse Foo nach einer Klasse FooGen als Generator im gleichen Package gesucht haben, aber sowas kann sowieso nur eine Teillösung sein.

1 Like