Project_TestableComponents.jsx
React.js/lessons/21_testing_best_practices/projects/Project_TestableComponents.jsx
/**
* Project: 可測試的元件設計 — 測試與最佳實踐
*
* 學習重點:
* - 單一職責的元件設計
* - 容器/展示元件分離
* - 使用 data-testid 方便測試定位
* - 派生狀態(不需要額外 state)
* - 清晰的 Props 介面
*
* 此檔案展示了良好的元件設計模式
* 使用方式:將此檔案內容複製到你的 App.jsx 中
*
* 測試範例在 Project_TestExamples.test.jsx 中
*/
import { useState, useCallback } from 'react';
// ============================================================
// 展示元件(純粹渲染 UI,容易測試)
// ============================================================
const StatusBadge = ({ status }) => {
const config = {
active: { bg: '#f0fdf4', color: '#166534', label: '啟用' },
inactive: { bg: '#fef2f2', color: '#991b1b', label: '停用' },
pending: { bg: '#fffbeb', color: '#92400e', label: '待審' },
};
const c = config[status] || config.pending;
return (
<span
data-testid={`status-${status}`}
style={{
padding: '2px 10px', borderRadius: '6px',
backgroundColor: c.bg, color: c.color,
fontSize: '0.75rem', fontWeight: '600',
}}
>
{c.label}
</span>
);
};
const SearchInput = ({ value, onChange, placeholder = '搜尋...' }) => (
<input
data-testid="search-input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
width: '100%', padding: '10px 14px', borderRadius: '10px',
border: '2px solid #e2e8f0', fontSize: '0.95rem', outline: 'none',
boxSizing: 'border-box',
}}
/>
);
const EmptyState = ({ icon = '📋', message = '沒有資料' }) => (
<div data-testid="empty-state" style={{ textAlign: 'center', padding: '40px', color: '#cbd5e1' }}>
<p style={{ fontSize: '2rem', margin: '0 0 8px' }}>{icon}</p>
<p style={{ margin: 0 }}>{message}</p>
</div>
);
const UserRow = ({ user, onToggle, onDelete }) => (
<div
data-testid={`user-row-${user.id}`}
style={{
display: 'flex', alignItems: 'center', gap: '12px',
padding: '12px 16px', backgroundColor: 'white', borderRadius: '10px',
marginBottom: '8px', border: '1px solid #f1f5f9',
}}
>
<div
style={{
width: '40px', height: '40px', borderRadius: '50%',
backgroundColor: '#eef2ff', display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: '1rem', flexShrink: 0,
}}
>
{user.avatar}
</div>
<div style={{ flex: 1 }}>
<p data-testid={`user-name-${user.id}`} style={{ margin: '0 0 2px', fontWeight: '600', color: '#0f172a', fontSize: '0.95rem' }}>
{user.name}
</p>
<p style={{ margin: 0, color: '#94a3b8', fontSize: '0.8rem' }}>{user.email}</p>
</div>
<span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>{user.role}</span>
<StatusBadge status={user.status} />
<button
data-testid={`toggle-${user.id}`}
onClick={() => onToggle(user.id)}
style={{
padding: '4px 12px', borderRadius: '6px',
border: '1px solid #e2e8f0', backgroundColor: 'white',
cursor: 'pointer', fontSize: '0.75rem', color: '#64748b',
}}
>
切換
</button>
<button
data-testid={`delete-${user.id}`}
onClick={() => onDelete(user.id)}
style={{
padding: '4px 8px', borderRadius: '6px', border: 'none',
backgroundColor: 'transparent', color: '#e2e8f0',
cursor: 'pointer', fontSize: '1.1rem',
}}
>
×
</button>
</div>
);
const Stats = ({ total, active, inactive, pending }) => (
<div data-testid="stats" style={{
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px', marginBottom: '20px',
}}>
{[
{ label: '總計', value: total, color: '#4f46e5' },
{ label: '啟用', value: active, color: '#22c55e' },
{ label: '停用', value: inactive, color: '#ef4444' },
{ label: '待審', value: pending, color: '#f59e0b' },
].map((s) => (
<div key={s.label} style={{
padding: '16px', borderRadius: '10px', backgroundColor: 'white',
border: '1px solid #f1f5f9', textAlign: 'center',
}}>
<p data-testid={`stat-${s.label}`} style={{ margin: '0 0 2px', fontSize: '1.5rem', fontWeight: 'bold', color: s.color }}>
{s.value}
</p>
<p style={{ margin: 0, color: '#94a3b8', fontSize: '0.8rem' }}>{s.label}</p>
</div>
))}
</div>
);
// ============================================================
// 容器元件(管理狀態和邏輯)
// ============================================================
function UserManager() {
const [users, setUsers] = useState([
{ id: 1, name: 'Alice Chen', email: 'alice@example.com', role: 'Admin', status: 'active', avatar: '👩💻' },
{ id: 2, name: 'Bob Wang', email: 'bob@example.com', role: 'Editor', status: 'active', avatar: '👨💻' },
{ id: 3, name: 'Carol Lin', email: 'carol@example.com', role: 'Viewer', status: 'inactive', avatar: '👩🎨' },
{ id: 4, name: 'David Lee', email: 'david@example.com', role: 'Editor', status: 'pending', avatar: '👨💼' },
{ id: 5, name: 'Eva Huang', email: 'eva@example.com', role: 'Admin', status: 'active', avatar: '👩🔧' },
]);
const [searchQuery, setSearchQuery] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
// 派生狀態(不需要額外的 state)
const filteredUsers = users
.filter((u) => filterStatus === 'all' || u.status === filterStatus)
.filter((u) =>
u.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
);
const stats = {
total: users.length,
active: users.filter((u) => u.status === 'active').length,
inactive: users.filter((u) => u.status === 'inactive').length,
pending: users.filter((u) => u.status === 'pending').length,
};
const handleToggle = useCallback((id) => {
setUsers((prev) =>
prev.map((u) => {
if (u.id !== id) return u;
const statusCycle = { active: 'inactive', inactive: 'pending', pending: 'active' };
return { ...u, status: statusCycle[u.status] };
})
);
}, []);
const handleDelete = useCallback((id) => {
setUsers((prev) => prev.filter((u) => u.id !== id));
}, []);
return (
<div style={{
maxWidth: '700px', margin: '32px auto', padding: '0 16px',
fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
}}>
<h1 style={{ color: '#0f172a', marginBottom: '4px' }}>使用者管理</h1>
<p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '24px' }}>
展示良好的元件設計 — 容器/展示分離、派生狀態、data-testid
</p>
<Stats {...stats} />
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="搜尋使用者姓名或 Email..."
/>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
{['all', 'active', 'inactive', 'pending'].map((s) => (
<button
key={s}
data-testid={`filter-${s}`}
onClick={() => setFilterStatus(s)}
style={{
padding: '8px 14px', borderRadius: '8px', border: 'none',
backgroundColor: filterStatus === s ? '#4f46e5' : '#f1f5f9',
color: filterStatus === s ? 'white' : '#64748b',
cursor: 'pointer', fontSize: '0.8rem',
}}
>
{s === 'all' ? '全部' : s === 'active' ? '啟用' : s === 'inactive' ? '停用' : '待審'}
</button>
))}
</div>
</div>
<p style={{ color: '#94a3b8', fontSize: '0.8rem', marginBottom: '12px' }}>
顯示 {filteredUsers.length} / {users.length} 位使用者
</p>
{filteredUsers.length === 0 ? (
<EmptyState message="沒有符合條件的使用者" icon="🔍" />
) : (
filteredUsers.map((user) => (
<UserRow
key={user.id}
user={user}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))
)}
{/* 設計說明 */}
<div style={{
marginTop: '32px', padding: '20px', borderRadius: '12px',
backgroundColor: '#f8fafc', border: '1px solid #e2e8f0', fontSize: '0.85rem',
}}>
<h3 style={{ margin: '0 0 12px', color: '#0f172a' }}>此專案的設計重點</h3>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#475569', lineHeight: 2 }}>
<li><strong>容器/展示分離</strong>:UserManager 管理邏輯,UserRow 純渲染</li>
<li><strong>派生狀態</strong>:filteredUsers 和 stats 不用額外 useState</li>
<li><strong>data-testid</strong>:方便測試定位特定元素</li>
<li><strong>單一職責</strong>:StatusBadge、SearchInput、EmptyState 各司其職</li>
<li><strong>useCallback</strong>:穩定回呼函式,配合可能的 memo 優化</li>
</ul>
</div>
</div>
);
}
export default UserManager;
관련 글
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
글 읽기 →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
글 읽기 →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
글 읽기 →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
글 읽기 →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
글 읽기 →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
글 읽기 →