Parametrisierte Unit Tests für große Bit-Kombinationen (C++)

Alles, was nicht direkt mit Python-Problemen zu tun hat. Dies ist auch der perfekte Platz für Jobangebote.
Antworten
Benutzeravatar
Hyperion
Moderator
Beiträge: 7478
Registriert: Freitag 4. August 2006, 14:56
Wohnort: Hamburg
Kontaktdaten:

Hallo,

bevor jemand abgeschreckt wird: Ja ich muss das in C++ lösen, denke aber ein Lösungsprinzip oder die Methodik sollte sprachunabhängig sein...

Ich stehe gerade vor dem Problem, wie ich folgende Funktionalität in C++ mittels Unit Tests gut abdecken kann:

Code: Alles auswählen

void class::method(...) {
    if(obj_a.some_member &&
        obj_a.some_method() &&
        !obj_b.some_member &&
        obj_c.some_method()) {
            // make some call ← Testen, ob Aufruf erfolgt ist oder nicht
    }
}
Ich habe in einer Methode einen eigentlich simplen Aufbau: Nur wenn bestimmte Bedingungen zutreffen, soll etwas passieren. In diesem Fall ist das tatsächlich nur bei genau einer Kombination der Fall (True, True, False, True) - in allen anderen Fällen darf der innere Aufruf nicht passieren. Es können aber durchaus sieben *verschiedene* Bedingungen in einem ``if`` stehen. Ich kann diese auch einfach mocken, d.h. ich brauche vom Testframework nur die Info "erster Mock soll 'true' sein, zweiter 'false'" usw. So weit so gut und einfach.

Ach so, als Testframework kommt die Kombi googletest und googlemock zum Einsatz! Aber mir geht es hier eher ums generell Prinzip, also nicht davon abschrecken lassen :-)

Aber: Wie geht man das am besten in Unit Test(s) an? Insbesondere bei vielen Bedingungen wachsen die Kombinationen ja exponentiell! (eben 2^n).

Ich habe mir schon überlegt, dass man die Bedingungen natürlich sinnvoll bündeln könnte, so dass man diese zusammengefassten Spezifikationen separat testen kann und am Ende nur noch wenige en block im "finalen" ``if`` stehen hat. Dies ist aber vermutlich nicht immer so einfach und zum anderen können dann immer noch drei oder vier "Gruppen" übrig bleiben, was imho immer noch zu viel für separate Tests ist, sondern über parametrisierte abgedeckt werden muss.

Unabhängig davon bleibt die Frage, wie ich hier vorgehe... mir fallen da immer Wahrheitstafeln ein, die ich als Parameter in der Kombi ``0101 → True``, ``0001 → False``, ... einem Test übergeben kann. Für den oben geschilderten Fall ist das aber ziemlich "doof", weil ja nur *eine* Kombination überhaupt "wahr" wird, und alle anderen falsch sind. Die eine kann man ja leicht *explizit* angeben, die anderen vielen will man aber ja eher generiert haben... (eben mit Ausnahme der "wenigen").

Das wäre sicherlich irgend wie machbar, aber dann stellt sich die Frage nach der Leserlichkeit und Wartbarkeit? Ist es dann nicht ggf. doch besser, die Werte explizit anzugeben und sich die Kombis notfalls per Script zusammen basteln zu lassen?

Vielleicht ist auch der ganze Ansatz unschön, wobei ich da vermutlich nicht zu viel daran ändern kann...

Ich hoffe mal, jemand hat eine gute Idee dazu, oder bestärkt mich darin, es so anzugehen, wie ich mir das gedacht habe :-)
encoding_kapiert = all(verstehen(lesen(info)) for info in (Leonidas Folien, Blog, Folien & Text inkl. Python3, utf-8 everywhere))
assert encoding_kapiert
Sirius3
User
Beiträge: 17747
Registriert: Sonntag 21. Oktober 2012, 17:20

@Hyperion: entweder habe ich von Unit-Tests ein falsches Bild oder Du. Meiner Meinung nach sollte ein Unit-Tests alle Wege durch eine Funktion abtesten und nicht alle Möglichkeiten. Es mag da Ausnahmen geben und lebenswichtige Funktionen testet man vielleicht gründlicher, aber hier würde ich nur eine Handvoll Möglichkeiten testen, also den Erfolgsfall und verschiedene Kombinationen, so dass zumindest jeder Teiltest mal true und mal false ergibt.
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

@Hyperion: Du brauchst insgesamt 8 Objekte. Denn 4 Instanzen brauchst du von Typ A wegen den Möglichkeiten bei `.some_member` und `.some_method()`. Dann noch jeweils 2 von Typ B und Typ C. Davon berechnest du das Kreuzprodukt und schon hast du alle erdenklichen Kombinationen, die du durch den Test jagen kannst.

Ansonsten gilt das, was bereits angesprochen wurde: Tests haben meistens nicht den Anspruch, alle erdenklichen Eingabemöglichkeiten abzudecken, sondern nur häufige Fälle und Randbedingungen. Die Frage ist ja: Wie realistisch ist es, dass ein nicht abgedeckter Fall auftreten kann? Und: Wie dramatisch wäre es, wenn dieser Fall auftritt und tatsächlich ein unerwartetes Verhalten hervorruft?

Manchmal kann man sich auch schützen, indem man an einer anderen Stelle im Code entsprechende Zusicherungen (Plausibilitätsprüfungen, usw) einbaut. Wenn es dann bei der Zusicherung kracht, könnte das am Ende weniger kompliziert sein als eine perfekt getestete vorarbeitende Instanz zu haben.
Benutzeravatar
Hyperion
Moderator
Beiträge: 7478
Registriert: Freitag 4. August 2006, 14:56
Wohnort: Hamburg
Kontaktdaten:

@Sirius3: Wo liegt denn der Unterschied zwischen "alle Wege" und "alle Möglichkeiten"? Mir ist schon klar, dass Du meinst, dass es hier lediglich *zwei* Wege gibt, nämlich if-Bedingung erfüllt und nicht erfüllt. Damit siehst Du diese Bedingung als eine Einheit. Das kann man sicherlich tun, aber streng genommen, ist der Weg komplexer, da man das Zustandekommen der Bedingung imho nicht einfach ausblenden darf. Denn im Endeffekt kommt es ja genau darauf an. Ich glaube es jeder Programmiersprache, dass diese abhängig von der Wahrheitsbedingung in den Rumpf springt oder eben nicht. Aber wieso dies passieren muss, ist hier genau die Frage. Andererseits ist mir auch klar, dass das irgend wann Ausmaße annimmt, die unschön sind... daher ja hier meine Frage :-D

Evtl. reicht es tatsächlich aus, hier für jeden Parameter jeweils den falschen Wert anzunehmen, da alle Und-Verknüpft sind und demnach das Umkippen eines Wertes reicht, so dass die Gesamtbedingung nicht erfüllt ist. Das wären dann fünf Tests, bzw. zwei zu schreibende, wobei einer mit vier Parametern auskommt... also einer für den positiven Fall und einer für die vier negativen Möglichkeiten. Ja, das wäre wohl eine akzeptable Lösung.
snafu hat geschrieben:@Hyperion: Du brauchst insgesamt 8 Objekte. Denn 4 Instanzen brauchst du von Typ A wegen den Möglichkeiten bei `.some_member` und `.some_method()`. Dann noch jeweils 2 von Typ B und Typ C. Davon berechnest du das Kreuzprodukt und schon hast du alle erdenklichen Kombinationen, die du durch den Test jagen kannst.
Was eben genau 2^4 = 16 enstpricht ;-)

Ich denke aber ihr habt mich darin bestärkt, dass ein Test mit derartig vielen Möglichkeiten unschön ist. Ich werde also mal versuchen in Richtung "Specification Pattern" die Bedingungen kaskadierend zusammenzufassen, so dass ich an dieser Stelle die Möglickeiten reduzieren und die Bedingungen separat in den sachlich gebündelten Spezifikationsobjekten durchtesten kann. Bei wenigen Bedingungen kann man dann einfacher die wirklich zu testenden Fälle ausmachen :-)
encoding_kapiert = all(verstehen(lesen(info)) for info in (Leonidas Folien, Blog, Folien & Text inkl. Python3, utf-8 everywhere))
assert encoding_kapiert
Benutzeravatar
Hyperion
Moderator
Beiträge: 7478
Registriert: Freitag 4. August 2006, 14:56
Wohnort: Hamburg
Kontaktdaten:

Ich wollte kurz noch ein Feedback geben.

Ich habe es tatsächlich so gelöst, dass ich die Bedingungen in Richtung "specification pattern" gekapselt und zu logisch zusammengehörenden Blöcken aufgeteilt habe. Alleine dadurch konnte ich einzelne Spezifikationen leichter testen, weil die Vielzahl an Kombinationen wegfällt. Teilweise habe ich das aber auch tatsächlich so umgesetzt, dass ich bei reinen "Und"-Verknüpfungen nur die Fälle getestet habe, bei denen *ein* Element ``false`` gesetzt wird und nur dieses eine Element das erwartete Ergebnis bestimmt. Dadurch reduzieren sich die wirklich aussagekräftigen Testfälle ab drei Booleschen Elementen doch drastisch!

Hier mal der prinzipiell Aufbau:

Code: Alles auswählen

class SpecificationAFalsyCombinationsTests : 
    public ::testing::Test, 
    public ::testing::WithParamInterface<std::bitset<2>>
{
};

TEST_P(SpecificationAFalsyCombinationsTests, IsSatisfied_WithFalsyArgument_GivesFalse)
{
    // arrange
    SomeTypeFake someTypeStubA;
    SomeTypeFake someTypeStubB;
    SpecificationA sut(someTypeStubA, someTypeStubB, ...);
    
    auto bits = GetParam();

    someTypeStubA.some_member = bits[1];
    
    ON_CALL(someTypeStubB, some_member()).WillByDefault(Return(bits[0]));
    EXPECT_CALL(someTypeStubB, some_member()).Times(AnyNumber());
    
    // act
    auto result = sut.IsSatisfied();

    // assert
    ASSERT_FALSE(result);
}

const std::bitset<2> falsy_combinations_SpecificationATests[3] = {
    std::bitset<2>("00"),
    std::bitset<2>("01"),
    std::bitset<2>("11"),
};

INSTANTIATE_TEST_CASE_P(SpecificationATests,
                        SpecificationAFalsyCombinationsTests,
                        ::testing::ValuesIn(falsy_combinations_SpecificationATests));
encoding_kapiert = all(verstehen(lesen(info)) for info in (Leonidas Folien, Blog, Folien & Text inkl. Python3, utf-8 everywhere))
assert encoding_kapiert
Antworten