团队引入 Storybook 的初衷是隔离UI组件的开发与测试,但这很快就遇到了瓶颈。静态的 JSON mock 数据无法覆盖真实世界中后端服务的动态性和状态变化。一个组件在 Storybook 中看起来完美,集成到主应用后,面对真实的 API 交互时却频繁崩溃。常见的问题包括:乐观更新失败、错误状态处理不当、加载状态闪烁等。问题的根源在于,组件的“状态”不仅仅是 props
,还包括它所依赖的外部服务的状态。
静态 mock,无论是通过 msw
的 rest.get(...)
还是直接 import data from './data.json'
,其本质都是一次性的、无状态的。它们无法模拟一个操作(例如,用户点击“关注”按钮)如何改变后续另一个操作(获取用户信息)的返回结果。这导致我们的 Storybook 变成了组件的“静态标本陈列馆”,而不是一个能反映真实交互的“动态实验室”。
初步的构想是,我们需要一个“有记忆”的 mock 服务器。它必须是一个独立的、可运行的进程,拥有自己的内部状态。前端组件通过标准的 HTTP 请求与之交互,就像和真实的后端服务一样。同时,我们的 Storybook “故事”必须有能力在运行前,精准地“设定”这台服务器的状态,以确保每次测试的初始条件是可控且一致的。Node.js 配合 Express 是实现这个目标最直接的选择,因为它的轻量和灵活性非常适合构建这类开发工具。
第一步:定义 Mock Server 的双重职责
这个 Node.js 服务器需要扮演两个截然不同的角色:
- 数据平面 (Data Plane): 模拟真实的业务 API。例如,提供
GET /api/users/:id
或POST /api/posts
这样的接口。这些接口的行为将完全由服务器的内部状态驱动。 - 控制平面 (Control Plane): 提供一个特殊的、仅供测试使用的 API,用于从外部(即我们的 Storybook 故事)查询和设置服务器的内部状态。这是整个方案的核心,是连接 Storybook 与 Mock Server 的桥梁。
为了避免与真实 API 路径冲突,控制平面的 API 路径应该使用一个特殊的前缀,例如 /--mock-api--
。
sequenceDiagram participant SB as Storybook Story (Loader/Play Function) participant MS_Control as Mock Server (Control Plane) participant MS_State as Mock Server (In-Memory State) participant Component as React Component participant MS_Data as Mock Server (Data Plane) SB->>MS_Control: POST /--mock-api--/state (Setup initial data) MS_Control->>MS_State: Overwrite internal state MS_State-->>MS_Control: ack MS_Control-->>SB: { success: true } Note over Component: Story renders, triggers useEffect fetch Component->>MS_Data: GET /api/users/1 MS_Data->>MS_State: Read user data from state MS_State-->>MS_Data: return { id: 1, name: 'Alice', followers: 10 } MS_Data-->>Component: 200 OK with user data Note over Component: User clicks "Follow" button Component->>MS_Data: POST /api/users/1/follow MS_Data->>MS_State: Update user followers count in state MS_State-->>MS_Data: ack MS_Data-->>Component: 200 OK Note over Component: Component re-fetches user data to show update Component->>MS_Data: GET /api/users/1 MS_Data->>MS_State: Read updated user data from state MS_State-->>MS_Data: return { id: 1, name: 'Alice', followers: 11 } MS_Data-->>Component: 200 OK with updated data
第二步:实现 Node.js Mock Server
让我们开始构建服务器。项目结构很简单,一个 mock-server
目录,包含 server.js
和 package.json
。
mock-server/package.json
{
"name": "stateful-mock-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"body-parser": "^1.20.2"
}
}
mock-server/server.js
这是整个服务器的核心代码。这里的关键点在于 db
和 config
两个全局变量,它们共同构成了服务器的内部状态。
// mock-server/server.js
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid'); // To generate IDs
const app = express();
const PORT = 8080; // Mock server runs on a dedicated port
app.use(cors());
app.use(bodyParser.json());
// In-memory database. The core of our stateful mock.
// It's intentionally volatile. Server restart means a clean slate.
let db = {};
// Server-level configuration for simulating different network conditions.
let config = {
latency: 0, // in milliseconds
errorStatusCode: null, // e.g., 500, 403
};
// --- Control Plane ---
// This is the special API for Storybook to control the mock server's state.
const controlRouter = express.Router();
// Endpoint to completely reset and replace the database state.
controlRouter.post('/state', (req, res) => {
console.log('[CONTROL] Resetting database state.');
db = req.body;
res.status(200).json({ success: true, state: db });
});
// Endpoint to get the current database state for debugging.
controlRouter.get('/state', (req, res) => {
console.log('[CONTROL] Fetching current database state.');
res.status(200).json(db);
});
// Endpoint to configure server behavior like latency or forced errors.
controlRouter.patch('/config', (req, res) => {
console.log(`[CONTROL] Updating server config:`, req.body);
config = { ...config, ...req.body };
res.status(200).json({ success: true, config });
});
app.use('/--mock-api--', controlRouter);
// Middleware to apply configured latency and errors to all data plane requests.
const simulationMiddleware = (req, res, next) => {
setTimeout(() => {
if (config.errorStatusCode) {
console.log(`[DATA] Simulating server error: ${config.errorStatusCode}`);
// Reset after firing once to avoid affecting subsequent tests.
const code = config.errorStatusCode;
config.errorStatusCode = null;
return res.status(code).json({ error: `Simulated server error` });
}
next();
}, config.latency);
};
app.use('/api', simulationMiddleware);
// --- Data Plane ---
// These are the APIs that our components will actually interact with.
// Get a user profile
app.get('/api/users/:id', (req, res) => {
const { id } = req.params;
const user = db.users?.find(u => u.id === id);
console.log(`[DATA] GET /api/users/${id}`);
if (user) {
res.status(200).json(user);
} else {
res.status(404).json({ error: 'User not found' });
}
});
// Follow a user (a stateful action)
app.post('/api/users/:id/follow', (req, res) => {
const { id } = req.params;
const user = db.users?.find(u => u.id === id);
console.log(`[DATA] POST /api/users/${id}/follow`);
if (user) {
// This is where statefulness matters. We are modifying the in-memory DB.
user.followers = (user.followers || 0) + 1;
user.isFollowed = true;
res.status(200).json(user);
} else {
res.status(404).json({ error: 'User not found' });
}
});
// Get all posts
app.get('/api/posts', (req, res) => {
console.log('[DATA] GET /api/posts');
res.status(200).json(db.posts || []);
});
// Default handler for unhandled routes
app.use((req, res) => {
res.status(404).send("Mock API endpoint not found.");
});
app.listen(PORT, () => {
console.log(`Stateful Mock Server listening on http://localhost:${PORT}`);
});
这个服务器现在已经完全可用。我们可以通过 node mock-server/server.js
启动它。
第三步:集成到 Storybook 工作流
要在 Storybook 中使用这个服务器,我们需要解决两个问题:
- 启动流程: 在启动 Storybook 的同时,自动启动 mock server。
- API通信: 让 Storybook 中的组件能够与
localhost:8080
通信,并让故事文件能够控制服务器状态。
对于第一个问题,concurrently
是一个完美的工具。我们修改主项目的 package.json
。
// project/package.json
{
"scripts": {
"storybook": "storybook dev -p 6006",
"mock-server": "node mock-server/server.js",
"dev": "concurrently \"npm:storybook\" \"npm:mock-server\""
},
"devDependencies": {
"concurrently": "^8.2.2",
// ... other dependencies
}
}
现在,运行 npm run dev
就会同时启动两个服务。
对于第二个问题,我们需要在 Storybook 中创建一个辅助函数来与 Mock Server 的控制平面通信。
src/stories/mock.helper.js
// src/stories/mock.helper.js
const MOCK_SERVER_URL = 'http://localhost:8080';
/**
* Resets the mock server's state to a provided fixture.
* This should be called in a Storybook loader.
* @param {object} state The entire desired state for the mock database.
*/
export const setupMockState = async (state) => {
try {
const response = await fetch(`${MOCK_SERVER_URL}/--mock-api--/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
});
if (!response.ok) {
throw new Error(`Failed to set mock state: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error setting mock state:', error);
// Return an error object that stories can check for.
return { error: error.message };
}
};
/**
* Configures the mock server's behavior, like latency or forced errors.
* @param {object} config The configuration to apply. e.g., { latency: 500 }
*/
export const setupMockConfig = async (config) => {
try {
const response = await fetch(`${MOCK_SERVER_URL}/--mock-api--/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!response.ok) {
throw new Error(`Failed to set mock config: ${response.statusText}`);
}
// Always reset to defaults after setting, to avoid polluting other stories
// except for the state itself.
await fetch(`${MOCK_SERVER_URL}/--mock-api--/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latency: 0, errorStatusCode: null, ...config }),
});
return await response.json();
} catch (error) {
console.error('Error setting mock config:', error);
return { error: error.message };
}
};
第四步:编写可状态化的组件故事
现在,我们可以为组件编写能够模拟真实后端交互的故事了。假设我们有一个 UserProfile
组件,它会获取并显示用户信息,并允许用户关注。
src/components/UserProfile.js
(一个简化的实现)
import React, { useState, useEffect } from 'react';
export const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const API_BASE = 'http://localhost:8080/api';
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/users/${userId}`);
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUser();
}, [userId]);
const handleFollow = async () => {
try {
await fetch(`${API_BASE}/users/${userId}/follow`, { method: 'POST' });
// Re-fetch user data to show the updated follower count
await fetchUser();
} catch (e) {
// Handle follow error
}
};
if (loading) return <div>Loading profile...</div>;
if (error) return <div style={{ color: 'red' }}>Failed to load profile: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>Followers: {user.followers}</p>
<button onClick={handleFollow} disabled={user.isFollowed}>
{user.isFollowed ? 'Following' : 'Follow'}
</button>
</div>
);
};
src/stories/UserProfile.stories.js
这就是见证奇迹的时刻。每个故事都通过 loader
函数来精确控制后端的状态,从而测试组件在不同场景下的表现。
import React from 'react';
import { UserProfile } from '../components/UserProfile';
import { setupMockState, setupMockConfig } from './mock.helper';
import { userEvent, within } from '@storybook/testing-library';
export default {
title: 'Components/UserProfile',
component: UserProfile,
// Reset config before each story to ensure isolation
loaders: [async () => setupMockConfig({ latency: 0, errorStatusCode: null })],
};
// Story 1: Default successful case
export const Default = {
args: {
userId: 'user-1',
},
loaders: [
async () => {
await setupMockState({
users: [
{ id: 'user-1', name: 'Alice', followers: 150, isFollowed: false },
],
});
},
],
};
// Story 2: User not found (404)
export const NotFound = {
args: {
userId: 'user-nonexistent',
},
loaders: [
async () => {
// Set a state where the requested user doesn't exist
await setupMockState({
users: [
{ id: 'user-1', name: 'Alice', followers: 150, isFollowed: false },
],
});
},
],
};
// Story 3: Server error (500)
export const ServerError = {
args: {
userId: 'user-1',
},
loaders: [
async () => {
// The state doesn't matter, we force an error
await setupMockConfig({ errorStatusCode: 500 });
await setupMockState({ users: [] }); // State can be empty
},
],
};
// Story 4: Slow network simulation
export const SlowNetwork = {
args: {
userId: 'user-1',
},
loaders: [
async () => {
await setupMockConfig({ latency: 2000 });
await setupMockState({
users: [
{ id: 'user-1', name: 'Alice', followers: 150, isFollowed: false },
],
});
},
],
};
// Story 5: Testing stateful interaction with the play function
export const FollowInteraction = {
args: {
userId: 'user-1',
},
loaders: [
async () => {
await setupMockState({
users: [
{ id: 'user-1', name: 'Alice', followers: 150, isFollowed: false },
],
});
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the component to finish loading initial data
const followButton = await canvas.findByText('Follow');
// Simulate a user click
await userEvent.click(followButton);
// After the click, the component re-fetches.
// The mock server's state has been updated by the POST request.
// We should now see the updated state reflected in the UI.
const newFollowerCount = await canvas.findByText('Followers: 151');
const followingButton = await canvas.findByText('Following');
// You can add assertions here using @storybook/jest
// expect(newFollowerCount).toBeInTheDocument();
// expect(followingButton).toBeDisabled();
},
};
在 FollowInteraction
这个故事中,我们清晰地看到了这个方案的威力。play
函数模拟了用户的点击行为,这个行为触发了对 mock server 的 POST
请求,改变了服务器的内部状态。随后组件重新 GET
数据,获取到了更新后的状态(粉丝数+1),并重新渲染UI。这一切都在 Storybook 中独立、可靠地完成了,完美模拟了真实的应用交互流程。
方案的局限性与未来迭代
这个方案并非没有缺点。首先,它完全依赖内存,服务器重启后所有状态都会丢失,这在组件开发场景中通常是优点(确保测试隔离),但在需要持久化 mock 数据的场景下则是缺点。
其次,mock 数据的维护是手动的。如果 API 发生变更,我们需要手动更新 mock server 中的路由和数据结构。一个可行的优化路径是,引入 OpenAPI (Swagger) 或 GraphQL Schema,让 mock server 能够基于契约自动生成部分路由和校验,从而保证 mock 服务与真实 API 的一致性。
最后,当前的实现是单用户模式。所有故事共享同一个服务器实例和状态。虽然 loader
在每个故事开始时重置状态,但在复杂的并发测试场景下可能会存在问题。未来的迭代可以考虑为每个测试会话(或 story)动态创建独立的 mock 上下文,但这会显著增加实现的复杂度。尽管存在这些局限,对于绝大多数前端团队而言,这套方案已经足够解决 Storybook 中最痛的“状态模拟”问题,将组件开发和测试的可靠性提升一个台阶。