Mocking externer Abhängigkeiten für robuste Rust-Entwicklung
Grace Collins
Solutions Engineer · Leapcell

Einleitung
In der Welt der Softwareentwicklung hängt die Erstellung zuverlässiger und wartbarer Anwendungen maßgeblich von der Fähigkeit ab, sie effektiv zu testen. Wenn unser Code jedoch mit externen Diensten wie Datenbanken, Drittanbieter-APIs oder Nachrichtenwarteschlangen interagiert, kann das direkte Testen langsam, unvorhersehbar oder sogar kostspielig werden. Diese externen Abhängigkeiten führen zu Nichtdeterminismus und erschweren die Isolierung der zu testenden Einheit und die Gewährleistung konsistenter Ergebnisse. Hier kommt Mocking ins Spiel. Durch den Ersatz echter Abhängigkeiten durch kontrollierte, simulierte Versionen können wir schnelle, deterministische und isolierte Tests erreichen. In Rust haben wir überzeugende Strategien, um diese Herausforderung zu meistern. Dieser Artikel befasst sich mit zwei prominenten Ansätzen zum Mocking von Datenbanken oder externen Diensten: traitbasiertes Mocking und die leistungsstarke mockall-Crate, die einen klaren Weg zur Entwicklung robusterer Rust-Anwendungen ebnen.
Grundlegende Konzepte verstehen
Bevor wir uns mit den Implementierungsdetails befassen, lassen Sie uns einige grundlegende Konzepte klären, die für das Verständnis von Mocking-Strategien in Rust unerlässlich sind.
Mocking: Beim Softwaretest bezieht sich Mocking auf die Erstellung simulierter Objekte, die das Verhalten echter Abhängigkeiten nachahmen. Diese Mock-Objekte sind so konzipiert, dass sie auf vordefinierte Weise auf Aufrufe reagieren, sodass Tester die Umgebung kontrollieren und Interaktionen überprüfen können, ohne auf tatsächliche externe Systeme angewiesen zu sein.
Traits: Im Herzen von Rusts Polymorphie und Abstraktion definieren Traits eine Reihe gemeinsamer Verhaltensweisen, die Typen implementieren können. Sie bieten einen Vertrag, an den ein Typ gebunden ist, und ermöglichen es uns, generischen Code zu schreiben, der mit jedem Typ arbeitet, der einen bestimmten Trait implementiert. Dies ist grundlegend für traitbasiertes Mocking.
Dependency Injection: Ein Entwurfsmuster, bei dem eine Komponente ihre Abhängigkeiten von einer externen Quelle erhält, anstatt sie selbst zu erstellen. Dies fördert lose Kopplung und erleichtert den Austausch verschiedener Implementierungen von Abhängigkeiten, einschließlich Mock-Objekten, während der Tests.
Test Doubles: Ein allgemeiner Begriff für jedes Objekt, das verwendet wird, um ein echtes Objekt zu Testzwecken zu ersetzen. Mocks sind eine spezielle Art von Test Double, die es uns ermöglichen, Interaktionen und Verhaltensweisen zu assertieren. Andere Typen sind Stubs (geben vordefinierte Antworten zurück) und Fakes (einfachere In-Memory-Implementierungen).
Traitbasiertes Mocking: Der Rust-native Ansatz
Traitbasiertes Mocking nutzt Rusts mächtiges Trait-System, um Dependency Inversion zu erreichen und den einfachen Austausch von Abhängigkeiten zu ermöglichen. Die Kernidee besteht darin, einen Trait zu definieren, der die Schnittstelle Ihres externen Dienstes beschreibt. Ihre konkrete Implementierung (z. B. ein Datenbankclient) implementiert dann diesen Trait. Für Tests erstellen Sie eine separate "Mock"-Struktur, die ebenfalls denselben Trait implementiert, aber ihre Methoden enthalten kontrolliertes, vordefiniertes Verhalten.
Prinzip und Implementierung
- Trait definieren: Erstellen Sie einen Trait, der die Operationen repräsentiert, die Ihre Anwendung mit dem externen Dienst durchführt.
- Konkrete Dienstimplementierung: Ihre tatsächliche Dienstimplementierung (z. B. Interaktion mit einer PostgreSQL-Datenbank) implementiert diesen Trait.
- Mock-Dienstimplementierung: Erstellen Sie eine Mock-Struktur, die ebenfalls denselben Trait implementiert. Ihre Methoden enthalten test-spezifische Logik, wie z. B. das Zurückgeben vordefinierter Werte oder das Aufzeichnen von Methodenaufrufen.
- Dependency Injection: Injizieren Sie die entsprechende Implementierung (echt oder Mock) in Ihren Anwendungscode, typischerweise über einen Konstruktor oder ein Funktionsargument.
Code-Beispiel
Stellen wir uns vor, wir haben einen Dienst, der mit einer Benutzerdatenbank interagieren muss.
// src/lib.rs // 1. Trait für unsere Datenbankoperationen definieren pub trait UserRepository { fn get_user(&self, id: u32) -> Option<String>; fn save_user(&self, id: u32, name: String) -> bool; } // 2. Konkrete Implementierung (z. B. ein echter Datenbankclient) // In einer realen Anwendung würde dies eine Verbindung zu einer DB herstellen. #[derive(Debug)] pub struct RealDbRepository; impl UserRepository for RealDbRepository { fn get_user(&self, id: u32) -> Option<String> { println!("Real DB: Fetching user with ID {}", id); // Datenbankabfrage simulieren match id { 1 => Some("Alice".to_string()), _ => None, } } fn save_user(&self, id: u32, name: String) -> bool { println!("Real DB: Saving user ID {} with name {}", id, name); // Datenbankspeicherung simulieren true } } // Anwendungsdienst, der UserRepository verwendet pub struct UserService<R: UserRepository> { repository: R, } impl<R: UserRepository> UserService<R> { pub fn new(repository: R) -> Self { UserService { repository } } pub fn fetch_and_display_user(&self, user_id: u32) -> String { match self.repository.get_user(user_id) { Some(name) => format!("User found: {}", name), None => format!("User with ID {} not found", user_id), } } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::sync::Mutex; // Für gleichzeitige Testläufe // 3. Mock-Implementierung für Tests pub struct MockUserRepository { // Wir verwenden einen Mutex, um schreibbaren Zugriff über Tests hinweg zu ermöglichen // und um Aufrufe für Assertions aufzuzeichnen. pub users: Mutex<HashMap<u32, String>>, pub get_user_calls: Mutex<Vec<u32>>, pub save_user_calls: Mutex<Vec<(u32, String)>>, } impl MockUserRepository { pub fn new(initial_users: HashMap<u32, String>) -> Self { MockUserRepository { users: Mutex::new(initial_users), get_user_calls: Mutex::new(Vec::new()), save_user_calls: Mutex::new(Vec::new()), } } } impl UserRepository for MockUserRepository { fn get_user(&self, id: u32) -> Option<String> { self.get_user_calls.lock().unwrap().push(id); self.users.lock().unwrap().get(&id).cloned() } fn save_user(&self, id: u32, name: String) -> bool { self.save_user_calls.lock().unwrap().push((id, name.clone())); self.users.lock().unwrap().insert(id, name); true } } #[test] fn test_fetch_existing_user() { let mut initial_users = HashMap::new(); initial_users.insert(1, "Alice".to_string()); let mock_repo = MockUserRepository::new(initial_users); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(1); assert_eq!(result, "User found: Alice"); assert_eq!(user_service.repository.get_user_calls.lock().unwrap().len(), 1); assert_eq!(user_service.repository.get_user_calls.lock().unwrap()[0], 1); } #[test] fn test_fetch_non_existing_user() { let mock_repo = MockUserRepository::new(HashMap::new()); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(99); assert_eq!(result, "User with ID 99 not found"); assert_eq!(user_service.repository.get_user_calls.lock().unwrap().len(), 1); assert_eq!(user_service.repository.get_user_calls.lock().unwrap()[0], 99); } }
Anwendungsszenarien
Traitbasiertes Mocking eignet sich ideal für Szenarien, in denen:
- Sie ein starkes Typsystem beibehalten und Rusts Garantien nutzen möchten.
- Sie die volle Kontrolle über den internen Zustand und das Verhalten des Mocks benötigen.
- Die Mocking-Anforderungen relativ einfach sind und Sie keinen manuellen Aufwand für die Erstellung jedes Mocks scheuen.
- Sie einen "Zero-Cost Abstraction"-Ansatz ohne externe Mocking-Frameworks bevorzugen.
Mockall: Ein leistungsstarkes Mocking-Framework
Obwohl traitbasiertes Mocking effektiv ist, kann es bei komplexen Schnittstellen oder wenn dynamische Erwartungen für Methodenaufrufe definiert werden müssen, umständlich werden. mockall ist eine beliebte Rust-Crate, die die Erstellung von Mock-Objekten vereinfacht, indem sie automatisch Mock-Implementierungen von Traits generiert. Sie können damit Erwartungen an Methodenaufrufe festlegen, vordefinierte Werte zurückgeben und sogar Aufrufe für spätere Überprüfungen aufzeichnen.
Prinzip und Implementierung
mockall verwendet prozedurale Makros, um Mock-Strukturen und deren Implementierungen zur Kompilierzeit zu generieren. Sie annotieren Ihren Trait mit #[automock], und mockall kümmert sich um die Erstellung einer entsprechenden Mock-Struktur.
mockall-Abhängigkeit hinzufügen: Fügen Siemockallin IhreCargo.tomlein.- Trait annotieren: Fügen Sie
#[automock]über der Trait-Definition ein. - Mock-Objekt generieren:
mockallerstellt automatisch eineMockTraitName-Struktur, die den Trait implementiert. - Erwartungen festlegen: Verwenden Sie die
expect_*()-Methoden des Mock-Objekts, um zu definieren, wie es auf bestimmte Methodenaufrufe reagieren soll. Dies umfasst die Angabe von Rückgabewerten, Argumenten und Aufrufanzahlen.
Code-Beispiel
Lassen Sie uns das Beispiel für den Benutzer-Repository mit mockall neu implementieren.
// Cargo.toml // [dev-dependencies] // mockall = "0.12" // src/lib.rs // Keine Änderung an UserService oder RealDbRepository #[cfg(test)] // mockall ist normalerweise eine Dev-Abhängigkeit mod tests { use super::*; use mockall::{automock, predicate::*}; // automock und Prädikate importieren // 1. Den Trait mit #[automock] annotieren #[automock] pub trait UserRepository { fn get_user(&self, id: u32) -> Option<String>; fn save_user(&self, id: u32, name: String) -> bool; } #[test] fn test_fetch_existing_user_with_mockall() { // 2. mockall generiert MockUserRepository let mut mock_repo = MockUserRepository::new(); // 3. Erwartungen festlegen // Wenn get_user() mit 1 aufgerufen wird, soll es Some("Alice".to_string()) zurückgeben // und genau einmal aufgerufen werden. mock_repo.expect_get_user() .with(eq(1)) // Verwenden Sie ein Prädikat, um das Argument abzugleichen .times(1) .returning(|_| Some("Alice".to_string())); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(1); assert_eq!(result, "User found: Alice"); // Diese Assertion bezieht sich nur auf die Ausgabe von UserService // mock_repo wird seine Erwartungen geltend machen, wenn es aus dem Gültigkeitsbereich fällt oder wenn .checkpoint() aufgerufen wird. } #[test] fn test_fetch_non_existing_user_with_mockall() { let mut mock_repo = MockUserRepository::new(); // Wenn get_user() mit einer beliebigen u32 aufgerufen wird, soll es None zurückgeben. // Es soll genau einmal aufgerufen werden. mock_repo.expect_get_user() .with(always()) // Beliebige Eingabe abgleichen .times(1) .returning(|_| None); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(99); assert_eq!(result, "User with ID 99 not found"); } #[test] fn test_save_user_with_mockall() { let mut mock_repo = MockUserRepository::new(); mock_repo.expect_save_user() .with(eq(101), eq("Bob".to_string())) .times(1) .returning(|_, _| true); let user_service = UserService::new(mock_repo); // In einem realen Szenario würde UserService save_user basierend auf einer Logik aufrufen. // Für diesen einfachen Test rufen wir es direkt auf, um den Mock zu demonstrieren. // Hinweis: UserService in diesem Beispiel ruft `save_user` nicht direkt // in seinen öffentlichen Methoden auf. Wir würden normalerweise eine Methode testen, die es tut. // Nehmen wir für diesen Test an, wir überprüfen die Fähigkeit des Mocks, zu antworten. let saved = user_service.repository.save_user(101, "Bob".to_string()); assert!(saved); } }
Anwendungsszenarien
mockall glänzt in Situationen, in denen:
- Komplexe Schnittstellen: Sie haben Traits mit vielen Methoden, und die manuelle Implementierung von Mocks wird mühsam.
- Dynamische Erwartungen: Sie müssen unterschiedliche Verhaltensweisen für dieselbe Methode basierend auf Argumenten definieren oder die Aufruf-Reihenfolge/Anzahl überprüfen.
- Refactoring: Es erleichtert das Refactoring, da Sie Mock-Implementierungen nicht manuell aktualisieren müssen.
- Weniger Boilerplate: Es reduziert die Menge an Boilerplate-Code, die für Mocking erforderlich ist, erheblich.
- Verhaltensüberprüfung: Sie möchten überprüfen, ob bestimmte Methoden mit bestimmten Argumenten aufgerufen wurden und wie oft.
Fazit
Sowohl traitbasiertes Mocking als auch mockall bieten robuste Lösungen zum Mocking von Datenbanken und externen Diensten in Rust, wobei jeder Ansatz seine Stärken hat. Traitbasiertes Mocking bietet einen schlanken, Rust-idiomatischen Ansatz, der Ihnen die Feinkontrolle auf Kosten manueller Implementierung ermöglicht. mockall hingegen automatisiert einen großen Teil des Mocking-Prozesses und bietet ein leistungsstarkes, funktionsreiches Framework für komplexere und dynamischere Mocking-Szenarien, das den Boilerplate-Code erheblich reduziert. Die Wahl zwischen ihnen hängt von der Komplexität Ihres Projekts, den Präferenzen des Teams und den spezifischen Anforderungen Ihrer Tests ab. Letztendlich ermöglicht Ihnen effektives Mocking, unabhängig vom gewählten Ansatz, die Erstellung hochgradig testbarer, wartbarer und zuverlässiger Rust-Anwendungen, indem Ihr Code von externen Abhängigkeiten isoliert wird.

