diff --git a/readme.md b/readme.md index 4a5123c097c5d7f5251a770b7520247516e2da89..e1d989d9ab7c10a3ecea3ea729e399f19011ebeb 100644 --- a/readme.md +++ b/readme.md @@ -294,6 +294,17 @@ Command line to run: mvn compile jib:build -X -DjibSerialize=true -Djib.to.auth.username=xxx -Djib.to.auth.password=xxxxx ``` +## Performance Testing + +To benchmark the scalability of the PetClinic REST API, a JMeter test plan is available. + +- See the [JMeter Performance Test](src/test/jmeter/README.md) for details. +- Run the test using: + ```sh + jmeter -n -t src/test/jmeter/petclinic-jmeter-crud-benchmark.jmx \ + -Jthreads=100 -Jduration=600 -Jops=2000 -Jramp_time=120 \ + -l results/petclinic-test-results.jtl + ## Interesting Spring Petclinic forks The Spring Petclinic master branch in the main [spring-projects](https://github.com/spring-projects/spring-petclinic) diff --git a/src/test/jmeter/README.md b/src/test/jmeter/README.md new file mode 100644 index 0000000000000000000000000000000000000000..03407375d5ec6824f44a45071c96110c5eb1cbe1 --- /dev/null +++ b/src/test/jmeter/README.md @@ -0,0 +1,107 @@ +# PetClinic JMeter Performance Test + +## Overview + +This JMeter test plan is designed to benchmark and measure the scalability of the **Spring PetClinic REST API**. The test performs CRUD operations on the API endpoints, simulating real-world usage patterns. + +### What This Test Covers + +- Creating, updating, retrieving, and deleting **owners** and **pets**. +- Scheduling and managing **vet visits**. +- Configurable workload parameters for **scalability testing**. + +## Running the Test + +### Prerequisites + +Ensure you have the following installed: + +- **Apache JMeter 5.6.3+** ([Download here](https://jmeter.apache.org/download_jmeter.cgi)) +- **Spring PetClinic REST API running locally** + ```sh + # Navigate to the Spring PetClinic REST project directory and start the application + cd /path/to/spring-petclinic-rest + mvn spring-boot:run + ``` + +_(Runs on http://localhost:9966 by default)_ + +### Running the Test from CLI + +Run the JMeter test in **non-GUI mode**: + +```sh +jmeter -n -t src/test/jmeter/petclinic-jmeter-crud-benchmark.jmx \ + -Jthreads=100 -Jduration=600 -Jops=2000 -Jramp_time=120 \ + -l results/petclinic-test-results.jtl +``` + +#### CLI Parameters + +| Parameter | Description | Default Value | +|---------------|--------------------------------------|---------------| +| `threads` | Number of concurrent users | 50 | +| `duration` | Duration of the test (seconds) | 300 | +| `ops` | Target throughput (operations/sec) | 1000 | +| `ramp_time` | Time to ramp up threads (seconds) | 60 | + +## Analyzing Test Results + +1. **Generate an HTML Report** + + To generate an **interactive HTML performance report**: + ```sh + jmeter -g results/petclinic-test-results.jtl -o results/html-report + ``` + + Then open `results/html-report/index.html` in your browser. + +2. **View Raw Results in CLI** + + To quickly inspect the last 20 results: + ```sh + tail -n 20 results/petclinic-test-results.jtl + ``` + +3. **Process CSV Results for Custom Analysis** + + JMeter results are stored as `.jtl` files, which use `CSV` format by default unless configured otherwise. You can analyze them using: + - Python Pandas: + ```python + import pandas as pd + df = pd.read_csv('results/petclinic-test-results.jtl') + print(df.describe()) + ``` + - Command-line tools (`awk`): + ```sh + awk -F',' '{print $1, $2, $3}' results/petclinic-test-results.jtl | head -20 + ``` +### Performance Summary Table + + +| **Transaction** | **Total Requests** | **Avg Response Time (ms)** | **Min Response Time (ms)** | **Max Response Time (ms)** | **90th Percentile (ms)** | **95th Percentile (ms)** | **99th Percentile (ms)** | +|--------------------------------|--------------------|----------------------------|----------------------------|----------------------------|--------------------------|--------------------------|--------------------------| +| **Add Pet to Owner** | 60,284 | 60.17 | 1 | 534 | 151.00 | 190.00 | 255.15 | +| **Create Owner** | 60,284 | 145.94 | 2 | 696 | 315.00 | 368.00 | 476.15 | +| **Delete Owner** | 60,224 | 158.76 | 2 | 825 | 322.00 | 375.00 | 490.00 | +| **Delete Pet** | 60,239 | 179.94 | 2 | 829 | 358.00 | 411.00 | 550.00 | +| **Get Pet Belonging to Owner** | 60,249 | 70.37 | 1 | 479 | 171.00 | 208.00 | 260.50 | +| **Schedule Visit** | 60,279 | 166.48 | 2 | 880 | 329.00 | 381.00 | 507.20 | +| **Update Owner** | 60,264 | 163.76 | 2 | 681 | 345.00 | 396.00 | 486.35 | +| **Update Pet** | 60,249 | 33.53 | 2 | 402 | 84.00 | 108.50 | 161.50 | + + +**Explanation:** +- **Total Requests**: Number of executions of the request. +- **Avg Response Time (ms)**: Average time taken for the request. +- **Min/Max Response Time (ms)**: The shortest and longest recorded response times. +- **Percentile Metrics**: The 90th, 95th, and 99th percentile response times show performance under load. + + +## Next Steps + +- Run with different configurations to simulate varied workloads. +- Integrate into CI/CD pipelines for automated performance testing. +___ + +Contribute: Feel free to submit PRs to improve the test coverage or parameters! \ No newline at end of file diff --git a/src/test/jmeter/petclinic-jmeter-crud-benchmark.jmx b/src/test/jmeter/petclinic-jmeter-crud-benchmark.jmx new file mode 100644 index 0000000000000000000000000000000000000000..0c5dae8dd4bb564f8acdfa18de7f7661296b6185 --- /dev/null +++ b/src/test/jmeter/petclinic-jmeter-crud-benchmark.jmx @@ -0,0 +1,235 @@ +<?xml version="1.0" encoding="UTF-8"?> +<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3"> + <hashTree> + <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="PetClinic CRUD Benchmark Test"> + <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables"> + <collectionProp name="Arguments.arguments"> + <elementProp name="cores" elementType="Argument"> + <stringProp name="Argument.name">cores</stringProp> + <stringProp name="Argument.value">${__P(cores,2)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + <elementProp name="threads" elementType="Argument"> + <stringProp name="Argument.name">threads</stringProp> + <stringProp name="Argument.value">${__P(threads,50)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + <elementProp name="ops" elementType="Argument"> + <stringProp name="Argument.name">ops</stringProp> + <stringProp name="Argument.value">${__P(ops,1000)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + <elementProp name="duration" elementType="Argument"> + <stringProp name="Argument.name">duration</stringProp> + <stringProp name="Argument.value">${__P(duration,300)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + <elementProp name="ramp_time" elementType="Argument"> + <stringProp name="Argument.name">ramp_time</stringProp> + <stringProp name="Argument.value">${__P(ramp_time,60)}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + </TestPlan> + <hashTree> + <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Scalability Load Test"> + <stringProp name="ThreadGroup.num_threads">${threads}</stringProp> + <stringProp name="ThreadGroup.ramp_time">${ramp_time}</stringProp> + <stringProp name="ThreadGroup.duration">${duration}</stringProp> + <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp> + <boolProp name="ThreadGroup.scheduler">true</boolProp> + <stringProp name="ThreadGroup.on_sample_error">continue</stringProp> + <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller"> + <intProp name="LoopController.loops">-1</intProp> + <boolProp name="LoopController.continue_forever">false</boolProp> + </elementProp> + </ThreadGroup> + <hashTree> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Owner" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/owners</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"firstName": "John", "lastName": "Doe", "address": "1234 Elm St", "city": "Austin", "telephone": "5121234567"}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + </HTTPSamplerProxy> + <hashTree> + <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Owner ID" enabled="true"> + <stringProp name="JSONPostProcessor.referenceNames">owner_id</stringProp> + <stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp> + <stringProp name="JSONPostProcessor.match_numbers">1</stringProp> + </JSONPostProcessor> + <hashTree/> + </hashTree> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Add Pet to Owner" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/owners/${owner_id}/pets</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"name": "Buddy", "birthDate": "2022-06-15", "type": {"id": 1, "name": "dog"}}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + </HTTPSamplerProxy> + <hashTree> + <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Pet ID" enabled="true"> + <stringProp name="JSONPostProcessor.referenceNames">pet_id</stringProp> + <stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp> + <stringProp name="JSONPostProcessor.match_numbers">1</stringProp> + </JSONPostProcessor> + <hashTree/> + </hashTree> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Schedule Visit" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/visits</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"petId": "${pet_id}", "date": "2025-02-21", "description": "Annual check-up"}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + </HTTPSamplerProxy> + <hashTree/> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Owner" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/owners/${owner_id}</stringProp> + <stringProp name="HTTPSampler.method">PUT</stringProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"firstName": "JohnUpdated", "lastName": "DoeUpdated", "address": "5678 Oak St", "city": "Houston", "telephone": "7139876543"}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + </HTTPSamplerProxy> + <hashTree/> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Pet" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/pets/${pet_id}</stringProp> + <stringProp name="HTTPSampler.method">PUT</stringProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"name": "Buddy Updated", "birthDate": "2022-06-20", "type": {"id": 1, "name": "dog"}}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + </elementProp> + </collectionProp> + </elementProp> + </HTTPSamplerProxy> + <hashTree/> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Get Pet Belonging to Owner" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/owners/${owner_id}/pets/${pet_id}</stringProp> + <stringProp name="HTTPSampler.method">GET</stringProp> + <boolProp name="HTTPSampler.postBodyRaw">false</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + </HTTPSamplerProxy> + <hashTree/> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Delete Pet" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/pets/${pet_id}</stringProp> + <stringProp name="HTTPSampler.method">DELETE</stringProp> + <boolProp name="HTTPSampler.postBodyRaw">false</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + </HTTPSamplerProxy> + <hashTree/> + <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Delete Owner" enabled="true"> + <stringProp name="HTTPSampler.path">/petclinic/api/owners/${owner_id}</stringProp> + <stringProp name="HTTPSampler.method">DELETE</stringProp> + <boolProp name="HTTPSampler.postBodyRaw">false</boolProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + </HTTPSamplerProxy> + <hashTree/> + </hashTree> + <ResultCollector guiclass="SummaryReport" testclass="ResultCollector" testname="Summary Report"> + <boolProp name="ResultCollector.error_logging">false</boolProp> + <objProp> + <name>saveConfig</name> + <value class="SampleSaveConfiguration"> + <time>true</time> + <latency>true</latency> + <timestamp>true</timestamp> + <success>true</success> + <label>true</label> + <code>true</code> + <message>true</message> + <threadName>true</threadName> + <dataType>true</dataType> + <encoding>false</encoding> + <assertions>true</assertions> + <subresults>true</subresults> + <responseData>false</responseData> + <samplerData>false</samplerData> + <xml>false</xml> + <fieldNames>true</fieldNames> + <responseHeaders>false</responseHeaders> + <requestHeaders>false</requestHeaders> + <responseDataOnError>false</responseDataOnError> + <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage> + <assertionsResultsToSave>0</assertionsResultsToSave> + <bytes>true</bytes> + <sentBytes>true</sentBytes> + <url>true</url> + <threadCounts>true</threadCounts> + <idleTime>true</idleTime> + <connectTime>true</connectTime> + </value> + </objProp> + <stringProp name="filename"></stringProp> + </ResultCollector> + <hashTree/> + <ConstantThroughputTimer guiclass="TestBeanGUI" testclass="ConstantThroughputTimer" testname="Throughput Control"> + <intProp name="calcMode">0</intProp> + <stringProp name="throughput">${ops}</stringProp> + </ConstantThroughputTimer> + <hashTree/> + <ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP Request Defaults" enabled="true"> + <stringProp name="HTTPSampler.domain">localhost</stringProp> + <stringProp name="HTTPSampler.port">9966</stringProp> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + <stringProp name="HTTPSampler.implementation"></stringProp> + </ConfigTestElement> + <hashTree/> + <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true"> + <collectionProp name="HeaderManager.headers"> + <elementProp name="Content-Type" elementType="Header"> + <stringProp name="Header.name">Content-Type</stringProp> + <stringProp name="Header.value">application/json</stringProp> + </elementProp> + <elementProp name="Accept" elementType="Header"> + <stringProp name="Header.name">Accept</stringProp> + <stringProp name="Header.value">application/json</stringProp> + </elementProp> + </collectionProp> + </HeaderManager> + <hashTree/> + </hashTree> + </hashTree> +</jmeterTestPlan>