Ferris Talk #15: Bedingte Kompilierung in Rust

Seite 3: Bedingte Kompilierung für automatisierte Tests

Inhaltsverzeichnis

Häufig kommt bedingte Kompilierung in Zusammenhang mit automatisierten Tests zum Einsatz. Das Attribut #[cfg(test)] dient dazu, den Code für Tests nur beim Aufruf von cargo test zu kompilieren. Der folgende Beispielcode bleibt beim Thema Caesar Salad, aber diesmal erfolgt die Auswahl der Rezeptvariante nicht über [features], sondern durch die Übergabe an die Methode get_ingredients zur Laufzeit.

Der automatisierte Test no_anchovies_in_vegetarian soll sicherstellen, dass in der vegetarischen Variante keine Sardellen enthalten sind. Diesen Code übersetzt der Compiler nur beim Aufruf von cargo test, nicht jedoch bei cargo build.

#[derive(Debug, PartialEq, Eq)]
pub enum RecipeType { Traditional, Vegetarian, Vegan, }

pub mod caesar_salad {
  use super::*;

  // Get ingredients for a Caesar salad
  pub fn get_ingredients(recipe_type: RecipeType) 
    -> Vec<&'static str> 
  {
    let mut ingredients = vec![
      "Romaine lettuce", "Croutons", "Lemon juice",
      "Olive oil", "Salt", "Black pepper",
    ];

    ingredients.push(match recipe_type {
      RecipeType::Traditional | 
      RecipeType::Vegetarian => "Parmesan cheese",
      _ => "Vegan Parmesan cheese substitute",
    });

    ingredients.extend_from_slice(match recipe_type {
      RecipeType::Traditional 
        => &["Anchovies", "Egg yolks"],
      RecipeType::Vegetarian 
        => &["Capers", "Egg yolks"],
      _ => &["Capers", "Vegan mayonnaise"],
    });

    ingredients
  }
}

// The code for automated tests must only be
// compiled when the test suite is run (cargo test).
#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn no_anchovies_in_vegetarian() {
    let ingredients = 
      caesar_salad::get_ingredients(RecipeType::Vegetarian);
    // Make sure there are no anchovies in a 
    //vegetarian salad
    assert!(!ingredients.contains(&"Anchovies"));
  }

  // Add additional tests here
}

Neben cfg existiert ein weiteres Attribut namens cfg_attr. Während cfg dazu dient, Code bedingt zu kompilieren, erlaubt cfg_attr das bedingte Hinzufügen von Attributen. Diese Option erscheint auf den ersten Blick ungewöhnlich und Rust-Einsteigerinnen und -Einsteiger erkennen vielleicht nicht sofort, wie wichtig diese Sprachfunktion ist. Ein weiteres Beispiel bringt Licht ins Dunkel. Es führt alle besprochenen Aspekte von bedingter Kompilierung zusammen. Die Anforderungen für den Code sehen folgendermaßen aus:

  • Zu entwickeln ist eine Bibliothek (Crate), die Rezepte aus Dateien lesen und in Markdown umwandeln kann.
  • Rezepte bestehen aus Titel (title), einer optionalen Zutatenliste (ingredients) und einer ebenfalls optionalen Liste mit Schritten für die Zubereitung des Gerichts (steps).
  • Der Code kann die Dateiformate JSON und YAML lesen.
  • Die Bibliothek lässt sich mit Features konfigurieren, um die Anbindung an JSON und YAML separat aktivieren zu können.
  • Es muss einen automatisierten Test geben, der sicherstellt, dass in dem generierten Markdown-Text keine Überschrift "## Ingredients" vorkommt, falls das Rezept keine Zutatenliste enthält.

Die Umsetzung beginnt mit dem Erstellen der Cargo.toml-Datei. Neu bei den definierten Features ist, dass sich Abhängigkeiten zu externen Crates bedingt hinzufügen lassen. Das ist notwendig, da die Crates serde_json und serde_yaml nur dann erforderlich sind, wenn die Features json beziehungsweise yaml aktiv sind.

toml
[package]
name = "recipe_reader"
version = "0.1.0"
edition = "2021"

[features]
default = ["json"]
all = ["json", "yaml"]
json = ["dep:serde_json"]
yaml = ["dep:serde_yaml"]

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
serde_yaml = { version = "0", optional = true }
anyhow = "1"

[dev-dependencies]
mockall = "0"

Im folgenden Code liegt der Schwerpunkt auf dem cfg_attr-Attribut. Es wird verwendet, um über das mockall-Crate im Rahmen von Tests ein Mock-Objekt für einen Trait zu erstellen. Dadurch lassen sich die zwei Funktionen des Codes entkoppeln: Lesen der Datei auf der einen und Umwandeln des Rezepts in Markdown auf der anderen Seite.

Die Kommentare im Code weisen auf die wichtigen Aspekte beim Verwenden von cfg und cfg_attr hin.

