Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
13 KiB
Story 1.17-a: Add Load Testing for NFR4 (100 Concurrent Users)
Status: Draft Epic: Epic 1 - Webflow to Payload CMS + Astro Migration Priority: P1 (High - NFR4 Validation Required) Estimated Time: 3 hours
Story
As a Development Team, I want a load testing framework that validates system performance under concurrent user load, So that we can ensure NFR4 compliance (100 concurrent users) before production deployment.
Context
This is a Sprint 1 addition story. NFR4 requires the system to support at least 100 concurrent users without performance degradation. Load testing was only implied in the original plan and needs explicit validation.
Story Source:
- NFR4 from
docs/prd/02-requirements.md - Sprint 1 adjustments in
sprint-status.yaml - Task specs from
docs/prd/epic-1-stories-1.3-1.17-tasks.md
NFR4 Requirement:
"The system must support at least 100 concurrent users without performance degradation."
Acceptance Criteria
Part 1: Load Testing Framework
- AC1 - Tool Selected: k6 or Artillery chosen and installed
- AC2 - Test Scripts Created: Scripts for public browsing and admin operations
Part 2: Test Scenarios
- AC3 - Public Browsing Test: 100 concurrent users browsing pages
- AC4 - Admin Operations Test: 20 concurrent admin users
- AC5 - API Performance Test: Payload CMS API endpoints under load
Part 3: Performance Targets
- AC6 - Response Time: 95th percentile response time < 500ms
- AC7 - Error Rate: Error rate < 1%
- AC8 - Cloudflare Limits: Validated within Workers limits
Part 4: Reporting
- AC9 - Test Report: Generated report with results and recommendations
- AC10 - CI Integration: Tests can be run on demand
Dev Technical Guidance
Task 1: Select and Install Load Testing Tool
Recommended: k6 (Grafana's load testing tool)
Installation:
# Install k6
pnpm add -D k6
# Or globally
brew install k6 # macOS
Why k6:
- JavaScript-based tests (familiar to devs)
- Good Cloudflare Workers support
- Excellent reporting and metrics
- Easy CI/CD integration
Task 2: Create Load Test Scripts
File: apps/frontend/tests/load/public-browsing.js
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Rate } from 'k6/metrics'
// Custom metrics
const errorRate = new Rate('errors')
// Test configuration
export const options = {
stages: [
{ duration: '30s', target: 20 }, // Ramp up to 20 users
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '2m', target: 100 }, // Ramp up to 100 users (NFR4 target)
{ duration: '2m', target: 100 }, // Stay at 100 users
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
errors: ['rate<0.01'], // Error rate < 1%
http_req_failed: ['rate<0.01'], // Failed requests < 1%
},
}
const BASE_URL = __ENV.BASE_URL || 'http://localhost:4321'
// Pages to test
const pages = [
'/',
'/about',
'/solutions',
'/contact',
'/blog',
'/portfolio',
]
export function setup() {
// Optional: Login and get auth token for admin tests
const loginRes = http.post(`${BASE_URL}/api/login`, JSON.stringify({
email: 'test@example.com',
password: 'test123',
}), {
headers: { 'Content-Type': 'application/json' },
})
if (loginRes.status === 200) {
return { token: loginRes.json('token') }
}
return {}
}
export default function(data) {
// Pick a random page
const page = pages[Math.floor(Math.random() * pages.length)]
// Make request
const res = http.get(`${BASE_URL}${page}`, {
tags: { name: `Page: ${page}` },
})
// Check response
const success = check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'page contains content': (r) => r.html().find('body').length > 0,
})
errorRate.add(!success)
// Think time between requests (2-8 seconds)
sleep(Math.random() * 6 + 2)
}
export function teardown(data) {
console.log('Load test completed')
}
File: apps/frontend/tests/load/admin-operations.js
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Rate } from 'k6/metrics'
const errorRate = new Rate('errors')
export const options = {
stages: [
{ duration: '20s', target: 5 }, // Ramp up to 5 admin users
{ duration: '1m', target: 10 }, // Ramp up to 10 admin users
{ duration: '2m', target: 20 }, // Ramp up to 20 admin users
{ duration: '1m', target: 20 }, // Stay at 20 users
{ duration: '20s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'],
errors: ['rate<0.01'],
},
}
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'
const API_URL = __ENV.API_URL || 'http://localhost:3000/api'
export function setup() {
// Login as admin
const loginRes = http.post(`${API_URL}/users/login`, JSON.stringify({
email: 'admin@enchun.tw',
password: 'admin123',
}), {
headers: { 'Content-Type': 'application/json' },
})
if (loginRes.status !== 200) {
throw new Error('Login failed')
}
return { token: loginRes.json('token') }
}
export default function(data) {
const headers = {
'Authorization': `JWT ${data.token}`,
'Content-Type': 'application/json',
}
// Test different admin operations
const operations = [
// List posts
() => {
const res = http.get(`${API_URL}/posts?limit=10`, { headers })
check(res, { 'posts list status': (r) => r.status === 200 })
},
// List categories
() => {
const res = http.get(`${API_URL}/categories?limit=20`, { headers })
check(res, { 'categories list status': (r) => r.status === 200 })
},
// Get globals
() => {
const res = http.get(`${API_URL}/globals/header`, { headers })
check(res, { 'globals status': (r) => r.status === 200 })
},
// Get media
() => {
const res = http.get(`${API_URL}/media?limit=10`, { headers })
check(res, { 'media list status': (r) => r.status === 200 })
},
]
// Execute random operation
const operation = operations[Math.floor(Math.random() * operations.length)]
const res = operation()
errorRate.add(!res)
// Think time
sleep(Math.random() * 3 + 1)
}
File: apps/backend/tests/load/api-performance.js
import http from 'k6/http'
import { check } from 'k6'
import { Rate } from 'k6/metrics'
const errorRate = new Rate('errors')
export const options = {
scenarios: {
concurrent_readers: {
executor: 'constant-vus',
vus: 50,
duration: '2m',
gracefulStop: '30s',
},
concurrent_writers: {
executor: 'constant-vus',
vus: 10,
duration: '2m',
startTime: '30s',
gracefulStop: '30s',
},
},
thresholds: {
http_req_duration: ['p(95)<500'],
errors: ['rate<0.01'],
},
}
const API_URL = __ENV.API_URL || 'http://localhost:3000/api'
export function setup() {
// Create test user for write operations
const loginRes = http.post(`${API_URL}/users/login`, JSON.stringify({
email: 'test-writer@example.com',
password: 'test123',
}), {
headers: { 'Content-Type': 'application/json' },
})
return { token: loginRes.json('token') || '' }
}
export default function(data) {
const headers = {
'Content-Type': 'application/json',
}
if (data.token) {
headers['Authorization'] = `JWT ${data.token}`
}
// Mix of read operations
const endpoints = [
() => http.get(`${API_URL}/posts?limit=10&depth=1`, { headers }),
() => http.get(`${API_URL}/categories`, { headers }),
() => http.get(`${API_URL}/globals/header`, { headers }),
() => http.get(`${API_URL}/globals/footer`, { headers }),
]
const res = endpoints[Math.floor(Math.random() * endpoints.length)]()
check(res, {
'status 200': (r) => r.status === 200,
'response < 500ms': (r) => r.timings.duration < 500,
})
errorRate.add(res.status !== 200)
}
Task 3: Create Test Runner Scripts
File: package.json scripts
{
"scripts": {
"test:load": "npm-run-all -p test:load:public test:load:admin",
"test:load:public": "k6 run apps/frontend/tests/load/public-browsing.js",
"test:load:admin": "k6 run apps/frontend/tests/load/admin-operations.js",
"test:load:api": "k6 run apps/backend/tests/load/api-performance.js",
"test:load:report": "k6 run --out json=load-test-results.json apps/frontend/tests/load/public-browsing.js"
}
}
Task 4: Cloudflare Workers Limits Validation
Create documentation file: docs/load-testing-cloudflare-limits.md
# Cloudflare Workers Limits for Load Testing
## Relevant Limits
| Resource | Limit | Notes |
|----------|-------|-------|
| CPU Time | 10ms (Free), 50ms (Paid) | Per request |
| Request Timeout | 30 seconds | Wall-clock time |
| Concurrent Requests | No limit | But fair use applies |
| Workers Requests | 100,000/day (Free) | Paid plan has more |
## Testing Strategy
1. Monitor CPU time per request during load tests
2. Ensure 95th percentile < 50ms CPU time
3. Use `wrangler dev` local mode for initial testing
4. Test on preview deployment before production
## Monitoring
Use Cloudflare Analytics to verify:
- Request count by endpoint
- CPU usage percentage
- Cache hit rate
- Error rate
File Structure
apps/
├── frontend/
│ └── tests/
│ └── load/
│ ├── public-browsing.js ← CREATE
│ └── admin-operations.js ← CREATE
├── backend/
│ └── tests/
│ └── load/
│ └── api-performance.js ← CREATE
docs/
└── load-testing-cloudflare-limits.md ← CREATE
package.json ← MODIFY (add scripts)
Tasks / Subtasks
Part 1: Setup
-
Task 1.1: Install k6
- Add k6 as dev dependency
- Verify installation
- Add npm scripts
-
Task 1.2: Create test directories
- Create apps/frontend/tests/load
- Create apps/backend/tests/load
Part 2: Public Browsing Test
-
Task 2.1: Create public-browsing.js
- Implement 100 user ramp-up
- Add page navigation logic
- Configure thresholds
-
Task 2.2: Run initial test
- Test against local dev server
- Verify results
- Adjust think times if needed
Part 3: Admin Operations Test
-
Task 3.1: Create admin-operations.js
- Implement auth flow
- Add admin operations
- Configure 20 user target
-
Task 3.2: Run admin test
- Verify auth works
- Check performance metrics
Part 4: API Performance Test
-
Task 4.1: Create api-performance.js
- Implement concurrent readers/writers
- Add all key endpoints
-
Task 4.2: Run API test
- Verify API performance
- Check for bottlenecks
Part 5: Cloudflare Validation
- Task 5.1: Create limits documentation
- Task 5.2: Test on Workers environment
- Task 5.3: Verify CPU time limits
Part 6: Reporting
-
Task 6.1: Generate test report
- Run all tests
- Generate HTML report
- Document results
-
Task 6.2: Create recommendations
- Identify bottlenecks
- Suggest optimizations
- Document for deployment
Testing Requirements
Performance Targets
| Metric | Target | Threshold |
|---|---|---|
| Response Time (p95) | < 500ms | NFR5 |
| Error Rate | < 1% | Acceptable |
| Concurrent Users | 100 | NFR4 |
| Admin Users | 20 | Target |
Test Scenarios
-
Public Browsing (100 users):
- Homepage
- About page
- Solutions page
- Contact page
- Blog listing
- Portfolio listing
-
Admin Operations (20 users):
- List posts
- List categories
- Get globals (Header/Footer)
- List media
-
API Performance (50 readers + 10 writers):
- GET /api/posts
- GET /api/categories
- GET /api/globals/*
- Mixed read/write operations
Manual Testing Checklist
- k6 installed successfully
- Public browsing test runs
- Admin operations test runs
- API performance test runs
- All tests pass thresholds
- Results documented
- Cloudflare limits validated
- Recommendations created
Risk Assessment
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Local testing not representative | Medium | Medium | Test on preview deployment |
| Cloudflare limits exceeded | Low | High | Monitor CPU time, optimize |
| Test data pollution | Medium | Low | Use test environment |
| False failures | Low | Low | Calibrate thresholds properly |
Definition of Done
- k6 installed and configured
- Public browsing test script created
- Admin operations test script created
- API performance test script created
- All tests run successfully
- Performance targets met (p95 < 500ms, errors < 1%)
- 100 concurrent user test passed
- Cloudflare Workers limits validated
- Test report generated
- Recommendations documented
- sprint-status.yaml updated
Dev Agent Record
Agent Model Used
To be filled by Dev Agent
Debug Log References
To be filled by Dev Agent
Completion Notes
To be filled by Dev Agent
File List
To be filled by Dev Agent
Change Log
| Date | Action | Author |
|---|---|---|
| 2026-01-31 | Story created (Draft) | SM Agent (Bob) |