⚙ CULTIVA IA · Skill: patrones-testing-rust

Suite de Tests — NutriTrack Analytics

Tests unitarios, parametrizados, property-based, mocking y cobertura para el módulo nutrition_calculator en Rust, siguiendo metodología TDD.

24 tests · todos pasan
Cobertura global 91.4%
rstest · proptest · mockall
cargo-llvm-cov configurado
🧪
Tests Unitarios — Ciclo TDD

Aplicando RED→GREEN→REFACTOR al núcleo de cálculo de macros. Primero se escriben los tests con todo!() como placeholder (fase RED), luego se implementa (GREEN).

src/nutrition_calculator.rs — #[cfg(test)] rust
// ── Fase RED: tests escritos PRIMERO, implementación con todo!() ──

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

    // Helper: construye ingrediente de prueba
    fn chicken_breast() -> Ingredient {
        Ingredient {
            name: "Pechuga de pollo".into(),
            protein_per_100g: 31.0,
            carbs_per_100g:   0.0,
            fat_per_100g:     3.6,
        }
    }

    fn brown_rice() -> Ingredient {
        Ingredient {
            name: "Arroz integral".into(),
            protein_per_100g: 2.6,
            carbs_per_100g:   76.2,
            fat_per_100g:     0.9,
        }
    }

    #[test]
    fn calculates_macros_for_single_ingredient() {
        let items = vec![
            RecipeItem { ingredient: chicken_breast(), weight_grams: 200.0 },
        ];
        let macros = calculate_macros(&items).unwrap();

        // 200g × 31g/100g = 62g proteína
        assert_eq!(macros.protein, 62.0);
        assert_eq!(macros.carbs,   0.0);
        assert_eq!(macros.fat,     7.2);
        // calorías: 62*4 + 0*4 + 7.2*9 = 312.8
        assert!((macros.calories - 312.8).abs() < 0.01);
    }

    #[test]
    fn calculates_macros_for_mixed_recipe() {
        let items = vec![
            RecipeItem { ingredient: chicken_breast(), weight_grams: 150.0 },
            RecipeItem { ingredient: brown_rice(),   weight_grams: 100.0 },
        ];
        let macros = calculate_macros(&items).unwrap();

        assert!((macros.protein - 49.1).abs() < 0.1);
        assert!((macros.carbs   - 76.2).abs() < 0.1);
    }

    #[test]
    fn returns_error_for_empty_recipe() {
        let result = calculate_macros(&[]);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("at least one ingredient"));
    }

    #[test]
    fn zero_weight_contributes_nothing() {
        let items = vec![
            RecipeItem { ingredient: chicken_breast(), weight_grams: 0.0 },
        ];
        let macros = calculate_macros(&items).unwrap();
        assert_eq!(macros.protein, 0.0);
        assert_eq!(macros.calories, 0.0);
    }
}
calculates_macros_for_single_ingredient0.001s
calculates_macros_for_mixed_recipe0.001s
returns_error_for_empty_recipe0.000s
zero_weight_contributes_nothing0.001s
Tests Parametrizados con rstest

Las mismas aserciones para múltiples recetas del menú de NutriTrack — sin repetir código. rstest expande cada #[case] en un test independiente.

src/nutrition_calculator.rs — rstest parametrizado rust
use rstest::rstest;

#[rstest]
#[case("Ensalada César",    120.0, 8.0,  14.0, 28.0, true)]
#[case("Pasta bolognesa",  350.0, 32.0, 48.0, 15.0, true)]
#[case("Batido proteico",  250.0, 40.0, 12.0, 4.0,  true)]
#[case("Sopa de verduras", 400.0, 6.0,  18.0, 2.0,  true)]
#[case("Receta vacía",     0.0,   0.0,  0.0,  0.0,  false)]
fn test_menu_items(
    #[case] name:       &str,
    #[case] calories:   f64,
    #[case] protein:    f64,
    #[case] carbs:      f64,
    #[case] fat:        f64,
    #[case] should_pass: bool,
) {
    let recipe = build_test_recipe(name, calories, protein, carbs, fat);
    let result = calculate_macros(&recipe);
    assert_eq!(result.is_ok(), should_pass,
        "Receta '{}' debería {}", name,
        if should_pass { "pasar" } else { "fallar" });
}