#[cfg(any(feature = "json", feature = "yaml"))]
use std::{fs::File, io::Read};

use anyhow::Result;
use serde::Deserialize;

// We only reference automock when testing
#[cfg(test)]
use mockall::automock;

#[derive(Debug, Deserialize)]
pub struct Recipe {
  pub title: String,
  pub ingredients: Option<Vec<String>>,
  pub steps: Option<Vec<String>>,
}

// We add the automock attribute to the trait
// ONLY when testing. This is where cfg_attr
// comes in handy.
#[cfg_attr(test, automock)]
pub trait RecipeReader {
  fn read_from_file(&self, path: &str) -> Result<Recipe>;
}

// The json module with the JsonRecipeReader is
// only compiled when the json feature is enabled.
#[cfg(feature = "json")]
pub mod json
{
  use super::*;

  pub struct JsonRecipeReader();

  impl RecipeReader for JsonRecipeReader {
     fn read_from_file(&self, path: &str) 
       -> Result<Recipe> {
       let mut contents = String::new();
       File::open(path)?.read_to_string(&mut contents)?;
       Ok(serde_json::from_str(&contents)?)
     }
  }
}

// The yaml module with the YamlRecipeReader is
// only compiled when the yaml feature is enabled.
#[cfg(feature = "yaml")]
pub mod yaml
{
  use super::*;
    
  pub struct YamlRecipeReader();

  impl RecipeReader for YamlRecipeReader {
    fn read_from_file(&self, path: &str) 
      -> Result<Recipe> 
    {
      let mut contents = String::new();
      File::open(path)?.read_to_string(&mut contents)?;
      Ok(serde_yaml::from_str(&contents)?)
    }
  }
}

pub struct RecipeMarkdownFactory();

impl RecipeMarkdownFactory {
  pub fn file_to_markdown(&self, 
                          path: &str, 
                          reader: impl RecipeReader) 
    -> Result<String> 
  {
    // Read recipe from file using a reader
    let recipe = reader.read_from_file(path)?;

    // Convert recipe into markdown
    let mut markdown = String::new();
    markdown.push_str(&format!("# {}\n\n", 
                      recipe.title));
    if let Some(i) = recipe.ingredients {
      markdown.push_str("## Ingredients\n\n");
      for ingredient in i {
        markdown.push_str(&format!("* {ingredient}\n"));
      }
      markdown.push('\n');
    }

    if let Some(s) = recipe.steps{
      markdown.push_str("## Steps\n\n");
      for (i, step) in s.iter().enumerate() {
        markdown.push_str(&format!("{}. {step}\n",
                          i + 1));
      }
    }

    Ok(markdown)
  }
}

// The code for automated tests must only be 
// compiled when the test suite is run (cargo test).
#[cfg(test)]
mod tests {
  use mockall::predicate::eq;

  use super::*;

  // Parts of the tests are only compiled when the 
  // corresponding feature is enabled.
  #[cfg(feature = "json")]
  use super::json::*;
  #[cfg(feature = "yaml")]
  use super::yaml::*;

  #[cfg(any(feature = "json", feature = "yaml"))]
  fn get_expected_markdown() -> String {
    let mut markdown = String::new();
    File::open("caesar_expected.md")
      .unwrap().read_to_string(&mut markdown).unwrap();
    markdown
  }

  #[cfg(feature = "json")]
  #[test]
  fn json_caesar_salad_to_markdown() {
    let reader = JsonRecipeReader();
    let markdown_factory = RecipeMarkdownFactory();
    let markdown = 
    markdown_factory
     .file_to_markdown("caesar_salad.json", 
                       reader).unwrap();

    assert_eq!(markdown, get_expected_markdown());
  }

  #[cfg(feature = "yaml")]
  #[test]
  fn yaml_caesar_salad_to_markdown() {
    let reader = YamlRecipeReader();
    let markdown_factory = RecipeMarkdownFactory();
    let markdown = 
      markdown_factory
        .file_to_markdown("caesar_salad.yaml", 
                          reader).unwrap();

    assert_eq!(markdown, get_expected_markdown());
  }

  #[test]
  fn no_ingredients() {
    // Here we use the mockall crate to create a mock
    // of the RecipeReader trait. We can then use this
    // mock to test the RecipeMarkdownFactory without
    // having to read from a file.
    let mut reader_mock = MockRecipeReader::new();
    reader_mock
      .expect_read_from_file()
      .with(eq("no_ingredients.json"))
      .returning(|_| {
        Ok(Recipe {
          title: "No Ingredients".to_string(),
          ingredients: None,
          steps: Some(vec!["Step 1".to_string(), 
                           "Step 2".to_string()]),
        })
    });
        
    let markdown_factory = RecipeMarkdownFactory();
    let markdown = markdown_factory
     .file_to_markdown("no_ingredients.json", 
                       reader_mock).unwrap();

    assert!(!markdown.contains("## Ingredients"));
  }
}