Welcome!
Contents hopefully coming really soon. If things are not actually as documented here, please draft a pull request.
Conceptual overview
To motivate hh200, let's mentally execute an HTTP server test scenario three different ways.
The test scenario says that a sequence of POST /login { "username": Person, password }
and GET /status (bearing login token) always return Person's status. We're also interested in learning
the number of parallel users after which the system-under-test starts to respond in >1 second.
Alternatives
Postman
Units of HTTP request are organized in "Collections". Unless we're in a team with a specific workflow, we can do the following:
- In example-based manner, create requests for the POST and GET endpoints under a Collection.
- (Alternative 1) Utilize pre-request and post-response "Scripts" to encode the necessary chaining effects (i.e. parsing access token, passing the token as variable)
- (Alternative 2) Utilize "Flows". To my knowledge, at the time of writing, simulating parallel users is tricky even with this premium feature.
We had AI sketch what the script could look like. The script does more (i.e. {average, max} {login, get status} time) than it's asked while it's at it, but after deleting the extraneous lines:
AI-generated pre-request & post-response scripts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
// ===========================================
// PRE-REQUEST SCRIPT FOR LOGIN ENDPOINT
// ===========================================
// Initialize or increment user counter for parallel testing
let userCounter = pm.globals.get('userCounter') || 1;
let currentIteration = pm.info.iteration + 1;
let totalIterations = pm.info.iterationCount;
// Generate unique username for this iteration
const username = `user${userCounter}`;
pm.collectionVariables.set('current_user', username);
// Increment counter for next iteration
pm.globals.set('userCounter', userCounter + 1);
console.log(`Iteration ${currentIteration}/${totalIterations}: Testing with user ${username}`);
// Initialize performance tracking
if (!pm.globals.get('testResults')) {
pm.globals.set('testResults', JSON.stringify([]));
}
// Set start time for this user's complete flow
pm.globals.set(`startTime_${username}`, Date.now());
// ===========================================
// POST-RESPONSE SCRIPT FOR LOGIN ENDPOINT
// ===========================================
const username = pm.collectionVariables.get('current_user');
const responseTime = pm.response.responseTime;
// Test response status
pm.test('Login successful', function () {
pm.response.to.have.status(200);
});
// Test response time threshold
pm.test('Login response time < 1s', function () {
pm.expect(pm.response.responseTime).to.be.below(1000);
});
// Extract and save token
if (pm.response.code === 200) {
const response = pm.response.json();
pm.test('Token exists in response', function () {
pm.expect(response).to.have.property('token');
});
if (response.token) {
pm.collectionVariables.set('auth_token', response.token);
console.log(`Login successful for ${username} (${responseTime}ms)`);
}
} else {
console.log(`Login failed for ${username}: Status ${pm.response.code}`);
}
// Track login performance
pm.globals.set(`loginTime_${username}`, responseTime);
// ===========================================
// PRE-REQUEST SCRIPT FOR STATUS ENDPOINT
// ===========================================
const username = pm.collectionVariables.get('current_user');
console.log(`Fetching data for ${username}`);
// Ensure we have an auth token
const token = pm.collectionVariables.get('auth_token');
if (!token) {
console.error(`No auth token available for ${username}`);
}
// ===========================================
// POST-RESPONSE SCRIPT FOR STATUS ENDPOINT
// ===========================================
const username = pm.collectionVariables.get('current_user');
const responseTime = pm.response.responseTime;
const loginTime = pm.globals.get(`loginTime_${username}`) || 0;
const startTime = pm.globals.get(`startTime_${username}`) || Date.now();
const totalTime = Date.now() - startTime;
// Test response status
pm.test('Data retrieval successful', function () {
pm.response.to.have.status(200);
});
// Test response time threshold
pm.test('Data response time < 1s', function () {
pm.expect(pm.response.responseTime).to.be.below(1000);
});
// Verify user data
if (pm.response.code === 200) {
const response = pm.response.json();
pm.test('Correct user data returned', function () {
pm.expect(response.username).to.equal(username);
});
console.log(`Data retrieval successful for ${username} (${responseTime}ms)`);
} else {
console.log(`Data retrieval failed for ${username}: Status ${pm.response.code}`);
}
// Collect performance data
let testResults = JSON.parse(pm.globals.get('testResults') || '[]');
testResults.push({
username: username,
loginTime: loginTime,
dataTime: responseTime,
totalTime: totalTime,
loginSuccess: pm.globals.get(`loginTime_${username}`) ? true : false,
dataSuccess: pm.response.code === 200,
timestamp: new Date().toISOString()
});
pm.globals.set('testResults', JSON.stringify(testResults));
// Performance analysis on final iteration
const currentIteration = pm.info.iteration + 1;
const totalIterations = pm.info.iterationCount;
if (currentIteration === totalIterations) {
console.log('\n=== PERFORMANCE ANALYSIS ===');
const results = JSON.parse(pm.globals.get('testResults') || '[]');
const successfulResults = results.filter(r => r.loginSuccess && r.dataSuccess);
const failedResults = results.filter(r => !r.loginSuccess || !r.dataSuccess);
// Calculate statistics
const totalTests = results.length;
const successCount = successfulResults.length;
const failureCount = failedResults.length;
const successRate = (successCount / totalTests * 100).toFixed(1);
// Response time analysis
const loginTimes = successfulResults.map(r => r.loginTime);
const dataTimes = successfulResults.map(r => r.dataTime);
const totalTimes = successfulResults.map(r => r.totalTime);
// Count slow responses (>1s)
const slowLoginResponses = loginTimes.filter(t => t > 1000).length;
const slowDataResponses = dataTimes.filter(t => t > 1000).length;
const totalSlowResponses = slowLoginResponses + slowDataResponses;
console.log(`Total Users Tested: ${totalTests}`);
console.log(`Successful: ${successCount} (${successRate}%)`);
console.log(`Failed: ${failureCount}`);
console.log(`Slow Responses (>1s): ${totalSlowResponses}`);
console.log(` - Slow Login: ${slowLoginResponses}`);
console.log(` - Slow Data: ${slowDataResponses}`);
// Threshold detection
if (totalSlowResponses > 0) {
console.log(`\n*** PERFORMANCE THRESHOLD DETECTED ***`);
console.log(`System shows degraded performance with ${totalTests} parallel users`);
console.log(`${totalSlowResponses} responses exceeded 1 second threshold`);
} else {
console.log(`\n*** ALL RESPONSES UNDER 1 SECOND ***`);
console.log(`System handles ${totalTests} parallel users efficiently`);
console.log(`Consider testing with more users to find threshold`);
}
// Performance test results for collection variables
pm.collectionVariables.set('final_results', JSON.stringify({
totalUsers: totalTests,
successRate: successRate,
slowResponses: totalSlowResponses,
thresholdExceeded: totalSlowResponses > 0
}));
// Clean up globals
pm.globals.unset('userCounter');
pm.globals.unset('testResults');
// Clean up individual user data
for (let i = 1; i <= totalTests; i++) {
pm.globals.unset(`startTime_user${i}`);
pm.globals.unset(`loginTime_user${i}`);
}
}
// ===========================================
// COLLECTION-LEVEL PRE-REQUEST SCRIPT
// ===========================================
// Initialize test environment
console.log('Initializing Login Performance Load Test...');
// Reset any previous test data
pm.globals.unset('userCounter');
pm.globals.unset('testResults');
// Set test configuration
const testConfig = {
baseUrl: pm.collectionVariables.get('base_url') || 'http://staging.example.com',
responseTimeThreshold: parseInt(pm.collectionVariables.get('response_time_threshold') || '1000'),
maxUsers: 50
};
console.log('Test Configuration:', testConfig);
console.log(`Testing will run with ${pm.info.iterationCount} parallel users`);
console.log(`Looking for responses exceeding ${testConfig.responseTimeThreshold}ms threshold`);
// ===========================================
// COLLECTION-LEVEL POST-REQUEST SCRIPT
// ===========================================
// This runs after all requests in the collection
console.log('Load test execution completed');
// Final cleanup and summary
const finalResults = pm.collectionVariables.get('final_results');
if (finalResults) {
const results = JSON.parse(finalResults);
console.log('\n=== FINAL SUMMARY ===');
console.log(`Tested ${results.totalUsers} parallel users`);
console.log(`Success Rate: ${results.successRate}%`);
console.log(`Performance Threshold ${results.thresholdExceeded ? 'EXCEEDED' : 'NOT REACHED'}`);
if (results.thresholdExceeded) {
console.log(`⚠️ System performance degrades with ${results.totalUsers} parallel users`);
} else {
console.log(`✅ System handles ${results.totalUsers} parallel users efficiently`);
}
}
Hurl
Without specifying the Hurl version to keep things conceptual, the main bit can look as follows for the simplest case of testing one user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
AI-generated final bash script
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | |
General purpose language
Why not a general purpose language like python? I've been developing an unreleased python package that allows the following program.
1 2 3 | |
Alternatively, the following looks good to me. (Truly, unironically. The point of this section is to visualize the accidental complexity of using a GPL.)
AI-generated alternative python implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
"""
Login Performance Load Test
Tests the login/status retrieval flow and finds the threshold where response time exceeds 1 second
"""
import asyncio
import aiohttp
import time
import json
from typing import Dict, List, Tuple
from dataclasses import dataclass
@dataclass
class TestResult:
user_id: str
login_time: float
data_time: float
success: bool
error: str = ""
class LoginLoadTester:
def __init__(self, base_url: str = "http://staging.example.com"):
self.base_url = base_url
self.results: List[TestResult] = []
async def test_single_user(self, session: aiohttp.ClientSession, user_num: int) -> TestResult:
"""Test login and data retrieval for a single user using async"""
username = f"user{user_num}"
try:
# Login request
login_start = time.time()
login_payload = {
"username": username,
"password": "password123"
}
async with session.post(
f"{self.base_url}/login",
json=login_payload,
headers={"Content-Type": "application/json"}
) as login_response:
login_end = time.time()
login_time = login_end - login_start
if login_response.status != 200:
return TestResult(username, login_time, 0, False, f"Login failed: {login_response.status}")
login_data = await login_response.json()
token = login_data.get("token")
if not token:
return TestResult(username, login_time, 0, False, "No token in login response")
# Data retrieval request
data_start = time.time()
async with session.get(
f"{self.base_url}/status",
headers={"Authorization": f"Bearer {token}"}
) as data_response:
data_end = time.time()
data_time = data_end - data_start
if data_response.status != 200:
return TestResult(username, login_time, data_time, False, f"Data request failed: {data_response.status}")
data = await data_response.json()
# Verify the response contains the correct user data
if data.get("username") != username:
return TestResult(username, login_time, data_time, False, "Username mismatch in response")
return TestResult(username, login_time, data_time, True)
except Exception as e:
return TestResult(username, 0, 0, False, str(e))
async def run_load_test(self, num_users: int) -> Tuple[List[TestResult], float]:
"""Run load test with specified number of parallel users using async"""
start_time = time.time()
connector = aiohttp.TCPConnector(limit=None, limit_per_host=None)
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
tasks = [self.test_single_user(session, i) for i in range(1, num_users + 1)]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle any exceptions that occurred
processed_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
processed_results.append(TestResult(f"user{i+1}", 0, 0, False, str(result)))
else:
processed_results.append(result)
end_time = time.time()
total_time = end_time - start_time
return processed_results, total_time
def analyze_results(self, results: List[TestResult]) -> Dict:
"""Analyze test results and return statistics"""
successful_results = [r for r in results if r.success]
failed_results = [r for r in results if not r.success]
if not successful_results:
return {
"total_tests": len(results),
"successful": 0,
"failed": len(failed_results),
"success_rate": 0.0,
"avg_login_time": 0,
"avg_data_time": 0,
"max_login_time": 0,
"max_data_time": 0,
"slow_responses": 0,
"errors": [r.error for r in failed_results]
}
login_times = [r.login_time for r in successful_results]
data_times = [r.data_time for r in successful_results]
# Count responses over 1 second
slow_login = len([t for t in login_times if t > 1.0])
slow_data = len([t for t in data_times if t > 1.0])
slow_responses = slow_login + slow_data
return {
"total_tests": len(results),
"successful": len(successful_results),
"failed": len(failed_results),
"success_rate": len(successful_results) / len(results) * 100,
"avg_login_time": sum(login_times) / len(login_times),
"avg_data_time": sum(data_times) / len(data_times),
"max_login_time": max(login_times),
"max_data_time": max(data_times),
"slow_responses": slow_responses,
"slow_login_responses": slow_login,
"slow_data_responses": slow_data,
"errors": list(set([r.error for r in failed_results if r.error]))
}
def find_performance_threshold(self, max_users: int = 50, step: int = 5):
"""Find the number of parallel users where response time exceeds 1 second"""
print("Testing parallel user performance threshold...")
print("Looking for the point where response time exceeds 1 second")
print("=" * 60)
threshold_found = False
for num_users in range(step, max_users + 1, step):
print(f"\nTesting with {num_users} parallel users...")
try:
results, total_time = asyncio.run(self.run_load_test(num_users))
stats = self.analyze_results(results)
print(f"Results for {num_users} users:")
print(f" - Total test time: {total_time:.2f}s")
print(f" - Success rate: {stats['success_rate']:.1f}%")
print(f" - Successful tests: {stats['successful']}")
print(f" - Failed tests: {stats['failed']}")
print(f" - Avg login time: {stats['avg_login_time']:.3f}s")
print(f" - Avg data time: {stats['avg_data_time']:.3f}s")
print(f" - Max login time: {stats['max_login_time']:.3f}s")
print(f" - Max data time: {stats['max_data_time']:.3f}s")
print(f" - Slow responses (>1s): {stats['slow_responses']}")
if stats['errors']:
print(f" - Errors: {stats['errors'][:3]}...") # Show first 3 errors
# Check if we've found the threshold
if stats['slow_responses'] > 0 or stats['max_login_time'] > 1.0 or stats['max_data_time'] > 1.0:
print(f"\n*** THRESHOLD FOUND ***")
print(f"System starts responding >1s with {num_users} parallel users")
print(f"Slow login responses: {stats['slow_login_responses']}")
print(f"Slow data responses: {stats['slow_data_responses']}")
threshold_found = True
break
print(f"All responses under 1s with {num_users} users, continuing...")
# Brief pause between test runs
time.sleep(1)
except Exception as e:
print(f"Error testing {num_users} users: {e}")
continue
if not threshold_found:
print(f"\nNo threshold found up to {max_users} users. System handles load well!")
print("\nLoad test completed.")
def main():
# Example usage
tester = LoginLoadTester("http://staging.example.com")
# Test basic functionality first
print("Testing basic functionality...")
result, _ = asyncio.run(tester.run_load_test(1))
if result[0].success:
print("✓ Basic functionality test passed")
# Find performance threshold
tester.find_performance_threshold(max_users=50, step=5)
else:
print(f"✗ Basic functionality test failed: {result[0].error}")
print("Please check your server setup before running load tests")
if __name__ == "__main__":
main()
The following table hopes to list a fair overview for the three methods.
| method [link] | slogan |
|---|---|
| Postman | Explore, test, and document APIs collaboratively in one place |
| Hurl | Requests in simple text format. |
| (any GPL) | The test scripts are as important as the system-under-test itself; might as well use the same main language. |
Argued ultimate abstraction
Hopefully we have set the stage well for introducing hh200. This is one example of HTTP server testing problem, the domain where hh200 tries to specialize in.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
All in all, hh200 doesn't compete with Postman GUI; agrees with the above python approach where complexity is preferably hidden; and ultimately aspires to be more modern Hurl.
The following extensions to the above test scenario might help weighing whether hh200 serves your taste decently well.
- insert another endpoint call after "get token"
- reorder the endpoint calls
- warn if "get token" returns 201 code instead of 200
- read from csv for all users that we want to serve in parallel
- specify the target number of parallel users before which it starts to respond with non-success codes
- download the status (
GET /status&file=xls) - download a file whose name already exist (keeping both)
API reference
- <hackage project url>
- <hoogle>