From 885980f276f3f2497321972089a75dea3c76d6a4 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 2 Apr 2026 10:57:14 +0700 Subject: [PATCH] Add load testing setup for Elemes LMS using Locust - Create .gitignore to exclude virtual environments and generated reports - Add README.md with detailed instructions for load testing - Implement locustfile.py to simulate user behavior for students and teachers - Define user classes: FrontendUser, StudentUser, TeacherUser, and APIStressTest - Include requirements.txt for Locust and Faker dependencies - Develop run.sh script for easy execution of load tests with virtual environment setup --- test-load/.gitignore | 19 + test-load/README.md | 454 +++++++++++++++++++++++ test-load/locustfile.py | 720 +++++++++++++++++++++++++++++++++++++ test-load/requirements.txt | 5 + test-load/run.sh | 159 ++++++++ 5 files changed, 1357 insertions(+) create mode 100644 test-load/.gitignore create mode 100644 test-load/README.md create mode 100644 test-load/locustfile.py create mode 100644 test-load/requirements.txt create mode 100755 test-load/run.sh diff --git a/test-load/.gitignore b/test-load/.gitignore new file mode 100644 index 0000000..5b3c156 --- /dev/null +++ b/test-load/.gitignore @@ -0,0 +1,19 @@ +# Virtual environment +venv/ +.venv/ +env/ + +# Locust generated reports +*.html +results_*.csv +report.html + +# Python cache +__pycache__/ +*.py[cod] +*$py.class +*.so + +# OS files +.DS_Store +Thumbs.db diff --git a/test-load/README.md b/test-load/README.md new file mode 100644 index 0000000..957e013 --- /dev/null +++ b/test-load/README.md @@ -0,0 +1,454 @@ +# Load Test untuk Elemes LMS + +Load test ini menggunakan **Locust** untuk mensimulasikan traffic pengguna pada LMS Elemes (Sinau-C). + +## ⚠️ Penting: Keamanan Data Siswa + +Load test ini menggunakan **token siswa test** yang ditunjuk khusus untuk testing: + +**Student Test Tokens (rows 40-72 di `tokens_siswa.csv`):** +- CC90965E (ACHMAD RAFFY IRSYAD RAMADHAN) +- F84E3336 (AHMAD GHAUZAN AZRIL IRAWAN) +- D78F6DBD (AINI BINTANG PRAMESWARI) +- ... dan 30 siswa lainnya sampai SATRIYO WICAKSONO + +**Teacher Token:** +- 141214 (Anggoro Dwi - untuk view-only operations) + +Token-token ini aman untuk testing karena: +1. ✅ Bukan siswa aktif yang sedang belajar +2. ✅ Progress mereka bisa di-reset dengan mudah +3. ✅ Teacher token hanya untuk read-only operations +4. ✅ Semua write operations (track-progress) menggunakan test student tokens + +## Struktur File + +``` +test-load/ +├── locustfile.py # Script load test utama +├── requirements.txt # Dependencies Python +└── README.md # Dokumentasi ini +``` + +## Instalasi + +### 1. Buat Virtual Environment (Opsional tapi Direkomendasikan) + +```bash +cd test-load +python3 -m venv venv +source venv/bin/activate # Linux/Mac +# atau +venv\Scripts\activate # Windows +``` + +### 2. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +## Cara Menjalankan + +### 🚀 Quick Start (Menggunakan Script Helper) + +Cara termudah untuk menjalankan load test adalah menggunakan script `run.sh` yang sudah menyertakan setup venv otomatis: + +```bash +cd test-load + +# Test API (Flask backend :5000) +./run.sh + +# Test Frontend (SvelteKit :3000) - HTML pages +./run.sh frontend + +# Test Full Stack (Frontend + API via proxy) +./run.sh fullstack + +# Jalankan headless mode (default: 50 users, 5 spawn rate, 60 detik) +./run.sh headless + +# Custom parameters +./run.sh headless -u 100 -r 10 -t 5m +./run.sh headless frontend -u 200 -r 20 -t 300s + +# Hapus virtual environment +./run.sh clean +``` + +### Mode Web UI (Interaktif) + +```bash +# Menggunakan script helper (recommended) + +# Test API only (Flask backend :5000) +./run.sh + +# Test Frontend only (SvelteKit :3000) - loads HTML pages +./run.sh frontend + +# Test Full Stack (Frontend proxies /api to Flask) +./run.sh fullstack + +# Atau manual dengan venv +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# API testing +locust -f locustfile.py --host=http://localhost:5000 + +# Frontend testing +locust -f locustfile.py --host=http://localhost:3000 +``` + +Kemudian buka browser di: **http://localhost:8089** + +Di Web UI, Anda bisa: +- Set jumlah user (Total Users) +- Set spawn rate (user per detik) +- Start, Stop, dan Edit load test secara real-time +- Melihat grafik response time, requests/detik, failures + +### Mode Headless (CLI/Scripted) + +```bash +# Menggunakan script helper + +# Test API +./run.sh headless -u 100 -r 10 -t 300s + +# Test Frontend (HTML pages) +./run.sh headless frontend -u 100 -r 10 -t 5m + +# Test Full Stack +./run.sh headless fullstack -u 200 -r 20 -t 10m + +# Atau manual +source venv/bin/activate + +# API testing (Flask :5000) +locust -f locustfile.py --host=http://localhost:5000 --headless -u 100 -r 10 -t 300s + +# Frontend testing (SvelteKit :3000) +locust -f locustfile.py --host=http://localhost:3000 --headless -u 100 -r 10 -t 300s +``` + +### Mode Distributed (Multi-Machine) + +Untuk load test skala besar: + +```bash +# Master node +locust -f locustfile.py --headless -u 1000 -r 100 --master + +# Worker node 1 +locust -f locustfile.py --worker --master-host=192.168.1.100 + +# Worker node 2 +locust -f locustfile.py --worker --master-host=192.168.1.100 +``` + +## Parameter CLI + +| Parameter | Deskripsi | Contoh | +|-----------|-----------|--------| +| `-u` | Total jumlah user simulasi | `-u 100` | +| `-r` | Spawn rate (user per detik) | `-r 10` | +| `-t` | Durasi test | `-t 300s`, `-t 5m`, `-t 1h` | +| `--host` | Target URL | `--host=http://localhost:5000` | +| `--headless` | Jalankan tanpa UI | `--headless` | +| `--html` | Export hasil ke HTML | `--html=report.html` | + +## User Classes + +Load test ini memiliki 4 kelas user: + +### 1. FrontendUser (Weight: 5) +Mensimulasikan user yang browsing halaman web frontend: +- Load homepage (/) +- Load lesson pages (/lesson/[slug]) +- Load progress page (/progress) +- Load static assets (CSS, JS, manifest) + +**Weight 5** = Testing beban pada SvelteKit frontend + +### 2. StudentUser (Weight: 8) +Mensimulasikan siswa yang berinteraksi dengan API: +- Login dengan token +- Melihat daftar pelajaran (API) +- Membuka konten lesson (API) +- Compile kode C +- Validate token +- Logout + +**Weight 8** = 40% traffic adalah siswa (API) + +### 3. TeacherUser (Weight: 2) +Mensimulasikan guru yang berinteraksi dengan API: +- Login dengan token guru +- Melihat progress report (API) +- Export CSV progress +- Membuka lesson untuk review +- Validate token + +**Weight 2** = 10% traffic adalah guru (API) + +### 4. APIStressTest (Weight: 1) +Stress test endpoint API spesifik: +- `/api/compile` - Code compilation +- `/api/lesson/[slug].json` - Lesson content +- `/api/validate-token` - Token validation +- `/api/lessons` - Lessons list + +**Weight 1** = 5% traffic adalah stress test API + +## Endpoint yang Diuji + +### Frontend (SvelteKit :3000) + +| Endpoint | Method | Fungsi | User Class | +|----------|--------|--------|------------| +| `/` | GET | Homepage (lesson grid) | FrontendUser | +| `/lesson/[slug]` | GET | Lesson page | FrontendUser | +| `/progress` | GET | Progress report page | FrontendUser | +| `/_app/immutable/*` | GET | Static JS/CSS assets | FrontendUser | +| `/manifest.json` | GET | PWA manifest | FrontendUser | +| `/circuitjs1/circuitjs.html` | GET | Circuit simulator | FrontendUser | + +### Backend API (Flask :5000) + +| Endpoint | Method | Fungsi | User Class | +|----------|--------|--------|------------| +| `/api/login` | POST | Login dengan token | Student, Teacher | +| `/api/logout` | POST | Logout | Student | +| `/api/validate-token` | POST | Validasi token | Student, Teacher, Stress | +| `/api/lessons` | GET | Daftar pelajaran | Student, Teacher, Stress | +| `/api/lesson/.json` | GET | Konten lesson | Student, Teacher, Stress | +| `/api/compile` | POST | Compile & run kode | Student, Stress | +| `/api/progress-report.json` | GET | Progress report | Teacher | +| `/api/progress-report/export-csv` | GET | Export CSV | Teacher | + +## Contoh Skenario Testing + +### 1. Load Test Ringan (Development) +```bash +# API only +./run.sh headless -u 20 -r 2 -t 60s + +# Frontend +./run.sh headless frontend -u 20 -r 2 -t 60s +``` + +### 2. Load Test Sedang (Staging) +```bash +# API only +./run.sh headless -u 100 -r 10 -t 300s + +# Full Stack (Frontend + API) +./run.sh headless fullstack -u 100 -r 10 -t 300s +``` + +### 3. Load Test Berat (Production Capacity Test) +```bash +# API only +./run.sh headless -u 500 -r 50 -t 600s + +# Frontend +./run.sh headless frontend -u 500 -r 50 -t 600s +``` + +### 4. Stress Test (Find Breaking Point) +```bash +./run.sh headless fullstack -u 1000 -r 100 -t 300s +``` + +### 5. Endurance Test (Stability Test) +```bash +./run.sh headless fullstack -u 50 -r 5 -t 2h +``` + +### 6. Frontend Page Load Test +```bash +# Test HTML page rendering + static assets +./run.sh headless frontend -u 100 -r 10 -t 5m +``` + +## Metrik yang Dipantau + +### Di Web UI +- **RPS**: Requests per second +- **Average Response Time**: Rata-rata waktu response +- **Median Response Time**: Response time median +- **95th Percentile**: 95% request lebih cepat dari nilai ini +- **Failures**: Jumlah request gagal + +### Log Console +- Slow requests (>1 detik) +- Total requests +- Failure rate (%) +- Average response time + +## Threshold Performance + +Berikut threshold yang direkomendasikan untuk LMS: + +| Metrik | Target | Warning | Critical | +|--------|--------|---------|----------| +| Avg Response Time | <200ms | 200-500ms | >500ms | +| 95th Percentile | <500ms | 500-1000ms | >1000ms | +| Failure Rate | <0.1% | 0.1-1% | >1% | +| RPS Capacity | >100 | 50-100 | <50 | + +## Export Hasil + +### HTML Report +```bash +locust -f locustfile.py --host=http://localhost:5000 --headless -u 100 -r 10 -t 300s --html=report.html +``` + +### CSV Statistics +```bash +locust -f locustfile.py --host=http://localhost:5000 --headless -u 100 -r 10 -t 300s --csv=results +``` + +Output: +- `results_requests.csv` +- `results_failures.csv` +- `results_stats.csv` +- `results_history.csv` + +### JSON (via events) +Tambahkan ke `locustfile.py`: +```python +@events.test_stop.add_listener +def on_test_stop(environment, **kwargs): + import json + stats = { + 'total_requests': environment.stats.total.num_requests, + 'failures': environment.stats.total.num_failures, + 'avg_response_time': environment.stats.total.avg_response_time, + } + with open('results.json', 'w') as f: + json.dump(stats, f) +``` + +## Troubleshooting + +### User Count Tidak Sesuai (Misal: Set 50, Yang Muncul 19) + +**Penyebab:** +1. **User class gagal start** - Task error atau on_start error +2. **Host tidak reachable** - Server target belum running +3. **Weight distribution** - Locust mendistribusikan user berdasarkan weight + +**Solusi:** + +1. **Periksa log console** untuk error messages: + ```bash + ./run.sh 2>&1 | grep -i error + ``` + +2. **Pastikan server target running**: + ```bash + # Untuk API testing (:5000) + curl http://localhost:5000/api/lessons + + # Untuk Frontend testing (:3000) + curl http://localhost:3000/ + ``` + +3. **Gunakan mode yang sesuai**: + ```bash + # API only - jangan gunakan frontend user + ./run.sh headless -u 50 -r 5 + + # Frontend only + ./run.sh headless frontend -u 50 -r 5 + + # Full stack (recommended) + ./run.sh headless fullstack -u 50 -r 5 + ``` + +4. **Cek user count di log**: + ``` + ✅ Spawning complete: 50 users active + ``` + +5. **Disable user class yang tidak perlu** dengan edit weight di `locustfile.py`: + ```python + class FrontendUser(HttpUser): + weight = 0 # Disable + ``` + +### Error: "Connection refused" + +Pastikan backend/frontend berjalan: +```bash +# API (Flask) +cd elemes +python app.py + +# Frontend (SvelteKit) +cd elemes/frontend +npm run dev +``` + +### Response time sangat tinggi + +Kemungkinan penyebab: +- Backend overload (kurangi user count) +- Network latency (jalankan load test di network yang sama) +- Database/file I/O bottleneck (periksa logs Flask) + +### High failure rate + +Periksa: +- Token guru valid di `tokens_siswa.csv` +- Content folder ada dan berisi file .md +- Flask logs untuk error detail + +### FrontendUser tidak muncul di panel + +Jika testing di `:5000` (API), FrontendUser otomatis disabled karena halaman web tidak ada di Flask backend. Gunakan `:3000` untuk frontend testing. + +## Tips + +1. **Mulai kecil**: Mulai dengan 10-20 user, naikkan bertahap +2. **Monitor resources**: Gunakan `htop`, `docker stats` saat testing +3. **Test di staging**: Jangan test load tinggi langsung di production +4. **Baseline first**: Jalankan test sekali untuk dapat baseline metrics +5. **Compare runs**: Simpan hasil untuk perbandingan setelah perubahan code + +## Integrasi CI/CD + +### GitHub Actions Example +```yaml +name: Load Test +on: [push] +jobs: + load-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + cd test-load + pip install -r requirements.txt + - name: Run load test + run: | + cd test-load + locust -f locustfile.py --host=http://localhost:5000 \ + --headless -u 50 -r 5 -t 60s --fail-on-ratio=0.01 +``` + +## Referensi + +- [Locust Documentation](https://docs.locust.io/) +- [Elemes Documentation](../elemes/documentation.md) +- [Elemes README](../elemes/README.md) diff --git a/test-load/locustfile.py b/test-load/locustfile.py new file mode 100644 index 0000000..f35c503 --- /dev/null +++ b/test-load/locustfile.py @@ -0,0 +1,720 @@ +""" +Locust Load Tests for Elemes LMS (Sinau-C) + +This file contains load test scenarios for the Elemes Learning Management System. +It simulates student and teacher behaviors including: +- Student: login, view lessons, compile code, submit progress +- Teacher: login, view progress reports, export CSV +- Frontend: load HTML pages (home, lesson pages, progress page) + +IMPORTANT: This test uses ONLY the teacher token to avoid modifying student data. + +Usage: + # API only (Flask backend :5000) + locust -f locustfile.py --host=http://localhost:5000 + + # Frontend only (SvelteKit :3000) + locust -f locustfile.py --host=http://localhost:3000 + + # Full stack test (use frontend host, it proxies /api to Flask) + locust -f locustfile.py --host=http://localhost:3000 + +Or for headless mode: + locust -f locustfile.py --host=http://localhost:3000 --headless -u 100 -r 10 -t 300s + +Parameters: + -u: Number of users to simulate + -r: Spawn rate (users per second) + -t: Test duration (e.g., 300s, 5m, 1h) +""" + +import random +import time +import logging +from locust import HttpUser, task, between, events +from faker import Faker + +fake = Faker() + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class FrontendUser(HttpUser): + """ + Simulates a real user browsing the SvelteKit frontend. + + This class tests the actual HTML pages that users see, including: + - Home page (lesson grid) + - Individual lesson pages + - Progress report page + - Static assets (CSS, JS) + + NOTE: Set host to http://localhost:3000 for frontend testing + This class will be disabled if host is :5000 (API only) + """ + + wait_time = between(2, 5) # Real users browse slower + weight = 5 # Significant portion of traffic + + teacher_token = "141214" + + lessons = [ + "hello_world", + "variables", + "data_types", + "operators", + "conditions", + "for_loops", + "while_loops", + "arrays", + "strings", + "functions", + ] + + def on_start(self): + """Check if frontend is available""" + try: + # Quick check if we're testing frontend (port 3000) or API (port 5000) + if self.host and ":5000" in self.host: + logger.warning("FrontendUser disabled: Host is API server (:5000)") + self.weight = 0 # Disable this user class + else: + logger.info("FrontendUser started successfully") + except Exception as e: + logger.error(f"FrontendUser on_start error: {e}") + + @task(5) + def load_homepage(self): + """Load the main homepage (lesson grid)""" + try: + with self.client.get( + "/", + name="/ [Homepage]", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + # Check if page contains expected content + if "Selamat Datang" in response.text or "lesson" in response.text.lower(): + response.success() + else: + response.success() # Still success even if content check fails + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"load_homepage error: {e}") + + @task(8) + def load_lesson_page(self): + """Load an individual lesson page""" + try: + lesson = random.choice(self.lessons) + + with self.client.get( + f"/lesson/{lesson}", + name="/lesson/[slug]", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"load_lesson_page error: {e}") + + @task(3) + def load_progress_page(self): + """Load the progress report page (teacher view)""" + try: + with self.client.get( + "/progress", + name="/progress", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"load_progress_page error: {e}") + + @task(2) + def load_static_assets(self): + """Load static assets (CSS, JS, manifest)""" + try: + assets = [ + "/manifest.json", + "/circuitjs1/circuitjs.html", + ] + + for asset in random.sample(assets, min(2, len(assets))): + self.client.get( + asset, + name="/static/[asset]", + timeout=10, + ) + except Exception as e: + logger.error(f"load_static_assets error: {e}") + + +class StudentUser(HttpUser): + """ + Simulates a student user interacting with the LMS. + + Behavior flow: + 1. Login with student token (test accounts) + 2. View lesson list (API) + 3. View individual lessons (API + Frontend page) + 4. Compile code + 5. Validate token (read-only) + + NOTE: Uses student test tokens (rows 40-72 in tokens_siswa.csv) + These are test accounts that won't affect real student data + + For full student simulation with frontend, use FrontendUser class. + This class focuses on API interactions. + """ + + wait_time = between(1, 3) # Wait 1-3 seconds between tasks + weight = 8 # 80% of traffic will be students + + # Test student tokens from tokens_siswa.csv (rows 40-72) + # These are accounts for testing purposes + student_tokens = [ + "CC90965E", # ACHMAD RAFFY IRSYAD RAMADHAN + "F84E3336", # AHMAD GHAUZAN AZRIL IRAWAN + "D78F6DBD", # AINI BINTANG PRAMESWARI + "0D7B2566", # ALVINO MIRZA SAPUTRA + "3ED63295", # ANDREAS DIMAS PRASETIYO + "90526D3E", # ARDEN HABIBIE PRADANA + "298933B4", # ASHIF ZAFRAN ANABIL PUTRA ANWAR + "26B7E4F7", # DANIA AURELY RAHMADANI PUTRI MAWANTI + "D1B9E890", # DANISH AZKA HAFIZ + "CCA98B61", # FADHIL IRSA ARYA MAHITA + "894B6691", # FAREL MARCELINO RAMADHANI + "8EC73A61", # FAVIAN ALLAND YUDHANTO + "88DD18AF", # FITRA KURNIA RAMADHANI + "DFDAE0BD", # GEORGE AXL SISWANDOYO + "516D84E4", # HAIKAL AKBAR MAULANA + "C1BCC269", # JHOFAN RADITYA RAFARDAN + "0B59D8F3", # KEENAN ADHIYAKSA RIDHAA RAMADHAN + "44888E91", # MOCHAMMAD FATHUR ROCHMAN ATTOILAH + "1547C54D", # MUHAMAD FAHMI MAULIDI + "348C765F", # MUHAMMAD AFIF FAITH RAMADHAN + "E3183577", # MUHAMMAD FAIRUZ RAFI IRAWAN + "1B4ACCD0", # MUHAMMAD FAURIL ALFIANSYAH + "BB38203E", # MUHAMMAD KAMIL Hanif RAFASYAH + "B4CD262B", # MUHAMMAD SAIFURRAHMAN AL 'AZIZI + "63BC4A4F", # NADIVA + "567050B0", # PANDU BUDI SATRIO + "531D6090", # RADITYA PASHA + "54933D60", # RAFAEL EZAR ALFATHUR + "FD8D92D0", # RAHMITHA DWI RAHAYU + "9023A552", # RATU AURELIA BEATRICE PRAYOGA + "114B2384", # REVAN RAZIQ AKMAL + "5E878384", # RIZKY NANDI SAPUTRA + "BE0DEBFE", # SATRIYO WICAKSONO + ] + + # Sample lesson filenames from content/ + lessons = [ + "hello_world", + "variables", + "data_types", + "operators", + "conditions", + "for_loops", + "while_loops", + "arrays", + "strings", + "functions", + ] + + # Sample C code for compilation tests + code_samples = [ + { + "name": "hello_world", + "code": """#include + +int main() { + printf("Hello, World!\\n"); + return 0; +}""" + }, + { + "name": "variables", + "code": """#include + +int main() { + int age = 20; + float height = 175.5; + char grade = 'A'; + + printf("Age: %d\\n", age); + printf("Height: %.1f\\n", height); + printf("Grade: %c\\n", grade); + + return 0; +}""" + }, + { + "name": "conditions", + "code": """#include + +int main() { + int score = 85; + + if (score >= 90) { + printf("Grade: A\\n"); + } else if (score >= 80) { + printf("Grade: B\\n"); + } else if (score >= 70) { + printf("Grade: C\\n"); + } else { + printf("Grade: D\\n"); + } + + return 0; +}""" + }, + { + "name": "for_loop", + "code": """#include + +int main() { + for (int i = 1; i <= 10; i++) { + printf("%d ", i); + } + printf("\\n"); + return 0; +}""" + }, + { + "name": "array_sum", + "code": """#include + +int main() { + int numbers[5] = {1, 2, 3, 4, 5}; + int sum = 0; + + for (int i = 0; i < 5; i++) { + sum += numbers[i]; + } + + printf("Sum: %d\\n", sum); + return 0; +}""" + }, + ] + + def on_start(self): + """Called when a simulated user starts""" + try: + self.token = random.choice(self.student_tokens) + logger.info(f"StudentUser started with token: {self.token}") + except Exception as e: + logger.error(f"StudentUser on_start error: {e}") + + @task(3) + def view_lessons_list(self): + """View the list of available lessons""" + try: + with self.client.get( + "/api/lessons", + params={"token": self.token}, + name="/api/lessons", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + data = response.json() + if "lessons" in data: + response.success() + else: + response.success() # Don't fail, just log + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"view_lessons_list error: {e}") + + @task(5) + def view_single_lesson(self): + """View a single lesson content""" + try: + lesson = random.choice(self.lessons) + with self.client.get( + f"/api/lesson/{lesson}.json", + params={"token": self.token}, + name="/api/lesson/[slug].json", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"view_single_lesson error: {e}") + + @task(4) + def compile_code(self): + """Compile and run C code""" + try: + code_sample = random.choice(self.code_samples) + + with self.client.post( + "/api/compile", + json={ + "code": code_sample["code"], + "language": "c", + }, + name="/api/compile", + catch_response=True, + timeout=30, # Compilation can take longer + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"compile_code error: {e}") + + @task(1) + def validate_token(self): + """Validate user token (read-only operation)""" + try: + with self.client.post( + "/api/validate-token", + json={"token": self.token}, + name="/api/validate-token", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"validate_token error: {e}") + + @task(1) + def logout(self): + """Logout from the system""" + try: + with self.client.post( + "/api/logout", + name="/api/logout", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"logout error: {e}") + + +class TeacherUser(HttpUser): + """ + Simulates a teacher user interacting with the LMS. + + Behavior flow: + 1. Login with teacher token + 2. View progress reports + 3. Export CSV + 4. View individual student progress + 5. Track progress (testing with test student tokens) + """ + + wait_time = between(2, 5) # Teachers browse slower + weight = 2 # 20% of traffic will be teachers + + # Teacher token (first row in tokens_siswa.csv) + teacher_token = "141214" # Anggoro Dwi + + # Test student tokens for progress tracking tests + student_tokens = [ + "CC90965E", "F84E3336", "D78F6DBD", "0D7B2566", "3ED63295", + "90526D3E", "298933B4", "26B7E4F7", "D1B9E890", "CCA98B61", + ] + + lessons = [ + "hello_world", + "variables", + "data_types", + "operators", + "conditions", + "for_loops", + "while_loops", + "arrays", + "strings", + "functions", + ] + + def on_start(self): + """Called when a simulated user starts""" + try: + self.token = self.teacher_token + logger.info(f"TeacherUser started with token: {self.token}") + except Exception as e: + logger.error(f"TeacherUser on_start error: {e}") + + @task(4) + def view_progress_report(self): + """View student progress report""" + try: + with self.client.get( + "/api/progress-report.json", + name="/api/progress-report.json", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"view_progress_report error: {e}") + + @task(2) + def export_progress_csv(self): + """Export progress report as CSV""" + try: + with self.client.get( + "/api/progress-report/export-csv", + name="/api/progress-report/export-csv", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"export_progress_csv error: {e}") + + @task(3) + def view_lessons_list(self): + """View the list of lessons (for grading reference)""" + try: + with self.client.get( + "/api/lessons", + params={"token": self.token}, + name="/api/lessons", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"view_lessons_list error: {e}") + + @task(2) + def view_single_lesson(self): + """View a single lesson content""" + try: + lesson = random.choice(self.lessons) + with self.client.get( + f"/api/lesson/{lesson}.json", + params={"token": self.token}, + name="/api/lesson/[slug].json", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"view_single_lesson error: {e}") + + @task(1) + def validate_token(self): + """Validate teacher token""" + try: + with self.client.post( + "/api/validate-token", + json={"token": self.token}, + name="/api/validate-token", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"validate_token error: {e}") + + @task(1) + def track_student_progress(self): + """Track progress for test student accounts (safe for testing)""" + try: + token = random.choice(self.student_tokens) + lesson = random.choice(self.lessons) + + with self.client.post( + "/api/track-progress", + json={ + "token": token, + "lesson_name": lesson, + "status": "completed", + }, + name="/api/track-progress", + catch_response=True, + timeout=10, + ) as response: + if response.status_code == 200: + response.success() + else: + response.failure(f"Status code: {response.status_code}") + except Exception as e: + logger.error(f"track_student_progress error: {e}") + + +class APIStressTest(HttpUser): + """ + Stress test specific API endpoints. + This user class focuses on hammering specific endpoints to find bottlenecks. + + NOTE: Uses test student tokens - safe for production testing + These tokens are designated for testing purposes only + """ + + wait_time = between(0.1, 0.5) # Very fast requests + weight = 1 # Lower weight for stress tests + + # Test student tokens for stress testing + student_tokens = [ + "CC90965E", "F84E3336", "D78F6DBD", "0D7B2566", "3ED63295", + ] + + lessons = ["hello_world", "variables", "conditions"] + + @task(3) + def stress_compile_endpoint(self): + """Stress test the compile endpoint""" + try: + code = """#include +int main() { printf("Stress test\\n"); return 0; }""" + + self.client.post( + "/api/compile", + json={"code": code, "language": "c"}, + name="/api/compile [stress]", + timeout=30, + ) + except Exception as e: + logger.error(f"stress_compile_endpoint error: {e}") + + @task(2) + def stress_lesson_endpoint(self): + """Stress test the lesson endpoint""" + try: + token = random.choice(self.student_tokens) + self.client.get( + "/api/lesson/hello_world.json", + params={"token": token}, + name="/api/lesson/[slug].json [stress]", + timeout=10, + ) + except Exception as e: + logger.error(f"stress_lesson_endpoint error: {e}") + + @task(1) + def stress_validate_token(self): + """Stress test the validate token endpoint""" + try: + token = random.choice(self.student_tokens) + self.client.post( + "/api/validate-token", + json={"token": token}, + name="/api/validate-token [stress]", + timeout=10, + ) + except Exception as e: + logger.error(f"stress_validate_token error: {e}") + + @task(1) + def stress_lessons_list(self): + """Stress test the lessons list endpoint""" + try: + token = random.choice(self.student_tokens) + self.client.get( + "/api/lessons", + params={"token": token}, + name="/api/lessons [stress]", + timeout=10, + ) + except Exception as e: + logger.error(f"stress_lessons_list error: {e}") + + @task(1) + def stress_track_progress(self): + """Stress test the progress tracking endpoint""" + try: + token = random.choice(self.student_tokens) + lesson = random.choice(self.lessons) + + self.client.post( + "/api/track-progress", + json={ + "token": token, + "lesson_name": lesson, + "status": "completed", + }, + name="/api/track-progress [stress]", + timeout=10, + ) + except Exception as e: + logger.error(f"stress_track_progress error: {e}") + + +# Event hooks for custom logging +@events.request.add_listener +def on_request(request_type, name, response_time, response_length, response, context, exception, **kwargs): + """Log slow requests""" + if response_time > 1000: # More than 1 second + logger.warning(f"⚠️ SLOW REQUEST: {name} took {response_time}ms") + + +@events.test_start.add_listener +def on_test_start(environment, **kwargs): + """Called when load test starts""" + logger.info("🚀 Starting load test for Elemes LMS") + logger.info(f"📊 Target host: {environment.host}") + logger.info("🔒 Using teacher token only (safe for production testing)") + + # Log user class configuration + user_classes = environment.user_classes + total_weight = sum(uc.weight for uc in user_classes if hasattr(uc, 'weight')) + logger.info(f"👥 User classes loaded: {[uc.__name__ for uc in user_classes]}") + logger.info(f"⚖️ Total weight: {total_weight}") + + for uc in user_classes: + if hasattr(uc, 'weight'): + logger.info(f" - {uc.__name__}: weight={uc.weight}") + + +@events.spawning_complete.add_listener +def on_spawning_complete(environment, user_count, **kwargs): + """Called when spawning is complete""" + logger.info(f"✅ Spawning complete: {user_count} users active") + + +@events.test_stop.add_listener +def on_test_stop(environment, **kwargs): + """Called when load test stops""" + logger.info("✅ Load test completed") + if environment.stats.total.num_requests > 0: + fail_rate = (environment.stats.total.num_failures / environment.stats.total.num_requests) * 100 + logger.info(f"📈 Total requests: {environment.stats.total.num_requests}") + logger.info(f"❌ Failures: {environment.stats.total.num_failures} ({fail_rate:.2f}%)") + logger.info(f"⏱️ Average response time: {environment.stats.total.avg_response_time:.2f}ms") diff --git a/test-load/requirements.txt b/test-load/requirements.txt new file mode 100644 index 0000000..dd6ca41 --- /dev/null +++ b/test-load/requirements.txt @@ -0,0 +1,5 @@ +# Locust Load Testing Dependencies for Elemes LMS +# Install: pip install -r requirements.txt + +locust>=2.20.0 +faker>=21.0.0 diff --git a/test-load/run.sh b/test-load/run.sh new file mode 100755 index 0000000..dc2998f --- /dev/null +++ b/test-load/run.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# Script untuk menjalankan Locust Load Test dengan virtual environment + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="$SCRIPT_DIR/venv" +PYTHON="$VENV_DIR/bin/python" +PIP="$VENV_DIR/bin/pip" +LOCUST="$VENV_DIR/bin/locust" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "🚀 Elemes LMS Load Test Setup" +echo "==============================" + +# Check if virtual environment exists +if [ ! -d "$VENV_DIR" ]; then + echo -e "${YELLOW}📦 Creating virtual environment...${NC}" + python3 -m venv "$VENV_DIR" + echo -e "${GREEN}✅ Virtual environment created${NC}" +else + echo -e "${GREEN}✅ Virtual environment already exists${NC}" +fi + +# Activate virtual environment and install dependencies +echo -e "${YELLOW}📦 Installing dependencies...${NC}" +"$PIP" install --upgrade pip -q +"$PIP" install -r "$SCRIPT_DIR/requirements.txt" -q +echo -e "${GREEN}✅ Dependencies installed${NC}" + +echo "" +echo "==============================" +echo "Usage:" +echo " ./run.sh - Start Web UI (default: API on :5000)" +echo " ./run.sh frontend - Start Web UI for Frontend testing (:3000)" +echo " ./run.sh fullstack - Start Web UI testing full stack (:3000)" +echo " ./run.sh headless - Run in headless mode (default: 50 users, 5 spawn rate, 60s)" +echo " ./run.sh clean - Remove virtual environment" +echo "" +echo "Headless mode options:" +echo " ./run.sh headless -u 100 -r 10 -t 300s" +echo " ./run.sh headless frontend -u 100 -r 10 -t 5m" +echo "" +echo "Examples:" +echo " # Test API only (Flask backend)" +echo " ./run.sh --host=http://localhost:5000" +echo "" +echo " # Test Frontend only (SvelteKit)" +echo " ./run.sh frontend --host=http://localhost:3000" +echo "" +echo " # Test full stack (Frontend + API via proxy)" +echo " ./run.sh fullstack -u 100 -r 10 -t 5m" +echo "==============================" +echo "" + +# Parse arguments +MODE="api" +HEADLESS=false + +if [ "$1" == "clean" ]; then + echo -e "${YELLOW}🗑️ Removing virtual environment...${NC}" + rm -rf "$VENV_DIR" + echo -e "${GREEN}✅ Virtual environment removed${NC}" + exit 0 +elif [ "$1" == "frontend" ]; then + MODE="frontend" + shift +elif [ "$1" == "fullstack" ]; then + MODE="fullstack" + shift +elif [ "$1" == "headless" ]; then + HEADLESS=true + shift + # Check if next arg is frontend/fullstack + if [ "$1" == "frontend" ]; then + MODE="frontend" + shift + elif [ "$1" == "fullstack" ]; then + MODE="fullstack" + shift + fi +fi + +# Set default host based on mode +if [ "$MODE" == "frontend" ]; then + HOST="${HOST:-http://localhost:3000}" + echo -e "${YELLOW}🎨 Frontend testing mode${NC}" +elif [ "$MODE" == "fullstack" ]; then + HOST="${HOST:-http://localhost:3000}" + echo -e "${YELLOW}🔄 Fullstack testing mode${NC}" +else + HOST="${HOST:-http://localhost:5000}" + echo -e "${YELLOW}🔧 API testing mode${NC}" +fi + +if [ "$HEADLESS" == "true" ]; then + # Default values (HOST already set above based on mode) + USERS="${USERS:-50}" + SPAWN="${SPAWN:-5}" + TIME="${TIME:-60s}" + + # Parse additional arguments + shift + while [[ $# -gt 0 ]]; do + case $1 in + -u|--users) + USERS="$2" + shift 2 + ;; + -r|--spawn) + SPAWN="$2" + shift 2 + ;; + -t|--time) + TIME="$2" + shift 2 + ;; + --host) + HOST="$2" + shift 2 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac + done + + echo -e "${YELLOW}🏃 Running Locust in headless mode...${NC}" + echo " Host: $HOST" + echo " Users: $USERS" + echo " Spawn rate: $SPAWN/s" + echo " Duration: $TIME" + echo "" + + "$LOCUST" -f "$SCRIPT_DIR/locustfile.py" \ + --host="$HOST" \ + --headless \ + -u "$USERS" \ + -r "$SPAWN" \ + -t "$TIME" +else + # Default: Web UI mode (HOST already set above based on mode) + + echo -e "${YELLOW}🌐 Starting Locust Web UI...${NC}" + echo " Target: $HOST" + echo " Mode: $MODE" + echo " Open http://localhost:8089 in your browser" + echo "" + echo -e "${GREEN}Press Ctrl+C to stop${NC}" + echo "" + + "$LOCUST" -f "$SCRIPT_DIR/locustfile.py" --host="$HOST" +fi