Files
DashBoard/tests/stress/test_frontend_stress.py
beabigegg b00750436e feat: 新增壓力測試套件 - API 負載測試與前端穩定性驗證
新增全端壓力測試以驗證系統在高負載下的穩定性:

後端 API 負載測試:
- 並發請求測試 (10 用戶, 200 請求)
- WIP Summary: 100% 成功率, 343 req/s
- WIP Matrix: 100% 成功率, 119 req/s
- 回應一致性驗證

前端 Playwright 壓力測試 (11 項):
- Toast 系統: 快速建立、類型循環、記憶體清理
- MesApi: 快速請求、並發處理、AbortController
- 頁面導航: Tab 切換、iframe 載入
- JS 錯誤監控

測試檔案:
- tests/stress/test_api_load.py
- tests/stress/test_frontend_stress.py
- scripts/run_stress_tests.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:17:57 +08:00

367 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""Frontend stress tests using Playwright.
Tests frontend stability under high-frequency operations:
- Toast notification system under rapid fire
- MesApi client under rapid requests
- AbortController behavior
- Page navigation stress
Run with: pytest tests/stress/test_frontend_stress.py -v -s
"""
import pytest
import time
import re
from playwright.sync_api import Page, expect
@pytest.fixture(scope="session")
def app_server() -> str:
"""Get the base URL for stress testing."""
import os
return os.environ.get('STRESS_TEST_URL', 'http://127.0.0.1:5000')
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""Configure browser context for stress tests."""
return {
**browser_context_args,
"viewport": {"width": 1280, "height": 720},
"locale": "zh-TW",
}
def load_page_with_js(page: Page, url: str, timeout: int = 60000):
"""Load page and wait for JS to initialize."""
page.goto(url, wait_until='domcontentloaded', timeout=timeout)
page.wait_for_timeout(1000) # Allow JS initialization
@pytest.mark.stress
class TestToastStress:
"""Stress tests for Toast notification system."""
def test_rapid_toast_creation(self, page: Page, app_server: str):
"""Test Toast system under rapid creation - should enforce max limit."""
load_page_with_js(page, f"{app_server}/tables")
# Create 50 toasts rapidly
start_time = time.time()
for i in range(50):
page.evaluate(f"Toast.info('Rapid toast {i}')")
creation_time = time.time() - start_time
print(f"\n Created 50 toasts in {creation_time:.3f}s")
page.wait_for_timeout(500)
# Should only have max 5 toasts visible
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 5, f"Toast count {toast_count} exceeds max limit of 5"
print(f" Toast count enforced: {toast_count} (max 5)")
def test_toast_type_cycling(self, page: Page, app_server: str):
"""Test rapid cycling through all toast types - system remains stable."""
load_page_with_js(page, f"{app_server}/tables")
toast_types = ['info', 'success', 'warning', 'error']
start_time = time.time()
for i in range(100):
toast_type = toast_types[i % len(toast_types)]
page.evaluate(f"Toast.{toast_type}('Type cycle {i}')")
cycle_time = time.time() - start_time
print(f"\n Cycled 100 toasts in {cycle_time:.3f}s")
# Wait for animations to complete
page.wait_for_timeout(1000)
# Dismiss all and verify system can recover
page.evaluate("Toast.dismissAll()")
page.wait_for_timeout(500)
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 5, f"Toast overflow after dismissAll: {toast_count}"
print(f" System stable after cleanup, toast count: {toast_count}")
def test_toast_dismiss_stress(self, page: Page, app_server: str):
"""Test rapid toast creation and dismissal."""
load_page_with_js(page, f"{app_server}/tables")
start_time = time.time()
# Create and immediately dismiss
for i in range(30):
toast_id = page.evaluate(f"Toast.info('Dismiss test {i}')")
page.evaluate(f"Toast.dismiss({toast_id})")
dismiss_time = time.time() - start_time
print(f"\n Created and dismissed 30 toasts in {dismiss_time:.3f}s")
page.wait_for_timeout(500)
# Should have no or few toasts
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 2, f"Undismissed toasts remain: {toast_count}"
print(f" Remaining toasts: {toast_count}")
def test_loading_toast_stress(self, page: Page, app_server: str):
"""Test loading toasts can be created and properly dismissed."""
load_page_with_js(page, f"{app_server}/tables")
toast_ids = []
# Create 10 loading toasts
for i in range(10):
toast_id = page.evaluate(f"Toast.loading('Loading {i}...')")
toast_ids.append(toast_id)
page.wait_for_timeout(200)
# Loading toasts are created
loading_count = page.locator('.mes-toast-loading').count()
print(f"\n Created {len(toast_ids)} loading toasts, visible: {loading_count}")
# Dismiss all using dismissAll
page.evaluate("Toast.dismissAll()")
page.wait_for_timeout(500)
# All should be gone after dismissAll
loading_count = page.locator('.mes-toast-loading').count()
assert loading_count == 0, f"Loading toasts not dismissed: {loading_count}"
print(f" Loading toast dismiss test passed")
@pytest.mark.stress
class TestMesApiStress:
"""Stress tests for MesApi client."""
def test_rapid_api_requests(self, page: Page, app_server: str):
"""Test MesApi under rapid sequential requests."""
load_page_with_js(page, f"{app_server}/tables")
# Make 20 rapid API requests
results = page.evaluate("""
async () => {
const results = [];
const startTime = Date.now();
for (let i = 0; i < 20; i++) {
try {
const response = await MesApi.get('/api/wip/meta/workcenters');
results.push({ success: true, status: response?.status || 'ok' });
} catch (e) {
results.push({ success: false, error: e.message });
}
}
return {
results,
duration: Date.now() - startTime,
successCount: results.filter(r => r.success).length
};
}
""")
print(f"\n 20 requests in {results['duration']}ms")
print(f" Success: {results['successCount']}/20")
assert results['successCount'] >= 15, f"Too many failures: {20 - results['successCount']}"
def test_concurrent_api_requests(self, page: Page, app_server: str):
"""Test MesApi with concurrent requests using Promise.all."""
load_page_with_js(page, f"{app_server}/tables")
# Make 10 concurrent requests
results = page.evaluate("""
async () => {
const endpoints = [
'/api/wip/overview/summary',
'/api/wip/overview/matrix',
'/api/wip/meta/workcenters',
'/api/wip/meta/packages',
];
const startTime = Date.now();
const promises = [];
// 2 requests per endpoint = 8 total concurrent
for (const endpoint of endpoints) {
promises.push(MesApi.get(endpoint).catch(e => ({ error: e.message })));
promises.push(MesApi.get(endpoint).catch(e => ({ error: e.message })));
}
const results = await Promise.all(promises);
const successCount = results.filter(r => !r.error).length;
return {
duration: Date.now() - startTime,
total: results.length,
successCount
};
}
""")
print(f"\n {results['total']} concurrent requests in {results['duration']}ms")
print(f" Success: {results['successCount']}/{results['total']}")
assert results['successCount'] >= 6, f"Too many concurrent failures"
def test_abort_controller_stress(self, page: Page, app_server: str):
"""Test AbortController under rapid request cancellation."""
load_page_with_js(page, f"{app_server}/tables")
# Start requests and cancel them rapidly
results = page.evaluate("""
async () => {
const results = { started: 0, aborted: 0, completed: 0, errors: 0 };
for (let i = 0; i < 10; i++) {
results.started++;
const controller = new AbortController();
const request = fetch('/api/wip/overview/summary', {
signal: controller.signal
}).then(() => {
results.completed++;
}).catch(e => {
if (e.name === 'AbortError') {
results.aborted++;
} else {
results.errors++;
}
});
// Cancel after 50ms
setTimeout(() => controller.abort(), 50);
await new Promise(resolve => setTimeout(resolve, 100));
}
return results;
}
""")
print(f"\n Started: {results['started']}")
print(f" Aborted: {results['aborted']}")
print(f" Completed: {results['completed']}")
print(f" Errors: {results['errors']}")
# Most should either abort or complete
total_resolved = results['aborted'] + results['completed']
assert total_resolved >= 5, f"Too many unresolved requests"
@pytest.mark.stress
class TestPageNavigationStress:
"""Stress tests for rapid page navigation."""
def test_rapid_tab_switching(self, page: Page, app_server: str):
"""Test rapid tab switching in portal."""
page.goto(app_server, wait_until='domcontentloaded', timeout=30000)
page.wait_for_timeout(500)
tabs = [
'.tab:has-text("WIP 即時概況")',
'.tab:has-text("機台狀態報表")',
'.tab:has-text("數據表查詢工具")',
'.tab:has-text("Excel 批次查詢")',
]
start_time = time.time()
# Rapidly switch tabs 20 times
for i in range(20):
tab = tabs[i % len(tabs)]
page.locator(tab).click()
page.wait_for_timeout(50)
switch_time = time.time() - start_time
print(f"\n 20 tab switches in {switch_time:.3f}s")
# Page should still be responsive
expect(page.locator('h1')).to_contain_text('MES 報表入口')
print(" Portal remained stable")
def test_portal_iframe_stress(self, page: Page, app_server: str):
"""Test portal remains responsive with iframe loading."""
page.goto(app_server, wait_until='domcontentloaded', timeout=30000)
page.wait_for_timeout(500)
# Switch through all tabs
tabs = [
'WIP 即時概況',
'機台狀態報表',
'數據表查詢工具',
'Excel 批次查詢',
]
for tab_name in tabs:
page.locator(f'.tab:has-text("{tab_name}")').click()
page.wait_for_timeout(200)
# Verify tab is active
tab = page.locator(f'.tab:has-text("{tab_name}")')
expect(tab).to_have_class(re.compile(r'active'))
print(f"\n All {len(tabs)} tabs clickable and responsive")
@pytest.mark.stress
class TestMemoryStress:
"""Tests for memory leak detection."""
def test_toast_memory_cleanup(self, page: Page, app_server: str):
"""Check Toast system cleans up properly."""
load_page_with_js(page, f"{app_server}/tables")
# Create and dismiss many toasts
for batch in range(5):
for i in range(20):
page.evaluate(f"Toast.info('Memory test {batch}-{i}')")
page.evaluate("Toast.dismissAll()")
page.wait_for_timeout(100)
page.wait_for_timeout(500)
# Check DOM is clean
toast_count = page.locator('.mes-toast').count()
assert toast_count <= 5, f"Toast elements not cleaned up: {toast_count}"
print(f"\n Toast memory cleanup test passed (remaining: {toast_count})")
@pytest.mark.stress
class TestConsoleErrorMonitoring:
"""Monitor for JavaScript errors under stress."""
def test_no_js_errors_under_stress(self, page: Page, app_server: str):
"""Verify no JavaScript errors occur under stress conditions."""
js_errors = []
page.on("pageerror", lambda error: js_errors.append(str(error)))
load_page_with_js(page, f"{app_server}/tables")
# Perform stress operations
for i in range(30):
page.evaluate(f"Toast.info('Error check {i}')")
for i in range(10):
page.evaluate("""
MesApi.get('/api/wip/overview/summary').catch(() => {})
""")
page.wait_for_timeout(2000)
if js_errors:
print(f"\n JavaScript errors detected:")
for err in js_errors[:5]:
print(f" - {err[:100]}")
assert len(js_errors) == 0, f"Found {len(js_errors)} JavaScript errors"
print("\n No JavaScript errors under stress")