React 应用架构实战 0x4:模拟 API

38 阅读4分钟

为什么要模拟 API

Mocking 是模拟系统的过程,即它们不是生产环境准备好的,而是虚拟的版本,这对于开发和测试非常有用。

通过模拟 AIP 可以获得很多好处:

  • 开发过程中独立于外部服务
    • web 应用通常由许多不同部分组成,例如前端、后端、外部第三方 API 等
    • 在开发前端时,我们希望尽可能自治,而不会被某些不可用的系统部分阻塞
    • 如果我们的应用程序 API 已损坏或未完成,仍应该能够继续开发应用程序的前端部分
  • 适用于快速原型制作
    • 模拟的服务允许我们更快地制作原型应用程序,因为它们不需要任何其他设置,如后端服务器、数据库等
    • 非常适合构建概念证明(POC)和最小可行产品(MVP)应用程序
  • 离线开发:
    • 有模拟服务允许我们在没有互联网连接的情况下开发应用程序
  • 测试
    • 在测试前端部分时,不想使用或污染真实的服务,这正是模拟服务的价值
    • 可以构建和测试整个功能,就像我们正在构建真实的 API 一样,然后在生产环境中切换到真实的 API

什么是 msw

MSW(Mock Service Worker)是一个工具,可以用来创建模拟的 API。它作为一个 Service Worker,拦截所有预定义模拟版本的 API 请求。我们可以像调用真实 API 一样,在浏览器的 Network 标签页中检查请求和响应。

使用 MSW 最赞的一点就是我们的应用程序行为和使用真实 API 一样,并且可以通过关闭模拟服务轻松切换到使用真实 API(并不会拦截请求)。

另一个好处是由于拦截是在网络层进行的,因此我们仍然可以在浏览器开发工具的 Network 选项卡中查看请求。

配置

MSW 模拟的 API 在浏览器和服务器上都可以进行配置。

浏览器

浏览器版本的模拟 API 可以在应用程序开发过程中用于运行模拟的端点。

安装及初始化:

pnpm add msw --save-dev

pnpx msw init public/ --save

这将在 public 目录中创建一个名为 mockServiceWorker.js 的文件,该文件将用于拦截请求。

配置 Worker:

// src/testing/mocks/browser.ts
import { setupWorker } from "msw";

import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

后面会在 src/testing/mocks/handlers.ts 中定义请求处理程序。

服务器

服务器版本主要用于运行自动化测试,因为我们的测试运行程序在 Node 环境而不是浏览器中运行。服务器版本也适用于在服务器上执行的 API 调用,这在我们的应用程序进行服务器端渲染时非常有用。

// src/testing/mocks/server.ts
import { setupServer } from "msw/node";

import { handlers } from "./handlers";

export const server = setupServer(...handlers);

在应用中使用 MSW

根据不同环境进行初始化:

// src/testing/mocks/initialize.ts
import { IS_SERVER } from "@/config/constants";

const initializeMocks = () => {
  if (IS_SERVER) {
    const { server } = require("./server");
    server.listen();
  } else {
    const { worker } = require("./browser");
    worker.start();
  }
};

initializeMocks();

接下来,将它整合到项目中去:

import type { ReactNode } from "react";
import { MSWDevTools } from "msw-devtools";

import { IS_DEVELOPMENT } from "@/config/constants";
import { db, handlers } from "@/testing/mocks";

export type MSWWrapperProps = {
  children: ReactNode;
};

require("@/testing/mocks/initialize");

export const MSWWrapper = ({ children }: MSWWrapperProps) => {
  return (
    <>
      {IS_DEVELOPMENT && <MSWDevTools handlers={handlers} db={db} />}
      {children}
    </>
  );
};

在这里,我们定义了 MSWWrapper 组件,它将包装我们的应用程序并初始化 MSWMSWDevTools 到包装的应用程序中。

现在我们可以在 src/pages/_app.tsx 文件中将其集成到我们的应用程序中。

import { ReactElement, ReactNode } from "react";
import { NextPage } from "next";
import { AppProps } from "next/app";
import dynamic from "next/dynamic";

import { AppProvider } from "@/providers/app";
import { API_MOCKING } from "@/config/constants";
import type { MSWWrapperProps } from "@/lib/msw";

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode;
};

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout;
};

const MSWWrapper = dynamic<MSWWrapperProps>(() =>
  import("@/lib/msw").then(({ MSWWrapper }) => MSWWrapper)
);

