构建可状态化 Node.js Mock Server 赋能生产级 Storybook 组件开发


团队引入 Storybook 的初衷是隔离UI组件的开发与测试,但这很快就遇到了瓶颈。静态的 JSON mock 数据无法覆盖真实世界中后端服务的动态性和状态变化。一个组件在 Storybook 中看起来完美,集成到主应用后,面对真实的 API 交互时却频繁崩溃。常见的问题包括:乐观更新失败、错误状态处理不当、加载状态闪烁等。问题的根源在于,组件的“状态”不仅仅是 props,还包括它所依赖的外部服务的状态。

静态 mock,无论是通过 mswrest.get(...) 还是直接 import data from './data.json',其本质都是一次性的、无状态的。它们无法模拟一个操作(例如,用户点击“关注”按钮)如何改变后续另一个操作(获取用户信息)的返回结果。这导致我们的 Storybook 变成了组件的“静态标本陈列馆”,而不是一个能反映真实交互的“动态实验室”。

初步的构想是,我们需要一个“有记忆”的 mock 服务器。它必须是一个独立的、可运行的进程,拥有自己的内部状态。前端组件通过标准的 HTTP 请求与之交互,就像和真实的后端服务一样。同时,我们的 Storybook “故事”必须有能力在运行前,精准地“设定”这台服务器的状态,以确保每次测试的初始条件是可控且一致的。Node.js 配合 Express 是实现这个目标最直接的选择,因为它的轻量和灵活性非常适合构建这类开发工具。


第一步:定义 Mock Server 的双重职责

这个 Node.js 服务器需要扮演两个截然不同的角色:

  1. 数据平面 (Data Plane): 模拟真实的业务 API。例如,提供 GET /api/users/:idPOST /api/posts 这样的接口。这些接口的行为将完全由服务器的内部状态驱动。
  2. 控制平面 (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.jspackage.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

这是整个服务器的核心代码。这里的关键点在于 dbconfig 两个全局变量,它们共同构成了服务器的内部状态。

// 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 中使用这个服务器,我们需要解决两个问题:

  1. 启动流程: 在启动 Storybook 的同时,自动启动 mock server。
  2. 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 中最痛的“状态模拟”问题,将组件开发和测试的可靠性提升一个台阶。


  目录