From 25ecc1e097653f15ef528a84bd42ad09f79f854a Mon Sep 17 00:00:00 2001 From: Leon Hermann Date: Tue, 5 May 2026 19:51:12 +0200 Subject: [PATCH] feat: NCS-73 Refine Gatlin tests to reflect ordinary user behaviour (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added realistic user behaviour Smoke Test 1 user, runs once. Executes the full user journey exactly once. The goal is a fast sanity check — if this passes, the system is up and the critical path (register, login, import, move, resign) works end-to-end. Run this before any heavier test. --- Load Test Ramps up to maxUsers (default 10) over rampDuration (default 60s). Simulates normal growing traffic. Users are added gradually, each completing the full journey. This reveals how the system behaves under typical production load and establishes a performance baseline (response times, error rates) to compare other tests against. --- Stress Test Starts at startUsers (default 2), adds usersIncrement (default 2) users every stepDuration (default 30s) for steps (default 2) steps, with ramps between. Deliberately pushes the system beyond normal capacity in controlled steps. Each step holds load steady before increasing, so you can pinpoint exactly at which user count the system starts degrading or failing — the breaking point. --- Endurance Test Holds concurrentUsers (default 3) constant for duration (default 300s). Keeps a steady number of users playing full games repeatedly over a long period. This is the most important test for detecting slow degradation — memory leaks, connection pool exhaustion, database lock buildup — things that only appear after sustained use rather than under peak load. --- Spike Test Baseline → sudden burst of spikeUsers (default 15) → 5s pause → back to baseline. Simulates an unexpected traffic surge (e.g. a news article or viral moment). Tests whether the system can absorb a sudden large wave of users registering and starting games simultaneously, and whether it recovers cleanly once the spike subsides. --------- Co-authored-by: LQ63 Reviewed-on: https://git.janis-eccarius.de/NowChess/Gatlin/pulls/7 Reviewed-by: Janis Co-authored-by: Leon Hermann Co-committed-by: Leon Hermann --- .../scala/scenarios/ChessUserScenario.scala | 61 +++++++++++++++++++ .../simulations/EnduranceTestSimulation.scala | 18 +++--- .../simulations/LoadTestSimulation.scala | 19 +++--- .../simulations/SmokeTestSimulation.scala | 10 ++- .../simulations/SpikeTestSimulation.scala | 24 +++++--- .../simulations/StressTestSimulation.scala | 26 ++++---- 6 files changed, 121 insertions(+), 37 deletions(-) create mode 100644 src/gatling/scala/scenarios/ChessUserScenario.scala diff --git a/src/gatling/scala/scenarios/ChessUserScenario.scala b/src/gatling/scala/scenarios/ChessUserScenario.scala new file mode 100644 index 0000000..c39fb24 --- /dev/null +++ b/src/gatling/scala/scenarios/ChessUserScenario.scala @@ -0,0 +1,61 @@ +package scenarios + +import io.gatling.core.Predef._ +import io.gatling.core.structure.ScenarioBuilder +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +object ChessUserScenario { + + private def makeMove(uci: String) = + http(s"Move $uci") + .post(session => s"/api/board/game/${session("gameId").as[String]}/move/$uci") + .header("Authorization", "${jwt}") + .check(status.in(200, 201)) + + val play: ScenarioBuilder = scenario("Chess User Journey") + .exec(session => session.set("username", s"user_${System.currentTimeMillis()}_${session.userId}_${java.util.UUID.randomUUID().toString.take(8)}")) + .exec( + http("Register") + .post("/api/account") + .body(StringBody(session => + s"""{"username":"${session("username").as[String]}","email":"${session("username").as[String]}@test.com","password":"Password123!"}""" + )) + .check(status.is(200)) + ) + .exec( + http("Login") + .post("/api/account/login") + .body(StringBody(session => + s"""{"username":"${session("username").as[String]}","password":"Password123!"}""" + )) + .check(status.is(200)) + .check(jsonPath("$.token").saveAs("jwt")) + ) + .exec( + http("Import Game") + .post("/api/board/game/import/fen") + .header("Authorization", "${jwt}") + .body(StringBody(session => { + val username = session("username").as[String] + s"""{ + | "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + | "white": {"id": "$username", "displayName": "$username"}, + | "black": {"id": "opponent_${session.userId}", "displayName": "Opponent"}, + | "timeControl": {"limitSeconds": 300, "incrementSeconds": 3} + |}""".stripMargin + })) + .check(status.in(200, 201)) + .check(jsonPath("$.gameId").saveAs("gameId")) + ) + .exec(makeMove("e2e4")) + .exec(makeMove("e7e5")) + .exec(makeMove("g1f3")) + .exec( + http("Resign") + .post(session => s"/api/board/game/${session("gameId").as[String]}/resign") + .header("Authorization", "${jwt}") + .check(status.in(200, 201, 204)) + ) +} diff --git a/src/gatling/scala/simulations/EnduranceTestSimulation.scala b/src/gatling/scala/simulations/EnduranceTestSimulation.scala index e3a4868..eaf0ef9 100644 --- a/src/gatling/scala/simulations/EnduranceTestSimulation.scala +++ b/src/gatling/scala/simulations/EnduranceTestSimulation.scala @@ -1,8 +1,9 @@ package simulations import base.BaseSimulation -import endpoints.BoardEndpoints +import scenarios.ChessUserScenario import io.gatling.core.Predef._ +import io.gatling.http.Predef._ import scala.concurrent.duration._ @@ -11,12 +12,15 @@ class EnduranceTestSimulation extends BaseSimulation { private val concurrentUsers = sys.props.getOrElse("concurrentUsers", "3").toInt private val duration = sys.props.getOrElse("duration", "300").toInt + override protected val httpProtocol = http + .baseUrl(baseUrl) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + setUp( - BoardEndpoints.all.map { endpoint => - scenarioFromEndpoint(endpoint) - .inject( - constantConcurrentUsers(concurrentUsers).during(duration.seconds) - ) - }: _* + ChessUserScenario.play + .inject( + constantConcurrentUsers(concurrentUsers).during(duration.seconds) + ) ).protocols(httpProtocol) } diff --git a/src/gatling/scala/simulations/LoadTestSimulation.scala b/src/gatling/scala/simulations/LoadTestSimulation.scala index 3222b05..7c89859 100644 --- a/src/gatling/scala/simulations/LoadTestSimulation.scala +++ b/src/gatling/scala/simulations/LoadTestSimulation.scala @@ -1,20 +1,25 @@ package simulations import base.BaseSimulation -import endpoints.BoardEndpoints +import scenarios.ChessUserScenario import io.gatling.core.Predef._ +import io.gatling.http.Predef._ import scala.concurrent.duration._ class LoadTestSimulation extends BaseSimulation { - private val maxUsers = sys.props.getOrElse("maxUsers", "5").toInt - private val rampDuration = sys.props.getOrElse("rampDuration", "60").toInt + private val maxUsers = sys.props.getOrElse("maxUsers", "5").toInt + private val rampDuration = sys.props.getOrElse("rampDuration", "60").toInt + + // Each virtual user authenticates individually, so no global Bearer token + override protected val httpProtocol = http + .baseUrl(baseUrl) + .header("Accept", "application/json") + .header("Content-Type", "application/json") setUp( - BoardEndpoints.all.map { endpoint => - scenarioFromEndpoint(endpoint) - .inject(rampUsers(maxUsers).during(rampDuration.seconds)) - }: _* + ChessUserScenario.play + .inject(rampUsers(maxUsers).during(rampDuration.seconds)) ).protocols(httpProtocol) } diff --git a/src/gatling/scala/simulations/SmokeTestSimulation.scala b/src/gatling/scala/simulations/SmokeTestSimulation.scala index 730bb7b..d540551 100644 --- a/src/gatling/scala/simulations/SmokeTestSimulation.scala +++ b/src/gatling/scala/simulations/SmokeTestSimulation.scala @@ -1,13 +1,19 @@ package simulations import base.BaseSimulation -import endpoints.BoardEndpoints +import scenarios.ChessUserScenario import io.gatling.core.Predef._ +import io.gatling.http.Predef._ class SmokeTestSimulation extends BaseSimulation { + override protected val httpProtocol = http + .baseUrl(baseUrl) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + setUp( - scenarioFromEndpoint(BoardEndpoints.createGame) + ChessUserScenario.play .inject(atOnceUsers(1)) ).protocols(httpProtocol) } diff --git a/src/gatling/scala/simulations/SpikeTestSimulation.scala b/src/gatling/scala/simulations/SpikeTestSimulation.scala index 0795c51..56b0aed 100644 --- a/src/gatling/scala/simulations/SpikeTestSimulation.scala +++ b/src/gatling/scala/simulations/SpikeTestSimulation.scala @@ -1,8 +1,9 @@ package simulations import base.BaseSimulation -import endpoints.BoardEndpoints +import scenarios.ChessUserScenario import io.gatling.core.Predef._ +import io.gatling.http.Predef._ import scala.concurrent.duration._ @@ -12,15 +13,18 @@ class SpikeTestSimulation extends BaseSimulation { private val baselineDuration = sys.props.getOrElse("baselineDuration", "20").toInt private val spikeUsers = sys.props.getOrElse("spikeUsers", "15").toInt + override protected val httpProtocol = http + .baseUrl(baseUrl) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + setUp( - BoardEndpoints.all.map { endpoint => - scenarioFromEndpoint(endpoint) - .inject( - constantUsersPerSec(baselineUsers).during(baselineDuration.seconds), - atOnceUsers(spikeUsers), - nothingFor(5.seconds), - constantUsersPerSec(baselineUsers).during(baselineDuration.seconds) - ) - }: _* + ChessUserScenario.play + .inject( + constantUsersPerSec(baselineUsers).during(baselineDuration.seconds), + atOnceUsers(spikeUsers), + nothingFor(5.seconds), + constantUsersPerSec(baselineUsers).during(baselineDuration.seconds) + ) ).protocols(httpProtocol) } diff --git a/src/gatling/scala/simulations/StressTestSimulation.scala b/src/gatling/scala/simulations/StressTestSimulation.scala index 9f10adb..c0032ab 100644 --- a/src/gatling/scala/simulations/StressTestSimulation.scala +++ b/src/gatling/scala/simulations/StressTestSimulation.scala @@ -1,8 +1,9 @@ package simulations import base.BaseSimulation -import endpoints.BoardEndpoints +import scenarios.ChessUserScenario import io.gatling.core.Predef._ +import io.gatling.http.Predef._ import scala.concurrent.duration._ @@ -14,16 +15,19 @@ class StressTestSimulation extends BaseSimulation { private val stepDuration = sys.props.getOrElse("stepDuration", "30").toInt private val rampDuration = sys.props.getOrElse("rampDuration", "10").toInt + override protected val httpProtocol = http + .baseUrl(baseUrl) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + setUp( - BoardEndpoints.all.map { endpoint => - scenarioFromEndpoint(endpoint) - .inject( - incrementConcurrentUsers(usersIncrement) - .times(steps) - .eachLevelLasting(stepDuration.seconds) - .separatedByRampsLasting(rampDuration.seconds) - .startingFrom(startUsers) - ) - }: _* + ChessUserScenario.play + .inject( + incrementConcurrentUsers(usersIncrement) + .times(steps) + .eachLevelLasting(stepDuration.seconds) + .separatedByRampsLasting(rampDuration.seconds) + .startingFrom(startUsers) + ) ).protocols(httpProtocol) }