Tests - Stratégie de Tests

Stratégie de tests complète suivant la pyramide des tests.

Pyramide des Tests

            ▲
           / \
          /E2E\         < 10% (tests end-to-end complets)
         /_____\
        /       \
       /  BDD    \      < 20% (tests comportementaux Gherkin)
      /__________\
     /            \
    / Integration  \   < 30% (tests avec DB réelle)
   /________________\
  /                  \
 /    Unit Tests      \  < 40% (tests logique pure)
/______________________\

Objectif Coverage : 80%+ global (100% domaine, 80% application, 60% infrastructure)

Cycle TDD Red → Green → Blue

  1. Red (Test qui échoue) - Écrire le test au bon niveau de la pyramide (unitaire, intégration, BDD ou E2E). - Lancer uniquement la couche concernée pour voir l’échec :

    make test-unit            # Logique pure (RED rapide)
    make test-e2e-backend     # Scénarios API bout en bout
    make test-bdd             # Scénarios Cucumber
    make test-e2e             # Playwright (frontend + backend)
    
  2. Green (Test qui passe) - Implémenter la solution minimale. - Relancer le ou les tests ciblés jusqu’au passage au vert. - Consolider avec la suite complète si la fonctionnalité touche plusieurs couches :

    make test                 # Agrège unit + e2e backend + BDD
    
  3. Blue (Refactor / Qualité) - Nettoyer le code, extraire les abstractions et s’assurer du respect des standards. - Utiliser les outils qualité fournis par make :

    make format               # rustfmt + prettier
    make lint                 # cargo clippy + prettier --check
    make audit                # cargo audit + npm audit
    make coverage             # (optionnel) rapport tarpaulin
    
    • Terminer par un make test pour vérifier que la refactorisation n’a rien cassé.

Structure

backend/tests/
├── integration/           # Tests d'intégration (testcontainers)
│   ├── building_tests.rs
│   ├── unit_tests.rs
│   ├── owner_tests.rs
│   └── expense_tests.rs
├── bdd.rs                 # Tests BDD (Cucumber)
├── features/              # Fichiers Gherkin
│   ├── buildings.feature
│   ├── units.feature
│   ├── owners.feature
│   └── expenses.feature
└── e2e/                   # Tests end-to-end
    ├── api_tests.rs
    └── auth_tests.rs

Tests Unitaires

Localisation : Modules #[cfg(test)] dans chaque fichier source.

Cible : Logique domaine pure (entities, services).

Exemple :

// backend/src/domain/entities/building.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_create_building_success() {
        let building = Building::new(
            "Test Building".to_string(),
            "123 Main St".to_string(),
            "Paris".to_string(),
            "75001".to_string(),
            "France".to_string(),
            10,
            Some(2020),
        );

        assert!(building.is_ok());
        let building = building.unwrap();
        assert_eq!(building.name, "Test Building");
        assert_eq!(building.total_units, 10);
    }

    #[test]
    fn test_create_building_empty_name_fails() {
        let building = Building::new(
            "".to_string(),  // Invalide
            "123 Main St".to_string(),
            "Paris".to_string(),
            "75001".to_string(),
            "France".to_string(),
            10,
            None,
        );

        assert!(building.is_err());
        assert_eq!(
            building.unwrap_err(),
            "Building name cannot be empty"
        );
    }

    #[test]
    fn test_update_building_info() {
        let mut building = Building::new(
            "Old Name".to_string(),
            "Old Address".to_string(),
            "Old City".to_string(),
            "00000".to_string(),
            "France".to_string(),
            20,
            None,
        ).unwrap();

        let old_updated_at = building.updated_at;

        building.update_info(
            "New Name".to_string(),
            "New Address".to_string(),
            "New City".to_string(),
            "75001".to_string(),
        );

        assert_eq!(building.name, "New Name");
        assert!(building.updated_at > old_updated_at);
    }
}

Commande :

# Tous les tests unitaires
cargo test --lib

# Tests d'une entité spécifique
cargo test --lib domain::entities::building

# Tests avec output verbose
cargo test --lib -- --nocapture

Tests d’Intégration

Localisation : backend/tests/integration/

