Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 18: Testautomatisierung und Dokumentation

Ein Softwaresystem ist nur so gut wie seine Tests. In vielen traditionellen Programmiersprachen ist das Einrichten einer Testumgebung mühsam und erfordert externe Frameworks (wie JUnit in Java oder PyTest in Python). Ähnliches gilt für die Generierung von Dokumentationen: Häufig driften Dokumentation und echter Quellcode im Laufe der Zeit auseinander.

Rust löst beide Probleme, indem es sowohl ein vollwertiges Test-Framework als auch ein mächtiges Dokumentations-Werkzeug direkt in sein Standard-Werkzeugset (cargo) integriert. In diesem Kapitel lernen Sie, wie Sie Unit-Tests und Integrationstests schreiben, Ihre APIs dokumentieren und wie Sie sicherstellen, dass Ihre Codebeispiele in der Dokumentation niemals veralten.

In diesem Kapitel bieten wir Ihnen drei verschiedene Perspektiven auf das Thema an. Wählen Sie die Sicht, die am besten zu Ihrem Hintergrund passt:

  • Für Anfänger (Einfach): Konzentriert sich auf das Spielzeugauto-Prüfstand-Prinzip, das Erstellen von Unit-Tests mit #[test] und #[cfg(test)], Asserts und die Grundlagen von Markdown-Dokumentationen.
  • für Profis (Architektur): Behandelt Tests mit Result-Rückgaben, #[should_panic]- und #[ignore]-Attribute, Integrationstests im tests/-Verzeichnis und ausführbare Doc-Tests in der API-Dokumentation.
  • Hardware-Sicht (CPU/RAM): Analysiert den Test-Harness (Einstiegspunkt), die Eliminierung von Testcode aus Produktions-Builds, Speicher- und Dateikonflikte bei paralleler Testausführung und die Funktionsweise von cargo doc.

Begleitvideo zu Kapitel 18: Testautomatisierung und Dokumentation


Kapitel 18: Testautomatisierung und Dokumentation – Die Qualitätskontrolle

Stell dir vor, du leitest eine Spielzeugfabrik, die kleine ferngesteuerte Rennautos herstellt.

Bevor ein Auto in den bunten Karton gepackt und an Kunden verschickt wird, muss es auf Herz und Nieren geprüft werden. Dazu hast du am Ende der Produktionslinie einen kleinen Prüfstand aufgebaut. Ein Mitarbeiter setzt das Auto auf eine Teststrecke und prüft:

  1. Fährt das Auto vorwärts, wenn man den Hebel drückt?
  2. Funktionieren die Bremsen?
  3. Leuchten die Scheinwerfer?

Dieser Prüfstand befindet sich in der Werkstatt. Die Kunden im Laden bekommen ihn niemals zu Gesicht. Sie kaufen nur das fertige Auto. Aber ohne diesen Prüfstand hättest du keine Ahnung, ob manche Autos defekt ausgeliefert werden.

In der Programmierung ist das exakt dasselbe. Um sicherzustellen, dass dein Code fehlerfrei arbeitet (und auch nach zukünftigen Änderungen nicht kaputtgeht), schreiben wir Tests.

Zusätzlich legen wir dem Auto eine Bedienungsanleitung bei. In Rust schreiben wir diese Anleitung direkt in den Code, und Cargo baut daraus automatisch eine schicke Website für unsere Anwender.


1. Lernziele – Das wirst du heute lernen

  • Warum wir testen: Du verstehst die Bedeutung von automatischen Qualitätsprüfungen.
  • Unit-Tests schreiben: Du erstellst Testfunktionen mit der Annotation #[test].
  • Die Asserts anwenden: Du prüfst Werte mit assert_eq! und assert!.
  • Das Testmodul einrichten: Du nutzt #[cfg(test)], um Testcode vom fertigen Programm zu trennen.
  • Dokumentationen verfassen: Du schreibst verständliche Anleitungen direkt mit ///.

