Wie DevStorage seine GraphQL-API (Panel v5) testet

Software ist einer der wichigsten Bestandteile vieler Unternehmen. Umso wichtiger ist es, dass diese fehlerfrei funktioniert. Bugs und Systemausfälle können Unternehmen jede Menge Geld kosten. Um Fehler bereits im Vorhinein zu verhindern, müssen Softwareentwickler ihren Code und ihre Systeme testen – und das am besten völlig automatisiert. 

Unternehmen, darunter auch DevStorage, müssen dabei Strategien entwickeln, mit denen Sie die Funktionalität der Software testen und gleichzeitig auch in der Zukunft gewährleisten können, dass die Software auch nach Änderungen noch einwandfrei funktioniert.

Auch im Bezug auf die Sicherheit kann umfangreiches Testen Mängel und Probleme aufzeigen, die dadurch noch vor dem Deployment in Produktivsysteme erkannt und behoben werden können.

Wie wird Code allgemein getestet?

Tests werden in verschiedene Stufen unterteilt. Üblicherweise orientieren sich die Unternehmen hierbei am Konzept des Scrum-Mitbegründers Mike Cohn, der Test-Pyramide, die er in seinem Buch Succeeding with Agile beschreibt [1]:

Test Automation Pyramid

https://martinfowler.com/articles/practical-test-pyramid.html

Cohn unterteilt den Testprozess in unterschiedliche Schichten, die von unten nach oben ausgeführt werden. Die Pyramide an sich ist etwas zu vereinfacht und kann missverstanden werden. Trotzdem verdeutlicht sie, dass Tests mit verschiedenen Granularitäten geschrieben werden und die Tests mit Zunahme der Integration in ihrer Anzahl abnehmen müssen [1].

DevStorage teilt seine Teststrategie in folgende Granularitäten ein, die in den nachfolgenden Abschnitten erläutert werden:

  1. Unit-Tests
  2. Integration-Tests
  3. System-Tests
  4. UI-Tests

Unit-Tests

Wie der Name vermuten lässt, werden bei Unit-Tests einzelne Softwarekomponenten individuell und in Isolation getestet. Die Interaktion zwischen den Komponenten wird hierbei vernachlässigt. Der Unit-Test vereint die kleinste Einheit einer Applikation, die getestet werden kann. Entwickler schreiben diese Tests selbst, um die Anforderungen und das erwartete Verhalten zu überprüfen [2].

Unit-Tests beschränken sich nicht nur auf positives Verhalten, sondern überprüfen auch, ob eine Funktion fehlschlägt, wenn das gewollt ist. Somit können Probleme und Bugs bereits früzzeitig erkannt werden, was zur langfristigen Kostenersparnis führt. Sollten Fehler auftreten, können diese i.d.R. sehr schnell behoben werden, weil diese vor den Integration-Tests ausgeführt, bei denen Abhängigkeiten geprüft werden [2].

Aufgrund der Tatsache, dass Unit-Tests kleinste Code-Abschnitte auf ihre Funktionalität überprüfen, sind gefundene Fehler voneinander unabhängig, sodass andere Test-Cases nicht beeinflusst werden [2].

Außerdem vereinfachen Unit-Tests das Testen von Code, als auch die Behebungen von Fehlern in späteren Entwicklungsprozessen, da nicht nur der neueste Code getestet wird. Zudem bleiben Unit-Tests auch in der Zukunft einfach wartbar [2].

Integration-Tests

Integration-Tests überprüfen die Funktionalität zu einer Einheit kombinierter Softwarekomponenten. Ziel der Integration-Tests ist es, die Zuverlässlichkeit und Leistung des integrierten Systems zu überprüfen [2].

Sie werden auf bereits durch Unit-Tests überprüfte Module angewendet und sollen überprüfen, ob die Kombination und Interaktion der Module miteinander die gewünscht Aussage liefert oder nicht [2].

Üblicherweise werden die Integration-Tests von den Entwicklern selbst oder einem unabhängigen Test-Team erstellt [2]. Im Falle von DevStorage erledigen das die Entwickler selbst – schließlich kennen sie sich im jeweiligen Gebiet am besten aus.

System-Tests

System-Tests überprüfen ein gesamtes Softwaresystem auf seine Anforderungen. Sie sind sog. “Black Box”-Tests und werden unabhängig und ohne Zugriff auf den Quellcode definiert. Grundlage dieser Tests sind die Leistungsanforderungen und Geschäftsregeln des Unternehmens [3].

Um die Funktionsweise des gesamten Systems objektiv betrachten und Verhaltensweisen validieren zu können, müssen alle Tests unabhängig von der internen Struktur verfasst werden. Es ist dabei egal, welche Programmiersprache, welche Technologien und wie die Tests implementiert sind [3].

UI-Tests

UI-Tests überprüfen visuelle Indikatoren und grafische Symbole, wie z.B. Menüs, Optionsfelder, Textfelder, Kontrollkästchen, Symbolleisten, Farben oder Schriftarten. Die Durchführung kann entweder manuell oder mit einem automatisierten Testwerkzeug durchgeführt werden [4].

Grundlegend werden Features wie das visuelle Design, Funktionalität, Leistung und Konformität getestet, wobei der Fokus auf der korrekten Anzeige der Elemente sowie den Benutzeraktionen liegt, die durch Tastatur, Maus oder andere Eingabegeräte ausgelöst wird [4].

Zusammenfassung

KategorieUnit-TestsIntegration-Tests
ZweckTesten von indivuellen Code-Einheiten in IsolationTesten der Zusammenarbeit verschiedener, einzelner Einheiten
Test-EbeneModulInterface
AbhängigkeitenKeineBenötigt Interaktion mit externen Abhängigkeiten, z.B. einer Datenbank
AufwandEinfache ImplementierungKompiliziertere Implementierung
WartbarkeitGeringAufwändig

Wie schaut das konkret aus?

DevStorage schreibt sowohl Unit- als auch Integration-Tests. Da ein Großteil unserer neuen Services in Rust geschrieben ist, greifen wir auf die built-in Test-Funktionalität zurück. In jeder Binary oder Library definieren wir Unit-Tests, um einzelne Methoden auf ihre korrekte Funktionsweise automatisiert testen. Jedes Module hat sozusagen seinen eigenen Abschnitt, indem ausschließlich Code getestet wird. Konkret schaut das so aus:

#[cfg(test)]
mod tests {
    #[test]
    fn test_hello_world() {
        assert_eq!("Apples", "Apples");
        assert_ne!(1, 0);
    }
}

Mit Unit-Tests lassen sich zwar einzelne Methoden ideal testen, jedoch sind jene eher ungeeignet, um das zusammenhängede System zu testen. Man könnte auch sagen, dass sie das “Bigger Picture” in keiner Weise erkennen. Deshalb setzen wir auf Integration-Tests, die gesamte Endpunkte überprüfen.

DevStorage verfügt seit der Version 5 über eine eigene Bibliothek, die diese Art von Tests abdecken soll. Diese Library vereinfacht es immens, eine GraphQL-Request und deren Antwort mit den erwarteten Werten abzugleichen. Wir vermeiden dabei Code-Mehrfachverwendungen, da unsere Bibliothek zu jedem unserer Tests automatisch eine Methode generiert, die in einer simulierten Umgebung eine Anfrage an den Webserver stellt, die Antwort abwartet und anschließend mit den durch den Entwickler vorgebenen Assertions abgleicht. Sind alle Assertions korrekt, ist der Test bestanden.

Ein Beispiel für einen solchen Test seht ihr hier:

use service_tests::{SchemaProvider, GraphQlResponseExt};
use test_context::AsyncTestContext;
#[derive(SchemaProvider)]
pub struct Context {
    #[schema]
    schema: ProductSchema,
}
#[async_trait::async_trait]
impl AsyncTestContext for Context {
    async fn setup() -> Self {
        Context {
            schema: create_schema(),
        }
    }
}
#[service_tests::test(
    query=r"{
        product {
            name
            price
        }
    }"
)]
async fn test_product(#[context] context: &mut Context, res: warp::http::Response<warp::hyper::body::Bytes>) {
    // Returns the response body as serde_json::Value
    let graphql = res.graphql().unwrap(); 
    assert_eq!(graphql.value("product.name").unwrap().as_str().unwrap(), "KVM Server");
    assert_eq!(graphql.value("product.price").unwrap().as_str().unwrap(), "3.99");
}

Der Context kann für diese Erklärung vernachlässigt werden. Er enthält nur das Schema, welches der Webserver (hier: warp) benötigt, um die Endpunkte handzuhaben. Außerdem wird die Methode graphql() automatisch generiert, welche die Webserver-Handhabung für GraphQL bereitstellt.