Cible : Use cases + Repositories avec base de données réelle.

Testcontainers : PostgreSQL éphémère pour isolation complète.

Exemple :

// backend/tests/integration/building_tests.rs
use testcontainers::{clients::Cli, Container};
use testcontainers_modules::postgres::Postgres;
use sqlx::PgPool;

async fn setup_test_db(
    docker: &Cli
) -> (Container<'_, Postgres>, PgPool) {
    // Démarrer PostgreSQL testcontainer
    let postgres = docker.run(Postgres::default());

    let connection_string = format!(
        "postgres://postgres:postgres@127.0.0.1:{}/postgres",
        postgres.get_host_port_ipv4(5432)
    );

    // Créer pool
    let pool = PgPool::connect(&connection_string)
        .await
        .expect("Failed to connect to test database");

    // Run migrations
    sqlx::migrate!()
        .run(&pool)
        .await
        .expect("Failed to run migrations");

    (postgres, pool)
}

#[tokio::test]
async fn test_create_and_find_building() {
    let docker = Cli::default();
    let (_container, pool) = setup_test_db(&docker).await;

    // Créer repository
    let repo = PostgresBuildingRepository::new(pool.clone());

    // Créer building
    let building = Building::new(
        "Integration Test Building".to_string(),
        "123 Integration St".to_string(),
        "Paris".to_string(),
        "75001".to_string(),
        "France".to_string(),
        15,
        None,
    ).unwrap();

    // Sauvegarder
    let saved_building = repo.create(&building)
        .await
        .expect("Failed to create building");

    assert_eq!(saved_building.id, building.id);

    // Retrouver par ID
    let found_building = repo.find_by_id(building.id)
        .await
        .expect("Failed to find building")
        .expect("Building not found");

    assert_eq!(found_building.name, "Integration Test Building");
}

#[tokio::test]
async fn test_update_building() {
    let docker = Cli::default();
    let (_container, pool) = setup_test_db(&docker).await;

    let repo = PostgresBuildingRepository::new(pool);

    // Créer
    let mut building = Building::new(
        "Original Name".to_string(),
        "Original Address".to_string(),
        "Paris".to_string(),
        "75001".to_string(),
        "France".to_string(),
        10,
        None,
    ).unwrap();

    let saved_building = repo.create(&building).await.unwrap();

    // Mettre à jour
    building.update_info(
        "Updated Name".to_string(),
        "Updated Address".to_string(),
        "Lyon".to_string(),
        "69001".to_string(),
    );

    let updated_building = repo.update(&building).await.unwrap();

    assert_eq!(updated_building.name, "Updated Name");
    assert_eq!(updated_building.city, "Lyon");
}

#[tokio::test]
async fn test_delete_building() {
    let docker = Cli::default();
    let (_container, pool) = setup_test_db(&docker).await;

    let repo = PostgresBuildingRepository::new(pool);

    // Créer
    let building = Building::new(
        "To Delete".to_string(),
        "Address".to_string(),
        "City".to_string(),
        "12345".to_string(),
        "Country".to_string(),
        5,
        None,
    ).unwrap();

    let saved_building = repo.create(&building).await.unwrap();

    // Supprimer
    repo.delete(saved_building.id).await.unwrap();

    // Vérifier suppression
    let found = repo.find_by_id(saved_building.id).await.unwrap();
    assert!(found.is_none());
}

Commande :

# Tous les tests d'intégration
cargo test --test integration

# Test spécifique
cargo test --test integration test_create_and_find_building

Stockage S3/MinIO

Le test backend/tests/storage_s3.rs démarre un conteneur MinIO via testcontainers-modules et valide l’implémentation S3Storage (upload → lecture → suppression). Il vérifie également la création automatique du bucket lorsqu’il n’existe pas encore.

cargo test --test storage_s3

Monitoring Prometheus

Le backend expose un endpoint GET /metrics (format Prometheus) agrégé par défaut. Les opérations de stockage sont instrumentées via des compteurs/histogrammes, ce qui permet de suivre les succès, échecs et latences depuis la stack de monitoring (Prometheus/Grafana).

Tests BDD (Cucumber)