2. Der erste Unit-Test

In Rust schreiben wir Tests als ganz normale Funktionen, die wir mit dem Attribut #[test] kennzeichnen. Wenn wir im Terminal cargo test ausführen, sucht Cargo nach all diesen Funktionen und führt sie aus.

Lass uns eine einfache Funktion zum Addieren testen:

#![allow(unused)]
fn main() {
// Die Funktion, die wir prüfen wollen
pub fn addiere(a: i32, b: i32) -> i32 {
    a + b
}

// Wir erstellen ein spezielles Test-Modul.
// #[cfg(test)] sagt dem Compiler: "Kompiliere dieses Modul NUR, wenn wir 'cargo test' ausführen!"
// Wenn wir die App normal bauen (cargo build), wird dieses Modul komplett ignoriert.
#[cfg(test)]
mod tests {
    // Wir importieren die Funktion 'addiere' aus dem übergeordneten Modul
    use super::*;

    // #[test] macht diese Funktion zu einer Testfunktion
    #[test]
    fn test_addiere_positiv() {
        // assert_eq! prüft, ob beide Seiten identisch sind (eq = equal)
        assert_eq!(addiere(2, 2), 4);
    }

    #[test]
    fn test_addiere_negativ() {
        assert_eq!(addiere(-2, -3), -5);
    }
}
}

Die wichtigsten Zusicherungen (Asserts)

Um Werte zu prüfen, bietet uns Rust drei Makros:

  • assert!(bedingung): Der Test besteht, wenn die Bedingung true ergibt.
  • assert_eq!(a, b): Der Test besteht, wenn a gleich b ist.
  • assert_ne!(a, b): Der Test besteht, wenn a ungleich b ist (ne = not equal).

Wir können jedem dieser Makros eine eigene Fehlermeldung mitgeben:

#![allow(unused)]
fn main() {
assert_eq!(addiere(2, 2), 4, "Oh je! 2 + 2 war nicht gleich 4!");
}

3. Dokumentation für Anwender schreiben

Wenn andere Programmierer deinen Code verwenden sollen, brauchen sie eine Anleitung. In Rust schreiben wir Dokumentationen mit drei Schrägstrichen ///.

#![allow(unused)]
fn main() {
/// Multipliziert zwei Zahlen miteinander.
///
/// # Beispiele
///
/// ```
/// let ergebnis = multipliziere(3, 4);
/// assert_eq!(ergebnis, 12);
/// ```
pub fn multipliziere(a: i32, b: i32) -> i32 {
    a * b
}
}

Wenn du nun im Terminal des Projekts den Befehl:

cargo doc --open

ausführst, liest Cargo diese Kommentare, übersetzt das Markdown-Format in HTML und öffnet automatisch eine professionelle Dokumentations-Website in deinem Browser!


4. Compilerfehler-Show: Debug-Traits vergessen

Ein häufiger Fehler bei Anfängern betrifft die Nutzung von assert_eq! auf eigenen Strukturen.

#![allow(unused)]
fn main() {
struct Punkt {
    x: i32,
    y: i32,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_punkt() {
        let p1 = Punkt { x: 1, y: 2 };
        let p2 = Punkt { x: 1, y: 2 };
        assert_eq!(p1, p2); // Compilerfehler!
    }
}
}

Die Fehlermeldung des Compilers:

error[E0277]: can't compare `Punkt` with `Punkt`
  --> src/main.rs:14:9
   |
14 |         assert_eq!(p1, p2);
   |         ^^^^^^^^^^^^^^^^^^ no implementation for `Punkt == Punkt`
   |
   = help: the trait `PartialEq` is not implemented for `Punkt`

Die Erklärung:

Das Makro assert_eq! muss im Erfolgsfall wissen, ob zwei Objekte gleich sind. Schlägt der Test fehl, muss es die Objekte zudem auf der Konsole ausgeben können. Daher müssen alle Typen, die mit assert_eq! verglichen werden, zwei Eigenschaften (Traits) besitzen: PartialEq und Debug.

Die Lösung: Bitte den Compiler über das derive-Attribut, diese Traits automatisch für dich zu erstellen:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Debug)] // Hinzufügen der benötigten Hilfs-Traits
struct Punkt {
    x: i32,
    y: i32,
}
}

5. Zusammenfassung

  1. Tests sichern die Qualität deines Codes und verhindern zukünftige Fehler.
  2. Das Attribut #[test] kennzeichnet Testfunktionen.
  3. Über assert_eq!, assert_ne! und assert! prüfen wir Ergebnisse.
  4. Mit #[cfg(test)] kapseln wir das Testmodul, damit es nicht in der finalen Binärdatei landet.
  5. Dokumentationen schreiben wir mit /// direkt im Code und generieren sie mit cargo doc.

Kapitel 18: Testautomatisierung und Dokumentation – Professionelle Test-Strukturen und API-Dokumentation

In der professionellen Software-Architektur sind Tests und Dokumentation keine nachgelagerten Pflichten, sondern integraler Bestandteil des Entwurfs- und CI/CD-Prozesses. Gut strukturierte Test-Suiten ermöglichen angstfreies Refaktorisieren und sichern die Schnittstellen-Stabilität Ihrer Bibliotheken ab. Ausführbare Dokumentationstests garantieren zudem, dass Codebeispiele in der Dokumentation niemals veralten.


1. Lernziele – Das wirst du heute lernen

  • Result in Tests nutzen: Sie verwenden den ?-Operator in Testsignaturen zur sauberen Fehlerfortpflanzung.
  • Fehlerzustände testen (#[should_panic]): Sie verifizieren erwartete Programmabstürze.
  • Langsame Tests kontrollieren: Sie schließen rechenintensive Tests über #[ignore] aus.
  • Integrationstests aufbauen: Sie trennen Modultests (Unit-Tests) von API-Integrationstests im tests/-Verzeichnis.
  • Ausführbare Doc-Tests schreiben: Sie erstellen Dokumentation, die vom Compiler getestet wird.

2. Fortgeschrittene Test-Steuerung

Der ?-Operator in Testfunktionen

Wenn Sie Funktionen testen, die ein Result zurückgeben, müssen Sie nicht mühsam mit unwrap() arbeiten. Sie können Ihre Testfunktion so konfigurieren, dass sie selbst ein Result zurückgibt:

#![allow(unused)]
fn main() {
fn parsiere_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.trim().parse::<u16>()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parsiere_port() -> Result<(), std::num::ParseIntError> {
        let port = parsiere_port(" 8080 ")?; // Schlägt fehl, falls ein ParseError fliegt
        assert_eq!(port, 8080);
        Ok(())
    }
}
}

