Ferris Talk #13: Rust-Web-APIs und Mocking mit Axum

Seite 3: Entkopplung durch Traits

Inhaltsverzeichnis

Um die Datenzugriffsschicht austauschen zu können, muss man sie mit Traits entkoppeln. Traits in Rust sind ähnlich zu Interfaces in anderen Programmiersprachen, bieten jedoch weit mehr Möglichkeiten. Wir haben Traits in Ferris Talk #2 genauer vorgestellt.

Der folgende Codeausschnitt zeigt den Trait HeroesRepositoryTrait zum Entkoppeln des Datenzugriffs. Die Methode get_by_name, die bisher Teil der HeroesRepository-Struktur war, siedelt in den Trait um. Zur Laufzeit erfolgt das Bereitstellen der echten Implementierung HeroesRepository über Axum. Für die Tests kommen Mock-Objekte zum Einsatz, die sich mit der mockall-Crate erstellen lassen. Das Makro automock generiert eine Mock-Implementierung des Trait namens MockHeroesRepositoryTrait, die sich später in den Unit Tests verwenden lässt.

/// The repository trait for heroes
///
/// This trait is used to abstract the data access
/// layer. At runtime, the trait is implemented by
/// the HeroesRepository struct. During unit
/// testing, the trait is mocked by the 
/// MockHeroesRepositoryTrait.
/// This allows us to test the handler functions 
/// without having to access the database.
/// MockHeroesRepositoryTrait is generated by 
/// the automock macro (mockall crate).

#[cfg_attr(test, automock)]
#[async_trait]
trait HeroesRepositoryTrait {
  /// Gets a list of heroes from the DB filted by name
  async fn get_by_name(&self, name: &str) 
    -> Result<Vec<Hero>, DataAccessError>;
}

/// Dummy implementation for our repository
/// 
/// In real life, this repository would access 
/// a database with persisted heroes. However, 
/// for the sake of simplicity, we just simulate 
/// database access by using an in-memory
/// collection of heroes.
struct HeroesRepository();

#[async_trait]
impl HeroesRepositoryTrait for HeroesRepository {
  async fn get_by_name(&self, name: &str) 
    -> Result<Vec<Hero>, DataAccessError> {
    // Same code as shown before when we did not 
    // have the HeroesRepositoryTrait trait yet
        ...
  }
}

Für das Zusammenspiel zwischen Axum und dem Trait sind Trait Objects erforderlich. Sie implementieren eine Rust-typische Form von Polymorphismus, mit dem sich unterschiedliche Strukturen – in der Beispielanwendung HeroesRepository und die generierte Struktur MockHeroesRepositoryTrait – über ein einzelnes Trait-Objekt referenzieren lassen. Letzteres enthält eine Referenz auf das jeweilige Objekt, das den Trait implementiert, und eine Tabelle mit Referenzen auf die Methoden des Traits, über die zur Laufzeit der dynamische Methodenaufruf erfolgt.

Der folgende Codeausschnitt zeigt den für Axum erforderlichen Typalias. Zu beachten ist das Schlüsselwort dyn, das den Einsatz eines Trait-Objekts und damit dynamische Methodenaufrufe anstößt. Die Traits Send und Sync sind erforderlich, weil die Handler-Funktionen im Beispiel asynchron sind und damit die beiden Traits voraussetzen. Nähere Informationen zu Send und Sync findet sich in der Tokio-Dokumentation.

/// The repository trait for the state of our router.
type DynHeroesRepository = 
  Arc<dyn HeroesRepositoryTrait + Send + Sync>;

Der Trait ist nun fertig und lässt sich im Code mit der Handler-Funktion einsetzen. Im folgenden Codeausschnitt ist zu beachten, dass der State-Extraktor von Axum nicht mehr das HeroesRepository verwendet, sondern das Trait DynHeroesRepository. Die Handler-Funktion kann somit ebenso mit anderen Implementierungen des HeroesRepositoryTrait wie der generierten Mock-Struktur MockHeroesRepositoryTrait umgehen.

/// The handler function for the get_heroes endpoint.
async fn get_heroes(
  State(repo): State<DynHeroesRepository>,
  filter: Query<GetHeroFilter>,
) -> impl IntoResponse {
    // Same code as shown before when we did 
    //not have the DynHeroesRepository trait yet
    ...
}

Der Code zum Konfigurieren der Routen und zum Starten des Servers ändert sich kaum. Im Wesentlichen wird nur HeroesRepository durch DynHeroesRepository ausgetauscht. Axum ist damit zufrieden, da die Typsicherheit sichergestellt ist.

