From 0909c0d852b5c75cb1ae8b21b4227fda2e14e39f Mon Sep 17 00:00:00 2001 From: TM-Squared Date: Fri, 4 Jul 2025 16:50:24 +0200 Subject: [PATCH 1/4] for test unitaires --- .env-example | 30 ++ .github/workflows/docker-tests.yml | 42 +++ README.md | 561 +++++++++++++---------------- docker-compose.test.yml | 119 ++++++ scripts/run_tests_docker.sh | 86 +++++ tests/Dockerfile | 35 ++ tests/conftest.py | 111 ++++++ tests/pytest.ini | 17 + tests/requirements.txt | 17 + tests/run_tests.py | 75 ++++ tests/sql/init_test_db.sql | 35 ++ tests/test_airflow_dags.py | 50 +++ tests/test_api.py | 94 +++++ tests/test_integration.py | 137 +++++++ tests/test_models.py | 109 ++++++ tests/test_performance.py | 65 ++++ tests/test_training.py | 60 +++ 17 files changed, 1331 insertions(+), 312 deletions(-) create mode 100644 .env-example create mode 100644 .github/workflows/docker-tests.yml create mode 100644 docker-compose.test.yml create mode 100644 scripts/run_tests_docker.sh create mode 100644 tests/Dockerfile create mode 100644 tests/conftest.py create mode 100644 tests/pytest.ini create mode 100644 tests/requirements.txt create mode 100644 tests/run_tests.py create mode 100644 tests/sql/init_test_db.sql create mode 100644 tests/test_airflow_dags.py create mode 100644 tests/test_api.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_models.py create mode 100644 tests/test_performance.py create mode 100644 tests/test_training.py diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..f104756 --- /dev/null +++ b/.env-example @@ -0,0 +1,30 @@ +MYSQL_USER=plants_user +MYSQL_ROOT_PASSWORD=mlops +MYSQL_DATABASE=plants +MYSQL_PASSWORD=mlops_project +MYSQL_HOST_PORT=3306 + + +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY= ## +MINIO_BUCKET_NAME=mlops-bucket +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +POSTGRES_USER=airflow +POSTGRES_PASSWORD=airflow +POSTGRES_DB=airflow +POSTGRES_PORT=5432 + +AIRFLOW__CORE__EXECUTOR=LocalExecutor +AIRFLOW__CORE__FERNET_KEY= ## +AIRFLOW_USERNAME=airflow +AIRFLOW_PASSWORD=airflow +AIRFLOW_EMAIL=contact@airflow.com +AIRFLOW_WEBSERVER_PORT=8080 + +MLFLOW_PORT=5000 + + +API_PORT=8000 +WEBAPP_PORT=8501 \ No newline at end of file diff --git a/.github/workflows/docker-tests.yml b/.github/workflows/docker-tests.yml new file mode 100644 index 0000000..c8d72db --- /dev/null +++ b/.github/workflows/docker-tests.yml @@ -0,0 +1,42 @@ +name: Tests dans Docker + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +jobs: + docker-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create test environment file + run: | + cp .env.example .env + + - name: Run tests in Docker + run: | + chmod +x scripts/run_tests_docker.sh + ./scripts/run_tests_docker.sh + + - name: Upload test results + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-results + path: test-results/ + + - name: Publish test results + uses: dorny/test-reporter@v1 + if: always() + with: + name: Docker Tests Results + path: test-results/junit.xml + reporter: java-junit diff --git a/README.md b/README.md index c27293b..b7e2934 100644 --- a/README.md +++ b/README.md @@ -1,421 +1,358 @@ -# Projet MLOps : Classification d'Images - Pissenlits vs Herbe +# 🌱 Plant Classification MLOps -## 🎯 Objectif du Projet +**Classification d'images de plantes (Pissenlit vs Herbe) avec pipeline MLOps complet** -Ce projet implémente un pipeline MLOps complet pour la classification binaire d'images, distinguant les pissenlits (dandelion) de l'herbe (grass). Il démontre l'application pratique des principes MLOps avec TensorFlow, incluant l'entraînement automatisé, le déploiement, le monitoring et l'intégration continue. +![Python](https://img.shields.io/badge/Python-3.10-blue) +![TensorFlow](https://img.shields.io/badge/TensorFlow-2.13-orange) +![Apache Airflow](https://img.shields.io/badge/Apache%20Airflow-2.10.1-red) +![Docker](https://img.shields.io/badge/Docker-Compose-blue) +![MLflow](https://img.shields.io/badge/MLflow-2.7.1-green) +![MinIO](https://img.shields.io/badge/MinIO-S3-yellow) ## 📋 Table des Matières -- [Architecture du Projet](#architecture-du-projet) -- [Prérequis](#prérequis) +- [Aperçu du Projet](#aperçu-du-projet) +- [Architecture](#architecture) +- [Technologies Utilisées](#technologies-utilisées) - [Installation](#installation) -- [Structure du Projet](#structure-du-projet) - [Utilisation](#utilisation) -- [Environnements](#environnements) -- [Pipeline MLOps](#pipeline-mlops) -- [API et WebApp](#api-et-webapp) -- [Monitoring](#monitoring) +- [API Documentation](#api-documentation) - [Tests](#tests) +- [Monitoring](#monitoring) - [Déploiement](#déploiement) -- [Contributions](#contributions) +- [Contribution](#contribution) + +## Aperçu du Projet + +Ce projet implémente un pipeline MLOps complet pour la classification binaire d'images de plantes, distinguant les dandelion de l'herbe (grass). Il démontre les meilleures pratiques MLOps incluant l'automatisation, le monitoring, et le déploiement continu. -## 🏗️ Architecture du Projet +### Fonctionnalités Principales + +- **🤖 Classification automatique** d'images avec TensorFlow/MobileNetV2 +- **📊 Pipeline d'entraînement** automatisé avec Apache Airflow +- **🗄️ Stockage distribué** avec MinIO (compatible S3) +- **📈 Tracking d'expériences** avec MLflow +- **🌐 API REST** avec FastAPI +- **💻 Interface web** avec Streamlit +- **🔄 Entraînement continu** et déploiement automatique +- **🐳 Containerisation** complète avec Docker + +## 🏗️ Architecture ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Data Source │───▶│ Apache │───▶│ ML Model │ -│ (MySQL DB) │ │ Airflow │ │ Training │ +│ Data Sources │ │ Data Storage │ │ Processing │ +│ │ │ │ │ │ +│ • GitHub URLs │───▶│ • MinIO (S3) │───▶│ • Apache Airflow│ +│ • Manual Upload │ │ • MySQL │ │ • TensorFlow │ └─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ + │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Monitoring │ │ CI/CD │ │ Model │ -│ (Prometheus) │ │ (GitHub │ │ Registry │ -│ │ │ Actions) │ │ (S3/MLflow) │ +│ Monitoring │ │ Model Store │ │ ML Training │ +│ │ │ │ │ │ +│ • MLflow UI │◀───│ • MinIO Models │◀───│ • Model Training│ +│ • Logs │ │ • Model Registry│ │ • Evaluation │ └─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ + │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ WebApp │ │ Kubernetes │ │ FastAPI │ -│ (Streamlit) │ │ Deployment │ │ Serving │ +│ User Interface│ │ API Layer │ │ Deployment │ +│ │ │ │ │ │ +│ • Streamlit App │───▶│ • FastAPI │◀───│ • Auto Deploy │ +│ • Web Interface │ │ • REST Endpoints│ │ • Model Serving │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` -## 🛠️ Prérequis - -- **Python 3.8+** -- **Docker & Docker Compose** -- **Kubernetes** (Minikube ou Docker Desktop) -- **Git** - -### Technologies Utilisées - -| Composant | Technologie | -|-----------|-------------| -| **ML Framework** | TensorFlow 2.x | -| **Orchestration** | Apache Airflow | -| **API** | FastAPI | -| **WebApp** | Streamlit | -| **Model Registry** | MLflow | -| **Storage** | AWS S3 (Minio) | -| **Database** | MySQL | -| **Monitoring** | Prometheus + Grafana | -| **CI/CD** | GitHub Actions | -| **Containerization** | Docker | -| **Deployment** | Kubernetes | +## Technologies Utilisées + +### Machine Learning & Data +- **TensorFlow 2.13** - Framework de deep learning +- **MobileNetV2** - Modèle de transfer learning léger +- **MLflow** - Tracking d'expériences et registry de modèles +- **Pandas/NumPy** - Manipulation de données + +### Infrastructure & Orchestration +- **Apache Airflow** - Orchestration de pipelines +- **Docker & Docker Compose** - Containerisation +- **MinIO** - Stockage objet compatible S3 +- **MySQL** - Base de données relationnelle +- **PostgreSQL** - Base de données Airflow + +### API & Interface +- **FastAPI** - API REST moderne et rapide +- **Streamlit** - Interface web interactive +- **Uvicorn** - Serveur ASGI haute performance + +### DevOps & Monitoring +- **GitHub Actions** - CI/CD (prêt pour déploiement) +- **pytest** - Framework de tests +- **Logging** - Monitoring et debugging ## 🚀 Installation -### 1. Cloner le repository +### Prérequis -```bash -git clone https://github.com/TM-Squared/mlops_image-classification.git -cd mlops_image-classification -``` +- Docker & Docker Compose +- Git +- 8GB RAM minimum +- 10GB espace disque libre -### 2. Environnement de développement +### Installation Rapide ```bash -# Créer un environnement virtuel -python -m venv venv -source venv/bin/activate # Linux/Mac -# ou -venv\Scripts\activate # Windows - -# Installer les dépendances -pip install -r requirements.txt -``` +# 1. Cloner le repository +git clone +cd plant-classification-mlops -### 3. Configuration avec Docker Compose +# 2. Créer le fichier d'environnement +cp .env.example .env -```bash -# Lancer l'environnement complet -docker-compose up -d +# 3. Créer les dossiers nécessaires +mkdir -p airflow/logs models tests/data + +# 4. Lancer l'environnement +docker-compose up --build -d -# Vérifier les services -docker-compose ps +# 5. Attendre le démarrage (2-3 minutes) +docker-compose logs -f ``` -## 📁 Structure du Projet +### Configuration des Variables d'Environnement +Modifier le fichier `.env` selon vos besoins : + +```bash +# Base de données +POSTGRES_USER=airflow +POSTGRES_PASSWORD=airflow123 +MYSQL_USER=plants_user +MYSQL_PASSWORD=plants123 + +# MinIO +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin123 + +# Airflow +AIRFLOW_USERNAME=admin +AIRFLOW_PASSWORD=admin123 ``` -mlops_image-classification/ -├── src/ -│ ├── data/ -│ │ ├── __init__.py -│ │ ├── data_loader.py # Chargement des données -│ │ └── preprocessing.py # Préprocessing des images -│ ├── models/ -│ │ ├── __init__.py -│ │ ├── model.py # Définition du modèle -│ │ └── training.py # Entraînement -│ ├── api/ -│ │ ├── __init__.py -│ │ ├── main.py # FastAPI application -│ │ └── schemas.py # Modèles Pydantic -│ └── utils/ -│ ├── __init__.py -│ ├── config.py # Configuration -│ └── logger.py # Logging -├── airflow/ -│ ├── dags/ -│ │ ├── data_pipeline.py # Pipeline d'extraction -│ │ ├── training_pipeline.py # Pipeline d'entraînement -│ │ └── inference_pipeline.py # Pipeline d'inférence -│ └── plugins/ -├── webapp/ -│ ├── app.py # Application Streamlit -│ └── utils.py -├── tests/ -│ ├── unit/ -│ │ ├── test_data_loader.py -│ │ ├── test_model.py -│ │ └── test_api.py -│ └── integration/ -│ └── test_pipeline.py -├── docker/ -│ ├── Dockerfile.api -│ ├── Dockerfile.webapp -│ └── Dockerfile.airflow -├── k8s/ -│ ├── namespace.yaml -│ ├── api-deployment.yaml -│ ├── webapp-deployment.yaml -│ └── monitoring/ -├── monitoring/ -│ ├── prometheus/ -│ │ └── prometheus.yml -│ └── grafana/ -│ └── dashboards/ -├── .github/ -│ └── workflows/ -│ ├── ci.yml -│ ├── cd.yml -│ └── tests.yml -├── docker-compose.yml -├── requirements.txt -└── README.md -``` -## 🎮 Utilisation +## Utilisation + +### 1. Premier Démarrage -### 1. Préparation des données +Après le lancement, configurez les connexions Airflow : ```bash -# Initialiser la base de données -python src/data/init_db.py +# Accéder à Airflow +open http://localhost:8080 +# Login: admin / admin123 -# Lancer le pipeline d'extraction -python src/data/data_loader.py +# Déclencher le DAG "setup_connections" pour configurer automatiquement les connexions ``` -### 2. Entraînement du modèle +### 2. Pipeline d'Ingestion de Données ```bash -# Entraînement local -python src/models/training.py - -# Ou via Airflow -# Accéder à http://localhost:8080 -# Déclencher le DAG "training_pipeline" +# Dans Airflow UI, activer et déclencher : +# 1. "plants_data_ingestion_pipeline" - Ingestion des données +# 2. "model_training_minio_pipeline" - Entraînement du modèle ``` -### 3. Lancement de l'API +### 3. Test de l'API ```bash -# Mode développement -uvicorn src.api.main:app --reload --port 8000 +# Vérifier l'API +curl http://localhost:8000/health -# Accéder à la documentation : http://localhost:8000/docs +# Tester une prédiction +curl -X POST "http://localhost:8000/predict-url" \ + -H "Content-Type: application/json" \ + -d '{"image_url": "https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000000.jpg"}' ``` -### 4. WebApp Streamlit +### 4. Interface Web ```bash -# Lancer l'application -streamlit run webapp/app.py - -# Accéder à : http://localhost:8501 +# Accéder à la WebApp +open http://localhost:8501 ``` -## 🌍 Environnements - -### Développement (Local) -- **Services** : Docker Compose -- **Base de données** : MySQL local -- **Storage** : MinIO local -- **Monitoring** : Prometheus + Grafana locaux - -### Production (Kubernetes) -- **Orchestration** : Kubernetes (Minikube/Cloud) -- **Base de données** : MySQL avec persistance -- **Storage** : S3 compatible -- **Monitoring** : Stack Prometheus complet -- **Ingress** : Nginx Ingress Controller - -## 🔄 Pipeline MLOps - -### 1. Pipeline de Données (`data_pipeline.py`) -- Extraction des images depuis les URLs -- Validation et nettoyage des données -- Stockage dans S3 -- Mise à jour des métadonnées - -### 2. Pipeline d'Entraînement (`training_pipeline.py`) -- Chargement des données depuis S3 -- Préprocessing et augmentation -- Entraînement du modèle TensorFlow -- Évaluation et validation -- Sauvegarde du modèle dans MLflow - -### 3. Pipeline de Déploiement (`inference_pipeline.py`) -- Téléchargement du meilleur modèle -- Déploiement automatique de l'API -- Tests de santé du service -- Monitoring des performances - -### 4. Entraînement Continu (CT) -- **Triggers** : - - Nouvelles données disponibles - - Entraînement hebdomadaire programmé - - Dégradation des performances détectée -- **Actions** : - - Ré-entraînement automatique - - Validation A/B testing - - Déploiement conditionnel - -## 🚦 API et WebApp - -### API FastAPI - -**Endpoints principaux :** -- `POST /predict` : Prédiction sur une image -- `GET /health` : Santé du service -- `GET /metrics` : Métriques Prometheus -- `GET /model/info` : Informations sur le modèle - -**Exemple d'utilisation :** -```python -import requests +## 🔌 API Documentation -# Prédiction -files = {"file": open("image.jpg", "rb")} -response = requests.post("http://localhost:8000/predict", files=files) -print(response.json()) -``` +### Endpoints Principaux -### WebApp Streamlit +| Endpoint | Méthode | Description | +|----------|---------|-------------| +| `/` | GET | Informations sur l'API | +| `/health` | GET | Statut de santé | +| `/predict` | POST | Prédiction via upload | +| `/predict-url` | POST | Prédiction via URL | +| `/models` | GET | Liste des modèles | +| `/reload-model` | POST | Recharger le modèle | -**Fonctionnalités :** -- Interface de téléchargement d'images -- Affichage des prédictions en temps réel -- Visualisation des métriques du modèle -- Historique des prédictions +### Exemple d'Utilisation -## 📊 Monitoring +```python +import requests -### Métriques Collectées +# Prédiction via URL +response = requests.post( + "http://localhost:8000/predict-url", + params={"image_url": "https://example.com/image.jpg"} +) -1. **Métriques Applicatives** - - Nombre de prédictions - - Latence des requêtes - - Accuracy du modèle - - Distributions des prédictions +result = response.json() +print(f"Classe prédite: {result['predicted_class']}") +print(f"Confiance: {result['confidence']:.2%}") +``` -2. **Métriques Système** - - Utilisation CPU/RAM - - Espace disque - - Statut des services +### Documentation Interactive -3. **Métriques Business** - - Taux d'utilisation - - Temps de réponse utilisateur - - Disponibilité du service +La documentation Swagger est disponible à : `http://localhost:8000/docs` -### Dashboards Grafana +## Tests -- **Dashboard Principal** : Vue d'ensemble des services -- **Dashboard ML** : Métriques spécifiques au modèle -- **Dashboard Infrastructure** : Monitoring système +### Lancer les Tests -### Alertes +```bash +# Tests unitaires +docker-compose exec airflow-webserver python -m pytest tests/ -v -- Service indisponible -- Dégradation des performances -- Erreurs d'entraînement -- Espace disque faible +# Tests d'intégration +docker-compose exec api python -m pytest tests/ -v -## 🧪 Tests +# Tests end-to-end +python tests/test_e2e.py +``` -### Tests Unitaires +### Coverage ```bash -# Lancer tous les tests -pytest tests/unit/ - -# Tests spécifiques -pytest tests/unit/test_model.py -v +# Générer un rapport de couverture +docker-compose exec airflow-webserver python -m pytest tests/ --cov=ml --cov-report=html ``` -### Tests d'Intégration +## 📊 Monitoring -```bash -# Tests end-to-end -pytest tests/integration/ +### Interfaces de Monitoring -# Tests avec couverture -pytest --cov=src tests/ -``` +- **Airflow** : `http://localhost:8080` - Monitoring des DAGs +- **MLflow** : `http://localhost:5000` - Tracking des expériences +- **MinIO Console** : `http://localhost:9001` - Gestion du stockage +- **API Docs** : `http://localhost:8000/docs` - Documentation API -### Tests de Charge +### Logs ```bash -# Installer Locust -pip install locust +# Logs en temps réel +docker-compose logs -f -# Lancer les tests -locust -f tests/load/locustfile.py --host=http://localhost:8000 +# Logs spécifiques +docker-compose logs airflow-scheduler +docker-compose logs api ``` -## 🚀 Déploiement +### Métriques Importantes + +- **Précision du modèle** : Suivi dans MLflow +- **Temps de réponse API** : Logs FastAPI +- **Utilisation stockage** : Console MinIO +- **Statut des DAGs** : Interface Airflow + +## 🚢 Déploiement -### Déploiement Local (Kubernetes) +### Environnement de Production ```bash -# Démarrer Minikube -minikube start +# Utiliser le fichier de production +docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d -# Déployer l'application +# Ou déployment Kubernetes (manifests dans k8s/) kubectl apply -f k8s/ - -# Vérifier les déploiements -kubectl get pods -kubectl get services ``` -### Déploiement Cloud (Optionnel) +### CI/CD avec GitHub Actions + +Le pipeline CI/CD est configuré dans `.github/workflows/` et inclut : + +- Tests automatiques +- Build et push des images Docker +- Déploiement automatique +- Tests de santé post-déploiement + +### Variables de Production ```bash -# Configurer kubectl pour votre cluster cloud -# Déployer avec Helm -helm install mlops-app ./helm/mlops-chart +# Production .env +POSTGRES_PASSWORD= +MYSQL_PASSWORD= +MINIO_SECRET_KEY= +AIRFLOW_PASSWORD= ``` +## 🔧 Dépannage -## 🤝 Contributions +### Problèmes Courants -1. **Fork** le repository -2. **Créer** une branche feature (`git checkout -b feature/AmazingFeature`) -3. **Commit** vos changements (`git commit -m 'Add some AmazingFeature'`) -4. **Push** vers la branche (`git push origin feature/AmazingFeature`) -5. **Ouvrir** une Pull Request +**1. Erreur de permissions MinIO** +```bash +# Vérifier les clés d'accès +docker-compose logs minio +# Recréer les connexions Airflow +``` -## 📝 License +**2. Modèle non trouvé** +```bash +# Vérifier les modèles dans MinIO +curl http://localhost:8000/models +# Relancer l'entraînement +``` -Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails. +**3. Erreur de base de données** +```bash +# Réinitialiser les bases +docker-compose down -v +docker-compose up --build -d +``` -## 👥 Équipe +### Support -- **Nom du Team** : TOUSSI Manoël Malaury -- **Contact** : [manoel@malaurytoussi.cm] +Pour obtenir de l'aide : +1. Vérifiez les [issues GitHub](../../issues) +2. Consultez les logs : `docker-compose logs` +3. Ouvrez une nouvelle issue avec les détails +## 👥 Contribution -## 📚 Ressources Supplémentaires +### Guide de Contribution -### Choix Techniques +1. Fork le projet +2. Créer une branche feature (`git checkout -b feature/AmazingFeature`) +3. Commit vos changements (`git commit -m 'Add AmazingFeature'`) +4. Push vers la branche (`git push origin feature/AmazingFeature`) +5. Ouvrir une Pull Request -**Pourquoi TensorFlow ?** -- Excellent support pour la classification d'images -- Intégration native avec TensorFlow Serving -- Écosystème mature pour la production +### Standards de Code -**Pourquoi FastAPI ?** -- Performance élevée (basé sur Starlette) -- Documentation automatique (Swagger) -- Validation des données native +- **Format** : Black pour Python +- **Linting** : Flake8 +- **Tests** : pytest avec coverage > 80% +- **Documentation** : Docstrings pour toutes les fonctions -**Pourquoi Airflow ?** -- Orchestration robuste des pipelines -- Interface graphique intuitive -- Gestion des dépendances complexes +### Structure des Commits -### Optimisations Réalisées +``` +type(scope): description -1. **Modèle** : - - Transfer learning avec MobileNetV2 - - Augmentation des données - - Early stopping et callbacks +- feat: nouvelle fonctionnalité +- fix: correction de bug +- docs: documentation +- test: ajout de tests +- refactor: refactoring +``` -2. **API** : - - Mise en cache des modèles - - Traitement asynchrone - - Validation des entrées +## 📄 Licence -3. **Infrastructure** : - - Répartition de charge - - Monitoring proactif - - Scaling automatique +Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails. ---- -*Ce README sera maintenu à jour avec les évolutions du projet. N'hésitez pas à contribuer à son amélioration !* \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..cd6f3a0 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,119 @@ +services: + test-runner: + build: + context: . + dockerfile: tests/Dockerfile + volumes: + - ./tests:/app/tests + - ./ml:/app/ml + - ./api:/app/api + - ./webapp:/app/webapp + - ./airflow/dags:/app/airflow/dags + - test-results:/app/test-results + environment: + - PYTHONPATH=/app:/app/ml:/app/api:/app/webapp + - AIRFLOW_HOME=/app/airflow + - MYSQL_HOST=mysql + - MYSQL_USER=plants_user + - MYSQL_PASSWORD=plants123 + - MYSQL_DATABASE=plants + - AWS_ACCESS_KEY_ID=minioadmin + - AWS_SECRET_ACCESS_KEY=minioadmin123 + - MLFLOW_S3_ENDPOINT_URL=http://minio:9000 + - MLFLOW_TRACKING_URI=http://mlflow:5000 + - API_URL=http://api:8000 + depends_on: + postgres: + condition: service_healthy + mysql: + condition: service_healthy + minio: + condition: service_healthy + api: + condition: service_started + mlflow: + condition: service_started + command: > + bash -c " + echo '🧪 Démarrage des tests dans Docker...' && + sleep 30 && + python -m pytest tests/ -v --tb=short --junitxml=/app/test-results/junit.xml --html=/app/test-results/report.html --self-contained-html + " + + # Base de données PostgreSQL pour Airflow + postgres: + image: postgres:13 + environment: + POSTGRES_USER: airflow + POSTGRES_PASSWORD: airflow123 + POSTGRES_DB: airflow + healthcheck: + test: ["CMD", "pg_isready", "-U", "airflow"] + interval: 10s + timeout: 5s + retries: 5 + + # Base de données MySQL pour les données + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_DATABASE: plants + MYSQL_USER: plants_user + MYSQL_PASSWORD: plants123 + volumes: + - ./tests/sql/init_test_db.sql:/docker-entrypoint-initdb.d/init_test_db.sql:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 10 + + # MinIO pour le stockage S3 + minio: + image: minio/minio:RELEASE.2025-04-03T14-56-28Z-cpuv1 + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + # MLflow pour le tracking + mlflow: + image: python:3.10-slim + command: > + bash -c "pip install mlflow boto3 pymysql psycopg2-binary && + mlflow server --host 0.0.0.0 --port 5000 + --backend-store-uri mysql+pymysql://plants_user:plants123@mysql:3306/plants + --default-artifact-root s3://mlflow/" + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin123 + MLFLOW_S3_ENDPOINT_URL: http://minio:9000 + depends_on: + mysql: + condition: service_healthy + minio: + condition: service_healthy + + # API pour les tests d'intégration + api: + build: + context: ./api + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin123 + MLFLOW_S3_ENDPOINT_URL: http://minio:9000 + MLFLOW_TRACKING_URI: http://mlflow:5000 + depends_on: + mlflow: + condition: service_started + minio: + condition: service_healthy + +volumes: + test-results: \ No newline at end of file diff --git a/scripts/run_tests_docker.sh b/scripts/run_tests_docker.sh new file mode 100644 index 0000000..cf7ee6b --- /dev/null +++ b/scripts/run_tests_docker.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +echo "🧪 Lancement des tests dans Docker" + +# Couleurs pour les messages +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Nettoyer les anciens conteneurs +print_status "Nettoyage des anciens conteneurs de test..." +docker-compose -f docker-compose.test.yml down -v + +# Construire les images +print_status "Construction des images de test..." +docker-compose -f docker-compose.test.yml build + +# Démarrer les services +print_status "Démarrage des services de test..." +docker-compose -f docker-compose.test.yml up -d postgres mysql minio mlflow api + +# Attendre que les services soient prêts +print_status "Attente de la disponibilité des services..." +sleep 45 + +# Vérifier la santé des services +print_status "Vérification de la santé des services..." + +services=( + "postgres:5432" + "mysql:3306" + "minio:9000" + "mlflow:5000" + "api:8000" +) + +for service in "${services[@]}"; do + IFS=':' read -r host port <<< "$service" + if docker-compose -f docker-compose.test.yml exec -T test-runner nc -z $host $port; then + print_success "Service $service disponible" + else + print_error "Service $service non disponible" + fi +done + +# Lancer les tests +print_status "Lancement des tests..." +docker-compose -f docker-compose.test.yml run --rm test-runner + +# Capturer le code de sortie +test_exit_code=$? + +# Copier les résultats de test +print_status "Copie des résultats de test..." +docker cp $(docker-compose -f docker-compose.test.yml ps -q test-runner):/app/test-results ./test-results 2>/dev/null || true + +# Nettoyer +print_status "Nettoyage..." +docker-compose -f docker-compose.test.yml down -v + +# Résultats +if [ $test_exit_code -eq 0 ]; then + print_success "Tous les tests sont passés!" + echo "" + echo "📊 Résultats disponibles dans:" + echo " - ./test-results/junit.xml" + echo " - ./test-results/report.html" +else + print_error "Certains tests ont échoué" +fi + +exit $test_exit_code \ No newline at end of file diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..a460520 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.10-slim + +WORKDIR /app + +# Installer les dépendances système +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + curl \ + default-libmysqlclient-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Copier les requirements de test +COPY tests/requirements.txt /tmp/test-requirements.txt +COPY api/requirements.txt /tmp/api-requirements.txt +COPY ml/requirements.txt /tmp/ml-requirements.txt + +# Installer toutes les dépendances +RUN pip install --no-cache-dir -r /tmp/test-requirements.txt && \ + pip install --no-cache-dir -r /tmp/api-requirements.txt && \ + pip install --no-cache-dir -r /tmp/ml-requirements.txt + +# Copier le code source +COPY . /app/ + +# Créer les dossiers de résultats +RUN mkdir -p /app/test-results + +# Variables d'environnement +ENV PYTHONPATH="/app:/app/ml:/app/api:/app/webapp" +ENV TF_CPP_MIN_LOG_LEVEL=2 + +# Point d'entrée par défaut +CMD ["python", "-m", "pytest", "tests/", "-v"] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8244b41 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,111 @@ +import pytest +import os +import tempfile +import shutil +import numpy as np +from PIL import Image +import io +import boto3 +from moto import mock_s3 +import mysql.connector +from unittest.mock import Mock, patch +import tensorflow as tf +from sqlalchemy import create_engine +import pandas as pd + +# Configuration TensorFlow pour les tests +tf.config.set_visible_devices([], 'GPU') +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' + +@pytest.fixture(scope="session") +def test_config(): + """Configuration de test""" + return { + 'mysql_host': os.getenv('MYSQL_HOST', 'mysql'), + 'mysql_user': os.getenv('MYSQL_USER', 'plants_user'), + 'mysql_password': os.getenv('MYSQL_PASSWORD', 'plants123'), + 'mysql_database': os.getenv('MYSQL_DATABASE', 'plants'), + 'api_url': os.getenv('API_URL', 'http://api:8000'), + 'minio_endpoint': os.getenv('MLFLOW_S3_ENDPOINT_URL', 'http://minio:9000'), + 'mlflow_uri': os.getenv('MLFLOW_TRACKING_URI', 'http://mlflow:5000') + } + +@pytest.fixture +def temp_dir(): + """Créer un répertoire temporaire pour les tests""" + temp_dir = tempfile.mkdtemp() + yield temp_dir + shutil.rmtree(temp_dir) + +@pytest.fixture +def sample_image(): + """Créer une image de test""" + image_array = np.random.randint(0, 255, (224, 224, 3), dtype=np.uint8) + image = Image.fromarray(image_array) + + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG') + img_buffer.seek(0) + + return img_buffer.getvalue() + +@pytest.fixture +def sample_images_data(): + """Données d'exemple pour les tests""" + return { + 'image_urls': [ + 'https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000000.jpg', + 'https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000001.jpg', + 'https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/grass/00000000.jpg', + 'https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/grass/00000001.jpg' + ], + 'labels': ['dandelion', 'dandelion', 'grass', 'grass'], + 's3_keys': [ + 'raw/dandelion/00000000.jpg', + 'raw/dandelion/00000001.jpg', + 'raw/grass/00000000.jpg', + 'raw/grass/00000001.jpg' + ] + } + +@pytest.fixture +def mock_s3_client(): + """Mock du client S3/MinIO""" + with mock_s3(): + client = boto3.client( + 's3', + region_name='us-east-1', + aws_access_key_id='test', + aws_secret_access_key='test' + ) + + # Créer les buckets de test + client.create_bucket(Bucket='raw-data') + client.create_bucket(Bucket='models') + client.create_bucket(Bucket='mlflow') + + yield client + +@pytest.fixture +def real_mysql_connection(test_config): + """Connexion MySQL réelle pour les tests d'intégration""" + try: + connection = mysql.connector.connect( + host=test_config['mysql_host'], + user=test_config['mysql_user'], + password=test_config['mysql_password'], + database=test_config['mysql_database'] + ) + yield connection + connection.close() + except mysql.connector.Error: + pytest.skip("MySQL non disponible pour les tests d'intégration") + +@pytest.fixture +def mock_model(): + """Mock d'un modèle TensorFlow""" + model = Mock() + model.predict.return_value = np.array([[0.3, 0.7]]) + model.save = Mock() + model.summary = Mock() + return model \ No newline at end of file diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..0206fe0 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,17 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --color=yes + +markers = + unit: Tests unitaires + integration: Tests d'intégration + performance: Tests de performance + slow: Tests lents diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..dab1647 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,17 @@ +pytest +pytest-cov +pytest-mock +pytest-asyncio +pytest-html +pytest-xdist + +# Mocking et fixtures +moto +responses +factory-boy +faker + +# API Testing +httpx +requests +fastapi[test] \ No newline at end of file diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..c856bad --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,75 @@ +import subprocess +import sys +import os + +def run_command(command, description): + """Exécuter une commande et afficher le résultat""" + print(f"\n{'='*50}") + print(f"🧪 {description}") + print(f"{'='*50}") + + result = subprocess.run(command, shell=True, capture_output=True, text=True) + + if result.returncode == 0: + print(f"✅ {description} - SUCCÈS") + if result.stdout: + print("📋 Sortie:") + print(result.stdout) + else: + print(f"❌ {description} - ÉCHEC") + if result.stderr: + print("🚨 Erreur:") + print(result.stderr) + if result.stdout: + print("📋 Sortie:") + print(result.stdout) + + return result.returncode == 0 + +def main(): + """Fonction principale""" + print("🚀 Lancement de la suite de tests complète") + + # Changer vers le répertoire du projet + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + os.chdir(project_root) + + tests_commands = [ + ("python -m pytest tests/test_models.py -v", "Tests unitaires - Modèles"), + ("python -m pytest tests/test_api.py -v", "Tests unitaires - API"), + ("python -m pytest tests/test_training.py -v", "Tests unitaires - Entraînement"), + ("python -m pytest tests/test_airflow_dags.py -v", "Tests unitaires - DAGs Airflow"), + ("python -m pytest tests/test_integration.py -v", "Tests d'intégration"), + ("python -m pytest tests/test_performance.py -v", "Tests de performance"), + ("python -m pytest tests/ --cov=ml --cov=api --cov-report=html", "Tests avec couverture"), + ] + + results = [] + + for command, description in tests_commands: + success = run_command(command, description) + results.append((description, success)) + + # Résumé final + print(f"\n{'='*60}") + print("📊 RÉSUMÉ DES TESTS") + print(f"{'='*60}") + + for description, success in results: + status = "✅ SUCCÈS" if success else "❌ ÉCHEC" + print(f"{description:<40} {status}") + + total_tests = len(results) + passed_tests = sum(1 for _, success in results if success) + + print(f"\n📈 Résultat global: {passed_tests}/{total_tests} tests réussis") + + if passed_tests == total_tests: + print("🎉 Tous les tests sont passés!") + return 0 + else: + print("⚠️ Certains tests ont échoué") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/sql/init_test_db.sql b/tests/sql/init_test_db.sql new file mode 100644 index 0000000..a0d086c --- /dev/null +++ b/tests/sql/init_test_db.sql @@ -0,0 +1,35 @@ +-- Base de données de test +CREATE DATABASE IF NOT EXISTS plants_test; +USE plants_test; + +-- Table pour les données de plantes +CREATE TABLE IF NOT EXISTS plants_data ( + id INT AUTO_INCREMENT PRIMARY KEY, + url_source VARCHAR(500) NOT NULL, + url_s3 VARCHAR(500), + label VARCHAR(50) NOT NULL, + image_exists BOOLEAN DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- Insérer des données de test +INSERT INTO plants_data (url_source, url_s3, label, image_exists) VALUES +('https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000000.jpg', 's3://raw-data/raw/dandelion/00000000.jpg', 'dandelion', TRUE), +('https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000001.jpg', 's3://raw-data/raw/dandelion/00000001.jpg', 'dandelion', TRUE), +('https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/grass/00000000.jpg', 's3://raw-data/raw/grass/00000000.jpg', 'grass', TRUE), +('https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/grass/00000001.jpg', 's3://raw-data/raw/grass/00000001.jpg', 'grass', TRUE); + +-- Base de données principale +USE plants; + +-- Table pour les données de plantes +CREATE TABLE IF NOT EXISTS plants_data ( + id INT AUTO_INCREMENT PRIMARY KEY, + url_source VARCHAR(500) NOT NULL, + url_s3 VARCHAR(500), + label VARCHAR(50) NOT NULL, + image_exists BOOLEAN DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/tests/test_airflow_dags.py b/tests/test_airflow_dags.py new file mode 100644 index 0000000..3e7eb23 --- /dev/null +++ b/tests/test_airflow_dags.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import patch, Mock +from datetime import datetime + +class TestAirflowDAGs: + """Tests pour les DAGs Airflow""" + + def test_dag_import(self): + """Test d'import des DAGs""" + try: + # Tenter d'importer les DAGs + import sys + import os + + # Ajouter le chemin des DAGs + dags_path = os.path.join(os.path.dirname(__file__), '..', 'airflow', 'dags') + sys.path.append(dags_path) + + # Tester l'import de quelques DAGs + dag_files = [ + 'setup_connections', + 'check_connections' + ] + + for dag_file in dag_files: + try: + __import__(dag_file) + print(f"✅ DAG {dag_file} importé avec succès") + except ImportError as e: + print(f"⚠️ DAG {dag_file} non disponible: {e}") + + except Exception as e: + pytest.skip(f"Tests DAGs non disponibles: {e}") + + @patch('airflow.providers.mysql.hooks.mysql.MySqlHook') + def test_database_connection_task(self, mock_mysql_hook): + """Test de la tâche de connexion à la base""" + # Mock de la connexion MySQL + mock_hook_instance = Mock() + mock_mysql_hook.return_value = mock_hook_instance + mock_hook_instance.get_first.return_value = (1,) + + # Simuler une tâche de test de connexion + def test_mysql_connection(): + hook = mock_mysql_hook() + result = hook.get_first("SELECT 1") + return result[0] == 1 + + assert test_mysql_connection() is True + mock_hook_instance.get_first.assert_called_once() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..d251a72 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,94 @@ +import pytest +import requests +import json +import time + +class TestAPIInDocker: + """Tests de l'API dans l'environnement Docker""" + + @pytest.fixture + def api_base_url(self, test_config): + return test_config['api_url'] + + def test_api_root(self, api_base_url): + """Test de l'endpoint racine""" + try: + response = requests.get(f"{api_base_url}/", timeout=10) + assert response.status_code == 200 + + data = response.json() + assert "message" in data + assert "version" in data + assert "framework" in data + assert data["framework"] == "TensorFlow" + + except requests.exceptions.RequestException as e: + pytest.fail(f"Test API root échoué: {e}") + + def test_api_model_info(self, api_base_url): + """Test des informations sur le modèle""" + try: + response = requests.get(f"{api_base_url}/model-info", timeout=10) + assert response.status_code == 200 + + data = response.json() + assert "model_type" in data + assert "classes" in data + assert "input_size" in data + assert data["classes"] == ["grass", "dandelion"] + + except requests.exceptions.RequestException as e: + pytest.fail(f"Test model-info échoué: {e}") + + def test_api_predict_url(self, api_base_url): + """Test de prédiction via URL""" + try: + test_url = "https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000000.jpg" + + response = requests.post( + f"{api_base_url}/predict-url", + params={"image_url": test_url}, + timeout=30 + ) + + assert response.status_code == 200 + + data = response.json() + assert "predicted_class" in data + assert "confidence" in data + assert "probabilities" in data + + # Vérifier les valeurs + assert data["predicted_class"] in ["grass", "dandelion"] + assert 0 <= data["confidence"] <= 1 + assert "grass" in data["probabilities"] + assert "dandelion" in data["probabilities"] + + except requests.exceptions.RequestException as e: + pytest.fail(f"Test predict-url échoué: {e}") + + def test_api_models_list(self, api_base_url): + """Test de la liste des modèles""" + try: + response = requests.get(f"{api_base_url}/models", timeout=10) + assert response.status_code == 200 + + data = response.json() + assert "available_models" in data + assert "total_models" in data + assert isinstance(data["available_models"], list) + + except requests.exceptions.RequestException as e: + pytest.fail(f"Test models list échoué: {e}") + + def test_api_reload_model(self, api_base_url): + """Test de rechargement du modèle""" + try: + response = requests.post(f"{api_base_url}/reload-model", timeout=15) + assert response.status_code == 200 + + data = response.json() + assert "message" in data + + except requests.exceptions.RequestException as e: + pytest.fail(f"Test reload-model échoué: {e}") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..4ba5384 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,137 @@ +import pytest +import requests +import time +import json +from sqlalchemy import create_engine, text +import mysql.connector + +class TestDockerIntegration: + """Tests d'intégration dans l'environnement Docker""" + + def test_mysql_connection(self, test_config): + """Test de connexion à MySQL""" + try: + connection = mysql.connector.connect( + host=test_config['mysql_host'], + user=test_config['mysql_user'], + password=test_config['mysql_password'], + database=test_config['mysql_database'] + ) + + cursor = connection.cursor() + cursor.execute("SELECT 1") + result = cursor.fetchone() + + assert result[0] == 1 + + cursor.close() + connection.close() + + except mysql.connector.Error as e: + pytest.fail(f"Connexion MySQL échouée: {e}") + + def test_minio_connection(self, test_config): + """Test de connexion à MinIO""" + try: + import boto3 + + s3_client = boto3.client( + 's3', + endpoint_url=test_config['minio_endpoint'], + aws_access_key_id='minioadmin', + aws_secret_access_key='minioadmin123', + region_name='us-east-1' + ) + + # Lister les buckets + response = s3_client.list_buckets() + buckets = [bucket['Name'] for bucket in response['Buckets']] + + # Vérifier que les buckets existent ou peuvent être créés + for bucket in ['raw-data', 'models', 'mlflow']: + if bucket not in buckets: + s3_client.create_bucket(Bucket=bucket) + + # Test d'écriture/lecture + s3_client.put_object( + Bucket='raw-data', + Key='test/test.txt', + Body=b'test content' + ) + + response = s3_client.get_object(Bucket='raw-data', Key='test/test.txt') + content = response['Body'].read() + + assert content == b'test content' + + except Exception as e: + pytest.fail(f"Connexion MinIO échouée: {e}") + + def test_mlflow_connection(self, test_config): + """Test de connexion à MLflow""" + try: + response = requests.get(f"{test_config['mlflow_uri']}/health", timeout=10) + assert response.status_code == 200 + except requests.exceptions.RequestException as e: + pytest.fail(f"Connexion MLflow échouée: {e}") + + def test_api_health(self, test_config): + """Test de santé de l'API""" + try: + response = requests.get(f"{test_config['api_url']}/health", timeout=10) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + + except requests.exceptions.RequestException as e: + pytest.fail(f"API non disponible: {e}") + + def test_api_prediction(self, test_config): + """Test de prédiction via API""" + try: + test_url = "https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000000.jpg" + + response = requests.post( + f"{test_config['api_url']}/predict-url", + params={"image_url": test_url}, + timeout=30 + ) + + assert response.status_code == 200 + + data = response.json() + assert "predicted_class" in data + assert "confidence" in data + assert data["predicted_class"] in ["grass", "dandelion"] + assert 0 <= data["confidence"] <= 1 + + except requests.exceptions.RequestException as e: + pytest.fail(f"Test prédiction API échoué: {e}") + + def test_database_data(self, test_config): + """Test des données dans la base""" + try: + connection = mysql.connector.connect( + host=test_config['mysql_host'], + user=test_config['mysql_user'], + password=test_config['mysql_password'], + database=test_config['mysql_database'] + ) + + cursor = connection.cursor() + cursor.execute("SELECT COUNT(*) FROM plants_data") + count = cursor.fetchone()[0] + + assert count > 0, "La table plants_data devrait contenir des données" + + cursor.execute("SELECT DISTINCT label FROM plants_data") + labels = [row[0] for row in cursor.fetchall()] + + assert 'dandelion' in labels or 'grass' in labels + + cursor.close() + connection.close() + + except mysql.connector.Error as e: + pytest.fail(f"Test données base échoué: {e}") \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..fdddccf --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,109 @@ +import pytest +import numpy as np +from unittest.mock import patch, Mock, MagicMock +import sys +import os +import tempfile + +# Configuration pour les tests Docker +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' + +class TestModelsInDocker: + """Tests des modèles dans l'environnement Docker""" + + def test_tensorflow_import(self): + """Test d'import de TensorFlow""" + try: + import tensorflow as tf + assert tf.__version__.startswith('2.13') + except ImportError: + pytest.fail("TensorFlow non disponible") + + def test_model_creation(self): + """Test de création de modèle""" + try: + import tensorflow as tf + from tensorflow import keras + + # Créer un modèle simple + model = keras.Sequential([ + keras.layers.Dense(10, activation='relu', input_shape=(5,)), + keras.layers.Dense(2, activation='softmax') + ]) + + model.compile(optimizer='adam', loss='sparse_categorical_crossentropy') + + # Test de prédiction + test_input = np.random.random((1, 5)) + prediction = model.predict(test_input, verbose=0) + + assert prediction.shape == (1, 2) + assert np.allclose(np.sum(prediction), 1.0, atol=1e-6) + + except Exception as e: + pytest.fail(f"Erreur création modèle: {e}") + + def test_model_save_load(self): + """Test de sauvegarde/chargement de modèle""" + try: + import tensorflow as tf + from tensorflow import keras + + # Créer un modèle + model = keras.Sequential([ + keras.layers.Dense(10, activation='relu', input_shape=(5,)), + keras.layers.Dense(2, activation='softmax') + ]) + + model.compile(optimizer='adam', loss='sparse_categorical_crossentropy') + + # Sauvegarder dans un fichier temporaire + with tempfile.NamedTemporaryFile(suffix='.keras', delete=False) as tmp_file: + model.save(tmp_file.name) + + # Charger le modèle + loaded_model = keras.models.load_model(tmp_file.name) + + # Test que les modèles sont équivalents + test_input = np.random.random((1, 5)) + original_pred = model.predict(test_input, verbose=0) + loaded_pred = loaded_model.predict(test_input, verbose=0) + + np.testing.assert_array_almost_equal(original_pred, loaded_pred) + + # Nettoyer + os.unlink(tmp_file.name) + + except Exception as e: + pytest.fail(f"Erreur sauvegarde/chargement: {e}") + + @patch('boto3.client') + def test_minio_model_manager(self, mock_boto_client): + """Test du gestionnaire de modèles MinIO""" + try: + # Simuler les modules si disponibles + import sys + from unittest.mock import Mock + + # Mock MinIOModelManager si le module n'est pas disponible + mock_manager = Mock() + mock_manager.save_model_to_minio.return_value = ['model_key_1', 'model_key_2'] + mock_manager.load_model_from_minio.return_value = (Mock(), 'model_key') + mock_manager.list_models.return_value = [ + {'key': 'test_model.keras', 'size': 1024, 'format': 'keras'} + ] + + # Tests des méthodes + saved_keys = mock_manager.save_model_to_minio(Mock(), "test_model") + assert len(saved_keys) == 2 + + model, key = mock_manager.load_model_from_minio("test_model") + assert model is not None + assert key == 'model_key' + + models = mock_manager.list_models("test_model") + assert len(models) == 1 + assert models[0]['format'] == 'keras' + + except Exception as e: + pytest.fail(f"Erreur test MinIOModelManager: {e}") \ No newline at end of file diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..d8c1936 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,65 @@ +import pytest +import time +import requests +import concurrent.futures +from unittest.mock import Mock + +class TestPerformance: + """Tests de performance""" + + def test_api_response_time(self): + """Test du temps de réponse de l'API""" + try: + start_time = time.time() + response = requests.get("http://localhost:8000/health", timeout=10) + end_time = time.time() + + response_time = end_time - start_time + assert response_time < 2.0 # Moins de 2 secondes + assert response.status_code == 200 + + except requests.exceptions.ConnectionError: + pytest.skip("API non disponible pour tests de performance") + + def test_prediction_performance(self): + """Test de performance des prédictions""" + try: + test_url = "https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/dandelion/00000000.jpg" + + start_time = time.time() + response = requests.post( + "http://localhost:8000/predict-url", + params={"image_url": test_url}, + timeout=30 + ) + end_time = time.time() + + prediction_time = end_time - start_time + assert prediction_time < 10.0 # Moins de 10 secondes + assert response.status_code == 200 + + except requests.exceptions.ConnectionError: + pytest.skip("API non disponible pour tests de performance") + + def test_concurrent_predictions(self): + """Test de prédictions concurrentes""" + try: + def make_prediction(): + test_url = "https://raw.githubusercontent.com/btphan95/greenr-airflow/refs/heads/master/data/grass/00000000.jpg" + response = requests.post( + "http://localhost:8000/predict-url", + params={"image_url": test_url}, + timeout=30 + ) + return response.status_code == 200 + + # Lancer 5 prédictions en parallèle + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(make_prediction) for _ in range(5)] + results = [future.result() for future in concurrent.futures.as_completed(futures)] + + # Toutes les prédictions doivent réussir + assert all(results) + + except requests.exceptions.ConnectionError: + pytest.skip("API non disponible pour tests de concurrence") diff --git a/tests/test_training.py b/tests/test_training.py new file mode 100644 index 0000000..a1138f6 --- /dev/null +++ b/tests/test_training.py @@ -0,0 +1,60 @@ +import pytest +from unittest.mock import patch, Mock, MagicMock +import pandas as pd + +class TestTraining: + """Tests pour l'entraînement des modèles""" + + @patch('ml.training.trainer.MySqlHook') + @patch('ml.training.trainer.train_model_from_minio') + def test_train_from_database_minio_success(self, mock_train, mock_mysql_hook): + """Test d'entraînement réussi depuis la base""" + # Configuration du mock MySQL + mock_hook_instance = Mock() + mock_mysql_hook.return_value = mock_hook_instance + + # Mock des données de la base + mock_df = pd.DataFrame({ + 'url_s3': ['s3://raw-data/raw/dandelion/test.jpg', 's3://raw-data/raw/grass/test.jpg'], + 'label': ['dandelion', 'grass'] + }) + mock_hook_instance.get_pandas_df.return_value = mock_df + + # Mock de l'entraînement + mock_model = Mock() + mock_train.return_value = (mock_model, 0.85) + + try: + from ml.training.trainer import train_from_database_minio + + result = train_from_database_minio(num_epochs=1) + + assert result['accuracy'] == 0.85 + assert result['num_samples'] == 2 + assert result['data_source'] == 'MinIO via Database' + mock_train.assert_called_once() + + except ImportError: + pytest.skip("Modules d'entraînement non disponibles") + + @patch('ml.training.trainer.train_quick_model') + def test_train_from_urls(self, mock_train): + """Test d'entraînement depuis URLs""" + # Mock de l'entraînement + mock_model = Mock() + mock_train.return_value = (mock_model, 0.80) + + try: + from ml.training.trainer import train_from_urls + + urls = ['http://example.com/1.jpg', 'http://example.com/2.jpg'] + labels = ['dandelion', 'grass'] + + result = train_from_urls(urls, labels, num_epochs=1) + + assert result['accuracy'] == 0.80 + assert result['num_samples'] == 2 + mock_train.assert_called_once() + + except ImportError: + pytest.skip("Modules d'entraînement non disponibles") From c18805ca96207cf56936c2e48f7af65d5b7223ff Mon Sep 17 00:00:00 2001 From: TM-Squared Date: Fri, 4 Jul 2025 16:52:57 +0200 Subject: [PATCH 2/4] change version of upload artifact --- .github/workflows/docker-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-tests.yml b/.github/workflows/docker-tests.yml index c8d72db..b653643 100644 --- a/.github/workflows/docker-tests.yml +++ b/.github/workflows/docker-tests.yml @@ -27,7 +27,7 @@ jobs: ./scripts/run_tests_docker.sh - name: Upload test results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: test-results From 40f949b87eb8ec3c061e38fa3fb1f31b9a94d96a Mon Sep 17 00:00:00 2001 From: TM-Squared Date: Fri, 4 Jul 2025 16:54:37 +0200 Subject: [PATCH 3/4] rename .env-example to .env.example --- .env-example => .env.example | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .env-example => .env.example (100%) diff --git a/.env-example b/.env.example similarity index 100% rename from .env-example rename to .env.example From 34478e29942e9b7aab6a89c8b54fa52145375209 Mon Sep 17 00:00:00 2001 From: TM-Squared Date: Fri, 4 Jul 2025 16:56:28 +0200 Subject: [PATCH 4/4] reformat docker compose --- scripts/run_tests_docker.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/run_tests_docker.sh b/scripts/run_tests_docker.sh index cf7ee6b..19d4009 100644 --- a/scripts/run_tests_docker.sh +++ b/scripts/run_tests_docker.sh @@ -23,15 +23,15 @@ print_error() { # Nettoyer les anciens conteneurs print_status "Nettoyage des anciens conteneurs de test..." -docker-compose -f docker-compose.test.yml down -v +docker compose -f docker-compose.test.yml down -v # Construire les images print_status "Construction des images de test..." -docker-compose -f docker-compose.test.yml build +docker compose -f docker-compose.test.yml build # Démarrer les services print_status "Démarrage des services de test..." -docker-compose -f docker-compose.test.yml up -d postgres mysql minio mlflow api +docker compose -f docker-compose.test.yml up -d postgres mysql minio mlflow api # Attendre que les services soient prêts print_status "Attente de la disponibilité des services..." @@ -50,7 +50,7 @@ services=( for service in "${services[@]}"; do IFS=':' read -r host port <<< "$service" - if docker-compose -f docker-compose.test.yml exec -T test-runner nc -z $host $port; then + if docker compose -f docker-compose.test.yml exec -T test-runner nc -z $host $port; then print_success "Service $service disponible" else print_error "Service $service non disponible" @@ -59,18 +59,18 @@ done # Lancer les tests print_status "Lancement des tests..." -docker-compose -f docker-compose.test.yml run --rm test-runner +docker compose -f docker-compose.test.yml run --rm test-runner # Capturer le code de sortie test_exit_code=$? # Copier les résultats de test print_status "Copie des résultats de test..." -docker cp $(docker-compose -f docker-compose.test.yml ps -q test-runner):/app/test-results ./test-results 2>/dev/null || true +docker cp $(docker compose -f docker-compose.test.yml ps -q test-runner):/app/test-results ./test-results 2>/dev/null || true # Nettoyer print_status "Nettoyage..." -docker-compose -f docker-compose.test.yml down -v +docker compose -f docker-compose.test.yml down -v # Résultats if [ $test_exit_code -eq 0 ]; then