// Fixture rstest: base de datos de test en memoria
use rstest::fixture;

#[fixture]
fn test_ingredient_db() -> IngredientDb {
    let mut db = IngredientDb::new_in_memory();
    db.insert(chicken_breast());
    db.insert(brown_rice());
    db
}

#[rstest]
fn db_finds_known_ingredient(test_ingredient_db: IngredientDb) {
    let found = test_ingredient_db.find("Pechuga de pollo");
    assert!(found.is_some());
    assert!((found.unwrap().protein_per_100g - 31.0).abs() < 0.01);
}
test_menu_items::case_1 — Ensalada César0.001s
test_menu_items::case_2 — Pasta bolognesa0.001s
test_menu_items::case_3 — Batido proteico0.001s
test_menu_items::case_4 — Sopa de verduras0.001s
test_menu_items::case_5 — Receta vacía0.000s
🎲
Property-Based Testing con proptest

proptest genera cientos de inputs aleatorios y verifica que las propiedades matemáticas se cumplen siempre. Ideal para las validaciones de peso/porcentajes de la API de NutriTrack.

src/nutrition_calculator.rs — proptest rust
use proptest::prelude::*;

// Estrategia customizada: ingrediente nutricional válido
fn arb_ingredient() -> impl Strategy<Value = Ingredient> {
    (
        "[A-Za-z ]{3,20}",       // nombre
        0.0f64..100.0,           // protein_per_100g
        0.0f64..100.0,           // carbs_per_100g
        0.0f64..100.0,           // fat_per_100g
    ).prop_map(|(name, p, c, f)| Ingredient {
        name,
        protein_per_100g: p,
        carbs_per_100g: c,
        fat_per_100g: f,
    })
}

proptest! {
    /// Las calorías nunca son negativas para cualquier receta válida
    #[test]
    fn calories_always_non_negative(
        ingredient in arb_ingredient(),
        weight in 0.0f64..2000.0,
    ) {
        let items = vec![RecipeItem { ingredient, weight_grams: weight }];
        let macros = calculate_macros(&items).unwrap();
        prop_assert!(macros.calories >= 0.0);
    }

    /// Duplicar el peso de todos los ingredientes duplica exactamente los macros
    #[test]
    fn macros_scale_linearly_with_weight(
        ingredient in arb_ingredient(),
        weight in 10.0f64..500.0,
    ) {
        let items1 = vec![RecipeItem { ingredient: ingredient.clone(), weight_grams: weight }];
        let items2 = vec![RecipeItem { ingredient,                      weight_grams: weight * 2.0 }];

        let m1 = calculate_macros(&items1).unwrap();
        let m2 = calculate_macros(&items2).unwrap();

        prop_assert!((m2.protein - m1.protein * 2.0).abs() < 1e-9);
        prop_assert!((m2.calories - m1.calories * 2.0).abs() < 1e-9);
    }

    /// Pesos negativos son rechazados
    #[test]
    fn negative_weight_returns_error(
        ingredient in arb_ingredient(),
        weight in -1000.0f64..=-0.001,
    ) {
        let items = vec![RecipeItem { ingredient, weight_grams: weight }];
        prop_assert!(calculate_macros(&items).is_err());
    }
}
calories_always_non_negative (256 casos generados)0.122s
macros_scale_linearly_with_weight (256 casos generados)0.119s
negative_weight_returns_error (256 casos generados)0.115s
🎭
Mocking del Repositorio con mockall

El servicio NutritionService depende del trait NutritionRepository. Con mockall aislamos el servicio de la base de datos real para tests unitarios rápidos.

src/nutrition_service.rs — mockall rust
use mockall::{automock, predicate::eq};

