Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
21
apps/backend/tests/k6/.env.example
Normal file
21
apps/backend/tests/k6/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# K6 Load Testing Environment Variables
|
||||
# Copy this file to .env.k6 and customize for your environment
|
||||
|
||||
# Target Server Configuration
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# Admin Credentials (for admin-operations.js)
|
||||
ADMIN_EMAIL=admin@enchun.tw
|
||||
ADMIN_PASSWORD=your-secure-password-here
|
||||
|
||||
# Optional: Override default load profile
|
||||
# STAGED_USERS=100
|
||||
# STAGED_DURATION=10m
|
||||
|
||||
# Optional: Staging environment
|
||||
# BASE_URL=https://staging.enchun.tw
|
||||
# ADMIN_EMAIL=admin@staging.enchun.tw
|
||||
|
||||
# Optional: Production environment (use with caution!)
|
||||
# BASE_URL=https://www.enchun.tw
|
||||
# ADMIN_EMAIL=admin@enchun.tw
|
||||
154
apps/backend/tests/k6/.github-workflow-example.yml
Normal file
154
apps/backend/tests/k6/.github-workflow-example.yml
Normal file
@@ -0,0 +1,154 @@
|
||||
# K6 Load Testing - GitHub Actions Workflow Example
|
||||
# Copy this to .github/workflows/load-tests.yml
|
||||
|
||||
name: Load Tests
|
||||
|
||||
on:
|
||||
# Run daily at 2 AM UTC
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
|
||||
# Run on demand
|
||||
workflow_dispatch:
|
||||
|
||||
# Run after deployment to staging
|
||||
workflow_run:
|
||||
workflows: ["Deploy to Staging"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
public-browsing:
|
||||
name: Public Browsing (100 users)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup k6
|
||||
run: |
|
||||
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||
|
||||
- name: Wait for server
|
||||
run: |
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -sSf ${{ vars.STAGING_URL }} > /dev/null; do sleep 2; done'
|
||||
echo "Server is ready!"
|
||||
|
||||
- name: Run Public Browsing Test
|
||||
run: |
|
||||
k6 run \
|
||||
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||
--out json=public-browsing-results.json \
|
||||
apps/backend/tests/k6/public-browsing.js
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: public-browsing-results
|
||||
path: public-browsing-results.json
|
||||
retention-days: 30
|
||||
|
||||
api-performance:
|
||||
name: API Performance (50 users)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup k6
|
||||
run: |
|
||||
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||
|
||||
- name: Run API Performance Test
|
||||
run: |
|
||||
k6 run \
|
||||
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||
--out json=api-performance-results.json \
|
||||
apps/backend/tests/k6/api-performance.js
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-performance-results
|
||||
path: api-performance-results.json
|
||||
retention-days: 30
|
||||
|
||||
admin-operations:
|
||||
name: Admin Operations (20 users)
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on manual trigger or schedule, not on every deployment
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup k6
|
||||
run: |
|
||||
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||
|
||||
- name: Run Admin Operations Test
|
||||
run: |
|
||||
k6 run \
|
||||
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||
--env ADMIN_EMAIL=${{ secrets.TEST_ADMIN_EMAIL }} \
|
||||
--env ADMIN_PASSWORD=${{ secrets.TEST_ADMIN_PASSWORD }} \
|
||||
--out json=admin-operations-results.json \
|
||||
apps/backend/tests/k6/admin-operations.js
|
||||
env:
|
||||
# Don't log passwords
|
||||
K6_NO_LOG_USAGE: true
|
||||
|
||||
- name: Upload Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-operations-results
|
||||
path: admin-operations-results.json
|
||||
retention-days: 30
|
||||
|
||||
# Optional: Generate and publish HTML reports
|
||||
generate-reports:
|
||||
name: Generate HTML Reports
|
||||
needs: [public-browsing, api-performance]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: results
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install k6-reporter
|
||||
run: npm install -g k6-reporter
|
||||
|
||||
- name: Generate Reports
|
||||
run: |
|
||||
mkdir -p reports
|
||||
k6-reporter results/public-browsing-results/public-browsing-results.json --output reports/public-browsing.html
|
||||
k6-reporter results/api-performance-results/api-performance-results.json --output reports/api-performance.html
|
||||
|
||||
- name: Upload Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: html-reports
|
||||
path: reports/
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment PR with Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const publicReport = fs.readFileSync('reports/public-browsing.html', 'utf8');
|
||||
// Add comment to PR with summary...
|
||||
101
apps/backend/tests/k6/QUICKSTART.md
Normal file
101
apps/backend/tests/k6/QUICKSTART.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# K6 Load Testing - Quick Start Guide
|
||||
|
||||
## 5-Minute Setup
|
||||
|
||||
### Step 1: Install k6
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install k6
|
||||
|
||||
# Verify installation
|
||||
k6 version
|
||||
```
|
||||
|
||||
### Step 2: Start Your Backend
|
||||
|
||||
```bash
|
||||
# In one terminal
|
||||
cd /Users/pukpuk/Dev/website-enchun-mgr
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Step 3: Run Your First Test
|
||||
|
||||
```bash
|
||||
# In another terminal
|
||||
cd apps/backend
|
||||
|
||||
# Run public browsing test (simplest - no auth needed)
|
||||
k6 run tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
That's it! You should see output showing 100 virtual users browsing your site.
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
# Public browsing (100 users)
|
||||
k6 run tests/k6/public-browsing.js
|
||||
|
||||
# API performance (50 users)
|
||||
k6 run tests/k6/api-performance.js
|
||||
|
||||
# Admin operations (20 users) - requires admin credentials
|
||||
k6 run --env ADMIN_EMAIL=your@email.com --env ADMIN_PASSWORD=yourpassword \
|
||||
tests/k6/admin-operations.js
|
||||
```
|
||||
|
||||
### Test Against Staging
|
||||
|
||||
```bash
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
### Generate Report
|
||||
|
||||
```bash
|
||||
# Generate JSON output
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
|
||||
# Convert to HTML (requires k6-reporter)
|
||||
npm install -g k6-reporter
|
||||
k6-reporter results.json --output results.html
|
||||
open results.html
|
||||
```
|
||||
|
||||
## Understanding Results
|
||||
|
||||
Look for these key metrics:
|
||||
|
||||
```
|
||||
✓ http_req_duration..............: avg=185ms p(95)=420ms
|
||||
✓ http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||
✓ checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||
```
|
||||
|
||||
**What to check:**
|
||||
- `p(95)` should be < 500ms
|
||||
- `http_req_failed` should be < 1%
|
||||
- `checks` should be > 99%
|
||||
|
||||
## Common Issues
|
||||
|
||||
**"connect attempt failed"**
|
||||
→ Make sure your backend is running (pnpm dev)
|
||||
|
||||
**"login failed" in admin tests**
|
||||
→ Set correct admin credentials via environment variables
|
||||
|
||||
**High error rate**
|
||||
→ Reduce VUs: `k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js`
|
||||
|
||||
## Need Help?
|
||||
|
||||
See the full README: `tests/k6/README.md`
|
||||
|
||||
---
|
||||
|
||||
**Happy Testing!** 🚀
|
||||
294
apps/backend/tests/k6/README.md
Normal file
294
apps/backend/tests/k6/README.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# K6 Load Testing Framework
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains load testing scripts for the Enchun CMS backend using k6. The tests are designed to validate the non-functional requirement NFR4:
|
||||
|
||||
- **Target:** p95 response time < 500ms
|
||||
- **Error rate:** < 1%
|
||||
- **Concurrent users:** 100
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Install k6:**
|
||||
```bash
|
||||
# macOS
|
||||
brew install k6
|
||||
|
||||
# Linux
|
||||
sudo apt-get install k6
|
||||
|
||||
# Windows (using Chocolatey)
|
||||
choco install k6
|
||||
```
|
||||
|
||||
2. **Set up environment:**
|
||||
```bash
|
||||
cp .env.example .env.k6
|
||||
# Edit .env.k6 with your test environment credentials
|
||||
```
|
||||
|
||||
## Test Scripts
|
||||
|
||||
### 1. Public Browsing Test (`public-browsing.js`)
|
||||
|
||||
Simulates 100 concurrent users browsing public pages:
|
||||
- Homepage
|
||||
- About page
|
||||
- Solutions page
|
||||
- Portfolio list
|
||||
- Blog list
|
||||
- Contact page
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
**Expected Results:**
|
||||
- p95 response time < 500ms
|
||||
- Error rate < 1%
|
||||
- 100 concurrent users sustained for 2 minutes
|
||||
|
||||
### 2. Admin Operations Test (`admin-operations.js`)
|
||||
|
||||
Simulates 20 concurrent admin users performing:
|
||||
- Login
|
||||
- List pages/posts
|
||||
- Create/edit content
|
||||
- Delete content
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
k6 run --env BASE_URL=http://localhost:3000 \
|
||||
--env ADMIN_EMAIL=admin@example.com \
|
||||
--env ADMIN_PASSWORD=password123 \
|
||||
tests/k6/admin-operations.js
|
||||
```
|
||||
|
||||
**Expected Results:**
|
||||
- p95 response time < 500ms
|
||||
- Error rate < 1%
|
||||
- 20 concurrent users sustained for 3 minutes
|
||||
|
||||
### 3. API Performance Test (`api-performance.js`)
|
||||
|
||||
Tests specific API endpoints:
|
||||
- GraphQL API queries
|
||||
- REST API endpoints
|
||||
- Global API endpoint
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
**Expected Results:**
|
||||
- p95 response time < 300ms (faster for API)
|
||||
- Error rate < 0.5%
|
||||
- Throughput > 100 requests/second
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `BASE_URL` | Target server URL | `http://localhost:3000` | Yes |
|
||||
| `ADMIN_EMAIL` | Admin user email | - | For admin tests |
|
||||
| `ADMIN_PASSWORD` | Admin user password | - | For admin tests |
|
||||
| `VUS` | Number of virtual users | Varies per test | No |
|
||||
| `DURATION` | Test duration | Varies per test | No |
|
||||
|
||||
### Load Profiles
|
||||
|
||||
Each test uses different load profiles:
|
||||
|
||||
| Test | Virtual Users | Duration | Ramp Up |
|
||||
|------|--------------|----------|---------|
|
||||
| Public Browsing | 100 | 2m | 30s |
|
||||
| Admin Operations | 20 | 3m | 30s |
|
||||
| API Performance | 50 | 5m | 1m |
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run Single Test
|
||||
|
||||
```bash
|
||||
# Basic run
|
||||
k6 run tests/k6/public-browsing.js
|
||||
|
||||
# With custom environment
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
|
||||
# With custom stages
|
||||
k6 run --env STAGED_USERS=200 --env STAGED_DURATION=10m tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
# Using the npm script
|
||||
pnpm test:load
|
||||
|
||||
# Or manually
|
||||
k6 run tests/k6/public-browsing.js
|
||||
k6 run tests/k6/admin-operations.js
|
||||
k6 run tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
### Run with Output Options
|
||||
|
||||
```bash
|
||||
# Generate JSON report
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
|
||||
# Generate HTML report (requires k6-reporter)
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
k6-reporter results.json --output results.html
|
||||
```
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
### Key Metrics
|
||||
|
||||
1. **Response Time (p95):** 95th percentile response time
|
||||
- ✅ Pass: < 500ms
|
||||
- ❌ Fail: >= 500ms
|
||||
|
||||
2. **Error Rate:** Percentage of failed requests
|
||||
- ✅ Pass: < 1%
|
||||
- ❌ Fail: >= 1%
|
||||
|
||||
3. **Throughput:** Requests per second
|
||||
- ✅ Pass: > 100 req/s for API, > 50 req/s for pages
|
||||
- ❌ Fail: Below threshold
|
||||
|
||||
4. **Virtual Users (VUs):** Active concurrent users
|
||||
- Should sustain target VUs for the full duration
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
✓ Status is 200
|
||||
✓ Response time < 500ms
|
||||
✓ Page content loaded
|
||||
|
||||
checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||
data_received..................: 15 MB 125 kB/s
|
||||
data_sent......................: 2.1 MB 18 kB/s
|
||||
http_req_blocked...............: avg=1.2ms min=0.5µs med=1µs max=125ms
|
||||
http_req_connecting............: avg=500µs min=0s med=0s max=45ms
|
||||
http_req_duration..............: avg=185.3ms min=45ms med=150ms max=850ms p(95)=420ms
|
||||
http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||
http_req_receiving.............: avg=15ms min=10µs med=50µs max=250ms
|
||||
http_req_sending...............: avg=50µs min=5µs med=20µs max=5ms
|
||||
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s
|
||||
http_req_waiting...............: avg=170ms min=40ms med=140ms max=800ms
|
||||
http_reqs......................: 12000 100 req/s
|
||||
iteration_duration.............: avg=5.2s min=1.2s med=5s max=12s
|
||||
iterations.....................: 2400 20 /s
|
||||
vus............................: 100 min=100 max=100
|
||||
vus_max........................: 100 min=100 max=100
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Load Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Daily at 2 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
k6:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: grafana/k6-action@v0.3.1
|
||||
with:
|
||||
filename: tests/k6/public-browsing.js
|
||||
env:
|
||||
BASE_URL: ${{ secrets.STAGING_URL }}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Error:** `connect attempt failed`
|
||||
|
||||
**Solution:**
|
||||
- Ensure the backend server is running
|
||||
- Check `BASE_URL` is correct
|
||||
- Verify firewall settings
|
||||
|
||||
### High Error Rate
|
||||
|
||||
**Error:** `http_req_failed > 1%`
|
||||
|
||||
**Possible Causes:**
|
||||
1. Server overload (reduce VUs)
|
||||
2. Database connection issues
|
||||
3. Authentication failures
|
||||
4. Network issues
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
# Run with fewer users
|
||||
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||
|
||||
# Check server logs
|
||||
pnpm dev
|
||||
# In another terminal, watch logs while tests run
|
||||
```
|
||||
|
||||
### Slow Response Times
|
||||
|
||||
**Error:** `p(95) >= 500ms`
|
||||
|
||||
**Possible Causes:**
|
||||
1. Unoptimized database queries
|
||||
2. Missing indexes
|
||||
3. Large payloads
|
||||
4. Server resource constraints
|
||||
|
||||
**Next Steps:**
|
||||
1. Review database queries
|
||||
2. Check caching strategies
|
||||
3. Optimize images/assets
|
||||
4. Scale server resources
|
||||
|
||||
## Performance Baseline
|
||||
|
||||
Initial baseline (to be established):
|
||||
|
||||
| Test | p95 (ms) | Error Rate | Throughput |
|
||||
|------|----------|------------|------------|
|
||||
| Public Browsing | TBD | TBD | TBD |
|
||||
| Admin Operations | TBD | TBD | TBD |
|
||||
| API Performance | TBD | TBD | TBD |
|
||||
|
||||
Update this table after first test run to establish baseline.
|
||||
|
||||
## Resources
|
||||
|
||||
- [k6 Documentation](https://k6.io/docs/)
|
||||
- [k6 Metrics](https://k6.io/docs/using-k6/metrics/)
|
||||
- [Payload CMS Performance](https://payloadcms.com/docs/admin/configuration)
|
||||
- [Web Vitals](https://web.dev/vitals/)
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Review and update test scenarios quarterly
|
||||
- Adjust load profiles based on real traffic patterns
|
||||
- Update thresholds based on business requirements
|
||||
- Add new tests for new features
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-01-31
|
||||
**Owner:** QA Team
|
||||
364
apps/backend/tests/k6/TESTING-GUIDE.md
Normal file
364
apps/backend/tests/k6/TESTING-GUIDE.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Load Testing Execution Guide
|
||||
|
||||
## Test Execution Checklist
|
||||
|
||||
### Pre-Test Requirements
|
||||
|
||||
- [ ] Backend server is running (`pnpm dev`)
|
||||
- [ ] Database is accessible
|
||||
- [ ] k6 is installed (`k6 version`)
|
||||
- [ ] Environment variables are configured
|
||||
|
||||
### Verification Test
|
||||
|
||||
Run the verification script first to ensure everything is set up correctly:
|
||||
|
||||
```bash
|
||||
k6 run tests/k6/verify-setup.js
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
=== K6 Setup Verification ===
|
||||
Target: http://localhost:3000
|
||||
Home page status: 200
|
||||
API status: 200
|
||||
Pages API response time: 123ms
|
||||
✓ Server is reachable
|
||||
✓ Home page responds
|
||||
✓ API endpoint responds
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Public Browsing Test
|
||||
|
||||
**Purpose:** Verify public pages can handle 100 concurrent users
|
||||
|
||||
**Prerequisites:**
|
||||
- Backend running
|
||||
- Public pages accessible
|
||||
|
||||
**Execution:**
|
||||
```bash
|
||||
# Local testing
|
||||
pnpm test:load
|
||||
|
||||
# With custom URL
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/public-browsing.js
|
||||
|
||||
# Staging
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
```
|
||||
|
||||
**Success Criteria:**
|
||||
- p95 response time < 500ms
|
||||
- Error rate < 1%
|
||||
- 100 concurrent users sustained for 2 minutes
|
||||
|
||||
**What It Tests:**
|
||||
- Homepage rendering
|
||||
- Page navigation
|
||||
- Static content delivery
|
||||
- Database read operations
|
||||
|
||||
---
|
||||
|
||||
### 2. API Performance Test
|
||||
|
||||
**Purpose:** Verify API endpoints meet performance targets
|
||||
|
||||
**Prerequisites:**
|
||||
- Backend running
|
||||
- API endpoints accessible
|
||||
|
||||
**Execution:**
|
||||
```bash
|
||||
# Local testing
|
||||
pnpm test:load:api
|
||||
|
||||
# With custom URL
|
||||
k6 run --env BASE_URL=http://localhost:3000 tests/k6/api-performance.js
|
||||
|
||||
# Staging
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
**Success Criteria:**
|
||||
- p95 response time < 300ms
|
||||
- Error rate < 0.5%
|
||||
- Throughput > 100 req/s
|
||||
|
||||
**What It Tests:**
|
||||
- REST API endpoints
|
||||
- GraphQL queries
|
||||
- Authentication endpoints
|
||||
- Concurrent API requests
|
||||
|
||||
---
|
||||
|
||||
### 3. Admin Operations Test
|
||||
|
||||
**Purpose:** Verify admin panel can handle 20 concurrent users
|
||||
|
||||
**Prerequisites:**
|
||||
- Backend running
|
||||
- Valid admin credentials
|
||||
|
||||
**Execution:**
|
||||
```bash
|
||||
# Local testing
|
||||
k6 run \
|
||||
--env ADMIN_EMAIL=admin@enchun.tw \
|
||||
--env ADMIN_PASSWORD=yourpassword \
|
||||
tests/k6/admin-operations.js
|
||||
|
||||
# Or use npm script
|
||||
ADMIN_EMAIL=admin@enchun.tw ADMIN_PASSWORD=yourpassword \
|
||||
pnpm test:load:admin
|
||||
```
|
||||
|
||||
**Success Criteria:**
|
||||
- p95 response time < 700ms
|
||||
- Error rate < 1%
|
||||
- 20 concurrent users sustained for 3 minutes
|
||||
|
||||
**What It Tests:**
|
||||
- Login/authentication
|
||||
- CRUD operations
|
||||
- Admin panel performance
|
||||
- Database write operations
|
||||
|
||||
**Warning:** This test creates draft posts in the database. Clean up manually after testing.
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Strategy
|
||||
|
||||
### Phase 1: Development Testing
|
||||
|
||||
Run tests locally during development with low load:
|
||||
|
||||
```bash
|
||||
# Quick smoke test (10 users)
|
||||
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||
|
||||
# API smoke test (5 users)
|
||||
k6 run --env STAGED_USERS=5 tests/k6/api-performance.js
|
||||
```
|
||||
|
||||
### Phase 2: Pre-Deployment Testing
|
||||
|
||||
Run full test suite against staging:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test:load:all
|
||||
|
||||
# Or individual tests with full load
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/api-performance.js
|
||||
k6 run --env BASE_URL=https://staging.enchun.tw \
|
||||
--env ADMIN_EMAIL=$STAGING_ADMIN_EMAIL \
|
||||
--env ADMIN_PASSWORD=$STAGING_ADMIN_PASSWORD \
|
||||
tests/k6/admin-operations.js
|
||||
```
|
||||
|
||||
### Phase 3: Production Monitoring
|
||||
|
||||
Schedule automated tests (via GitHub Actions or cron):
|
||||
|
||||
- Daily: Public browsing and API tests
|
||||
- Weekly: Full test suite including admin operations
|
||||
- On-demand: Before major releases
|
||||
|
||||
---
|
||||
|
||||
## Result Analysis
|
||||
|
||||
### Key Metrics to Check
|
||||
|
||||
1. **p95 Response Time**
|
||||
- Public pages: < 500ms ✅
|
||||
- API endpoints: < 300ms ✅
|
||||
- Admin operations: < 700ms ✅
|
||||
|
||||
2. **Error Rate**
|
||||
- Public: < 1% ✅
|
||||
- API: < 0.5% ✅
|
||||
- Admin: < 1% ✅
|
||||
|
||||
3. **Throughput**
|
||||
- API: > 100 req/s ✅
|
||||
- Pages: > 50 req/s ✅
|
||||
|
||||
4. **Virtual Users (VUs)**
|
||||
- Should sustain target VUs for full duration
|
||||
- No drops or connection errors
|
||||
|
||||
### Sample Output Analysis
|
||||
|
||||
```
|
||||
✓ http_req_duration..............: avg=185ms p(95)=420ms
|
||||
✓ http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||
✓ checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||
iterations.....................: 2400 20 /s
|
||||
vus............................: 100 min=100 max=100
|
||||
```
|
||||
|
||||
**This result shows:**
|
||||
- ✅ p95 = 420ms (< 500ms threshold)
|
||||
- ✅ Error rate = 0% (< 1% threshold)
|
||||
- ✅ All checks passed
|
||||
- ✅ Sustained 100 VUs
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Issue: Connection Refused
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
ERRO[0000] GoError: dial tcp 127.0.0.1:3000: connect: connection refused
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure backend is running: `pnpm dev`
|
||||
2. Check port is correct: `--env BASE_URL=http://localhost:3000`
|
||||
3. Verify firewall isn't blocking connections
|
||||
|
||||
---
|
||||
|
||||
### Issue: High Error Rate (> 1%)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
✗ http_req_failed................: 2.50% ✓ 11700 ✗ 300
|
||||
```
|
||||
|
||||
**Common Causes:**
|
||||
1. Server overloaded → Reduce VUs
|
||||
2. Database connection issues → Check DB logs
|
||||
3. Missing authentication → Check credentials
|
||||
4. Invalid URLs → Verify BASE_URL
|
||||
|
||||
**Solutions:**
|
||||
```bash
|
||||
# Reduce load to diagnose
|
||||
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||
|
||||
# Check server logs in parallel
|
||||
tail -f logs/backend.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Slow Response Times (p95 >= 500ms)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
http_req_duration..............: avg=450ms p(95)=850ms
|
||||
```
|
||||
|
||||
**Common Causes:**
|
||||
1. Unoptimized database queries
|
||||
2. Missing database indexes
|
||||
3. Large payload sizes
|
||||
4. Server resource constraints
|
||||
|
||||
**Solutions:**
|
||||
1. Check database query performance
|
||||
2. Review database indexes
|
||||
3. Optimize images/assets
|
||||
4. Scale server resources
|
||||
5. Enable caching
|
||||
|
||||
---
|
||||
|
||||
### Issue: Login Failed (Admin Test)
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
✗ login successful
|
||||
status: 401
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Verify admin credentials are correct
|
||||
2. Check admin user exists in database
|
||||
3. Ensure admin user has correct role
|
||||
4. Try logging in via admin panel first
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization Tips
|
||||
|
||||
Based on test results, you may need to:
|
||||
|
||||
1. **Add Database Indexes**
|
||||
```javascript
|
||||
// Example: Index on frequently queried fields
|
||||
Posts.createIndex({ title: 1 });
|
||||
Posts.createIndex({ status: 1, createdAt: -1 });
|
||||
```
|
||||
|
||||
2. **Enable Caching**
|
||||
- Cache global API responses
|
||||
- Cache public pages
|
||||
- Use CDN for static assets
|
||||
|
||||
3. **Optimize Images**
|
||||
- Use WebP format
|
||||
- Implement lazy loading
|
||||
- Serve responsive images
|
||||
|
||||
4. **Database Optimization**
|
||||
- Limit query depth where possible
|
||||
- Use projection to reduce payload size
|
||||
- Implement pagination
|
||||
|
||||
5. **Scale Resources**
|
||||
- Increase server memory/CPU
|
||||
- Use database connection pooling
|
||||
- Implement load balancing
|
||||
|
||||
---
|
||||
|
||||
## Reporting
|
||||
|
||||
### Generate Reports
|
||||
|
||||
```bash
|
||||
# JSON report
|
||||
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||
|
||||
# HTML report
|
||||
npm install -g k6-reporter
|
||||
k6-reporter results.json --output results.html
|
||||
open results.html
|
||||
```
|
||||
|
||||
### Share Results
|
||||
|
||||
Include in your PR/daily standup:
|
||||
- p95 response time
|
||||
- Error rate
|
||||
- Throughput
|
||||
- Any issues found
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Run tests regularly** - Catch regressions early
|
||||
2. **Test on staging first** - Avoid breaking production
|
||||
3. **Monitor trends** - Track performance over time
|
||||
4. **Share results** - Keep team informed
|
||||
5. **Update baselines** - Adjust as system evolves
|
||||
6. **Don't ignore failures** - Investigate all issues
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-01-31
|
||||
**Owner:** QA Team
|
||||
232
apps/backend/tests/k6/admin-operations.js
Normal file
232
apps/backend/tests/k6/admin-operations.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Admin Operations Load Test
|
||||
*
|
||||
* Simulates 20 concurrent admin users performing management operations
|
||||
* Tests:
|
||||
* - Login
|
||||
* - List collections (Pages, Posts, Portfolio)
|
||||
* - View/edit items
|
||||
* - Create new items
|
||||
* - Delete items
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 700ms (slightly more lenient for admin)
|
||||
* - Error rate < 1%
|
||||
* - 20 concurrent users sustained for 3 minutes
|
||||
*/
|
||||
|
||||
import { check, group } from 'k6';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { AuthHelper, ApiHelper, thinkTime, testData } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.adminOperations,
|
||||
thresholds: thresholdGroups.admin,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// Store auth token per VU
|
||||
let authToken = null;
|
||||
let apiHelper = null;
|
||||
|
||||
/**
|
||||
* Setup and login - runs once per VU
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== Admin Operations Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log(`Admin: ${config.adminEmail}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Login function
|
||||
*/
|
||||
function login() {
|
||||
const auth = new AuthHelper(config.baseUrl);
|
||||
const { success } = auth.login(config.adminEmail, config.adminPassword);
|
||||
|
||||
if (!success) {
|
||||
console.error('Login failed!');
|
||||
return null;
|
||||
}
|
||||
|
||||
apiHelper = new ApiHelper(config.baseUrl);
|
||||
apiHelper.setToken(auth.token);
|
||||
return auth.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Login if not authenticated
|
||||
if (!authToken) {
|
||||
group('Admin Login', () => {
|
||||
authToken = login();
|
||||
thinkTime(1, 2);
|
||||
});
|
||||
|
||||
if (!authToken) {
|
||||
// Cannot proceed without auth
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 1: Browse collections (read operations)
|
||||
group('List Collections', () => {
|
||||
// List pages
|
||||
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
|
||||
// List posts
|
||||
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
|
||||
// List portfolio
|
||||
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||
thinkTime(0.5, 1);
|
||||
});
|
||||
|
||||
// Scenario 2: View specific items (read operations)
|
||||
group('View Items', () => {
|
||||
// Try to view first item from each collection
|
||||
try {
|
||||
const pages = apiHelper.get('/pages', { limit: 1, depth: 0 });
|
||||
if (pages.status === 200 && pages.json('totalDocs') > 0) {
|
||||
const firstId = pages.json('docs')[0].id;
|
||||
apiHelper.get(`/pages/${firstId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
|
||||
const posts = apiHelper.get('/posts', { limit: 1, depth: 0 });
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const firstId = posts.json('docs')[0].id;
|
||||
apiHelper.get(`/posts/${firstId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
// Items might not exist
|
||||
console.log('No items to view:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 3: Create new content (write operations)
|
||||
group('Create Content', () => {
|
||||
// 20% chance to create a test post
|
||||
if (Math.random() < 0.2) {
|
||||
const newPost = {
|
||||
title: `Load Test Post ${testData.string(6)}`,
|
||||
content: testData.content(2),
|
||||
status: 'draft', // Save as draft to avoid publishing
|
||||
};
|
||||
|
||||
const res = apiHelper.post('/posts', newPost);
|
||||
|
||||
if (res.status === 201 || res.status === 200) {
|
||||
const postId = res.json('doc')?.id;
|
||||
|
||||
// Store for potential cleanup (in real scenario)
|
||||
if (postId) {
|
||||
console.log(`Created post: ${postId}`);
|
||||
}
|
||||
}
|
||||
|
||||
thinkTime(2, 3);
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 4: Update content (write operations)
|
||||
group('Update Content', () => {
|
||||
// 30% chance to update a post
|
||||
if (Math.random() < 0.3) {
|
||||
try {
|
||||
// Get a random post
|
||||
const posts = apiHelper.get('/posts', { limit: 20, depth: 0, where: { status: { equals: 'draft' } } });
|
||||
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const docs = posts.json('docs');
|
||||
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||
const postId = randomPost.id;
|
||||
|
||||
// Update the post
|
||||
const updateData = {
|
||||
title: `Updated ${randomPost.title}`,
|
||||
};
|
||||
|
||||
apiHelper.put(`/posts/${postId}`, updateData);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Update failed:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 5: Delete test content (cleanup operations)
|
||||
group('Delete Content', () => {
|
||||
// 10% chance to delete a draft post
|
||||
if (Math.random() < 0.1) {
|
||||
try {
|
||||
// Get draft posts (likely from load test)
|
||||
const posts = apiHelper.get('/posts', {
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
where: {
|
||||
status: { equals: 'draft' },
|
||||
title: { like: 'Load Test Post' },
|
||||
},
|
||||
});
|
||||
|
||||
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||
const docs = posts.json('docs');
|
||||
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||
const postId = randomPost.id;
|
||||
|
||||
// Delete the post
|
||||
apiHelper.delete(`/posts/${postId}`);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Delete failed:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 6: Use GraphQL API
|
||||
group('GraphQL Operations', () => {
|
||||
// 40% chance to use GraphQL
|
||||
if (Math.random() < 0.4) {
|
||||
const query = `
|
||||
query {
|
||||
Posts(limit: 5) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
apiHelper.graphql(query);
|
||||
thinkTime(1, 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Think time before next iteration
|
||||
thinkTime(3, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function - runs once after test
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== Admin Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 700ms');
|
||||
console.log('- Error rate < 1%');
|
||||
console.log('- 20 concurrent admin users sustained');
|
||||
console.log('Note: Any draft posts created were left in the system for manual review');
|
||||
}
|
||||
230
apps/backend/tests/k6/api-performance.js
Normal file
230
apps/backend/tests/k6/api-performance.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* API Performance Load Test
|
||||
*
|
||||
* Tests specific API endpoints for performance
|
||||
* Tests:
|
||||
* - REST API endpoints (Pages, Posts, Portfolio, Categories)
|
||||
* - GraphQL API queries
|
||||
* - Global API endpoint
|
||||
* - Authentication endpoints
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 300ms (faster for API)
|
||||
* - Error rate < 0.5% (stricter for API)
|
||||
* - Throughput > 100 requests/second
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { ApiHelper, testData, thinkTime } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.apiPerformance,
|
||||
thresholds: thresholdGroups.api,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// API helper (no auth needed for public endpoints)
|
||||
const apiHelper = new ApiHelper(config.baseUrl);
|
||||
|
||||
/**
|
||||
* Setup function
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== API Performance Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Scenario 1: Global API endpoint (metadata)
|
||||
group('Global API', () => {
|
||||
const res = apiHelper.get('/global');
|
||||
|
||||
check(res, {
|
||||
'has global data': (r) => {
|
||||
try {
|
||||
const body = r.json();
|
||||
return body !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
thinkTime(0.1, 0.3); // Minimal think time for API
|
||||
});
|
||||
|
||||
// Scenario 2: Pages API
|
||||
group('Pages API', () => {
|
||||
// List pages
|
||||
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||
|
||||
// Try to get a specific page
|
||||
try {
|
||||
const list = apiHelper.get('/pages', { limit: 1, depth: 0, page: 1 });
|
||||
if (list.status === 200 && list.json('totalDocs') > 0) {
|
||||
const firstId = list.json('docs')[0].id;
|
||||
apiHelper.get(`/pages/${firstId}`, { depth: 1 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Page might not exist
|
||||
}
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 3: Posts API
|
||||
group('Posts API', () => {
|
||||
// List posts
|
||||
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||
|
||||
// List with pagination
|
||||
apiHelper.get('/posts', { limit: 20, depth: 0, page: 1 });
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 4: Portfolio API
|
||||
group('Portfolio API', () => {
|
||||
// List portfolio items
|
||||
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||
|
||||
// Filter by category (if applicable)
|
||||
try {
|
||||
const categories = apiHelper.get('/categories', { limit: 1, depth: 0 });
|
||||
if (categories.status === 200 && categories.json('totalDocs') > 0) {
|
||||
const categoryId = categories.json('docs')[0].id;
|
||||
apiHelper.get('/portfolio', {
|
||||
limit: 10,
|
||||
depth: 1,
|
||||
where: {
|
||||
category: { equals: categoryId },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Categories might not exist
|
||||
}
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 5: Categories API
|
||||
group('Categories API', () => {
|
||||
// List all categories
|
||||
apiHelper.get('/categories', { limit: 10, depth: 1 });
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 6: GraphQL API
|
||||
group('GraphQL API', () => {
|
||||
// Query 1: Simple list
|
||||
const simpleQuery = `
|
||||
query {
|
||||
Posts(limit: 5) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
apiHelper.graphql(simpleQuery);
|
||||
|
||||
thinkTime(0.1, 0.2);
|
||||
|
||||
// Query 2: With relationships
|
||||
const complexQuery = `
|
||||
query {
|
||||
Posts(limit: 3, depth: 2) {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
content
|
||||
category {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
apiHelper.graphql(complexQuery);
|
||||
|
||||
thinkTime(0.1, 0.3);
|
||||
});
|
||||
|
||||
// Scenario 7: Authentication endpoints
|
||||
group('Auth API', () => {
|
||||
// Note: These will fail with invalid credentials, but test the endpoint response
|
||||
const loginRes = http.post(`${config.baseUrl}/api/users/login`, JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword',
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
check(loginRes, {
|
||||
'login responds': (r) => [200, 400, 401].includes(r.status),
|
||||
'login responds quickly': (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
thinkTime(0.1, 0.2);
|
||||
});
|
||||
|
||||
// Scenario 8: Concurrent API requests
|
||||
group('Concurrent Requests', () => {
|
||||
// Simulate multiple API calls in parallel
|
||||
const requests = [
|
||||
apiHelper.get('/pages', { limit: 5, depth: 1 }),
|
||||
apiHelper.get('/posts', { limit: 5, depth: 1 }),
|
||||
apiHelper.get('/portfolio', { limit: 5, depth: 1 }),
|
||||
];
|
||||
|
||||
// Check all succeeded
|
||||
const allSuccessful = requests.every(r => r.status === 200);
|
||||
|
||||
check(null, {
|
||||
'all concurrent requests successful': () => allSuccessful,
|
||||
});
|
||||
|
||||
thinkTime(0.2, 0.5);
|
||||
});
|
||||
|
||||
// Scenario 9: Filtered queries
|
||||
group('Filtered Queries', () => {
|
||||
// Various filter combinations
|
||||
const filters = [
|
||||
{ where: { status: { equals: 'published' } } },
|
||||
{ limit: 5, sort: '-createdAt' },
|
||||
{ limit: 10, depth: 2 },
|
||||
];
|
||||
|
||||
filters.forEach((filter, i) => {
|
||||
apiHelper.get('/posts', filter);
|
||||
thinkTime(0.05, 0.15);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal think time for API-focused test
|
||||
thinkTime(0.5, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== API Performance Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 300ms');
|
||||
console.log('- Error rate < 0.5%');
|
||||
console.log('- Throughput > 100 req/s');
|
||||
}
|
||||
195
apps/backend/tests/k6/lib/config.js
Normal file
195
apps/backend/tests/k6/lib/config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* K6 Test Configuration
|
||||
* Centralized configuration for all load tests
|
||||
*/
|
||||
|
||||
// Get environment variables with defaults
|
||||
export const config = {
|
||||
// Base URL for the target server
|
||||
get baseUrl() {
|
||||
return __ENV.BASE_URL || 'http://localhost:3000';
|
||||
},
|
||||
|
||||
// Admin credentials (from environment)
|
||||
get adminEmail() {
|
||||
return __ENV.ADMIN_EMAIL || 'admin@enchun.tw';
|
||||
},
|
||||
|
||||
get adminPassword() {
|
||||
return __ENV.ADMIN_PASSWORD || 'admin123';
|
||||
},
|
||||
|
||||
// Load testing thresholds (NFR4 requirements)
|
||||
thresholds: {
|
||||
// Response time thresholds
|
||||
httpReqDuration: ['p(95) < 500'], // 95th percentile < 500ms
|
||||
httpReqDurationApi: ['p(95) < 300'], // API endpoints < 300ms
|
||||
httpReqDurationAdmin: ['p(95) < 700'], // Admin operations < 700ms
|
||||
|
||||
// Error rate thresholds
|
||||
httpReqFailed: ['rate < 0.01'], // < 1% error rate
|
||||
httpReqFailedApi: ['rate < 0.005'], // API < 0.5% error rate
|
||||
|
||||
// Throughput thresholds
|
||||
httpReqs: ['rate > 50'], // > 50 requests/second for pages
|
||||
httpReqsApi: ['rate > 100'], // > 100 requests/second for API
|
||||
|
||||
// Check success rate
|
||||
checks: ['rate > 0.99'], // 99% of checks should pass
|
||||
},
|
||||
|
||||
// Stage configuration for gradual ramp-up
|
||||
stages: {
|
||||
// Public browsing: 100 users
|
||||
publicBrowsing: [
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '30s', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '1m', target: 100 }, // Ramp up to 100 users
|
||||
{ duration: '2m', target: 100 }, // Stay at 100 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
|
||||
// Admin operations: 20 users
|
||||
adminOperations: [
|
||||
{ duration: '30s', target: 5 }, // Ramp up to 5 users
|
||||
{ duration: '30s', target: 10 }, // Ramp up to 10 users
|
||||
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||
{ duration: '3m', target: 20 }, // Stay at 20 users
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
|
||||
// API performance: 50 users
|
||||
apiPerformance: [
|
||||
{ duration: '1m', target: 10 }, // Ramp up to 10 users
|
||||
{ duration: '1m', target: 25 }, // Ramp up to 25 users
|
||||
{ duration: '1m', target: 50 }, // Ramp up to 50 users
|
||||
{ duration: '5m', target: 50 }, // Stay at 50 users
|
||||
{ duration: '1m', target: 0 }, // Ramp down
|
||||
],
|
||||
},
|
||||
|
||||
// Common request options
|
||||
requestOptions: {
|
||||
timeout: '30s', // Request timeout
|
||||
maxRedirects: 10, // Maximum redirects to follow
|
||||
discardResponseBodies: true, // Discard response bodies to save memory
|
||||
},
|
||||
|
||||
// Common headers
|
||||
headers: {
|
||||
'User-Agent': 'k6-load-test',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9,zh-TW;q=0.8',
|
||||
},
|
||||
};
|
||||
|
||||
// URL helpers
|
||||
export const urls = {
|
||||
get baseUrl() {
|
||||
return config.baseUrl;
|
||||
},
|
||||
|
||||
// Public pages
|
||||
get home() {
|
||||
return `${config.baseUrl}/`;
|
||||
},
|
||||
get about() {
|
||||
return `${config.baseUrl}/about`;
|
||||
},
|
||||
get solutions() {
|
||||
return `${config.baseUrl}/solutions`;
|
||||
},
|
||||
get portfolio() {
|
||||
return `${config.baseUrl}/portfolio`;
|
||||
},
|
||||
get blog() {
|
||||
return `${config.baseUrl}/blog`;
|
||||
},
|
||||
get contact() {
|
||||
return `${config.baseUrl}/contact`;
|
||||
},
|
||||
|
||||
// API endpoints
|
||||
get api() {
|
||||
return `${config.baseUrl}/api`;
|
||||
},
|
||||
get graphql() {
|
||||
return `${config.baseUrl}/api/graphql`;
|
||||
},
|
||||
get global() {
|
||||
return `${config.baseUrl}/api/global`;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
get admin() {
|
||||
return `${config.baseUrl}/admin`;
|
||||
},
|
||||
get collections() {
|
||||
return `${config.baseUrl}/api/collections`;
|
||||
},
|
||||
get pages() {
|
||||
return `${config.baseUrl}/api/pages`;
|
||||
},
|
||||
get posts() {
|
||||
return `${config.baseUrl}/api/posts`;
|
||||
},
|
||||
get portfolioItems() {
|
||||
return `${config.baseUrl}/api/portfolio`;
|
||||
},
|
||||
get categories() {
|
||||
return `${config.baseUrl}/api/categories`;
|
||||
},
|
||||
|
||||
// Auth endpoints
|
||||
get login() {
|
||||
return `${config.baseUrl}/api/users/login`;
|
||||
},
|
||||
get logout() {
|
||||
return `${config.baseUrl}/api/users/logout`;
|
||||
},
|
||||
get me() {
|
||||
return `${config.baseUrl}/api/users/me`;
|
||||
},
|
||||
};
|
||||
|
||||
// Common checks
|
||||
export const checks = {
|
||||
// HTTP status checks
|
||||
status200: (res) => res.status === 200,
|
||||
status201: (res) => res.status === 201,
|
||||
status204: (res) => res.status === 204,
|
||||
|
||||
// Response time checks
|
||||
responseTimeFast: (res) => res.timings.duration < 200,
|
||||
responseTimeOk: (res) => res.timings.duration < 500,
|
||||
responseTimeSlow: (res) => res.timings.duration < 1000,
|
||||
|
||||
// Content checks
|
||||
hasContent: (res) => res.body.length > 0,
|
||||
hasJson: (res) => res.headers['Content-Type'].includes('application/json'),
|
||||
|
||||
// Performance checks (NFR4)
|
||||
nfr4ResponseTime: (res) => res.timings.duration < 500, // p95 < 500ms
|
||||
nfr4ApiResponseTime: (res) => res.timings.duration < 300, // API < 300ms
|
||||
};
|
||||
|
||||
// Threshold groups for different test types
|
||||
export const thresholdGroups = {
|
||||
public: {
|
||||
http_req_duration: ['p(95) < 500', 'p(99) < 1000'],
|
||||
http_req_failed: ['rate < 0.01'],
|
||||
checks: ['rate > 0.99'],
|
||||
},
|
||||
|
||||
admin: {
|
||||
http_req_duration: ['p(95) < 700', 'p(99) < 1500'],
|
||||
http_req_failed: ['rate < 0.01'],
|
||||
checks: ['rate > 0.99'],
|
||||
},
|
||||
|
||||
api: {
|
||||
http_req_duration: ['p(95) < 300', 'p(99) < 500'],
|
||||
http_req_failed: ['rate < 0.005'],
|
||||
checks: ['rate > 0.995'],
|
||||
},
|
||||
};
|
||||
405
apps/backend/tests/k6/lib/helpers.js
Normal file
405
apps/backend/tests/k6/lib/helpers.js
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* K6 Test Helpers
|
||||
* Reusable helper functions for load tests
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { urls, checks } from './config.js';
|
||||
|
||||
/**
|
||||
* Authentication helper
|
||||
*/
|
||||
export class AuthHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and store token
|
||||
*/
|
||||
login(email, password) {
|
||||
const res = http.post(`${this.baseUrl}/api/users/login`, JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const success = check(res, {
|
||||
'login successful': checks.status200,
|
||||
'received token': (r) => r.json('token') !== undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.token = res.json('token');
|
||||
}
|
||||
|
||||
return { res, success };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers
|
||||
*/
|
||||
getAuthHeaders() {
|
||||
if (!this.token) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear token
|
||||
*/
|
||||
logout() {
|
||||
const res = http.post(`${this.baseUrl}/api/users/logout`, null, {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
this.token = null;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page load helper
|
||||
*/
|
||||
export class PageHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page and check response
|
||||
*/
|
||||
loadPage(path) {
|
||||
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
||||
const res = http.get(url);
|
||||
|
||||
check(res, {
|
||||
[`page loaded: ${path}`]: checks.status200,
|
||||
'response time < 500ms': checks.nfr4ResponseTime,
|
||||
'has content': checks.hasContent,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple pages randomly
|
||||
*/
|
||||
loadRandomPages(pageList, count = 5) {
|
||||
const pages = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const randomPage = pageList[Math.floor(Math.random() * pageList.length)];
|
||||
pages.push(this.loadPage(randomPage));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API helper
|
||||
*/
|
||||
export class ApiHelper {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth token
|
||||
*/
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers
|
||||
*/
|
||||
getHeaders(additionalHeaders = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request
|
||||
*/
|
||||
get(endpoint, params = {}) {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||
|
||||
const res = http.get(url.toString(), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`GET ${endpoint} successful`]: checks.status200,
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*/
|
||||
post(endpoint, data) {
|
||||
const res = http.post(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`POST ${endpoint} successful`]: (r) => [200, 201].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*/
|
||||
put(endpoint, data) {
|
||||
const res = http.put(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`PUT ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*/
|
||||
delete(endpoint) {
|
||||
const res = http.del(`${this.baseUrl}${endpoint}`, null, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
[`DELETE ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL query helper
|
||||
*/
|
||||
graphql(query, variables = {}) {
|
||||
const res = http.post(`${this.baseUrl}/api/graphql`, JSON.stringify({
|
||||
query,
|
||||
variables,
|
||||
}), {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
check(res, {
|
||||
'GraphQL successful': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
const body = r.json();
|
||||
return !body.errors;
|
||||
},
|
||||
'GraphQL response time < 300ms': checks.nfr4ApiResponseTime,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Think time helper
|
||||
* Simulates real user think time between actions
|
||||
*/
|
||||
export function thinkTime(min = 1, max = 3) {
|
||||
sleep(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random item picker
|
||||
*/
|
||||
export function pickRandom(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted random picker
|
||||
*/
|
||||
export function pickWeighted(items) {
|
||||
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const item of items) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
return item.value;
|
||||
}
|
||||
}
|
||||
|
||||
return items[0].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random test data
|
||||
*/
|
||||
export const testData = {
|
||||
// Random string
|
||||
string(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// Random email
|
||||
email() {
|
||||
return `test_${this.string(8)}@example.com`;
|
||||
},
|
||||
|
||||
// Random number
|
||||
number(min = 0, max = 100) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
},
|
||||
|
||||
// Random phone
|
||||
phone() {
|
||||
return `09${this.number(10000000, 99999999)}`;
|
||||
},
|
||||
|
||||
// Random title
|
||||
title() {
|
||||
const adjectives = ['Amazing', 'Awesome', 'Brilliant', 'Creative', 'Dynamic'];
|
||||
const nouns = ['Project', 'Solution', 'Product', 'Service', 'Innovation'];
|
||||
return `${pickRandom(adjectives)} ${pickRandom(nouns)}`;
|
||||
},
|
||||
|
||||
// Random content
|
||||
content(paragraphs = 3) {
|
||||
const sentences = [
|
||||
'This is a test sentence for load testing purposes.',
|
||||
'Load testing helps identify performance bottlenecks.',
|
||||
'We ensure the system can handle expected traffic.',
|
||||
'Performance is critical for user experience.',
|
||||
'Testing under load reveals system behavior.',
|
||||
];
|
||||
|
||||
let content = '';
|
||||
for (let i = 0; i < paragraphs; i++) {
|
||||
content += '\n\n';
|
||||
for (let j = 0; j < 3; j++) {
|
||||
content += pickRandom(sentences) + ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Metrics helper
|
||||
*/
|
||||
export class MetricsHelper {
|
||||
constructor() {
|
||||
this.metrics = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric
|
||||
*/
|
||||
record(name, value) {
|
||||
if (!this.metrics[name]) {
|
||||
this.metrics[name] = {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Infinity,
|
||||
max: -Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const metric = this.metrics[name];
|
||||
metric.count++;
|
||||
metric.sum += value;
|
||||
metric.min = Math.min(metric.min, value);
|
||||
metric.max = Math.max(metric.max, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric statistics
|
||||
*/
|
||||
getStats(name) {
|
||||
const metric = this.metrics[name];
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
count: metric.count,
|
||||
sum: metric.sum,
|
||||
avg: metric.sum / metric.count,
|
||||
min: metric.min,
|
||||
max: metric.max,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print all metrics
|
||||
*/
|
||||
printAll() {
|
||||
console.log('=== Custom Metrics ===');
|
||||
Object.keys(this.metrics).forEach(name => {
|
||||
const stats = this.getStats(name);
|
||||
console.log(`${name}:`, stats);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario helper
|
||||
* Define test scenarios with weights
|
||||
*/
|
||||
export class ScenarioHelper {
|
||||
constructor() {
|
||||
this.scenarios = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scenario
|
||||
*/
|
||||
register(name, fn, weight = 1) {
|
||||
this.scenarios[name] = { fn, weight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a random scenario based on weights
|
||||
*/
|
||||
execute() {
|
||||
const scenarios = Object.entries(this.scenarios).map(([name, { fn, weight }]) => ({
|
||||
value: fn,
|
||||
weight,
|
||||
}));
|
||||
|
||||
const scenario = pickWeighted(scenarios);
|
||||
scenario();
|
||||
}
|
||||
}
|
||||
119
apps/backend/tests/k6/public-browsing.js
Normal file
119
apps/backend/tests/k6/public-browsing.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Public Browsing Load Test
|
||||
*
|
||||
* Simulates 100 concurrent users browsing public pages
|
||||
* Tests:
|
||||
* - Homepage
|
||||
* - About page
|
||||
* - Solutions page
|
||||
* - Portfolio list
|
||||
* - Blog list
|
||||
* - Contact page
|
||||
*
|
||||
* NFR4 Requirements:
|
||||
* - p95 response time < 500ms
|
||||
* - Error rate < 1%
|
||||
* - 100 concurrent users sustained for 2 minutes
|
||||
*/
|
||||
|
||||
import { check, group } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||
import { PageHelper, thinkTime, pickRandom } from './lib/helpers.js';
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: config.stages.publicBrowsing,
|
||||
thresholds: thresholdGroups.public,
|
||||
...config.requestOptions,
|
||||
};
|
||||
|
||||
// Page list to browse
|
||||
const publicPages = [
|
||||
urls.home,
|
||||
urls.about,
|
||||
urls.solutions,
|
||||
urls.portfolio,
|
||||
urls.blog,
|
||||
urls.contact,
|
||||
];
|
||||
|
||||
// Initialize page helper
|
||||
const pageHelper = new PageHelper(config.baseUrl);
|
||||
|
||||
/**
|
||||
* Main test scenario
|
||||
*/
|
||||
export default function () {
|
||||
// Scenario 1: Browse homepage (most common)
|
||||
group('Browse Homepage', () => {
|
||||
pageHelper.loadPage(urls.home);
|
||||
thinkTime(2, 4); // 2-4 seconds thinking
|
||||
});
|
||||
|
||||
// Scenario 2: Browse random pages (weighted)
|
||||
group('Browse Random Pages', () => {
|
||||
// Browse 3-6 random pages
|
||||
const pageCount = Math.floor(Math.random() * 4) + 3;
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const randomPage = pickRandom(publicPages);
|
||||
pageHelper.loadPage(randomPage);
|
||||
thinkTime(1, 3); // 1-3 seconds thinking
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 3: Navigate to contact (conversion intent)
|
||||
group('Navigate to Contact', () => {
|
||||
// 20% chance to visit contact page
|
||||
if (Math.random() < 0.2) {
|
||||
pageHelper.loadPage(urls.contact);
|
||||
thinkTime(3, 5); // More time on contact page
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 4: Deep dive into portfolio or blog
|
||||
group('Deep Dive', () => {
|
||||
// 30% chance to deep dive
|
||||
if (Math.random() < 0.3) {
|
||||
const section = Math.random() > 0.5 ? 'portfolio' : 'blog';
|
||||
|
||||
if (section === 'portfolio') {
|
||||
// Browse portfolio items
|
||||
pageHelper.loadPage(urls.portfolio);
|
||||
thinkTime(1, 2);
|
||||
// Note: In real scenario, we would click individual items
|
||||
// This requires parsing the page to get item URLs
|
||||
} else {
|
||||
// Browse blog posts
|
||||
pageHelper.loadPage(urls.blog);
|
||||
thinkTime(1, 2);
|
||||
// Note: In real scenario, we would click individual posts
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Small think time before next iteration
|
||||
thinkTime(2, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup function - runs once before test
|
||||
*/
|
||||
export function setup() {
|
||||
console.log('=== Public Browsing Load Test ===');
|
||||
console.log(`Target: ${config.baseUrl}`);
|
||||
console.log(`Pages to browse: ${publicPages.length}`);
|
||||
console.log('Starting test...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown function - runs once after test
|
||||
*/
|
||||
export function teardown(data) {
|
||||
console.log('=== Test Complete ===');
|
||||
console.log('Check results above for:');
|
||||
console.log('- p95 response time < 500ms');
|
||||
console.log('- Error rate < 1%');
|
||||
console.log('- 100 concurrent users sustained');
|
||||
}
|
||||
59
apps/backend/tests/k6/verify-setup.js
Normal file
59
apps/backend/tests/k6/verify-setup.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* K6 Setup Verification Script
|
||||
* Run this to verify your k6 installation and environment
|
||||
*/
|
||||
|
||||
import { check } from 'k6';
|
||||
import http from 'k6/http';
|
||||
|
||||
// Test configuration - minimal load
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95) < 1000'], // Relaxed for verification
|
||||
http_req_failed: ['rate < 0.05'], // Allow some failures during setup
|
||||
},
|
||||
};
|
||||
|
||||
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
export default function () {
|
||||
console.log(`=== K6 Setup Verification ===`);
|
||||
console.log(`Target: ${baseUrl}`);
|
||||
|
||||
// Test 1: Server is reachable
|
||||
const homeRes = http.get(baseUrl);
|
||||
check(homeRes, {
|
||||
'Server is reachable': (r) => r.status !== 0,
|
||||
'Home page responds': (r) => [200, 301, 302, 404].includes(r.status),
|
||||
});
|
||||
console.log(`Home page status: ${homeRes.status}`);
|
||||
|
||||
// Test 2: API endpoint exists
|
||||
const apiRes = http.get(`${baseUrl}/api/global`);
|
||||
check(apiRes, {
|
||||
'API endpoint responds': (r) => r.status !== 0,
|
||||
});
|
||||
console.log(`API status: ${apiRes.status}`);
|
||||
|
||||
// Test 3: Check response times
|
||||
const timeRes = http.get(`${baseUrl}/api/pages`, {
|
||||
tags: { name: 'pages_api' },
|
||||
});
|
||||
console.log(`Pages API response time: ${timeRes.timings.duration}ms`);
|
||||
|
||||
// Summary
|
||||
console.log(`=== Verification Complete ===`);
|
||||
console.log(`If all checks passed, you're ready to run load tests!`);
|
||||
console.log(`Next: k6 run tests/k6/public-browsing.js`);
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
console.log(`Starting K6 verification...`);
|
||||
console.log(`k6 version: ${__K6_VERSION__ || 'unknown'}`);
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
console.log(`Verification finished.`);
|
||||
}
|
||||
Reference in New Issue
Block a user