Einleitung: Warum Tests in Rust nicht langweilig sind
Wenn Sie jemals Tests für Code in Python oder JavaScript geschrieben haben, sind Sie sicherlich auf folgende Situation gestoßen: Ihre Methode ruft eine externe API auf, liest eine Datei von der Festplatte oder greift auf eine Datenbank zu. Solche Tests in einer realen Umgebung auszuführen, ist schmerzhaft. Sie sind langsam, unzuverlässig (was, wenn die API ausfällt?) und erfordern die Einrichtung der Umgebung.
In Rust unterscheidet sich die Situation nicht. Nur ist Rust eine systemnahe Sprache, und hier müssen Tests schnell, zuverlässig und deterministisch sein. Genau dafür braucht man Mock-Objekte und Fixtures.
In diesem Artikel werden wir uns ansehen, wie man Tests in Rust schreibt, die nicht von der Außenwelt abhängen. Sie erfahren, wie Sie Abhängigkeiten ersetzen, Daten für Tests vorbereiten und dies elegant tun. Los geht's!
Was sind Mock-Objekte und Fixtures?
Mock-Objekte (Mocks)
Mock-Objekt ist eine Nachbildung einer realen Systemkomponente. Sie erstellen ein Objekt, das sich so verhält, wie Sie es für den Test benötigen: es gibt vorgegebene Werte zurück, wirft Fehler aus oder überprüft, ob seine Methoden mit den richtigen Argumenten aufgerufen wurden.
Stellen Sie sich vor, Sie haben einen E-Mail-Versanddienst. Im Test möchten Sie keine echten E-Mails versenden – Sie verwenden einen Mock, der lediglich prüft, ob der Dienst mit der richtigen Adresse und dem richtigen Text aufgerufen wurde.
Fixtures
Fixtures sind vorbereitete Daten oder Zustände, die für die Durchführung eines Tests benötigt werden. Zum Beispiel eine Testdatenbank, eine Konfigurationsdatei, vorinstallierte Strukturen. In Rust werden Fixtures oft durch normale Funktionen implementiert, die fertige Objekte zurückgeben.
Zusammen geben Ihnen Mocks und Fixtures die vollständige Kontrolle über die Testumgebung. Sie testen nur Ihre eigene Logik, nicht die Arbeit externer Dienste.
Grundlegende Prinzipien des Testens in Rust
Bevor wir in die Mocks eintauchen, erinnern wir uns daran, wie Tests in Rust aufgebaut sind. Ein üblicher Modultest sieht so aus:
#[cfg(test)]mod tests { use super::*;
#[test] fn test_addition() { assert_eq!(2 + 2, 4); }}Ganz einfach. Aber wenn Ihr Code von externen Ressourcen (Dateien, HTTP-Anfragen, Datenbanken) abhängt, treten Probleme auf. Die Lösung ist die Umkehrung der Abhängigkeiten und die Verwendung des Dependency Injection-Musters.
Praktisches Beispiel: Datendienst
Angenommen, wir haben eine Struktur DataLoader, die Daten von einer externen API lädt und verarbeitet. Wir möchten die Verarbeitung testen, ohne die echte API aufzurufen.
Zuerst definieren wir ein Trait (Interface) für die Datenquelle:
pub trait DataSource { fn fetch_data(&self) -> Result<String, String>;}Jetzt implementieren wir eine echte Quelle, die eine HTTP-Anfrage durchführt (vereinfacht):
pub struct RealDataSource { pub url: String,}
impl DataSource for RealDataSource { fn fetch_data(&self) -> Result<String, String> { // Hier wäre eine echte HTTP-Anfrage Ok(format!("Daten von {}", self.url)) }}Und der Dienst, der diese Quelle verwendet:
pub struct DataLoader<T: DataSource> { source: T,}
impl<T: DataSource> DataLoader<T> { pub fn new(source: T) -> Self { DataLoader { source } }
pub fn load_and_process(&self) -> Result<String, String> { let raw_data = self.source.fetch_data()?; // Eine gewisse Verarbeitung Ok(format!("Verarbeitet: {}", raw_data)) }}Beachten Sie: DataLoader hängt nicht von einer konkreten Implementierung von DataSource ab. Es arbeitet mit jedem Typ, der dieses Trait implementiert. Das ist der Schlüssel zur Testbarkeit.
Manuelles Erstellen eines Mock-Objekts
Der einfachste Weg ist, eine Teststruktur zu erstellen