const App = ({ Component, pageProps }: AppPropsWithLayout) => {
  const getLayout = Component.getLayout ?? ((page) => page);
  const pageContent = getLayout(<Component {...pageProps} />);
  return (
    <AppProvider>{API_MOCKING ? <MSWWrapper>{pageContent}</MSWWrapper> : pageContent}</AppProvider>
  );
};

export default App;

注意,需要配置环境变量: .env.development.local

NEXT_PUBLIC_API_URL=https://api.jobsapp.com
NEXT_PUBLIC_API_MOCKING=true

实现请求处理逻辑

// src/testing/mocks/handlers.ts
import { rest } from "msw";

import { API_URL } from "@/config/constants";

export const handlers = [
  rest.get(`${API_URL}/healthcheck`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        status: "ok",
      })
    );
  }),
];

使用 MSWDevTools

为了方便调试,我们可以使用 MSWDevTools 来查看请求和响应。

配置数据模型

为了对应用程序数据进行建模,我们将使用 MSW 的 data 库,它非常有用且简单易用,可以类似于后端的对象关系映射器(ORM)那样操作数据。

要使我们的请求处理程序功能正常,我们可以只硬编码响应,但是那样似乎不是很有趣?使用 @mswjs/data 库,我们可以构建一个完全具有业务逻辑的模拟后端。

安装:

pnpm add @mswjs/data --save-dev

定义数据模型:

// src/testing/mocks/db.ts
import { factory, primaryKey } from "@mswjs/data";

import { uid } from "@/utils/uid";

const models = {
  user: {
    id: primaryKey(uid),
    createdAt: Date.now,
    email: String,
    password: String,
    organizationId: String,
  },
  organization: {
    id: primaryKey(uid),
    createdAt: Date.now,
    adminId: String,
    name: String,
    email: String,
    phone: String,
    info: String,
  },
  job: {
    id: primaryKey(uid),
    createdAt: Date.now,
    organizationId: String,
    position: String,
    info: String,
    location: String,
    department: String,
  },
};

export const db = factory(models);

我们可以使用每个模型上的许多不同方法,以更轻松地操作我们的数据,如下所示:

db.job.findFirst;
db.job.findMany;
db.job.create;
db.job.update;
db.job.delete;

可以在测试中预先填充一些数据,以便在应用程序中展示,需要进行数据库填充操作。

// src/testing/mocks/seed-db.ts
import { testData } from "../test-data";

import { db } from "./db";

export const seedDb = () => {
  const userCount = db.user.count();

  if (userCount > 0) return;

  testData.users.forEach((user) => db.user.create(user));

  testData.organizations.forEach((organization) => db.organization.create(organization));

  testData.jobs.forEach((job) => db.job.create(job));
};

在测试服务初始化时,我们可以调用 seedDb 函数,以便在应用程序中展示数据。

// src/testing/mocks/initialize.ts
import { IS_SERVER } from "@/config/constants";

import { seedDb } from "./seed-db";

export const initializeMocks = async () => {
  if (IS_SERVER) {
    const { server } = await import("./server");
    server.listen();
  } else {
    const { worker } = await import("./browser");
    worker.start();
  }
  seedDb();
};

然后就可以在 DevTools 中查看数据了。

配置请求处理

然后就可以给应用程序定义处理程序了。正如先前提到的,MSW 中的处理程序是一个函数,如果定义了它,它将拦截任何匹配的请求,不会将请求发送到网络,而是修改它们并返回模拟的响应。

API utils

src/testing/mocks/utils.ts 文件,编写一些我们将用于处理 API 处理程序业务逻辑的实用程序:

  • authenticate 接受用户凭据,如果它们有效,则会从数据库返回用户和身份验证令牌
  • getUser 返回一个测试用户对象
  • requireAuth 如果 cookie 中存在令牌,则返回当前用户;如果不存在令牌,则可以选择抛出错误
// src/testing/mocks/utils.ts
import { RestRequest } from "msw";

import { IS_TEST } from "@/config/constants";
import type { AuthUser } from "@/features/auth";

import { testData } from "../test-data";

import { db } from "./db";

const AUTH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";

export const AUTH_COOKIE = "auth-token";

const sanitizeUser = (user: any): AuthUser => {
  const sanitizedUser = { ...user };
  delete sanitizedUser.password;
  return sanitizedUser;
};

export const getUser = () => sanitizeUser(testData.users[0]);

export const authenticate = ({ email, password }: { email: string; password: string }) => {
  const user = db.user.findFirst({
    where: {
      email: {
        equals: email,
      },
    },
  });

  if (user?.password === password) {
    const sanitizedUser = sanitizeUser(user);
    const encodedToken = AUTH_TOKEN;
    return { user: sanitizedUser, jwt: encodedToken };
  }

  throw new Error("Invalid username or password");
};

