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).
// ── 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); } }
Las mismas aserciones para múltiples recetas del menú de NutriTrack — sin repetir código. rstest expande cada #[case] en un test independiente.
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); }
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.
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()); } }
El servicio NutritionService depende del trait NutritionRepository. Con mockall aislamos el servicio de la base de datos real para tests unitarios rápidos.
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(_))); } }
La API HTTP de NutriTrack usa Axum/Tokio. Los handlers se testean con #[tokio::test] sin levantar un servidor real.
#[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); }
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 | 87 | 87 | 100% | |
| nutrition_service.rs | 64 | 59 | 92% | |
| validators.rs | 41 | 39 | 95% | |
| api_handler.rs | 93 | 81 | 87% | |
| recipe_builder.rs | 55 | 48 | 87% | |
| models.rs | 32 | 28 | 88% | |
| TOTAL | 372 | 342 | 91.4% | ✓ >80% |
# 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
✓ All checks passed · 92s total · nutritrack-analytics/main · commit 4f2a1c9