昨天我們建立了一個 FastAPI 電商應用,今天我們將為它編寫完整的 Locust 測試腳本,模擬真實使用者的購物流程,並且實際走一次測試分析。
我們要測試的完整使用者流程:
day15/
├── README.md
├── locustfile.py # 主要測試檔案
├── user_behaviors.py # 使用者行為定義
├── test_data.py # 測試資料管理
└── requirements.txt # 依賴套件
# requirements.txt
locust==2.17.0
requests==2.31.0
faker==20.1.0
# test_data.py
import random
from faker import Faker
fake = Faker('zh_TW')
class TestDataGenerator:
"""測試資料生成器"""
# 預定義測試使用者
EXISTING_USERS = [
{"username": "testuser1", "password": "password123"},
{"username": "testuser2", "password": "password123"},
]
# 商品 ID 範圍
PRODUCT_IDS = [1, 2, 3, 4]
@staticmethod
def generate_new_user():
"""生成新使用者資料"""
username = fake.user_name()
timestamp = str(random.randint(1000, 9999))
return {
"username": f"{username}_{timestamp}",
"email": fake.email(),
"password": "password123"
}
@staticmethod
def get_random_existing_user():
"""獲取隨機現有使用者"""
return random.choice(TestDataGenerator.EXISTING_USERS)
@staticmethod
def get_random_product_id():
"""獲取隨機商品 ID"""
return random.choice(TestDataGenerator.PRODUCT_IDS)
@staticmethod
def get_random_quantity():
"""獲取隨機商品數量"""
return random.randint(1, 5)
# user_behaviors.py
import json
import random
from locust import HttpUser, task, between
from test_data import TestDataGenerator
class ShopUser(HttpUser):
"""電商使用者基礎類別"""
wait_time = between(1, 3)
host = "http://localhost:8000"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.auth_token = None
self.user_data = None
self.cart_items = []
def on_start(self):
"""使用者開始測試時的初始化"""
self.user_data = TestDataGenerator.generate_new_user()
self.register_and_login()
def register_and_login(self):
"""註冊並登入流程"""
# 註冊新使用者
response = self.client.post("/register", json=self.user_data)
if response.status_code == 200:
# 註冊成功,立即登入
login_data = {
"username": self.user_data["username"],
"password": self.user_data["password"]
}
with self.client.post("/login", json=login_data, catch_response=True) as response:
if response.status_code == 200:
token_data = response.json()
self.auth_token = token_data["access_token"]
response.success()
else:
response.failure(f"登入失敗: {response.status_code}")
def get_auth_headers(self):
"""獲取認證標頭"""
if self.auth_token:
return {"Authorization": f"Bearer {self.auth_token}"}
return {}
@task(5)
def browse_products(self):
"""瀏覽商品列表"""
with self.client.get("/products", catch_response=True) as response:
if response.status_code == 200:
products = response.json()
response.success()
# 隨機查看商品詳情
if products and random.random() < 0.3: # 30% 機率
product_id = random.choice(products)["id"]
self.view_product_detail(product_id)
else:
response.failure(f"無法載入商品列表: {response.status_code}")
def view_product_detail(self, product_id):
"""查看商品詳情"""
with self.client.get(f"/products/{product_id}", catch_response=True) as response:
if response.status_code == 200:
response.success()
else:
response.failure(f"無法載入商品詳情: {response.status_code}")
@task(3)
def add_to_cart(self):
"""添加商品到購物車"""
if not self.auth_token:
return
product_id = TestDataGenerator.get_random_product_id()
quantity = TestDataGenerator.get_random_quantity()
cart_item = {
"product_id": product_id,
"quantity": quantity
}
with self.client.post(
"/cart/items",
json=cart_item,
headers=self.get_auth_headers(),
catch_response=True
) as response:
if response.status_code == 200:
self.cart_items.append(cart_item)
response.success()
else:
response.failure(f"添加商品到購物車失敗: {response.status_code}")
@task(2)
def view_cart(self):
"""查看購物車"""
if not self.auth_token:
return
with self.client.get(
"/cart",
headers=self.get_auth_headers(),
catch_response=True
) as response:
if response.status_code == 200:
cart_data = response.json()
response.success()
else:
response.failure(f"無法查看購物車: {response.status_code}")
@task(1)
def checkout(self):
"""結帳流程"""
if not self.auth_token or len(self.cart_items) == 0:
return
with self.client.post(
"/checkout",
headers=self.get_auth_headers(),
catch_response=True
) as response:
if response.status_code == 200:
order_data = response.json()
response.success()
# 結帳成功,清空購物車記錄
self.cart_items = []
else:
response.failure(f"結帳失敗: {response.status_code}")
class BrowsingUser(HttpUser):
"""專門瀏覽商品的使用者(不需登入)"""
wait_time = between(0.5, 2)
host = "http://localhost:8000"
weight = 3
@task(1)
def browse_products_only(self):
"""只瀏覽商品,不進行購買"""
# 瀏覽商品列表
self.client.get("/products")
# 30% 機率查看商品詳情
if random.random() < 0.3:
product_id = TestDataGenerator.get_random_product_id()
self.client.get(f"/products/{product_id}")
class RegisterOnlyUser(HttpUser):
"""只註冊不購買的使用者"""
wait_time = between(2, 5)
host = "http://localhost:8000"
weight = 1
@task(1)
def register_and_browse(self):
"""註冊後簡單瀏覽"""
user_data = TestDataGenerator.generate_new_user()
# 註冊
response = self.client.post("/register", json=user_data)
if response.status_code == 200:
# 註冊成功後瀏覽一下商品
self.client.get("/products")
# locustfile.py
from locust import HttpUser, task, between, events
from user_behaviors import ShopUser, BrowsingUser, RegisterOnlyUser
import logging
# 設定日誌
logging.basicConfig(level=logging.INFO)
@events.test_start.add_listener
def on_test_start(environment, **kwargs):
"""測試開始時的初始化"""
logging.info("開始電商負載測試")
logging.info(f"目標主機: {environment.host}")
@events.test_stop.add_listener
def on_test_stop(environment, **kwargs):
"""測試結束時的統計"""
logging.info("電商負載測試結束")
stats = environment.stats
total_requests = stats.total.num_requests
total_failures = stats.total.num_failures
logging.info(f"總請求數: {total_requests}")
logging.info(f"失敗請求數: {total_failures}")
if total_requests > 0:
success_rate = (total_requests - total_failures) / total_requests * 100
logging.info(f"成功率: {success_rate:.2f}%")
# 設定不同類型使用者的權重
class CompleteShoppingUser(ShopUser):
"""完整購物流程使用者"""
weight = 5
class QuickBrowsingUser(BrowsingUser):
"""快速瀏覽使用者"""
weight = 3
class NewRegisterUser(RegisterOnlyUser):
"""新註冊使用者"""
weight = 1
# test_scenarios.py
from locust import LoadTestShape
class StepLoadPattern(LoadTestShape):
"""階段式負載模式"""
step_time = 60 # 每階段 60 秒
step_load = 10 # 每階段增加 10 個使用者
spawn_rate = 2 # 每秒生成 2 個使用者
time_limit = 600 # 總測試時間 10 分鐘
def tick(self):
run_time = self.get_run_time()
if run_time > self.time_limit:
return None
current_step = run_time // self.step_time + 1
users = min(current_step * self.step_load, 50) # 最大 50 個使用者
return (users, self.spawn_rate)
# 啟動 FastAPI 應用(在 day14 目錄)
cd ../day14
pip install fastapi uvicorn
uvicorn app.main:app --reload &
# 回到測試目錄
cd ../day15
pip install -r requirements.txt
# 基本測試 - Web UI 模式
locust -f locustfile.py --host http://localhost:8000
# 命令列模式 - 30 個使用者,持續 3 分鐘
locust -f locustfile.py --host http://localhost:8000 \
--users 30 --spawn-rate 5 --run-time 3m --headless
# 生成 HTML 報告
locust -f locustfile.py --host http://localhost:8000 \
--users 20 --spawn-rate 3 --run-time 2m --headless \
--html report.html --csv results
關注以下指標:
效能指標:
業務指標:
# peak_traffic.py - 尖峰流量測試
class PeakTrafficUser(HttpUser):
wait_time = between(0.1, 0.5) # 更短等待時間
weight = 10
# mobile_user.py - 行動裝置使用者測試
class MobileUser(HttpUser):
wait_time = between(2, 8) # 較長思考時間
def on_start(self):
self.client.headers.update({
"User-Agent": "Mobile App/1.0"
})
# 啟動 Master 節點
locust -f locustfile.py --master --host http://localhost:8000
# 啟動 Worker 節點
locust -f locustfile.py --worker --master-host localhost
# locust_config.yml
host: "http://localhost:8000"
users: 50
spawn-rate: 5
run-time: "5m"
headless: true
html: "shop_test_report.html"
csv: "shop_test_results"
# 使用配置檔執行
locust -f locustfile.py --config locust_config.yml
在測試過程中:
連線問題:
# 確認 FastAPI 應用正在運行
curl http://localhost:8000/products
# 檢查防火牆設定
netstat -tlnp | grep 8000
認證問題:
注意: 本節將記錄實際執行測試的結果,包含效能數據、截圖和分析。
# 主要測試參數
使用者數量: 20 個並發使用者
增長速率: 2 users/second
測試時間: 2 分鐘
目標主機: http://localhost:8000
# 使用者類型權重配置
- CompleteShoppingUser: 權重 5 (完整購物流程)
- QuickBrowsingUser: 權重 3 (純瀏覽商品)
- ExistingUserShoppingUser: 權重 2 (既有用戶購物)
- NewRegisterUser: 權重 1 (新用戶註冊)
指標 | 數值 | 備註 |
---|---|---|
總請求數 | 2,089 | - |
失敗請求數 | 150 | 主要因為庫存不足導致 |
成功率 | 92.82% | 良好的成功率 |
平均 RPS | 17.52 | 每秒請求數 |
平均回應時間 | 13ms | 響應速度很快 |
95% 回應時間 | 31ms | 絕大部分請求都很快 |
API 端點 | 平均回應時間 | 95% 回應時間 | 失敗率 | 備註 |
---|---|---|---|---|
POST /register | 240ms | 280ms | 0.0% | 使用者註冊,需密碼雜湊處理 |
POST /login | 232ms | 240ms | 0.0% | 使用者登入,JWT 產生時間 |
GET /products | 4ms | 9ms | 0.0% | 商品列表,效能優異 |
GET /products/1 | 3ms | 5ms | 0.0% | 商品詳情,快速回應 |
GET /products/2 | 4ms | 5ms | 0.0% | 商品詳情,快速回應 |
GET /products/3 | 5ms | 18ms | 0.0% | 商品詳情,快速回應 |
GET /products/4 | 3ms | 6ms | 0.0% | 商品詳情,快速回應 |
GET /products/5 | 4ms | 13ms | 0.0% | 商品詳情,快速回應 |
GET /products/6 | 3ms | 12ms | 0.0% | 商品詳情,快速回應 |
POST /cart/items | 4ms | 7ms | 41.5% | 加入購物車,庫存不足導致高失敗率 |
GET /cart | 5ms | 16ms | 0.0% | 查看購物車,效能良好 |
POST /checkout | 6ms | 13ms | 41.1% | 結帳,庫存不足導致高失敗率 |
業務指標 | 數值 | 分析 |
---|---|---|
註冊成功率 | 100% | 所有 69 個註冊請求都成功 |
登入成功率 | 100% | 所有 10 個登入請求都成功 |
商品瀏覽率 | 100% | 所有 1,635 個商品瀏覽請求都成功 |
購物車轉換率 | 58.5% | 289 個加購請求中有 169 個成功 |
結帳成功率 | 58.9% | 73 個結帳請求中有 43 個成功 |
final_report.html
test_results_stats.csv
, test_results_failures.csv
發現的效能瓶頸:
改善建議:
資源類型 | 使用情況 | 備註 |
---|---|---|
CPU 使用率 | 低-中等 | FastAPI 在 M2 晶片上運行順暢 |
記憶體使用率 | 低 | SQLite 記憶體使用量很小 |
磁碟 I/O | 輕量 | SQLite 檔案式資料庫,I/O 較少 |
網路 I/O | 正常 | 本地測試,網路延遲幾乎為零 |
常見錯誤類型:
添加商品到購物車失敗: 400 - {"detail":"庫存不足"}
HTTPError('400 Client Error: Bad Request for url: /cart/items')
錯誤原因分析:
解決方案:
今天我們為 FastAPI 電商應用建立了完整的 Locust 測試方案:
透過這套測試系統,我們可以: