04.06.13 Asio Mocks

Testen von Netzwerk-Software

Bei der Entwicklung von Software, die über das Internet mit anderen Komponenten kommuniziert, gibt es in der Regel besonders hohe Anforderungen an Robustheit und Korrektheit. Zusätzlich macht die asynchrone und nicht deterministische Natur des Netzes es einem schwer, hier besonders sorgfältig zu testen.

Beim meinem open source web server Sioux habe ich nach einem Weg gesucht, die Kommunikation über TCP/IP ausreichend zu testen. Dabei habe ich eine Lösung gefunden, die mir so gut gefällt, dass ich sie gerne teilen möchte.

Asio

Sioux ist in C++ geschrieben und verwendet Asio, um plattform-neutral HTTP über TCP/IP zu implementieren. Asio implementiert das Proactor Design Pattern. Dabei werden alle IO Operationen nur angestoßen und Asio wird ein handler mitgegeben, der aufgerufen wird, wenn eine Operation beendet wurde (weil Daten eingetroffen sind, oder ein Fehler aufgetreten ist). Es gibt im Grunde keine (kaum) blockierende Funktionen. Dadurch können mehrere IO Operationen simultan durchgeführt werden, ohne dass mehrere threads laufen müssen.

Über IP (sowohl V4 als auch V6) implementiert Asio TCP, UDP und ICMP. Alle drei Protokolle werden über eine eigene Klasse implementiert. Als weitere wichtige Funktion implementiert Asio timer, die in jedem Netzwerk-Protokoll Verwendung finden, um timeouts zu implementieren.

Asio ist auch schon seit langem ein Teil von Boost, kann aber auch seperat installiert werden. Das ist wirklich "rock-solid, state of the art"-Technik. Es ist also an der Zeit, die ganzen selbst geschriebenen socket wrapper mal zu entsorgen!

asio::io_service

io_service ist eine ganz zentrale Klasse in Asio. Jede der oben aufgeführten Klassen (für TCP, UDP, ICMP und timer) nimmt eine Referenz auf ein io_service Exemplar als Konstruktor-Argument. io_service implementiert eine event loop, die mit der run() Funktion am Laufen gehalten wird. Alle an Asio übergebenen handler werden direkt oder indirekt aus dieser run() Funktion heraus aufgerufen.

Mock für ip::tcp::socket

asio::ip::tcp::socket ist die TCP implementiert von Asio. Die beiden wesentlichen Funktionen implementieren Lesen und Schreiben über eine bestehende TCP/IP-Verbindung. Beide Funktionen nehmen einen Puffer für empfangende bzw. für zu sendende Daten und einen handler. Der handler wird aufgerufen, sobald Ergebnisse der Operation da sind, also z.B. Daten geschrieben oder gelesen wurden, oder ein Fehler aufgetreten ist.

    template<
        typename MutableBufferSequence,
        typename ReadHandler >
    void async_read_some(
        const MutableBufferSequence& buffers,
              ReadHandler            handler );

    template<
        typename ConstBufferSequence,
        typename WriteHandler >
    void async_write_some(
        const ConstBufferSequence& buffers,
              WriteHandler         handler );

Ein mock für ip::tcp::socket muss also im wesentlichen diese beiden Funktionen implementieren.

Der ReadHandler der async_read_some() Funktion nimmt einen Fehlerkode und die Anzahl gelesener bytes:

    void handler(
        const boost::system::error_code& error,
              std::size_t                bytes_transferred );        

Für das Lesen vom Netz muss also neben dem eigentlichen, zu simulierenden Inhalt auch die Anzahl an bytes vorgegegen werden können, mit dem das Eintreffen von Daten simuliert werden soll. Ein erster Konstruktor für den mock könnte also so aussehen:

    template < class Iterator >
    socket(
        boost::asio::io_service&    io_service, 
        Iterator                    begin, 
        Iterator                    end, 
        std::size_t                 bite_size, 
        unsigned                    times = 1 );

Der zusätzliche times Parameter gibt an, wie oft der mit begin, end vorgegebene Inhalt simuliert werden soll, bevor der mock das Schließen der Verbindung durch die Gegenseite simuliert. Damit kommt man schon ziemlich weit und kann prüfen, dass die Software mit unterschiedlichen Inhalten klarkommt und auch mit unterschiedlichen Längen der empfangenen Daten funktioniert.