export const requireAuth = ({
  req,
  shouldThrow = true,
}: {
  req: RestRequest;
  shouldThrow?: boolean;
}) => {
  if (IS_TEST) {
    return getUser();
  } else {
    const encodedToken = req.cookies[AUTH_COOKIE];

    if (encodedToken !== AUTH_TOKEN) {
      if (shouldThrow) {
        throw new Error("No authorization token provided!");
      }
      return null;
    }

    return getUser();
  }
};

在开始之前,将所有处理程序都包含在配置中。打开 src/testing/mocks/handlers/index.ts 文件并更改为以下内容:

// src/testing/mocks/handlers/index.ts
import { rest } from "msw";

import { API_URL } from "@/config/constants";

import { authHandlers } from "./auth";
import { jobsHandlers } from "./jobs";
import { organizationsHandlers } from "./organizations";

export const handlers = [
  ...authHandlers,
  ...jobsHandlers,
  ...organizationsHandlers,
  rest.get(`${API_URL}/healthcheck`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        status: "ok",
      })
    );
  }),
];

Auth handlers

// src/testing/mocks/handlers/auth.ts
import { rest } from "msw";

import { API_URL } from "@/config/constants";

import { authenticate, requireAuth, AUTH_COOKIE } from "../utils";

const loginHandler = rest.post(`${API_URL}/auth/login`, async (req, res, ctx) => {
  const credentials = await req.json();
  const { user, jwt } = authenticate(credentials);

  return res(
    ctx.delay(300),
    ctx.cookie(AUTH_COOKIE, jwt, {
      path: "/",
      httpOnly: true,
    }),
    ctx.json({ user })
  );
});

const logoutHandler = rest.post(`${API_URL}/auth/logout`, async (req, res, ctx) => {
  return res(
    ctx.delay(300),
    ctx.cookie(AUTH_COOKIE, "", {
      path: "/",
      httpOnly: true,
    }),
    ctx.json({ success: true })
  );
});

const meHandler = rest.get(`${API_URL}/auth/me`, async (req, res, ctx) => {
  const user = requireAuth({ req, shouldThrow: false });

  return res(ctx.delay(300), ctx.json(user));
});

export const authHandlers = [loginHandler, logoutHandler, meHandler];

Jobs handlers

// src/testing/mocks/handlers/jobs.ts
import { rest } from "msw";

import { API_URL } from "@/config/constants";

import { db } from "../db";
import { requireAuth } from "../utils";

const getJobsHandler = rest.get(`${API_URL}/jobs`, async (req, res, ctx) => {
  const organizationId = req.url.searchParams.get("organizationId") as string;

  const jobs = db.job.findMany({
    where: {
      organizationId: {
        equals: organizationId,
      },
    },
  });

  return res(ctx.delay(300), ctx.status(200), ctx.json(jobs));
});

const getJobHandler = rest.get(`${API_URL}/jobs/:jobId`, async (req, res, ctx) => {
  const jobId = req.params.jobId as string;

  const job = db.job.findFirst({
    where: {
      id: {
        equals: jobId,
      },
    },
  });

  if (!job) {
    return res(ctx.delay(300), ctx.status(404), ctx.json({ message: "Not found!" }));
  }

  return res(ctx.delay(300), ctx.status(200), ctx.json(job));
});

const createJobHandler = rest.post(`${API_URL}/jobs`, async (req, res, ctx) => {
  const user = requireAuth({ req });

  const jobData = await req.json();

  const job = db.job.create({
    ...jobData,
    organizationId: user?.organizationId,
  });

  return res(ctx.delay(300), ctx.status(200), ctx.json(job));
});

export const jobsHandlers = [getJobsHandler, getJobHandler, createJobHandler];

Organizations handlers

// src/testing/mocks/handlers/organizations.ts
import { rest } from "msw";

import { API_URL } from "@/config/constants";

import { db } from "../db";

const getOrganizationHandler = rest.get(
  `${API_URL}/organizations/:organizationId`,
  (req, res, ctx) => {
    const organizationId = req.params.organizationId as string;

    const organization = db.organization.findFirst({
      where: {
        id: {
          equals: organizationId,
        },
      },
    });

    if (!organization) {
      return res(ctx.status(404), ctx.json({ message: "Not found!" }));
    }

    return res(ctx.delay(300), ctx.status(200), ctx.json(organization));
  }
);

export const organizationsHandlers = [getOrganizationHandler];