系列文章: 前端工程師的 Modern Web 實踐之道 - Day 15
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前兩週的文章中,我們從現代化基礎建設到核心開發實踐,建構了完整的 Modern Web 開發體系。從今天開始,我們進入第三週的主題:品質與效能。而測試,正是確保程式碼品質的第一道防線。
想像一下這個場景:週五下午五點,你剛準備下班,突然收到一個緊急通知——剛上線的新功能導致整個購物車系統崩潰,線上使用者無法結帳。你急忙回滾程式碼,卻發現問題出在一個看似無關的功能模組,而這個 bug 在開發環境中完全沒有被發現。
這樣的經驗,相信許多工程師都不陌生。根據 IBM 的研究報告,在生產環境中修復一個 bug 的成本,是在開發階段發現並修復的 15 倍。這就是為什麼測試不是可有可無的「額外工作」,而是現代化開發流程中不可或缺的核心環節。
今天我們將深入探討從單元測試到 E2E 測試的完整策略,建立一套真正適合 Modern Web 應用的測試體系。
在談具體的測試技術之前,我們需要先理解一個重要的概念:測試金字塔。這個由 Mike Cohn 提出的模型,至今仍是測試策略設計的黃金準則。
/\
/ \ E2E Tests (端到端測試)
/____\
/ \
/ Integr-\ Integration Tests (整合測試)
/ ation \
/____________\
/ \
/ Unit Tests \ Unit Tests (單元測試)
/__________________\
這個金字塔的形狀隱含了三個重要的測試原則:
很多專案失敗的原因,就是把金字塔倒過來了——大量依賴 E2E 測試,卻忽略單元測試。結果就是測試跑一次要 30 分鐘,而且經常因為環境問題失敗,最後團隊乾脆不跑測試了。
根據我在多個大型專案的實踐經驗,以下是比較理想的測試配比:
這個比例不是絕對的,但可以作為起點。重點是要理解每種測試的特性和適用場景。
單元測試是測試金字塔的基礎,也是最容易被低估的部分。很多人認為單元測試「太細碎」、「沒有實際價值」,但實際上,單元測試是整個測試體系中投資報酬率最高的部分。
不是所有程式碼都需要單元測試。以下是我的判斷標準:
✅ 應該寫單元測試的:
❌ 不需要單元測試的:
假設我們有一個電商網站的折扣計算系統,需要處理多種優惠規則的組合:
// src/utils/priceCalculator.ts
/**
* 價格計算器 - 處理複雜的折扣規則
*/
export interface DiscountRule {
type: 'percentage' | 'fixed' | 'buyXGetY';
value: number;
minAmount?: number;
maxDiscount?: number;
}
export interface CartItem {
id: string;
price: number;
quantity: number;
category: string;
}
export class PriceCalculator {
/**
* 計算購物車總價,套用折扣規則
* @param items - 購物車商品列表
* @param discounts - 折扣規則列表
* @returns 最終價格資訊
*/
static calculateTotal(items: CartItem[], discounts: DiscountRule[] = []) {
// 計算原始總價
const subtotal = items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
// 套用折扣規則
let totalDiscount = 0;
for (const rule of discounts) {
const discount = this.applyDiscountRule(subtotal, rule);
totalDiscount += discount;
}
// 確保折扣不超過原價
totalDiscount = Math.min(totalDiscount, subtotal);
return {
subtotal,
discount: totalDiscount,
total: subtotal - totalDiscount,
};
}
/**
* 套用單一折扣規則
*/
private static applyDiscountRule(amount: number, rule: DiscountRule): number {
// 檢查最低消費門檻
if (rule.minAmount && amount < rule.minAmount) {
return 0;
}
let discount = 0;
switch (rule.type) {
case 'percentage':
discount = amount * (rule.value / 100);
break;
case 'fixed':
discount = rule.value;
break;
case 'buyXGetY':
// 簡化的買 X 送 Y 邏輯
discount = rule.value;
break;
default:
throw new Error(`Unknown discount type: ${(rule as any).type}`);
}
// 套用最大折扣限制
if (rule.maxDiscount) {
discount = Math.min(discount, rule.maxDiscount);
}
return discount;
}
/**
* 驗證折扣規則的合法性
*/
static validateDiscountRule(rule: DiscountRule): boolean {
if (rule.value < 0) {
return false;
}
if (rule.type === 'percentage' && rule.value > 100) {
return false;
}
if (rule.minAmount && rule.minAmount < 0) {
return false;
}
return true;
}
}
現在讓我們為這個邏輯寫完整的單元測試:
// src/utils/priceCalculator.test.ts
import { describe, it, expect } from 'vitest';
import { PriceCalculator, CartItem, DiscountRule } from './priceCalculator';
describe('PriceCalculator', () => {
// 準備測試資料
const mockItems: CartItem[] = [
{ id: '1', price: 100, quantity: 2, category: 'electronics' },
{ id: '2', price: 50, quantity: 1, category: 'books' },
];
describe('calculateTotal', () => {
it('應該正確計算沒有折扣的總價', () => {
const result = PriceCalculator.calculateTotal(mockItems);
expect(result).toEqual({
subtotal: 250,
discount: 0,
total: 250,
});
});
it('應該正確套用百分比折扣', () => {
const discounts: DiscountRule[] = [
{ type: 'percentage', value: 10 }, // 9折
];
const result = PriceCalculator.calculateTotal(mockItems, discounts);
expect(result).toEqual({
subtotal: 250,
discount: 25,
total: 225,
});
});
it('應該正確套用固定金額折扣', () => {
const discounts: DiscountRule[] = [
{ type: 'fixed', value: 50 },
];
const result = PriceCalculator.calculateTotal(mockItems, discounts);
expect(result).toEqual({
subtotal: 250,
discount: 50,
total: 200,
});
});
it('應該處理最低消費門檻', () => {
const discounts: DiscountRule[] = [
{ type: 'fixed', value: 50, minAmount: 300 }, // 需滿 300 才能折
];
const result = PriceCalculator.calculateTotal(mockItems, discounts);
// 因為總價 250 < 300,所以不套用折扣
expect(result).toEqual({
subtotal: 250,
discount: 0,
total: 250,
});
});
it('應該處理最大折扣限制', () => {
const discounts: DiscountRule[] = [
{ type: 'percentage', value: 50, maxDiscount: 100 }, // 最多折 100
];
const result = PriceCalculator.calculateTotal(mockItems, discounts);
// 50% 折扣是 125,但限制最多 100
expect(result).toEqual({
subtotal: 250,
discount: 100,
total: 150,
});
});
it('應該正確組合多個折扣', () => {
const discounts: DiscountRule[] = [
{ type: 'percentage', value: 10 },
{ type: 'fixed', value: 20 },
];
const result = PriceCalculator.calculateTotal(mockItems, discounts);
expect(result).toEqual({
subtotal: 250,
discount: 45, // 25 + 20
total: 205,
});
});
it('應該確保折扣不超過原價', () => {
const discounts: DiscountRule[] = [
{ type: 'fixed', value: 300 }, // 折扣超過總價
];
const result = PriceCalculator.calculateTotal(mockItems, discounts);
expect(result.total).toBeGreaterThanOrEqual(0);
expect(result.discount).toBeLessThanOrEqual(result.subtotal);
});
it('應該處理空購物車', () => {
const result = PriceCalculator.calculateTotal([]);
expect(result).toEqual({
subtotal: 0,
discount: 0,
total: 0,
});
});
it('應該拋出錯誤當遇到未知的折扣型別', () => {
const invalidDiscounts: any = [
{ type: 'unknown', value: 10 },
];
expect(() => {
PriceCalculator.calculateTotal(mockItems, invalidDiscounts);
}).toThrow('Unknown discount type: unknown');
});
});
describe('validateDiscountRule', () => {
it('應該拒絕負數折扣', () => {
const rule: DiscountRule = { type: 'fixed', value: -10 };
expect(PriceCalculator.validateDiscountRule(rule)).toBe(false);
});
it('應該拒絕超過 100% 的百分比折扣', () => {
const rule: DiscountRule = { type: 'percentage', value: 150 };
expect(PriceCalculator.validateDiscountRule(rule)).toBe(false);
});
it('應該接受合法的折扣規則', () => {
const rule: DiscountRule = { type: 'percentage', value: 10 };
expect(PriceCalculator.validateDiscountRule(rule)).toBe(true);
});
});
});
從上面的範例中,我們可以總結幾個重要的測試原則:
1. AAA 模式 (Arrange-Act-Assert)
it('測試描述', () => {
// Arrange: 準備測試資料和環境
const input = { ... };
// Act: 執行要測試的功能
const result = someFunction(input);
// Assert: 驗證結果
expect(result).toBe(expected);
});
2. 測試描述要清晰明確
it('測試計算功能')
it('應該正確套用百分比折扣')
3. 一個測試只驗證一個行為
不要在一個 it
中測試多個不相關的情境,保持測試的單一職責。
4. 測試邊界條件和錯誤情況
不只測試正常流程,更要測試:
單元測試確保每個零件正常運作,但零件組裝起來會不會出問題?這就是整合測試要解決的問題。
在前端開發中,整合測試主要關注:
讓我們實作一個使用者登入流程的整合測試:
// src/components/LoginForm.tsx
import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login, isLoading } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
// 登入成功後會自動導航(由 useAuth 處理)
} catch (err) {
setError(err instanceof Error ? err.message : '登入失敗,請稍後再試');
}
};
return (
<form onSubmit={handleSubmit} aria-label="登入表單">
<div>
<label htmlFor="email">電子郵件</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
aria-invalid={!!error}
/>
</div>
<div>
<label htmlFor="password">密碼</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
aria-invalid={!!error}
/>
</div>
{error && (
<div role="alert" className="error-message">
{error}
</div>
)}
<button type="submit" disabled={isLoading}>
{isLoading ? '登入中...' : '登入'}
</button>
</form>
);
}
現在讓我們寫整合測試,使用 React Testing Library:
// src/components/LoginForm.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { AuthProvider } from '../contexts/AuthContext';
import * as authApi from '../api/auth';
// Mock API 呼叫
vi.mock('../api/auth');
describe('LoginForm 整合測試', () => {
// 設定測試環境
const renderLoginForm = () => {
return render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('應該正確渲染登入表單', () => {
renderLoginForm();
expect(screen.getByLabelText('電子郵件')).toBeInTheDocument();
expect(screen.getByLabelText('密碼')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登入' })).toBeInTheDocument();
});
it('應該能輸入帳號密碼並送出', async () => {
const user = userEvent.setup();
// Mock 成功的 API 回應
vi.mocked(authApi.login).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
token: 'fake-token',
});
renderLoginForm();
// 使用者輸入帳號密碼
await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
await user.type(screen.getByLabelText('密碼'), 'password123');
// 點擊登入按鈕
await user.click(screen.getByRole('button', { name: '登入' }));
// 驗證 API 被正確呼叫
expect(authApi.login).toHaveBeenCalledWith('test@example.com', 'password123');
});
it('應該顯示載入狀態', async () => {
const user = userEvent.setup();
// Mock 一個延遲的 API 回應
vi.mocked(authApi.login).mockImplementation(() =>
new Promise((resolve) => setTimeout(resolve, 1000))
);
renderLoginForm();
await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
await user.type(screen.getByLabelText('密碼'), 'password123');
await user.click(screen.getByRole('button', { name: '登入' }));
// 按鈕應該顯示載入中
expect(screen.getByRole('button', { name: '登入中...' })).toBeDisabled();
});
it('應該正確處理登入失敗', async () => {
const user = userEvent.setup();
// Mock 失敗的 API 回應
vi.mocked(authApi.login).mockRejectedValue(
new Error('帳號或密碼錯誤')
);
renderLoginForm();
await user.type(screen.getByLabelText('電子郵件'), 'wrong@example.com');
await user.type(screen.getByLabelText('密碼'), 'wrongpass');
await user.click(screen.getByRole('button', { name: '登入' }));
// 應該顯示錯誤訊息
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('帳號或密碼錯誤');
});
});
it('應該在輸入錯誤時正確標記表單欄位', async () => {
const user = userEvent.setup();
vi.mocked(authApi.login).mockRejectedValue(
new Error('帳號或密碼錯誤')
);
renderLoginForm();
await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
await user.type(screen.getByLabelText('密碼'), 'wrong');
await user.click(screen.getByRole('button', { name: '登入' }));
await waitFor(() => {
const emailInput = screen.getByLabelText('電子郵件');
const passwordInput = screen.getByLabelText('密碼');
expect(emailInput).toHaveAttribute('aria-invalid', 'true');
expect(passwordInput).toHaveAttribute('aria-invalid', 'true');
});
});
it('應該清除先前的錯誤訊息', async () => {
const user = userEvent.setup();
// 第一次登入失敗
vi.mocked(authApi.login).mockRejectedValueOnce(
new Error('帳號或密碼錯誤')
);
renderLoginForm();
await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
await user.type(screen.getByLabelText('密碼'), 'wrong');
await user.click(screen.getByRole('button', { name: '登入' }));
// 確認錯誤訊息出現
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
// 第二次登入成功
vi.mocked(authApi.login).mockResolvedValueOnce({
user: { id: '1', email: 'test@example.com' },
token: 'fake-token',
});
await user.clear(screen.getByLabelText('密碼'));
await user.type(screen.getByLabelText('密碼'), 'correct');
await user.click(screen.getByRole('button', { name: '登入' }));
// 錯誤訊息應該消失
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
});
1. 使用 Testing Library 的原則
getByRole
, getByLabelText
等語義化查詢data-testid
(除非別無選擇)2. Mock 外部依賴
// Mock API 呼叫
vi.mock('../api/auth', () => ({
login: vi.fn(),
logout: vi.fn(),
}));
// Mock 第三方套件
vi.mock('axios', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
3. 測試非同步行為
// 使用 waitFor 等待異步更新
await waitFor(() => {
expect(screen.getByText('成功')).toBeInTheDocument();
});
// 或使用 findBy 查詢(內建等待)
const successMessage = await screen.findByText('成功');
E2E(End-to-End)測試站在使用者的角度,測試完整的業務流程。它是測試金字塔的頂端,數量最少但價值最高。
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('電商結帳流程', () => {
test.beforeEach(async ({ page }) => {
// 每個測試前先登入
await page.goto('/login');
await page.getByLabel('電子郵件').fill('test@example.com');
await page.getByLabel('密碼').fill('password123');
await page.getByRole('button', { name: '登入' }).click();
// 等待導航完成
await expect(page).toHaveURL('/');
});
test('應該能完成完整的購物流程', async ({ page }) => {
// 1. 瀏覽商品
await page.goto('/products');
await expect(page.getByRole('heading', { name: '商品列表' })).toBeVisible();
// 2. 選擇商品
const firstProduct = page.getByTestId('product-card').first();
await firstProduct.click();
// 3. 加入購物車
await page.getByRole('button', { name: '加入購物車' }).click();
// 驗證購物車數量更新
const cartBadge = page.getByTestId('cart-badge');
await expect(cartBadge).toHaveText('1');
// 4. 前往購物車
await page.getByRole('link', { name: '購物車' }).click();
await expect(page).toHaveURL('/cart');
// 5. 驗證購物車內容
const cartItems = page.getByTestId('cart-item');
await expect(cartItems).toHaveCount(1);
// 6. 前往結帳
await page.getByRole('button', { name: '前往結帳' }).click();
await expect(page).toHaveURL('/checkout');
// 7. 填寫配送資訊
await page.getByLabel('收件人姓名').fill('測試使用者');
await page.getByLabel('聯絡電話').fill('0912345678');
await page.getByLabel('配送地址').fill('台北市信義區信義路五段7號');
// 8. 選擇付款方式
await page.getByLabel('信用卡付款').check();
// 9. 確認訂單
await page.getByRole('button', { name: '確認訂單' }).click();
// 10. 驗證訂單成功
await expect(page).toHaveURL(/\/order\/\d+/);
await expect(page.getByText('訂單成功')).toBeVisible();
// 驗證訂單資訊
await expect(page.getByText('測試使用者')).toBeVisible();
await expect(page.getByText('0912345678')).toBeVisible();
});
test('應該能在結帳時套用優惠碼', async ({ page }) => {
// 先加入商品到購物車(簡化流程)
await page.goto('/cart/add/product-1');
// 前往結帳頁
await page.goto('/checkout');
// 查看原始金額
const originalTotal = await page.getByTestId('total-amount').textContent();
// 輸入優惠碼
await page.getByLabel('優惠碼').fill('WELCOME10');
await page.getByRole('button', { name: '套用' }).click();
// 驗證折扣訊息
await expect(page.getByText('已套用 10% 折扣')).toBeVisible();
// 驗證金額更新
const discountedTotal = await page.getByTestId('total-amount').textContent();
expect(discountedTotal).not.toBe(originalTotal);
});
test('應該在庫存不足時顯示警告', async ({ page }) => {
// 訪問一個庫存為 0 的商品
await page.goto('/products/out-of-stock-item');
// 加入購物車按鈕應該被禁用
const addToCartBtn = page.getByRole('button', { name: '加入購物車' });
await expect(addToCartBtn).toBeDisabled();
// 應該顯示缺貨訊息
await expect(page.getByText('此商品目前缺貨')).toBeVisible();
});
test('應該能正確處理網路錯誤', async ({ page }) => {
// 模擬網路錯誤
await page.route('**/api/checkout', (route) => {
route.abort('failed');
});
// 執行結帳流程
await page.goto('/checkout');
await page.getByLabel('收件人姓名').fill('測試使用者');
await page.getByRole('button', { name: '確認訂單' }).click();
// 應該顯示錯誤訊息
await expect(page.getByRole('alert')).toHaveText(/網路連線失敗/);
// 應該停留在結帳頁
await expect(page).toHaveURL('/checkout');
});
});
1. 使用測試專用的使用者帳號和資料
// 不要用真實使用者資料
const testUser = {
email: 'e2e-test@example.com',
password: 'test-password-123',
};
2. 獨立且可重複執行
每個測試都應該能獨立執行,不依賴其他測試的結果:
test.beforeEach(async ({ page }) => {
// 重置測試環境
await resetDatabase();
await seedTestData();
});
3. 使用有意義的等待
// ❌ 不好:硬編碼等待時間
await page.waitForTimeout(3000);
// ✅ 好:等待特定元素或狀態
await page.waitForSelector('[data-testid="loading"]', { state: 'detached' });
await expect(page.getByText('載入完成')).toBeVisible();
4. 平行執行以提升速度
// playwright.config.ts
export default {
workers: process.env.CI ? 2 : 4,
fullyParallel: true,
};
# 安裝單元測試和整合測試工具
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom
# 安裝 E2E 測試工具
npm install -D @playwright/test
# 初始化 Playwright
npx playwright install
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.test.ts',
'**/*.test.tsx',
],
},
},
});
// src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
// 擴展 expect 的匹配器
expect.extend(matchers);
// 每個測試後清理 DOM
afterEach(() => {
cleanup();
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html'],
['list'],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test:coverage && npm run test:e2e"
}
}
很多團隊把測試覆蓋率當成 KPI,要求達到 80% 甚至 90%。但這個指標可能會誤導:
覆蓋率高 ≠ 測試品質好
// 這段程式碼有 100% 覆蓋率,但測試價值為零
function add(a, b) {
return a + b;
}
test('測試 add 函式', () => {
add(1, 2); // 沒有任何斷言!
});
更重要的指標:
當時間有限時,應該優先測試什麼?
高優先級(必須測試):
中優先級(建議測試):
低優先級(可選測試):
TDD 不只是「先寫測試再寫程式碼」,它是一種設計方法論:
紅 → 綠 → 重構
1. 紅階段:寫一個失敗的測試
test('應該能計算折扣後的價格', () => {
const result = calculateDiscount(100, 10);
expect(result).toBe(90);
});
// 執行測試 → 失敗(函式還不存在)
2. 綠階段:寫最少的程式碼讓測試通過
function calculateDiscount(price: number, discount: number) {
return price - discount;
}
// 執行測試 → 通過
3. 重構階段:改進程式碼品質
function calculateDiscount(price: number, discountPercent: number) {
if (price < 0 || discountPercent < 0 || discountPercent > 100) {
throw new Error('Invalid input');
}
return price * (1 - discountPercent / 100);
}
// 執行測試 → 仍然通過
// 加入新的邊界測試...
TDD 的好處:
測試金字塔: 70% 單元測試、20% 整合測試、10% E2E 測試,這個比例確保測試套件快速且穩定。
單元測試: 專注測試純函式和業務邏輯,使用 AAA 模式,測試邊界條件和錯誤處理。每個測試應該獨立且只驗證一個行為。
整合測試: 使用 Testing Library 從使用者角度測試組件,Mock 外部依賴,測試真實的互動流程而非實作細節。
E2E 測試: 只測試關鍵業務流程,確保測試可獨立執行,使用有意義的等待而非硬編碼延遲。
測試策略: 覆蓋率不是唯一指標,更重要的是關鍵路徑覆蓋和邊界情況處理。根據業務重要性安排測試優先級。
測試與開發效率的平衡: 你的專案中,測試帶來的信心是否值得投入的時間成本?如何找到最佳平衡點?
遺留程式碼的測試策略: 面對一個完全沒有測試的舊專案,應該從哪裡開始補測試?是全面補齊還是只測新功能?
測試的 ROI: 如何衡量測試的投資報酬率?有哪些量化指標可以證明測試的價值?
AI 時代的測試: 隨著 AI 輔助寫程式碼的普及,測試的角色會如何變化?我們是否需要調整測試策略?