Wir bedienen uns hier einem fortgeschrittenen Feature der Programmiersprache Rust, den Prozeduralen Makros (oder kurz: Proc-Macros). Prozedurale Makros laufen bereits zur Kompilierzeit und bearbeiten die Rust-Syntax, indem sie Teile der Syntax einlesen und eine neue Syntax erzeugen. Man kann sich solche Makros als Funktionen vorstellen, die einen bestehenden Syntaxbaum (kurz: AST) in einen neuen Syntaxbaum umwandeln. Um selbst Prozedurale Makros zu erzeugen, muss der Typ der Crate im Manifest auf proc-macro gesetzt werden [5]:

[lib]
proc-macro = true

Die zurückgegebene Syntax kann nun entweder bestehende Syntax erweitern oder die gesamte Syntax ersetzen, was in unserem Fall eintritt. Tritt ein nicht behebarer Fehler auf, so wird dieser in Form eines Compiler-Fehlers angezeigt [5].

In unserem Fall nutzen wir ein Prozedurales Makro des Typs proc_macro_attribute. Dieses wird wie eine Methode definiert und bekommt zwei Parameter, jeweils vom Typ TokenStream, welcher eine Abfolge von Tokens enthält. Der erste TokenStream gibt nützliche Metadaten an, zum Beispiel erwartete Attributparameter; der zweite TokenStream repräsentiert die durch das Attribut markierte Syntax. Mithilfe der Crate syn lassen sich die TokenStreams in Syntaxbäume umwandeln und analysieren.

Das folgende Proc-Macro erzeugt für jeden Test eine neue Methode – der unten abgebildete Code ist natürlich eine verkürzte/ausgeschlachtete Darstellung:

#[proc_macro_attribute]
pub fn test(metadata: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let args = parse_macro_input!(metadata as AttributeArgs);
    let name = input.sig.ident;
    let response_ident = { /* Extracted from the overlying function parameter */ };
    let context_ident = { /* Extracted from the overlying function parameter */ };
    let context_path = { /* Extracted from the overlying function parameter */ };
    let query = { /* Extracted from the macro attributes */ };
    let input_block = input.block;
    proc_macro::TokenStream::from(quote!{
        #[test_context::test_context(#context_path)]
        #[tokio::test]
        async fn #name(#context_ident: &mut #context_path) {
            let macro_request_body = #query;
            let mut macro_body = std::collections::HashMap::new();
            macro_body.insert("query", macro_request_body);
            let macro_filter = #context_ident.graphql();
            let #response_ident = warp::test::request()
                .method("POST")
                .path("/")
                .json(&serde_json::json!(macro_body))
                .reply(&macro_filter).await;
            #input_block
        }
    })
}

Wie man erkennen kann, erzeugt der Compiler einfach einen Tokio-Test. Warp simuliert die GraphQL-Request:

let #response_ident = warp::test::request()
    .method("POST")
    .path("/")
    .json(&serde_json::json!(macro_body))
    .reply(&macro_filter).await;

Im Anschluss werden die Assertions, bzw. der gesamte Funktionsrumpf in die Funktion übernommen. Wir müssen sicherstellen, dass sich die Bezeichner für die Response und den Context der zu generierenden Funktion mit den vom Entwickler gewählten Bezeichnern der Test-Funktion übereinstimmen. Dazu lesen wir aus dem Syntaxbaum die Bezeichner aus. Beispielsweise entspricht #response_ident letztendlich dem Bezeichner res aus den Funktionsparametern aus der Funktion async fn test_product(context: &mut Context, res: Response<Bytes> { ... }; für den Context wird entsprechend ctx gewählt. So stellen wir sicher, dass keine Konflikte mit unterschiedlichen Bezeichnern vorliegen und lassen dem Entwickler freie Wahl, wie er die Parameter seiner Tests nennt.

#query stimmt mit dem Makroargument query überein, also:

{
    product {
        name
        price
    }
}

Wenn wir also richtig gebaut haben, dann sieht das so aus 🙂

System- und UI-Tests gibt es im Moment noch nicht. Diese werden alsbald ergänzt, wenn wir den Großteil unserer “Majestic Monoliths” fertigestellt haben und auch das Frontend seine Form angenommen hat.

O’zapft is – der Bayer hat’s verfasst 😛
Rechtschreibung- und Zeichensetzungsfehler schenke ich euch!

Quellen

[1] https://martinfowler.com/articles/practical-test-pyramid.html
[2] https://www.softwaretestinghelp.com/the-difference-between-unit-integration-and-functional-testing/
[3] https://blog.atinternet.com/de/systemtests-ein-objektiver-blick-auf-das-produkt/
[4] https://www.perfecto.io/blog/ui-testing-comprehensive-guide
[5] https://doc.rust-lang.org/reference/procedural-macros.html

administrator
X