Localisation : backend/tests/features/*.feature + backend/tests/bdd.rs

Cible : Comportements utilisateur (Gherkin → Rust steps).

Exemple Feature :

# backend/tests/features/buildings.feature
Feature: Gestion des immeubles
  En tant que syndic
  Je veux gérer les immeubles de copropriété
  Afin de suivre mon portefeuille

  Scenario: Créer un nouvel immeuble
    Given je suis un syndic authentifié
    When je crée un immeuble avec les données suivantes:
      | name            | Résidence Les Jardins      |
      | address         | 15 Rue de la Paix          |
      | city            | Paris                      |
      | postal_code     | 75001                      |
      | country         | France                     |
      | total_units     | 45                         |
      | construction_year | 1990                     |
    Then l'immeuble est créé avec succès
    And l'immeuble contient 45 lots

  Scenario: Lister les immeubles d'un syndic
    Given je suis un syndic authentifié
    And j'ai créé 3 immeubles
    When je demande la liste de mes immeubles
    Then je reçois une liste de 3 immeubles

  Scenario: Modifier un immeuble existant
    Given je suis un syndic authentifié
    And j'ai créé un immeuble nommé "Ancien Nom"
    When je modifie le nom en "Nouveau Nom"
    Then l'immeuble a le nom "Nouveau Nom"

  Scenario: Supprimer un immeuble
    Given je suis un syndic authentifié
    And j'ai créé un immeuble
    When je supprime cet immeuble
    Then l'immeuble n'existe plus

  Scenario: Échouer à créer un immeuble avec nom vide
    Given je suis un syndic authentifié
    When je tente de créer un immeuble avec un nom vide
    Then je reçois une erreur "Building name cannot be empty"

Implémentation Steps :

// backend/tests/bdd.rs
use cucumber::{given, when, then, World};

#[derive(Debug, Default, World)]
pub struct BuildingWorld {
    auth_token: Option<String>,
    buildings: Vec<Building>,
    last_error: Option<String>,
    last_building: Option<Building>,
}

#[given("je suis un syndic authentifié")]
async fn given_authenticated_syndic(world: &mut BuildingWorld) {
    // Mock JWT token
    world.auth_token = Some("mock-jwt-token".to_string());
}

#[when(regex = r"je crée un immeuble avec les données suivantes:")]
async fn when_create_building(
    world: &mut BuildingWorld,
    step: &Step
) {
    let table = step.table.as_ref().unwrap();
    let data: HashMap<String, String> = table
        .rows
        .iter()
        .map(|row| (row[0].clone(), row[1].clone()))
        .collect();

    let building = Building::new(
        data["name"].clone(),
        data["address"].clone(),
        data["city"].clone(),
        data["postal_code"].clone(),
        data["country"].clone(),
        data["total_units"].parse().unwrap(),
        data.get("construction_year").and_then(|y| y.parse().ok()),
    );

    match building {
        Ok(b) => {
            world.last_building = Some(b.clone());
            world.buildings.push(b);
        }
        Err(e) => {
            world.last_error = Some(e);
        }
    }
}

#[then("l'immeuble est créé avec succès")]
async fn then_building_created(world: &mut BuildingWorld) {
    assert!(world.last_building.is_some());
    assert!(world.last_error.is_none());
}

#[then(regex = r"l'immeuble contient (\d+) lots")]
async fn then_building_has_units(world: &mut BuildingWorld, units: usize) {
    let building = world.last_building.as_ref().unwrap();
    assert_eq!(building.total_units as usize, units);
}

Commande :

# Tous les tests BDD
cargo test --test bdd

# Feature spécifique
cargo test --test bdd -- buildings.feature

# Avec output détaillé
cargo test --test bdd -- --nocapture

Tests E2E (End-to-End)

Localisation : backend/tests/e2e/

Cible : API complète (HTTP requests → DB → responses).

Exemple :

// backend/tests/e2e/api_tests.rs
use actix_web::{test, App};

#[actix_web::test]
async fn test_complete_building_workflow() {
    // Setup test app
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(test_app_state()))
            .configure(configure_routes)
    ).await;

    // 1. Login
    let login_req = test::TestRequest::post()
        .uri("/api/v1/auth/login")
        .set_json(&json!({
            "email": "test@example.com",
            "password": "password123"
        }))
        .to_request();

    let login_resp = test::call_service(&app, login_req).await;
    assert_eq!(login_resp.status(), 200);

    let login_body: serde_json::Value = test::read_body_json(login_resp).await;
    let token = login_body["token"].as_str().unwrap();

    // 2. Create building
    let create_req = test::TestRequest::post()
        .uri("/api/v1/buildings")
        .insert_header(("Authorization", format!("Bearer {}", token)))
        .set_json(&json!({
            "name": "E2E Test Building",
            "address": "123 E2E St",
            "city": "Paris",
            "postal_code": "75001",
            "country": "France",
            "total_units": 20
        }))
        .to_request();

    let create_resp = test::call_service(&app, create_req).await;
    assert_eq!(create_resp.status(), 201);

    let create_body: serde_json::Value = test::read_body_json(create_resp).await;
    let building_id = create_body["id"].as_str().unwrap();

    // 3. Get building
    let get_req = test::TestRequest::get()
        .uri(&format!("/api/v1/buildings/{}", building_id))
        .insert_header(("Authorization", format!("Bearer {}", token)))
        .to_request();

    let get_resp = test::call_service(&app, get_req).await;
    assert_eq!(get_resp.status(), 200);

    let get_body: serde_json::Value = test::read_body_json(get_resp).await;
    assert_eq!(get_body["name"], "E2E Test Building");

    // 4. Update building
    let update_req = test::TestRequest::put()
        .uri(&format!("/api/v1/buildings/{}", building_id))
        .insert_header(("Authorization", format!("Bearer {}", token)))
        .set_json(&json!({
            "name": "Updated E2E Building",
            "address": "123 E2E St",
            "city": "Lyon",
            "postal_code": "69001",
            "country": "France",
            "total_units": 20
        }))
        .to_request();

    let update_resp = test::call_service(&app, update_req).await;
    assert_eq!(update_resp.status(), 200);

    // 5. Delete building
    let delete_req = test::TestRequest::delete()
        .uri(&format!("/api/v1/buildings/{}", building_id))
        .insert_header(("Authorization", format!("Bearer {}", token)))
        .to_request();

    let delete_resp = test::call_service(&app, delete_req).await;
    assert_eq!(delete_resp.status(), 204);

    // 6. Verify deletion
    let verify_req = test::TestRequest::get()
        .uri(&format!("/api/v1/buildings/{}", building_id))
        .insert_header(("Authorization", format!("Bearer {}", token)))
        .to_request();

    let verify_resp = test::call_service(&app, verify_req).await;
    assert_eq!(verify_resp.status(), 404);
}

Commande :

# Tous les tests E2E
cargo test --test e2e

Coverage

Tarpaulin : Génération rapports coverage.

# Générer coverage
cargo tarpaulin --out Html --output-dir coverage

# Output: coverage/index.html
# Ouvrir dans navigateur
xdg-open coverage/index.html

Cible :

  • Domain : 100%

  • Application : 80%+

  • Infrastructure : 60%+

  • Global : 80%+

CI/CD Tests

GitHub Actions : Exécution automatique tous les tests.

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable

      - name: Start PostgreSQL
        run: |
          docker-compose up -d postgres

      - name: Run migrations
        run: |
          cd backend && sqlx migrate run

      - name: Unit tests
        run: cargo test --lib

      - name: Integration tests
        run: cargo test --test integration

      - name: BDD tests
        run: cargo test --test bdd

      - name: E2E tests
        run: cargo test --test e2e

      - name: Coverage
        run: cargo tarpaulin --out Xml

      - name: Upload coverage
        uses: codecov/codecov-action@v3

Commandes Pratiques

# Tous les tests
make test
# ou
cargo test

# Tests rapides (skip integration/e2e)
cargo test --lib

# Tests avec output
cargo test -- --nocapture

# Tests parallèles
cargo test -- --test-threads=4

# Test spécifique
cargo test test_create_building_success

# Watch mode (auto-rerun)
cargo watch -x test

Références