Überprüfung auf erwarteten Absturz (#[should_panic])

Wenn Funktionen bei falschen Eingaben abstürzen sollen (z. B. Out-of-Bounds), prüfen wir dies über das Attribut #[should_panic]. Um Fehlalarme durch unbeteiligte Panics zu vermeiden, sollten Sie das erwartete Textfragment spezifizieren:

#![allow(unused)]
fn main() {
pub fn init_datenbank(verbindungen: u32) {
    if verbindungen == 0 {
        panic!("Datenbank-Pool darf nicht die Groesse 0 haben!");
    }
}

#[test]
#[should_panic(expected = "Pool darf nicht die Groesse 0 haben")]
fn test_datenbank_null_panict() {
    init_datenbank(0);
}
}

Tests ignorieren (#[ignore])

Tests, die externe Ressourcen benötigen (Netzwerk, Datenbanken) oder sehr langsam sind, markieren Sie mit #[ignore]. Sie werden bei einem standardmäßigen cargo test übersprungen:

#![allow(unused)]
fn main() {
#[test]
#[ignore]
fn schwerer_integrationstest() {
    // ...
}
}

Um gezielt nur die ignorierten Tests auszuführen: cargo test -- --ignored.


3. Integrationstests im tests/-Verzeichnis

Unit-Tests liegen direkt in den Quelldateien und dürfen auf private Implementierungsdetails zugreifen. Integrationstests hingegen testen die Bibliothek ausschließlich von außen über die öffentliche API (pub).

Sie liegen in einem separaten Verzeichnis namens tests/ auf der obersten Ebene des Projekts:

mein_projekt/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── api_tests.rs

Jede Datei im tests/-Verzeichnis wird vom Compiler als eigenständiges Crate übersetzt. Sie müssen Ihre Bibliothek dort explizit importieren:

#![allow(unused)]
fn main() {
// tests/api_tests.rs
use mein_projekt::multipliziere; // Import des öffentlichen APIs

#[test]
fn test_externe_api() {
    assert_eq!(multipliziere(2, 5), 10);
}
}

4. Ausführbare Dokumentationstests (Doc-Tests)

Rust bietet die Möglichkeit, Code-Beispiele in Dokumentationskommentaren automatisch als Tests auszuführen. Das verhindert veraltete und fehlerhafte Dokumentationsbeispiele.

#![allow(unused)]
fn main() {
/// Berechnet den Durchschnitt eines Vektors.
///
/// # Beispiele
///
/// ```
/// let daten = vec![1.0, 2.0, 3.0];
/// let ergebnis = mein_crate::durchschnitt(&daten);
/// assert_eq!(ergebnis, Some(2.0));
/// ```
pub fn durchschnitt(daten: &[f64]) -> Option<f64> {
    if daten.is_empty() {
        return None;
    }
    let summe: f64 = daten.iter().sum();
    Some(summe / daten.len() as f64)
}
}

Führen Sie cargo test aus, kompiliert der Compiler das Beispiel innerhalb des `/// ````-Blocks und führt es als eigenständigen Test aus.


Kapitel 18 - Hardware-Sicht: Test-Binaries, Parallelität und Compiler-Lints

Hallo Thorsten! Nachdem wir uns mit der Test-Organisation und API-Dokumentation beschäftigt haben, werfen wir einen Blick hinter die Kulissen und analysieren, wie das Test-Framework auf System- und Hardwareebene arbeitet.

Als Systemprogrammierer gibst du dich nicht mit der Erklärung „Es testet einfach“ zufrieden. Du willst wissen: Wie sieht der Einstiegspunkt des Testprogramms aus? Warum erhöht Testcode nicht die Binärgröße des Release-Builds? Und wie verhalten sich parallele Tests auf Hardware-Ebene?

Schnapp dir einen Kaffee – wir steigen tief in die Systemebene ab!


1. Die Funktionsweise des Test-Harness (Test-Einstiegspunkt)

Wenn du cargo test ausführst, baut der Compiler dein Programm grundlegend anders als bei einem normalen cargo build:

  1. Generierung der Test-Binary: Der Compiler erzeugt ein temporäres, ausführbares Programm (die Test-Binary).
  2. Der Test-Harness: Rust fügt automatisch einen eigenen Einstiegspunkt (eine generierte main-Funktion, den sogenannten Test Harness) ein. Diese main-Funktion verweist auf die interne Bibliotheks-Kiste libtest der Standardbibliothek.
  3. Test-Entdeckung: Alle Funktionen, die im AST mit dem Attribut #[test] markiert wurden, werden vom Compiler in eine interne Liste (ein Array aus Funktionszeigern) eingetragen.
  4. Ausführung: Die generierte main-Funktion läuft dieses Array durch und ruft jeden Test nacheinander auf.

Warum testet #[cfg(test)] ohne Release-Spuren?

Das Attribut #[cfg(test)] ist ein Compiler-Flag. Wenn Sie cargo build --release aufrufen, wird das Flag test nicht gesetzt. Der Compiler entfernt das gesamte Testmodul bereits in der Parser-Phase (Conditional Compilation). Es findet keine Codegenerierung statt, und alle Tests sowie die Bibliothek libtest werden komplett aus der finalen Binärdatei herausgefiltert (Dead Code Elimination).


2. Parallele Testausführung auf CPU-Ebene

Standardmäßig führt der Test-Harness alle Tests parallel aus, um die CPU-Kerne optimal auszulasten. Jeder Test läuft in einem eigenen Thread.

Das Hardware-Problem: Konflikte auf globalen System-Ressourcen

Da die Threads parallel laufen, teilen sie sich globale Ressourcen des Betriebssystems. Wenn Ihre Tests auf solche Zustände zugreifen, kommt es zu physischen Konflikten auf Speicher- oder Festplattenebene:

  1. Umgebungsvariablen: Wenn ein Test std::env::set_var aufruft, modifiziert er die globale Umgebungstabelle des Prozesses. Ein parallel laufender Test liest zeitgleich verfälschte Daten.
  2. Dateisystem: Schreiben zwei Tests in dieselbe Datei temp.txt, überschreiben sie sich gegenseitig.
  3. Datenbanken: Parallele Schreiboperationen auf derselben Tabelle führen zu inkonsistenten Testdaten und scheiternden Zusicherungen (sogenannte Flaky Tests).

Die Lösung auf Hardware-Ebene:

  • Umgebung: Vermeiden Sie globale Zustände. Übergeben Sie Konfigurationen explizit an Ihre Funktionen.
  • Dateien: Nutzen Sie Bibliotheken wie tempfile. Diese erstellen für jeden Testlauf ein eindeutiges, temporäres Verzeichnis auf der Festplatte (oder im RAM-basierten /tmp), sodass sich die Dateizugriffe physikalisch nicht stören.
  • Serielle Ausführung: Zwingen Sie den Harness zur sequenziellen Ausführung auf einem einzigen CPU-Kern:
    cargo test -- --test-threads=1
    

3. Wie cargo doc unter der Haube arbeitet

Der Befehl cargo doc baut keine normale Binärdatei. Er verhält sich wie ein statischer Website-Generator auf Compiler-Basis:

  1. AST-Analyse: Der Compiler liest das Crate ein und analysiert die Struktur (Typen, Felder, Schnittstellen). Er ignoriert den eigentlichen Funktionscode im Körper der Funktionen, da dieser für die Dokumentation irrelevant ist.
  2. Markdown-Rendering: Alle Kommentare, die mit /// oder //! beginnen, werden extrahiert und durch einen eingebauten Markdown-Parser in HTML-Code übersetzt.
  3. Verlinkung (Cross-Referencing): Rust sucht nach Code-Symbolen in den Kommentaren (z. B. [Vektor](crate::Vec)) und verknüpft sie automatisch mit den entsprechenden lokalen HTML-Dokumentationsseiten.

4. Verweis auf Übungen

Sie haben nun gelernt, wie Sie Unit- und Integrationstests schreiben, Ihre APIs dokumentieren und wie diese Prozesse auf System- und Hardwareebene ablaufen. Jetzt ist es an der Zeit, diese Konzepte in der Praxis anzuwenden.

Wechseln Sie in das Verzeichnis: exercises/04_collections/ (oder ein entsprechendes Test-Verzeichnis Ihres Übungs-Workspaces).

Dort finden Sie praktische Aufgaben, bei denen Sie:

  1. Fehlerhafte mathematische Logik durch Unit-Tests aufspüren und korrigieren müssen.
  2. Einen Integrationstest für eine externe API implementieren.
  3. Ein API-Dokumentation inklusive eines ausführbaren Doc-Tests erstellen.