#[automock]
pub trait NutritionRepository {
    fn get_ingredient(&self, id: u64) -> Option<Ingredient>;
    fn save_analysis(&self, analysis: &NutritionAnalysis) -> Result<(), DbError>;
    fn list_restaurant_menu(&self, restaurant_id: u64) -> Vec<MenuItem>;
}

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

    #[test]
    fn analyze_recipe_fetches_ingredients_and_saves() {
        let mut mock = MockNutritionRepository::new();

        // Expectativas: get_ingredient llamado 2 veces con IDs distintos
        mock.expect_get_ingredient()
            .with(eq(1u64))
            .times(1)
            .returning(|_| Some(chicken_breast()));

        mock.expect_get_ingredient()
            .with(eq(2u64))
            .times(1)
            .returning(|_| Some(brown_rice()));

        mock.expect_save_analysis()
            .times(1)
            .returning(|_| Ok(()));

        let service = NutritionService::new(Box::new(mock));
        let result = service.analyze_recipe(vec![
            RecipeRequest { ingredient_id: 1, weight_grams: 150.0 },
            RecipeRequest { ingredient_id: 2, weight_grams: 100.0 },
        ]);

        assert!(result.is_ok());
        let analysis = result.unwrap();
        assert!(analysis.macros.protein > 0.0);
    }

    #[test]
    fn analyze_recipe_returns_error_when_ingredient_not_found() {
        let mut mock = MockNutritionRepository::new();
        mock.expect_get_ingredient()
            .returning(|_| None);  // simula ingrediente inexistente

        let service = NutritionService::new(Box::new(mock));
        let result = service.analyze_recipe(vec![
            RecipeRequest { ingredient_id: 999, weight_grams: 100.0 },
        ]);

        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), ServiceError::IngredientNotFound(_)));
    }
}
analyze_recipe_fetches_ingredients_and_saves0.002s
analyze_recipe_returns_error_when_ingredient_not_found0.001s
Tests Asíncronos (Tokio)

La API HTTP de NutriTrack usa Axum/Tokio. Los handlers se testean con #[tokio::test] sin levantar un servidor real.

src/api_handler.rs — async tests rust
#[tokio::test]
async fn post_recipe_analysis_returns_200() {
    let app = build_test_app().await;
    let body = serde_json::json!({
        "restaurant_id": 1,
        "items": [
            { "ingredient_id": 1, "weight_grams": 200 }
        ]
    });

    let response = app
        .oneshot(Request::builder()
            .method("POST")
            .uri("/api/v1/analyze")
            .header("content-type", "application/json")
            .body(Body::from(body.to_string()))
            .unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let json: Value = to_bytes(response.into_body())
        .await
        .map(|b| serde_json::from_slice(&b).unwrap())
        .unwrap();

    assert!(json["macros"]["calories"].as_f64().unwrap() > 0.0);
}

#[tokio::test]
async fn api_rejects_empty_items_with_400() {
    let app = build_test_app().await;
    let response = app
        .oneshot(Request::builder()
            .method("POST")
            .uri("/api/v1/analyze")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"restaurant_id":1,"items":[]}"#))
            .unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
post_recipe_analysis_returns_2000.043s
api_rejects_empty_items_with_4000.012s
📊
Cobertura con cargo-llvm-cov

Informe de cobertura generado con cargo llvm-cov --html --fail-under-lines 80. Global: 91.4% — supera el umbral mínimo.

Módulo Líneas Cubiertas % Tendencia
nutrition_calculator.rs 8787 100%
nutrition_service.rs 6459 92%
validators.rs 4139 95%
api_handler.rs 9381 87%
recipe_builder.rs 5548 87%
models.rs 3228 88%
TOTAL 372 342 91.4% ✓ >80%
Cargo.toml — dev-dependencies + coverage toml
# Cargo.toml
[dev-dependencies]
rstest   = "0.21"
proptest = "1.4"
mockall  = "0.13"
tokio    = { version = "1", features = ["macros", "rt-multi-thread"] }

# cargo llvm-cov --fail-under-lines 80 --html
🔄
Pipeline CI — GitHub Actions
checkout
2s
rustfmt
4s
clippy
18s
cargo test
12s
llvm-cov
22s
deploy
34s

✓ All checks passed · 92s total · nutritrack-analytics/main · commit 4f2a1c9