commit 5f7dd2e281c4f1eab060918e1c27749d93277cb3 Author: Janis Date: Sun May 10 22:00:24 2026 +0200 feat: Added k6 performance tests diff --git a/README.md b/README.md new file mode 100644 index 0000000..73da277 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# K6 Load Tests + +Load tests for NowChess using K6 framework. + +## Installation + +```bash +# Install K6 - https://k6.io/docs/getting-started/installation/ +# macOS +brew install k6 + +# Ubuntu/Debian +sudo apt-get install k6 + +# Windows (Chocolatey) +choco install k6 +``` + +## Running Tests + +### Smoke Test +One user, basic journey validation. +```bash +k6 run simulations/smokeTest.js +``` + +### Load Test +Ramp up to max users over duration. +```bash +BASE_URL=http://localhost:8080 MAX_USERS=5 RAMP_DURATION=60 k6 run simulations/loadTest.js +``` + +### Stress Test +Incrementally increase users in steps. +```bash +BASE_URL=http://localhost:8080 \ + START_USERS=2 \ + USERS_INCREMENT=2 \ + STEPS=2 \ + STEP_DURATION=30 \ + RAMP_DURATION=10 \ + k6 run simulations/stressTest.js +``` + +### Spike Test +Baseline → spike → baseline. +```bash +BASE_URL=http://localhost:8080 \ + BASELINE_USERS=2 \ + BASELINE_DURATION=20 \ + SPIKE_USERS=15 \ + k6 run simulations/spikeTest.js +``` + +### Endurance Test +Constant load over long duration. +```bash +BASE_URL=http://localhost:8080 \ + CONCURRENT_USERS=3 \ + DURATION=300 \ + k6 run simulations/enduranceTest.js +``` + +## Environment Variables + +- `BASE_URL` - API base URL (default: `http://localhost:8080`) +- `AUTH_TOKEN` - Optional Bearer token for global auth +- Test-specific variables documented in each simulation file + +## Output + +Results summary printed to console. For detailed metrics, use K6 output options: + +```bash +# JSON output +k6 run --out json=results.json simulations/smokeTest.js + +# Cloud output (requires K6 Cloud account) +k6 run --out cloud simulations/smokeTest.js + +# InfluxDB + Grafana +k6 run --out influxdb simulations/smokeTest.js +``` + +## Structure + +- `config.js` - Shared configuration, headers, checks +- `scenarios/` - Reusable scenario definitions +- `simulations/` - Test simulations with load profiles diff --git a/config.js b/config.js new file mode 100644 index 0000000..75cdb62 --- /dev/null +++ b/config.js @@ -0,0 +1,23 @@ +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +export const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; + +export const httpConfig = { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...(AUTH_TOKEN && { 'Authorization': `Bearer ${AUTH_TOKEN}` }), + }, + timeout: '30s', +}; + +export const checks = { + statusOk: (res) => res.status === 200, + statusOkOrCreated: (res) => res.status === 200 || res.status === 201, + statusOkCreatedOrNoContent: (res) => [200, 201, 204].includes(res.status), + responseTimeUnder5s: (res) => res.timings.duration < 5000, +}; + +export const thresholds = { + 'http_req_duration': ['p(95)<5000', 'p(99)<10000'], + 'http_req_failed': ['rate<0.1'], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d080adc --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "k6-tests", + "version": "1.0.0", + "description": "K6 load tests for NowChess", + "scripts": { + "test:smoke": "k6 run simulations/smokeTest.js", + "test:load": "k6 run simulations/loadTest.js", + "test:stress": "k6 run simulations/stressTest.js", + "test:spike": "k6 run simulations/spikeTest.js", + "test:endurance": "k6 run simulations/enduranceTest.js" + }, + "keywords": ["k6", "load-testing", "performance"] +} diff --git a/scenarios/chessUserScenario.js b/scenarios/chessUserScenario.js new file mode 100644 index 0000000..8dc4550 --- /dev/null +++ b/scenarios/chessUserScenario.js @@ -0,0 +1,67 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, httpConfig } from '../config.js'; + +export function chessUserJourney() { + const username = `user_${Date.now()}_${__VU}_${Math.random().toString(36).substring(2, 10)}`; + const email = `${username}@test.com`; + const password = 'Password123!'; + + // Register + const registerRes = http.post(`${BASE_URL}/api/account`, JSON.stringify({ + username, + email, + password, + }), httpConfig); + check(registerRes, { 'Register: status 200': (r) => r.status === 200 }); + + // Login + const loginRes = http.post(`${BASE_URL}/api/account/login`, JSON.stringify({ + username, + password, + }), httpConfig); + check(loginRes, { 'Login: status 200': (r) => r.status === 200 }); + + const jwt = loginRes.json('token'); + if (!jwt) { + console.error('Failed to get JWT token'); + return; + } + + const authHeaders = { + ...httpConfig.headers, + 'Authorization': `Bearer ${jwt}`, + }; + + // Import Game + const importRes = http.post(`${BASE_URL}/api/board/game/import/fen`, JSON.stringify({ + fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', + white: { id: username, displayName: username }, + black: { id: `opponent_${__VU}`, displayName: 'Opponent' }, + timeControl: { limitSeconds: 300, incrementSeconds: 3 }, + }), { headers: authHeaders, timeout: '30s' }); + check(importRes, { 'Import Game: status 200/201': (r) => r.status === 200 || r.status === 201 }); + + const gameId = importRes.json('gameId'); + if (!gameId) { + console.error('Failed to get gameId'); + return; + } + + // Make moves + const moves = ['e2e4', 'e7e5', 'g1f3']; + for (const uci of moves) { + const moveRes = http.post(`${BASE_URL}/api/board/game/${gameId}/move/${uci}`, null, { + headers: authHeaders, + timeout: '30s', + }); + check(moveRes, { [`Move ${uci}: status 200/201`]: (r) => r.status === 200 || r.status === 201 }); + } + + // Resign + const resignRes = http.post(`${BASE_URL}/api/board/game/${gameId}/resign`, null, { + headers: authHeaders, + timeout: '30s', + }); + check(resignRes, { 'Resign: status 200/201/204': (r) => [200, 201, 204].includes(r.status) }); +} diff --git a/simulations/enduranceTest.js b/simulations/enduranceTest.js new file mode 100644 index 0000000..e74c5a8 --- /dev/null +++ b/simulations/enduranceTest.js @@ -0,0 +1,15 @@ +import { chessUserJourney } from '../scenarios/chessUserScenario.js'; +import { thresholds } from '../config.js'; + +const CONCURRENT_USERS = parseInt(__ENV.CONCURRENT_USERS || '3', 10); +const DURATION = parseInt(__ENV.DURATION || '300', 10); + +export const options = { + vus: CONCURRENT_USERS, + duration: `${DURATION}s`, + thresholds, +}; + +export default function () { + chessUserJourney(); +} diff --git a/simulations/loadTest.js b/simulations/loadTest.js new file mode 100644 index 0000000..997778b --- /dev/null +++ b/simulations/loadTest.js @@ -0,0 +1,16 @@ +import { chessUserJourney } from '../scenarios/chessUserScenario.js'; +import { thresholds } from '../config.js'; + +const MAX_USERS = parseInt(__ENV.MAX_USERS || '5', 10); +const RAMP_DURATION = parseInt(__ENV.RAMP_DURATION || '60', 10); + +export const options = { + stages: [ + { duration: `${RAMP_DURATION}s`, target: MAX_USERS }, + ], + thresholds, +}; + +export default function () { + chessUserJourney(); +} diff --git a/simulations/smokeTest.js b/simulations/smokeTest.js new file mode 100644 index 0000000..80e2015 --- /dev/null +++ b/simulations/smokeTest.js @@ -0,0 +1,12 @@ +import { chessUserJourney } from '../scenarios/chessUserScenario.js'; +import { thresholds } from '../config.js'; + +export const options = { + vus: 1, + duration: '1m', + thresholds, +}; + +export default function () { + chessUserJourney(); +} diff --git a/simulations/spikeTest.js b/simulations/spikeTest.js new file mode 100644 index 0000000..d6b237b --- /dev/null +++ b/simulations/spikeTest.js @@ -0,0 +1,21 @@ +import { chessUserJourney } from '../scenarios/chessUserScenario.js'; +import { thresholds } from '../config.js'; + +const BASELINE_USERS = parseInt(__ENV.BASELINE_USERS || '2', 10); +const BASELINE_DURATION = parseInt(__ENV.BASELINE_DURATION || '20', 10); +const SPIKE_USERS = parseInt(__ENV.SPIKE_USERS || '15', 10); + +export const options = { + stages: [ + { duration: `${BASELINE_DURATION}s`, target: BASELINE_USERS }, + { duration: '1s', target: BASELINE_USERS + SPIKE_USERS }, + { duration: '5s', target: BASELINE_USERS + SPIKE_USERS }, + { duration: '1s', target: BASELINE_USERS }, + { duration: `${BASELINE_DURATION}s`, target: BASELINE_USERS }, + ], + thresholds, +}; + +export default function () { + chessUserJourney(); +} diff --git a/simulations/stressTest.js b/simulations/stressTest.js new file mode 100644 index 0000000..ae60940 --- /dev/null +++ b/simulations/stressTest.js @@ -0,0 +1,34 @@ +import { chessUserJourney } from '../scenarios/chessUserScenario.js'; +import { thresholds } from '../config.js'; + +const START_USERS = parseInt(__ENV.START_USERS || '2', 10); +const USERS_INCREMENT = parseInt(__ENV.USERS_INCREMENT || '2', 10); +const STEPS = parseInt(__ENV.STEPS || '2', 10); +const STEP_DURATION = parseInt(__ENV.STEP_DURATION || '30', 10); +const RAMP_DURATION = parseInt(__ENV.RAMP_DURATION || '10', 10); + +const stages = []; +let currentUsers = START_USERS; + +for (let i = 0; i < STEPS; i++) { + // Ramp up + stages.push({ + duration: `${RAMP_DURATION}s`, + target: currentUsers, + }); + // Hold at level + stages.push({ + duration: `${STEP_DURATION}s`, + target: currentUsers, + }); + currentUsers += USERS_INCREMENT; +} + +export const options = { + stages, + thresholds, +}; + +export default function () { + chessUserJourney(); +}