Ein nächster Konstruktor nimmt zusätzlich noch Fehlerkodes, um beim Lesen oder Schreiben Fehler zu simulieren. Zwei Parameter beschreiben dabei die Position im stream, an denen die Fehler simuliert werden sollen. Simuliert wird ein Lese-Fehler, in dem die async_read_some() des mocks den mitgegebenen handler aufruft, und dabei den im Konstruktor des mocks vorgegebenen Fehlerkode (read_error) angibt.

    template < class Iterator >
    socket(
        boost::asio::io_service&         io_service, 
        Iterator                         begin, 
        Iterator                         end, 
        const boost::system::error_code& read_error, 
        std::size_t                      read_error_occurens,
        const boost::system::error_code& write_error, 
        std::size_t                      write_error_occurens );

Die derzeitige Implementierung des socket mocks hat 9 Konstruktoren. Mit einem kann z.B. die Länge der gelesenen und geschriebenen Daten mit einem Zufallsgenerator vorgegeben werden. Andere erlauben zusätzlich das zeitliche Verhalten genauer zu bestimmen.

Einen c'tor möchte ich noch mal genauer beschreiben:

    socket(
        boost::asio::io_service&    io_service, 
        const read_plan&            reads, 
        const write_plan&           writes = write_plan() );

Mit dem Konstruktor, kann man einen mock konstruieren, und dabei recht detailliert bestimmen, welches Verhalten simuliert werden soll. Ein read_plan gibt dabei vor, wie sich der mock lesender Weise verhält, ein optionaler write_plan gibt vor, wie das schreibende Verhalten auszusehen hat. Ein Beispiel, bei dem vom mock zuerst z.B. der Text "Hallo Wel" gelesen wird, dann, nach einer Pause von 100ms das fehlende "t" gelesen werden kann und die Gegenstelle dann erst nach 200ms auflegt, würde so aussehen:

    boost::asio::io_service queue;
    
    asio_mocks::socket<> socket( queue,
        asio_mocks::read_plan()
            << asio_mocks::read( "Hallo Wel" )
            << asio_mocks::delay( boost::posix_time::millisec( 100 ) )
            << asio_mocks::read( "t" )
            << asio_mocks::delay( boost::posix_time::millisec( 200 ) )
            << asio_mocks::disconnect_read() );

Mock für asio::deadline_timer

Timings kann man einfach in Echtzeit testen. Hat die Applikation irgendwo ein timeout von 20 Sekunden, dann kann man einfach 20 Sekunden warten, bis das timeout tatsächlich abläuft. Das kann Testen dann aber recht zäh und langwierig machen. Kommen mehrere Tests mit größeren timeouts zusammen, dann kann die gesammte Testzeit ganz schnell mehrere Minuten dauern und plötzlich macht das Testen überhaupt keinen Spaß mehr.

Ich habe beschlossen, das Verstreichen von Zeit mit zu simulieren. Die Bibliothek hält intern eine eigene Zeit; jeder timer wird während des Tests durch einen mock (asio_mocks::timer) ersetzt. Mit einer zusätzliche Funktion advance_time() lässt sich das Fortschreiten der Zeit vorgeben. Die callbacks aller timer, die zum vorgegebenen Zeitpunkt abgelaufen sind, werden dann in chronologischer Reihenfolge ausgeführt.

Lässt man die Zeit immer dann fortschreiten, wenn das boost::asio::io_service Objekt keine auszuführenden handler mehr hat (dann kehrt die run() Funktion zurück), und setzt die simulierte Zeit immer auf den Zeitpunkt, zu dem der nächste timer abläuft, bekommt man eine schöne, flüssige Simulation über alle Zeitpunkte, die in der Applikation eine Rolle spielen.

Leider bin ich auf die Idee erst später gekommen, sodass Sioux nun sowohl Tests enthält, die in Echtzeit ablaufen, als auch Tests, die mit simulierter Zeit laufen. Das ist z.B. auch der Grund dafür, warum der socket mock einen timer als template Parameter nimmt.

Praktische Erwägungen

Damit die zu testendenden Komponenten während des Tests nun mit dem mock arbeiten und nicht mehr mit den Asio Originalen, hat es sich bei mir bewährt, socket und timer als template-Parameter vorzugeben. Das macht dann aus jeder Klasse automatisch ein template. Den Nachteil (vor allem im Bezug auf die Übersetzungszeiten) gehe ich aber gerne ein, wenn ich im Gegenzug dafür gute Testbarkeit bekomme. Polymorphy wäre auch eine Lösung gewesen. Dazu müssten dann aber auch alle Puffer und handler auf einen gemeinsamen Typen gebracht werden.

Aussicht

Asio Mocks ist jetzt ein Teil von Sioux. Bei Interesse könnte dieser Teil aber auch in eine eigenständige Bibliothek extrahiert und dort weiter an die Bedürfnisse anderer Projekte angepasst werden. Wenn konkreter Bedarf besteht, helfe ich da gerne mit.