#[tokio::main]
async fn main() {
  // Create the repository for our API. 
  // Note that the respository is a trait object.
  // It implements the trait DynHeroesRepository. 
  // This is done to allow mocking the repository
  // during unit testing of our handler functions.
  let repo = Arc::new(HeroesRepository()) 
    as DynHeroesRepository;

  // Setup the top-level router. Here we only nest
  // one sub-router (heroes). In practice, you would
  // nest more sub-routers here. Note that we create
  // the router with state. The previously created
  // repository is our state.
  let app = Router::new()
      .nest("/heroes", heroes_routes())
      .with_state(repo);

  // Start the server. Note that for brevity, we 
  // do not add logging, graceful shutdown, etc.
  let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
  axum::Server::bind(&addr)
      .serve(app.into_make_service())
      .await
      .unwrap();
}

/// Setup the sub-router for the heroes endpoint.
fn heroes_routes() -> Router<DynHeroesRepository> {
  Router::new().route("/", get(get_heroes))
}

Die großen Änderungen durch den Trait ergeben sich beim Testen. Die generierte Mock-Struktur MockHeroesRepositoryTrait ermöglicht es, echte Unittests für die Handler-Funktion zu schreiben. Dabei kann man eine Datenzugriffsschicht simulieren und die richtige Behandlung jedes Fehlerzustands sicherstellen. In folgendem Codeausschnitt ist insbesondere das Konfigurieren der Mock-Objekte mit der Funktion expect_get_by_name zu beachten:

#[cfg(test)]
mod tests {
  use super::*;
  use axum::{body::Body, http::Request};
  use mockall::predicate::*;
  use rstest::rstest;
  use serde_json::{json, Value};
  use tower::ServiceExt;

  /// Helper function to create a GET request 
  /// for a given URI.
  fn send_get_request(uri: &str) -> Request<Body> {
    Request::builder()
      .uri(uri)
      .method("GET")
      .body(Body::empty())
      .unwrap()
  }

  /// Test function for the successful execution 
  /// of the get_heroes handler function.
  ///
  /// This test function uses the rstest crate 
  /// to test the handler function with different
  /// query parameters. The test function is executed
  ///  for each test case.
  #[rstest]
  // Verify that % is appended to the filter:
  #[case("/?name=Wonder", "Wonder%")] 
  // Verify that % is not appended to the filter 
  // if it already ends with %:
  #[case("/?name=Wonder%", "Wonder%")] 
  // Verify that % is used as the default filter:
  #[case("/", "%")] 
  #[tokio::test]
  async fn  get_by_name_success(
    #[case] uri: &'static str,
    #[case] expected_filter: &'static str) {
    // Create a vector of dummy heroes to return 
    // from the mock repository.
    let dummy_heroes = vec![Default::default()];

    // Create a mock repository and set the 
    // expectations for the get_by_name method.
    // Note that we are filtering the expectation 
    // by the expected filter string.
    let mut repo_mock = 
      MockHeroesRepositoryTrait::new();
    let result = Ok(dummy_heroes.clone());
    repo_mock
      .expect_get_by_name()
      .with(eq(expected_filter))
      .return_once(move |_| result);

    // Create mock repository
    let repo = Arc::new(repo_mock) 
      as DynHeroesRepository;

    // Create the app with the mock repository 
    // as state.
    let app = heroes_routes().with_state(repo);

    // Call the app with a GET request to the 
    // get_heroes endpoint.
    let response =
      app.oneshot(send_get_request(uri))
        .await
        .unwrap();

    // Check the response status code.
    assert_eq!(response.status(), StatusCode::OK);

    // Check the response body.
    let body = 
      hyper::body::to_bytes(response.into_body())
        .await
        .unwrap();
    let body: Value = 
      serde_json::from_slice(&body).unwrap();
    assert_eq!(body, json!(&dummy_heroes));
  }

  /// Test DB error handling in get_heroes 
  /// handler function.
  #[rstest]
  #[case(DataAccessError::NotFound, StatusCode::NOT_FOUND)]
  #[case(DataAccessError::TechnicalError, 
         StatusCode::INTERNAL_SERVER_ERROR)]
  #[tokio::test]
  async fn get_by_name_failure(
    #[case] db_result: DataAccessError,
    #[case] expected_status: StatusCode,
  ) {
    // Create a mock repository and set the 
    // expectations for the get_by_name method.
    // Note that this time, we are returning 
    // an error.
    let mut repo_mock = 
      MockHeroesRepositoryTrait::new();
    repo_mock
      .expect_get_by_name()
      .with(eq("Spider%"))
      .return_once(|_| Err(db_result));

    // Create mock repository
    let repo = Arc::new(repo_mock) 
      as DynHeroesRepository;

    // Create the app with the mock repository 
    // as state.
    let app = heroes_routes().with_state(repo);

    // Call the app with a GET request to the
    // get_heroes endpoint.
    let response = app
      .oneshot(send_get_request("/?name=Spider"))
      .await
      .unwrap();

    // Check the response status code.
    assert_eq!(response.status(), expected_status);
  }
}