React-生产环境应用架构-二-

54 阅读15分钟

React 生产环境应用架构(二)

原文:zh.annas-archive.org/md5/ceb525cb4b1ca0f3a18a581d1eef2dfa

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:模拟 API

在上一章中,我们构建了使用测试数据的应用程序页面。页面的 UI 是完整的,但页面尚未启用。我们正在使用测试数据,而不向 API 发送请求。

在本章中,我们将学习模拟是什么以及为什么它有用。我们将学习如何使用 msw 库模拟 API 端点,这是一个允许我们创建模拟 API 端点的强大工具,这些端点的行为类似于现实世界的 API 端点。

我们还将学习如何使用 @mswjs/data 库对应用程序实体的数据进行建模。

在本章中,我们将涵盖以下主题:

  • 为什么模拟是有用的?

  • MSW 简介

  • 配置数据模型

  • 配置 API 端点的请求处理器

到本章结束时,我们将学习如何生成具有完整功能的模拟 API,其中已设置数据模型,这将使我们的代码库在开发期间对外部 API 的依赖性降低。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们将在计算机上需要以下内容安装:

  • Node.js 版本 16 或以上和 npm 版本 8 或以上

安装 Node.js 和 npm 有多种方法。以下是一篇深入探讨的精彩文章:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js.

  • Visual Studio CodeVS Code)(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 集成良好,并且我们可以通过扩展来扩展其功能。可以从这里下载:code.visualstudio.com/.

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第五章,所以如果我们想跟随,可以选择 chapter-05-start,或者选择 chapter-05 来查看章节的最终结果。

一旦选择了章节,所有用于跟随本章所需的文件将显示出来。

更多关于设置细节的信息,请查看 README.md 文件。

为什么模拟是有用的?

模拟是模拟系统部分的过程,这意味着它们不是生产就绪的,而是有用的开发测试的假版本。

你可能会问自己,为什么我们要费心设置模拟 API 呢? 拥有模拟 API 有几个好处:

  • 开发期间外部服务的独立性:一个 Web 应用程序通常由许多不同的部分组成,如前端、后端、外部第三方 API 等。在开发前端时,我们希望尽可能自主,不被系统中的某些非功能部分所阻碍。如果我们的应用程序 API 损坏或不完整,我们仍然应该能够继续开发应用程序的前端部分。

  • 快速原型设计:模拟端点允许我们更快地原型化应用程序,因为它们不需要任何额外的设置,例如后端服务器、数据库等。这对于构建概念验证POCs)和最小可行产品MVP)应用程序非常有用。

  • 离线开发:通过模拟 API 端点,我们可以在没有互联网连接的情况下开发我们的应用程序。

  • 测试:我们不想在测试前端时触及我们的真实服务。这就是模拟 API 变得有用的地方。我们可以像针对真实 API 构建一样构建和测试整个功能,然后在生产时切换到真实的一个。

为了测试我们的 API 端点,我们将使用Mock Service WorkerMSW)库,这是一个非常棒的工具,它允许我们以非常优雅的方式模拟端点。

MSW 简介

MSW 是一个允许我们创建模拟 API 的工具。它作为一个服务工作者,拦截任何已定义模拟版本的 API 请求。我们可以像调用真实 API 一样,在我们的浏览器“网络”标签页中检查请求和响应。

为了获得其工作的高级概述,让我们看看他们网站上提供的图解:

图 5.1 – MSW 工作流程图

图 5.1 – MSW 工作流程图

MSW 的一个优点是,我们的应用程序将表现得就像它正在使用真实的 API 一样,而且通过关闭模拟端点和不拦截请求,切换到使用真实 API 是非常简单的。

另一件很棒的事情是,由于拦截发生在网络级别,我们仍然能够在浏览器开发者工具的“网络”标签页中检查我们的请求。

配置概述

我们已经将 MSW 包安装为开发依赖项。msw 模拟 API 可以被配置为在浏览器和服务器上同时工作。

浏览器

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

初始化

需要做的第一件事是创建一个服务工作者。这可以通过执行以下命令来完成:

npx msw init public/ --save

上述命令将在public/mockServiceWorker.js创建一个服务工作者,它将在浏览器中拦截我们的请求并相应地修改响应。

为浏览器配置工作器

我们现在可以配置我们的工作器使用我们将在不久后定义的端点。让我们打开src/testing/mocks/browser.ts文件并添加以下内容:

import { setupWorker } from 'msw';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);

上述代码片段将配置 MSW 与提供的处理程序在浏览器中一起工作。

服务器

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

为服务器配置 MSW

让我们打开src/testing/mocks/server.ts文件并添加以下内容:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

上述代码片段将处理程序应用到我们的模拟的服务器版本。

在应用程序中运行 MSW

现在我们已经配置了 MSW,我们需要让它在我们的应用程序中运行。为此,让我们打开src/testing/mocks/initialize.ts文件并修改initializeMocks函数如下:

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();

initializeMocks函数负责根据其被调用的环境调用适当的 MSW 设置。如果它在服务器上执行,它将运行服务器版本。否则,它将启动浏览器版本。

现在,我们需要集成我们的模拟。

让我们创建一个src/lib/msw.tsx文件并添加以下内容:

import { MSWDevTools } from 'msw-devtools';
import { ReactNode } from 'react';
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 db={db} handlers={handlers} />
      )}
      {children}
    </>
  );
};

在这里,我们定义了MSWWrapper,这是一个将包裹我们的应用程序并初始化 MSW 和 MSW 开发工具到包裹应用程序中的组件。

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

我们想要添加新的导入:

import dynamic from 'next/dynamic';
import { API_MOCKING } from '@/config/constants';
import { MSWWrapperProps } from '@/lib/msw';

然后,我们想要动态加载MSWWrapper

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

最后,让我们修改App组件的return语句如下:

return (
    <AppProvider>
      {API_MOCKING ? (
        <MSWWrapper>{pageContent}</MSWWrapper>
      ) : (
        pageContent
      )}
    </AppProvider>
  );

如您所见,我们只有在模拟启用时才会加载MSWWrapper组件并包裹页面内容。我们这样做是为了排除应用程序生产版本中的 MSW 相关代码,该版本使用真实 API,并且不需要冗余的 MSW 相关代码。

为了验证 MSW 是否正在运行,让我们打开控制台。我们应该看到如下内容:

图 5.2 – MSW 在我们的应用程序中运行

图 5.2 – MSW 在我们的应用程序中运行

现在我们已经成功安装并集成了 MSW 到我们的应用程序中,让我们实现我们的第一个模拟端点。

编写我们的第一个处理程序

要定义模拟端点,我们需要创建请求处理程序。将请求处理程序想象成函数,它们通过模拟其响应来确定是否应该拦截和修改请求。

让我们在src/testing/mocks/handlers/index.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({ healthy: true })
    );
  }),
];

我们正在使用msw提供的rest辅助工具来定义我们的 REST 端点。我们使用的是get方法,它接受路径和一个回调,该回调将修改响应。

处理程序回调将返回一个状态码为200的响应,并将响应数据设置为{ healthy: true }

为了验证我们的处理程序是否正常工作,让我们在右下角打开开发者工具,然后选择健康检查端点:

图 5.3 – 健康检查处理程序测试选择

图 5.3 – 健康检查处理程序测试选择

发送请求应该会给我们一个响应,如下所示:

图 5.4 – 健康检查处理程序测试结果

图 5.4 – 健康检查处理程序测试结果

Devtools小部件将为我们提供测试处理程序的能力,而无需立即在应用程序中创建 UI。

现在我们已经在应用程序中正确运行了 MSW,是时候为我们的应用程序创建数据模型了。

配置数据模型

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

要使我们的请求处理程序功能化,我们只需直接编写响应即可,但那样有什么乐趣呢?使用 MSW 及其数据库,我们可以构建一个模拟的后端,它包含业务逻辑,并且如果我们决定实现它,它将完全功能化。

要配置我们的数据模型,让我们打开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);

我们从@mswjs/data包中导入factoryprimaryKey函数。primaryKey函数允许我们在模拟数据库中定义主键,而factory函数创建一个内存数据库,我们可以用它来进行测试。

然后,我们可以访问每个模型的一组不同方法,这些方法允许我们更轻松地操作我们的数据,如下所示:

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

如果我们能在数据库中预先填充一些数据,那就太好了,这样我们总是有东西可以在我们的应用程序中展示。为此,我们应该对数据库进行预种。

让我们打开src/testing/mocks/seed-db.ts文件并添加以下内容:

import { db } from './db';
import { testData } from '../test-data';
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';
const initializeMocks = () => {
  if (IS_SERVER) {
    const { server } = require('./server');
    server.listen();
  } else {
    const { worker } = require('./browser');
    worker.start();
  }
  seedDb();
};
initializeMocks();

要检查我们数据库中的数据,我们可以在Devtools中打开数据标签页:

图 5.5 – 检查预种数据

图 5.5 – 检查预种数据

太棒了!现在,我们的数据库已经预先填充了一些测试数据。我们现在可以创建请求处理程序,它们将与数据库交互并消耗数据。

配置 API 端点的请求处理程序

在本节中,我们将定义我们应用程序的处理程序。如前所述,MSW 中的处理程序是一个函数,如果定义了它,将拦截任何匹配的请求,而不是将请求发送到网络,而是修改它们并返回模拟的响应。

API 工具

在开始之前,让我们快速查看src/testing/mocks/utils.ts文件,它包含我们将用于处理 API 处理程序业务逻辑的一些实用工具:

  • authenticate 接受用户凭证,如果它们有效,它将从数据库返回用户以及认证令牌。

  • getUser 返回一个测试用户对象。

  • requireAuth 如果 cookie 中的令牌可用,则返回当前用户。如果令牌不存在,它可以选择抛出一个错误。

在开始之前,让我们将所有处理器包含在配置中。打开 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({ healthy: true })
    );
  }),
];

我们将定义的所有处理器都包含在每个处理器的文件中,并使它们对 MSW 可用。

现在,我们可以开始为我们应用程序编写请求处理器。

认证处理器

对于 auth 功能,我们需要以下端点:

  • POST /auth/login

  • POST /auth/logout

  • GET /auth/me

auth 的端点将在 src/test/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 })
    );
  }
);

我们正在提取凭证并使用它们来获取用户信息和令牌。然后,我们将令牌附加到 cookie 中,并在 300 毫秒的延迟后以真实 API 的方式返回用户。

我们使用 httpOnly cookie,因为它更安全,因为它不可从客户端访问。

然后,让我们创建一个注销处理器:

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 })
    );
  }
);

该处理器将仅清空 cookie 并返回响应。任何后续请求到受保护的处理器都将抛出错误。

最后,我们有一个用于获取当前认证用户的端点:

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));
  }
);

该端点将提取令牌中的用户并将其作为响应返回。最后,我们应该导出处理器,以便它们可以被 MSW 消费:

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

工作处理器

对于 jobs 功能,我们需要以下端点:

  • GET /jobs

  • GET /jobs/:jobId

  • POST /jobs

jobs 的端点将在 src/test/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)
    );
  }
);

我们从查询参数中获取组织 ID,并使用它来获取给定组织的作业,然后将其作为响应返回。

我们还想要创建一个工作详情端点。我们可以通过创建以下处理器来实现:

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)
    );
  }
);

我们从 URL 参数中获取工作 ID,并使用它从数据库检索给定的工作。如果没有找到工作,我们返回一个 404 错误。否则,我们在响应中返回工作。

我们的应用程序还需要一个用于创建工作的端点。我们可以创建一个处理器,如下所示:

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)
    );
  }
);

我们首先检查用户是否已认证,因为我们不希望允许未认证用户创建(操作)。然后,我们从请求中获取工作数据,并使用这些数据创建一个新的工作,然后将其作为响应返回。

最后,我们想要导出处理器,以便它们对 MSW 可用:

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

组织处理器

对于 organizations 功能,我们需要 GET /``organizations/:organizationId 端点。

所有针对此功能的处理程序都将定义在 src/test/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)
    );
  }
);

我们从 URL 参数中获取组织 ID,并使用它来检索指定的组织。如果它在数据库中不存在,处理程序将返回一个 404 错误;否则,它将返回找到的组织。

最后,我们必须导出处理程序:

export const organizationsHandlers = [
  getOrganizationHandler,
];

为了验证我们已定义了所有处理程序,我们可以再次访问 Devtools

图 5.6 – 模拟端点

图 5.6 – 模拟端点

太好了!现在,我们已经拥有了所有必需的处理程序,使我们的应用程序能够像消费真实 API 一样工作。玩转这些处理程序以确保一切按预期工作。在下一章中,我们将将这些端点集成到应用程序中。

摘要

在本章中,我们学习了如何模拟 API。我们介绍了 MSW 库,这是一个以优雅方式模拟 API 的优秀工具。它可以在浏览器和服务器上工作。它在原型设计和开发过程中的测试中非常有用。

在下一章中,我们将集成应用程序的 API 层,该层将消费我们刚刚创建的端点。

第六章:将 API 集成到应用程序中

在上一章中,我们介绍了设置模拟 API,这是我们将在应用程序中消费的 API。

在本章中,我们将学习如何通过应用程序消费 API。

当我们说 API 时,我们指的是 API 后端服务器。我们将学习如何从客户端和服务器获取数据。对于 HTTP 客户端,我们将使用Axios,而对于处理获取的数据,我们将使用React Query库,它允许我们在 React 应用程序中处理 API 请求和响应。

在本章中,我们将涵盖以下主题:

  • 配置 API 客户端

  • 配置 React Query

  • 为功能创建 API 层

  • 在应用程序中使用 API 层

到本章结束时,我们将知道如何以干净和有序的方式使我们的应用程序与 API 进行通信。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们需要在计算机上安装以下内容:

  • Node.js版本 16 或以上和npm版本 8 或以上

安装 Node.js 和 npm 有多种方式。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)目前是 JavaScript/TypeScript 最受欢迎的编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 有很好的集成,并且我们可以通过扩展来扩展其功能。可以从code.visualstudio.com/下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆仓库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了仓库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

依赖项安装完成后,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第六章,所以如果我们想跟随着学习,可以选择chapter-06-start,或者选择chapter-06来查看本章的最终结果。

一旦选择了章节,所有跟随本章所需的文件都将出现。

更多关于设置细节的信息,请查看README.md文件。

配置 API 客户端

对于我们应用程序的 API 客户端,我们将使用 Axios,这是一个用于处理 HTTP 请求的非常流行的库。它在浏览器和服务器上都得到支持,并提供创建实例、拦截请求和响应、取消请求等功能。

让我们先创建一个 Axios 实例,这将包括我们希望在每次请求中完成的常见操作。

创建 src/lib/api-client.ts 文件并添加以下内容:

import Axios from 'axios';
import { API_URL } from '@/config/constants';
export const apiClient = Axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});
apiClient.interceptors.response.use(
  (response) => {
    return response.data;
  },
  (error) => {
    const message =
      error.response?.data?.message || error.message;
    console.error(message);
    return Promise.reject(error);
  }
);

在这里,我们创建了一个 Axios 实例,其中我们定义了一个公共基本 URL 和我们希望在每次请求中包含的标头。

然后,我们在想要提取数据属性并返回给客户端的地方附加了一个响应拦截器。我们还定义了错误拦截器,其中我们想要将错误记录到控制台。

然而,拥有一个配置好的 Axios 实例并不足以优雅地处理 React 组件中的请求。我们仍然需要处理调用 API、等待数据到达以及将其存储在状态中的操作。这就是 React Query 发挥作用的地方。

配置 React Query

React Query 是一个处理异步数据和使其在 React 组件中可用的优秀库。

为什么选择 React Query?

React Query 是处理异步远程状态的一个很好的选择,主要原因是它为我们处理了很多事情。

想象以下组件,它从 API 加载数据并显示:

const loadData = () => Promise.resolve('data');
const DataComponent = () => {
  const [data, setData] = useState();
  const [error, setError] = useState();
  const [isLoading, setIsLoading] = useState();
  useEffect(() => {
    setIsLoading(true);
    loadData()
      .then((data) => {
        setData(data);
      })
      .catch((error) => {
        setError(error);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);
  if (isLoading) return <div>Loading</div>;
  if (error) return <div>{error}</div>;
  return <div>{data}</div>;
};

如果我们只从 API 获取一次数据,这没问题,但在大多数情况下,我们需要从许多不同的端点获取数据。我们可以看到这里有一些样板代码:

  • 需要定义相同的 dataerrorisLoading 状态片段

  • 必须相应地更新不同的状态片段

  • 当我们离开组件时,数据就会被丢弃

这就是 React Query 发挥作用的地方。我们可以将我们的组件更新为以下内容:

import { useQuery } from '@tanstack/react-query';
const loadData = () => Promise.resolve('data');
const DataComponent = () => {
  const {data, error, isLoading} = useQuery({
    queryFn: loadData,
    queryKey: ['data']
  })
  if (isLoading) return <div>Loading</div>;
  if (error) return <div>{error}</div>;
  return <div>{data}</div>;
};

注意状态处理是如何从消费者中抽象出来的。我们不需要担心存储数据,或处理加载和错误状态;一切由 React Query 处理。React Query 的另一个好处是其缓存机制。对于每个查询,我们需要提供一个相应的查询键,该键将用于在缓存中存储数据。

这也有助于请求的去重。如果我们从多个地方调用相同的查询,它会确保 API 请求只发生一次。

配置 React Query

现在,回到我们的应用程序。我们已经有 react-query 安装了。我们只需要为我们的应用程序配置它。配置需要一个查询客户端,我们可以在 src/lib/react-query.ts 中创建它并添加以下内容:

import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
      refetchOnWindowFocus: false,
      useErrorBoundary: true,
    },
  },
});

React Query 在创建查询客户端时提供了一个默认配置,我们可以在创建过程中覆盖它。完整的选项列表可以在文档中找到。

现在我们已经创建了我们的查询客户端,我们必须将其包含在提供者中。让我们前往 src/providers/app.tsx 并将内容替换为以下内容:

import {
  ChakraProvider,
  GlobalStyle,
} from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/
  react-query';
import { ReactQueryDevtools } from '@tanstack/
  react-query-devtools';
import { ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { theme } from '@/config/theme';
import { queryClient } from '@/lib/react-query';
type AppProviderProps = {
  children: ReactNode;
};
export const AppProvider = ({
  children,
}: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <ErrorBoundary
        fallback={<div>Something went wrong!</div>}
        onError={console.error}
      >
        <GlobalStyle />
        <QueryClientProvider client={queryClient}>
          <ReactQueryDevtools initialIsOpen={false} />
          {children}
        </QueryClientProvider>
      </ErrorBoundary>
    </ChakraProvider>
  );
};

在这里,我们正在导入并添加 QueryClientProvider,这将使查询客户端及其配置可用于查询和突变。注意我们如何将查询客户端实例作为 client 属性传递。

我们还添加了ReactQueryDevtools,这是一个允许我们检查所有查询的小部件。它仅在开发中使用,这对于调试非常有用。

现在我们已经设置了react-query,我们可以开始实现功能的 API 层。

定义功能的 API 层

API 层将在每个功能的api文件夹中定义。一个 API 请求可以是查询或突变。查询描述了仅获取数据的请求。突变描述了一个在服务器上修改数据的 API 调用。

对于每个 API 请求,我们都会有一个包含并导出 API 请求定义函数和用于在 React 中消费请求的钩子的文件。对于请求定义函数,我们将使用我们刚刚用 Axios 创建的 API 客户端,对于钩子,我们将使用 React Query 的钩子。

我们将在接下来的章节中学习如何实际实现它。

工作

对于jobs功能,我们有三个 API 调用:

  • GET /jobs

  • GET /jobs/:jobId

  • POST /jobs

获取工作

让我们从获取工作的 API 调用开始。为了在我们的应用程序中定义它,让我们创建src/features/jobs/api/get-jobs.ts文件并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Job } from '../types';
type GetJobsOptions = {
  params: {
    organizationId: string | undefined;
  };
};
export const getJobs = ({
  params,
}: GetJobsOptions): Promise<Job[]> => {
  return apiClient.get('/jobs', {
    params,
  });
};
export const useJobs = ({ params }: GetJobsOptions) => {
  const { data, isFetching, isFetched } = useQuery({
    queryKey: ['jobs', params],
    queryFn: () => getJobs({ params }),
    enabled: !!params.organizationId,
    initialData: [],
  });
  return {
    data,
    isLoading: isFetching && !isFetched,
  };
};

如我们所见,这里发生了一些事情:

  1. 我们正在定义请求选项的类型。在那里,我们可以传递organizationId来指定我们想要获取工作的组织。

  2. 我们正在定义getJobs函数,这是获取工作的请求定义。

  3. 我们通过使用react-queryuseQuery钩子来定义useJobs钩子。useQuery钩子返回许多不同的属性,但我们只想暴露应用程序所需的内容。注意,通过使用enabled属性,我们正在告诉useQuery只有在organizationId提供时才运行。这意味着查询将在获取数据之前等待organizationId存在。

由于我们将在功能外部使用它,让我们在src/features/jobs/index.ts中使其可用:

export * from './api/get-jobs';

获取工作详情

获取工作请求应该是直接的。让我们创建src/features/jobs/api/get-job.ts文件并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Job } from '../types';
type GetJobOptions = {
  jobId: string;
};
export const getJob = ({
  jobId,
}: GetJobOptions): Promise<Job> => {
  return apiClient.get(`/jobs/${jobId}`);
};
export const useJob = ({ jobId }: GetJobOptions) => {
  const { data, isLoading } = useQuery({
    queryKey: ['jobs', jobId],
    queryFn: () => getJob({ jobId }),
  });
  return { data, isLoading };
};

如我们所见,我们正在定义和导出getJob函数和useJob查询,我们将在稍后使用它们。

我们希望在功能外部使用这个 API 请求,因此我们必须通过从src/features/jobs/index.ts重新导出它来使其可用:

export * from './api/get-job';

创建工作

正如我们已经提到的,每当我们在服务器上更改某些内容时,都应该将其视为突变。有了这个,让我们创建src/features/jobs/api/create-job.ts文件并添加以下内容:

import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { queryClient } from '@/lib/react-query';
import { Job, CreateJobData } from '../types';
type CreateJobOptions = {
  data: CreateJobData;
};
export const createJob = ({
  data,
}: CreateJobOptions): Promise<Job> => {
  return apiClient.post(`/jobs`, data);
};
type UseCreateJobOptions = {
  onSuccess?: (job: Job) => void;
};
export const useCreateJob = ({
  onSuccess,
}: UseCreateJobOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: createJob,
    onSuccess: (job) => {
      queryClient.invalidateQueries(['jobs']);
      onSuccess?.(job);
    },
  });
  return { submit, isLoading };
};

这里发生了一些事情:

  1. 我们定义了 API 请求的CreateJobOptions类型。它将需要一个包含创建新工作所需所有字段的数据对象。

  2. 我们定义了createJob函数,它向服务器发送请求。

  3. 我们定义了UseCreateJobOptions,它接受一个可选的回调函数,在请求成功时调用。这在我们想要显示通知、重定向用户或执行与 API 请求无直接关系的事情时可能很有用。

  4. 我们正在定义useCreateJob钩子,它使用react-query中的useMutation。如类型定义中所述,它接受一个可选的onSuccess回调,如果突变成功则被调用。

  5. 要创建突变,我们将createJob函数作为mutationFn提供。

  6. 我们定义useMutationonSuccess,在新工作创建后,我们使所有工作查询无效。使查询无效意味着我们想要在缓存中将它们设置为无效。如果我们再次需要它们,我们必须从 API 中获取它们。

  7. 我们正在减少useCreateJob钩子的 API 表面,只暴露那些应用程序使用的功能,所以我们只暴露submitisLoading。如果我们注意到我们需要更多东西,我们总是可以在未来暴露更多东西。

由于它只用于jobs功能内部,我们不需要从index.ts文件中导出这个请求。

组织

对于organizations功能,我们有一个 API 调用:

  • GET /organizations/:organizationId

获取组织详情

让我们创建src/features/organizations/api/get-organization.ts并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Organization } from '../types';
type GetOrganizationOptions = {
  organizationId: string;
};
export const getOrganization = ({
  organizationId,
}: GetOrganizationOptions): Promise<Organization> => {
  return apiClient.get(
    `/organizations/${organizationId}`
  );
};
export const useOrganization = ({
  organizationId,
}: GetOrganizationOptions) => {
  const { data, isLoading } = useQuery({
    queryKey: ['organizations', organizationId],
    queryFn: () => getOrganization({ organizationId }),
  });
  return { data, isLoading };
};

在这里,我们定义了一个查询,它将根据我们传递的organizationId属性获取组织。

由于这个查询也将被organizations功能外部使用,让我们也从src/features/organizations/index.ts中重新导出:

export * from './api/get-organization';

现在我们已经定义了所有的 API 请求,我们可以在我们的应用程序中开始使用它们了。

在应用程序中消费 API

为了能够在没有 API 功能的情况下构建 UI,我们在我们的页面上使用了测试数据。现在,我们想要用我们刚刚为与 API 通信而制作的真实查询和突变来替换它。

公共组织

我们现在需要替换一些东西。

让我们打开src/pages/organizations/[organizationId]/index.tsx并删除以下内容:

import {
  getJobs,
  getOrganization,
} from '@/testing/test-data';

现在,我们必须从 API 加载数据。我们可以通过从相应的features中导入getJobsgetOrganization来实现这一点。让我们添加以下内容:

import { JobsList, Job, getJobs } from '@/features/jobs';
import {
  getOrganization,
  OrganizationInfo,
} from '@/features/organizations';

新的 API 函数略有不同,所以我们需要替换以下代码:

const [organization, jobs] = await Promise.all([
  getOrganization(organizationId).catch(() => null),
  getJobs(organizationId).catch(() => [] as Job[]),
]);

我们必须用以下内容替换它:

const [organization, jobs] = await Promise.all([
  getOrganization({ organizationId }).catch(() => null),
  getJobs({
    params: {
      organizationId: organizationId,
    },
  }).catch(() => [] as Job[]),
]);

公共工作

对于公共工作页面,应该重复相同的过程。

让我们打开src/pages/organizations/[organizationId]/jobs/[jobId].tsx并删除以下内容:

import {
  getJob,
  getOrganization,
} from '@/testing/test-data';

现在,让我们从相应的功能中导入getJobgetOrganization

import { getJob, PublicJobInfo } from '@/features/jobs';
import { getOrganization } from '@/features/organizations';

然后,在getServerSideProps内部,我们需要更新以下内容:

const [organization, job] = await Promise.all([
  getOrganization({ organizationId }).catch(() => null),
  getJob({ jobId }).catch(() => null),
]);

仪表板工作

对于仪表板工作,我们唯一需要做的事情是更新导入,这样我们就不再从测试数据中加载工作,而是从 API 中加载。

让我们通过更新src/pages/dashboard/jobs/index.tsx中的以下行来从jobs功能导入useJobs而不是测试数据:

import { JobsList, useJobs } from '@/features/jobs';
import { useUser } from '@/testing/test-data';

目前我们仍然会保留来自 test-datauseUser;我们将在下一章替换它。

由于新创建的 useJobs 钩子与 test-data 中的钩子略有不同,我们需要更新其使用方式,如下所示:

const jobs = useJobs({
  params: {
    organizationId: user.data?.organizationId ?? '',
  },
});

仪表板工作

仪表板中的工作详情页面也非常简单。

src/pages/dashboard/jobs/[jobId].tsx 文件中,让我们移除从 test-data 导入的 useJob

import { useJob } from '@/testing/test-data';

现在,让我们从 jobs 功能中导入它:

import {
  DashboardJobInfo,
  useJob,
} from '@/features/jobs';

在这里,我们需要更新 useJob 的使用方式:

const job = useJob({ jobId });

创建工作

对于工作创建,我们需要更新表单,当提交时,将创建一个新的工作。

目前,表单不可用,因此我们需要添加一些内容。

打开 src/features/jobs/components/create-job-form/create-job-form.tsx 文件,并将内容替换为以下内容:

import { Box, Stack } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/button';
import { InputField } from '@/components/form';
import { useCreateJob } from '../../api/create-job';
import { CreateJobData } from '../../types';
export type CreateJobFormProps = {
  onSuccess: () => void;
};
export const CreateJobForm = ({
  onSuccess,
}: CreateJobFormProps) => {
  const createJob = useCreateJob({ onSuccess });
  const { register, handleSubmit, formState } =
    useForm<CreateJobData>();
  const onSubmit = (data: CreateJobData) => {
    createJob.submit({ data });
  };
  return (
    <Box w="full">
      <Stack
        as="form"
        onSubmit={handleSubmit(onSubmit)}
        w="full"
        spacing="8"
      >
        <InputField
          label="Position"
          {...register('position', {
            required: 'Required',
          })}
          error={formState.errors['position']}
        />
        <InputField
          label="Department"
          {...register('department', {
            required: 'Required',
          })}
          error={formState.errors['department']}
        />
        <InputField
          label="Location"
          {...register('location', {
            required: 'Required',
          })}
          error={formState.errors['location']}
        />
        <InputField
          type="textarea"
          label="Info"
          {...register('info', {
            required: 'Required',
          })}
          error={formState.errors['info']}
        />
        <Button
          isDisabled={createJob.isLoading}
          isLoading={createJob.isLoading}
          type="submit"
        >
          Create
        </Button>
      </Stack>
    </Box>
  );
};

在这个组件中,有几个值得注意的点:

  1. 我们正在使用 useForm 钩子来处理表单的状态。

  2. 我们正在导入并使用之前定义的 useCreateJob API 钩子来提交请求。

  3. 当突变成功时,会调用 onSuccess 回调。

注意

创建工作表单要求用户进行身份验证。由于我们尚未实现身份验证系统,您可以使用 MSW 开发工具使用测试用户进行身份验证以尝试表单提交。

摘要

在本章中,我们学习了如何使应用程序与其 API 进行通信。首先,我们定义了一个 API 客户端,它允许我们统一 API 请求。然后,我们介绍了 React Query,这是一个用于处理异步状态的库。使用它减少了样板代码并显著简化了代码库。

最后,我们声明了 API 请求,然后将其集成到应用程序中。

在下一章中,我们将学习如何为我们的应用程序创建一个身份验证系统,只有经过身份验证的用户才能访问仪表板。

第七章:实现用户认证和全局通知

在前面的章节中,我们配置了页面,创建了模拟 API,并从我们的应用程序中进行了 API 调用。然而,当涉及到管理仪表板中用户的认证时,应用程序仍然依赖于测试数据。

在本章中,我们将构建应用程序的认证系统,允许用户在管理仪表板中认证并访问受保护资源。我们还将创建一个吐司通知系统,以便在发生我们希望通知用户的行为时向用户提供反馈。

在本章中,我们将涵盖以下主题:

  • 实现认证系统

  • 实现通知

到本章结束时,我们将学会如何在我们的应用程序中认证用户,以及如何使用 Zustand 处理全局应用程序状态。

技术要求

在我们开始之前,我们需要设置项目。为了能够开发项目,你需要在你的计算机上安装以下内容:

  • Node.js版本 16 或以上以及npm版本 8 或以上。

安装 Node.js 和 npm 有多种方法。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 有很好的集成,并且你可以通过扩展来扩展其功能。可以从这里下载:code.visualstudio.com/

本章的代码文件可以在此处找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的正确代码库阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第七章,所以如果你想跟随,可以选择chapter-07-start,或者选择chapter-07来查看本章的最终结果。

一旦选择了章节,所有必要的文件将出现,以便跟随本章内容。

如需了解更多关于设置细节的信息,请查看README.md文件。

实现认证系统

认证是识别平台上的用户的过程。在我们的应用程序中,我们需要在用户访问管理仪表板时识别用户。

在实现系统之前,我们应该仔细研究它的工作方式。

认证系统概述

我们将使用基于令牌的认证系统来认证用户。这意味着 API 将期望用户在请求中发送他们的认证令牌以访问受保护资源。

让我们看一下以下图表和后续步骤:

图 7.1 – 认证系统概述

图 7.1 – 认证系统概述

以下是对先前图表的解释:

  1. 用户通过向/auth/login端点创建请求来使用凭据提交登录表单。

  2. 如果用户存在且凭据有效,将返回包含用户数据的响应。除了响应数据外,我们还在附加一个httpOnly cookie,从现在起将用于认证请求。

  3. 每当用户进行认证时,我们将从响应中存储用户对象到 react-query 的缓存中,并使其对应用程序可用。

  4. 由于认证是基于httpOnly cookie 的 cookie,我们不需要在前端处理认证令牌。任何后续请求都将自动包含令牌。

  5. 在页面刷新时持久化用户数据将通过调用/auth/me端点来处理,该端点将获取用户数据并将其存储在相同的 react-query 缓存中。

为了实现这个系统,我们需要以下内容:

  • 认证功能(登录、登出和访问认证用户)

  • 保护需要用户认证的资源

构建认证功能

为了构建认证功能,我们已经有实现了端点。我们在第五章,“模拟 API”中创建了它们。现在我们需要在我们的应用程序中消费它们。

登录

为了允许用户登录到仪表板,我们将要求他们输入他们的电子邮件和密码并提交表单。

为了实现登录功能,我们需要向服务器上的登录端点发起 API 调用。让我们创建src/features/auth/api/login.ts文件并添加以下内容:

import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { queryClient } from '@/lib/react-query';
import { AuthUser, LoginData } from '../types';
export const login = (
  data: LoginData
): Promise<{
  user: AuthUser;
}> => {
  return apiClient.post('/auth/login', data);
};
type UseLoginOptions = {
  onSuccess?: (user: AuthUser) => void;
};
export const useLogin = ({
  onSuccess,
}: UseLoginOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: login,
    onSuccess: ({ user }) => {
      queryClient.setQueryData(['auth-user'], user);
      onSuccess?.(user);
    },
  });
  return { submit, isLoading };
};

我们正在定义 API 请求和 API 突变钩子,允许我们从我们的应用程序中调用 API。

然后,我们可以更新登录表单以进行 API 调用。让我们修改src/features/auth/components/login-form/login-form.tsx

首先,让我们导入useLogin钩子:

import { useLogin } from '../../api/login';

然后,在LoginForm组件体内部,我们希望在提交处理程序中初始化登录突变并提交它:

export const LoginForm = ({
  onSuccess,
}: LoginFormProps) => {
  const login = useLogin({ onSuccess });
  const { register, handleSubmit, formState } =
    useForm<LoginData>();
  const onSubmit = (data: LoginData) => {
    login.submit(data);
  };
     // rest of the component body
}

我们还应该指出操作正在提交,通过禁用提交按钮:

<Button
  isLoading={login.isLoading}
  isDisabled={login.isLoading}
  type="submit"
>
  Log in
</Button>

当表单提交时,它将调用登录端点,如果凭据有效,将认证用户。

登出

为了实现登出功能,我们需要调用登出端点,这将清除认证 cookie。让我们创建src/features/auth/api/logout.ts文件并添加以下内容:

import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { queryClient } from '@/lib/react-query';
export const logout = () => {
  return apiClient.post('/auth/logout');
};
type UseLogoutOptions = {
  onSuccess?: () => void;
};
export const useLogout = ({
  onSuccess,
}: UseLogoutOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: logout,
    onSuccess: () => {
      queryClient.clear();
      onSuccess?.();
    },
  });
  return { submit, isLoading };
};

我们正在定义登出 API 请求和登出突变。

然后,我们可以通过从src/features/auth/index.ts文件中重新导出它来从认证功能中公开它:

export * from './api/logout';

我们希望在用户点击src/layouts/dashboard-layout.tsx文件并导入额外依赖项时使用它:

import { useRouter } from 'next/router';
import { useLogout } from '@/features/auth';

然后,在Navbar组件中,让我们使用useLogout钩子:

const Navbar = () => {
  const router = useRouter();
  const logout = useLogout({
    onSuccess: () => router.push('/auth/login'),
  });
  // the rest of the component
};

注意,当注销操作成功时,我们如何将用户重定向到登录页面。

让我们最终将操作连接到注销按钮:

<Button
  isDisabled={logout.isLoading}
  isLoading={logout.isLoading}
  variant="outline"
  onClick={() => logout.submit()}
>
  Log Out
</Button>

现在,当用户点击注销按钮时,将调用注销端点,然后用户将被带到登录页面。

获取经过认证的用户

要开始,让我们创建src/features/auth/api/get-auth-user.ts文件并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { AuthUser } from '../types';
export const getAuthUser = (): Promise<AuthUser> => {
  return apiClient.get('/auth/me');
};
export const useUser = () => {
  const { data, isLoading } = useQuery({
    queryKey: ['auth-user'],
    queryFn: () => getAuthUser(),
  });
  return { data, isLoading };
};

此端点将返回当前登录用户的信息。

然后,我们希望从src/features/auth/index.ts文件中导出它:

export * from './api/get-auth-user';

回到src/layouts/dashboard-layout.tsx文件,我们需要那里的用户数据。

让我们用以下内容替换测试数据中的useUser钩子:

import { useLogout, useUser } from '@/features/auth';

另一个需要用户数据的地方是仪表板工作页面。让我们打开src/pages/dashboard/jobs/index.tsx并导入useUser钩子:

import { useUser } from '@/features/auth';

保护需要用户认证的资源

如果未经认证的用户尝试查看受保护资源,会发生什么?我们希望确保任何此类尝试都将用户重定向到登录页面。为此,我们希望创建一个组件,该组件将包装受保护资源,并且只有在用户经过认证的情况下才允许用户查看受保护内容。

Protected组件将从/auth/me端点获取用户,如果用户存在,它将允许内容显示。否则,它将重定向用户到登录页面。

该组件已在src/features/auth/components/protected/protected.tsx文件中定义,但现在并没有做什么。让我们修改该文件如下:

import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { ReactNode, useEffect } from 'react';
import { Loading } from '@/components/loading';
import { useUser } from '../../api/get-auth-user';
export type ProtectedProps = {
  children: ReactNode;
};
export const Protected = ({
  children,
}: ProtectedProps) => {
  const { replace, asPath } = useRouter();
  const user = useUser();
  useEffect(() => {
    if (!user.data && !user.isLoading) {
      replace(
        `/auth/login?redirect=${asPath}`,
        undefined,
        { shallow: true }
      );
    }
  }, [user, asPath, replace]);
  if (user.isLoading) {
    return (
      <Flex direction="column" justify="center" h="full">
        <Loading />
      </Flex>
    );
  }
  if (!user.data && !user.isLoading) return null;
  return <>{children}</>;
};

该组件接受子内容作为 props,这意味着它将包裹嵌套内容并决定是否应该渲染。

我们从相同的useUser钩子中访问用户。最初,在数据正在获取时,组件渲染Loading组件。一旦数据被获取,我们在useEffect中检查用户是否存在,如果不存在,我们将重定向到登录页面。否则,我们可以像往常一样渲染子组件。

Protected组件旨在在仪表板中使用。由于我们已经有了一个可重用的仪表板布局,我们不需要在每一页上包裹Protected,我们可以在仪表板布局中只做一次。

让我们打开src/layouts/dashboard-layout.tsx并导入Protected组件:

import { Protected } from '@/features/auth';

然后,在DashboardLayout组件的 JSX 中,让我们将一切包裹在Protected中,如下所示:

export const DashboardLayout = ({
  children,
}: DashboardLayoutProps) => {
  const user = useUser();
  return (
    <Protected>
      <Box as="section" h="100vh" overflowY="auto">
        <Navbar />
        <Container as="main" maxW="container.lg" py="12">
          {children}
        </Container>
        <Box py="8" textAlign="center">
          <Link
            href={`/organizations/${user.data?.
              organizationId}`}
          >
            View Public Organization Page
          </Link>
        </Box>
      </Box>
    </Protected>
  );
};

如果您尝试访问http://localhost:3000/dashboard/jobs页面,您将被重定向到登录页面。

尝试使用现有的凭据(电子邮件:user1@test.com;密码:password)进行登录。如果一切顺利,您可以使用属于给定用户组织的数据访问仪表板。

实现通知

每当应用程序中发生某些事情,例如表单提交成功或 API 请求失败时,我们希望通知我们的用户。

我们需要创建一个全局存储库,用于跟踪所有通知。我们希望它是全局的,因为我们希望从应用程序的任何地方显示这些通知。

对于处理全局状态,我们将使用 Zustand,这是一个轻量级且非常简单的状态管理库。

创建存储库

让我们打开src/stores/notifications/notifications.ts文件并导入我们将要使用的依赖项:

import { createStore, useStore } from 'zustand';
import { uid } from '@/utils/uid';

然后,让我们声明存储库的通知类型:

export type NotificationType =
  | 'info'
  | 'warning'
  | 'success'
  | 'error';
export type Notification = {
  id: string;
  type: NotificationType;
  title: string;
  duration?: number;
  message?: string;
};
export type NotificationsStore = {
  notifications: Notification[];
  showNotification: (
    notification: Omit<Notification, 'id'>
  ) => void;
  dismissNotification: (id: string) => void;
};

存储库将跟踪活动通知的数组。要显示通知,我们需要调用showNotification方法,要关闭它,我们将调用dismissNotification

让我们创建存储库:

export const notificationsStore =
  createStore<NotificationsStore>((set, get) => ({
    notifications: [],
    showNotification: (notification) => {
      const id = uid();
      set((state) => ({
        notifications: [
          ...state.notifications,
          { id, ...notification },
        ],
      }));
      if (notification.duration) {
        setTimeout(() => {
          get().dismissNotification(id);
        }, notification.duration);
      }
    },
    dismissNotification: (id) => {
      set((state) => ({
        notifications: state.notifications.filter(
          (notification) => notification.id !== id
        ),
      }));
    },
  }));

为了创建存储库,我们使用来自zustand/vanillacreateStore来使其更便携和可测试。该函数为我们提供了setget辅助函数,分别允许我们修改和访问存储库。

由于我们使用纯方法创建了存储库,我们需要使其与 React 兼容。我们通过以下方式使用 Zustand 提供的useStore钩子来实现这一点:

export const useNotifications = () =>
  useStore(notificationsStore);

那就是通知存储库。如您所见,它非常简单,几乎没有样板代码。

任何时候我们需要在 React 组件或钩子内部访问存储库,我们都可以使用useNotifications钩子。或者,如果我们想从 React 之外的纯 JavaScript 函数中访问存储库,我们可以直接使用notificationStore

创建用户界面

现在我们有了通知存储库,我们需要构建一个 UI 来在活动时显示这些通知。

让我们打开src/components/notifications/notifications.tsx文件并导入所需的依赖项:

import {
  Flex,
  Box,
  CloseButton,
  Stack,
  Text,
} from '@chakra-ui/react';
import {
  Notification,
  NotificationType,
  useNotifications,
} from '@/stores/notifications';

然后,让我们创建Notifications组件,它将显示通知:

export const Notifications = () => {
  const { notifications, dismissNotification } =
    useNotifications();
  if (notifications.length < 1) return null;
  return (
    <Box
      as="section"
      p="4"
      position="fixed"
      top="12"
      right="0"
      zIndex="1"
    >
      <Flex gap="4" direction="column-reverse">
        {notifications.map((notification) => (
          <NotificationToast
            key={notification.id}
            notification={notification}
            onDismiss={dismissNotification}
          />
        ))}
      </Flex>
    </Box>
  );
};

我们通过useNotifications钩子访问通知,它为我们提供了对存储库的访问。

如您所见,我们正在映射活动通知。我们为每个活动通知渲染NotificationToast组件,并将通知对象和关闭处理程序作为属性传递。让我们通过描述变体和属性类型来实现它:

const notificationVariants: Record<
  NotificationType,
  { color: string }
> = {
  info: {
    color: 'primary',
  },
  success: {
    color: 'green',
  },
  warning: {
    color: 'orange',
  },
  error: {
    color: 'red',
  },
};
type NotificationToastProps = {
  notification: Omit<Notification, 'duration'>;
  onDismiss: (id: string) => void;
};

然后,实现NotificationToast组件:

const NotificationToast = ({
  notification,
  onDismiss,
}: NotificationToastProps) => {
  const { id, type, title, message } = notification;
  return (
    <Box
      w={{ base: 'full', sm: 'md' }}
      boxShadow="md"
      bg="white"
      borderRadius="lg"
      {...notificationVariants[type]}
    >
      <Stack
        direction="row"
        p="4"
        spacing="3"
        justifyContent="space-between"
      >
        <Stack spacing="2.5">
          <Stack spacing="1">
            <Text fontSize="sm" fontWeight="medium">
              {title}
            </Text>
            {notification.message && (
              <Text fontSize="sm" color="muted">
                {message}
              </Text>
            )}
          </Stack>
        </Stack>
        <CloseButton
          onClick={() => onDismiss(id)}
          transform="translateY(-6px)"
        />
      </Stack>
    </Box>
  );
};

现在我们已经有了通知存储库和创建的 UI,是时候将它们集成到应用程序中了。

集成和使用通知

要将通知集成到应用程序中,让我们打开src/providers/app.tsx文件并导入Notifications组件:

import { Notifications } from '@/components/notifications';

然后,让我们在AppProvider中渲染组件:

export const AppProvider = ({
  children,
}: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <GlobalStyle />
      <Notifications />
      {/* rest of the code */}
    </ChakraProvider>
  );
};

完美!现在我们准备好开始显示一些通知了。

如前所述,我们可以在 React 世界和其外部使用该存储。

我们需要在创建作业的页面 React 组件中使用它。每当成功创建一个作业时,我们希望让用户知道。

让我们打开src/pages/dashboard/jobs/create.tsx文件并导入useNotifications钩子:

import { useNotifications } from '@/stores/notifications';

然后,让我们在DashboardCreateJobPage组件体内部初始化钩子:

const { showNotification } = useNotifications();

然后,我们可以在onSuccess处理程序中调用showNotification

const onSuccess = () => {
  showNotification({
    type: 'success',
    title: 'Success',
    duration: 5000,
    message: 'Job Created!',
  });
  router.push(`/dashboard/jobs`);
};

我们展示了一个新的成功通知,它将在 5 秒后消失。

要查看其操作效果,让我们打开localhost:3000/dashboard/jobs/create并提交表单。如果提交成功,我们应该看到如下内容:

图 7.2 – 通知在操作中

图 7.2 – 通知在操作中

完美!每当创建一个作业时,用户都会收到通知。

我们可以利用通知的另一个地方是在 API 错误处理中。每当发生 API 错误时,我们希望让用户知道出了些问题。

我们可以在 API 客户端级别处理它。由于 Axios 支持拦截器,并且我们已经配置了它们,我们只需要修改响应错误拦截器。

让我们打开src/lib/api-client.ts并导入存储:

import { notificationsStore } from '@/stores/notifications';

然后,在响应错误拦截器中,让我们定位以下内容:

console.error(message);

我们将用以下内容替换它:

notificationsStore.getState().showNotification({
  type: 'error',
  title: 'Error',
  duration: 5000,
  message,
});

要访问 vanilla Zustand 存储上的值和方法,我们需要调用getState方法。

每当 API 发生错误时,都会向用户显示错误通知。

值得注意的是,Chakra UI 自带一个开箱即用的 toast 通知系统,使用起来非常简单,非常适合我们的需求,但我们还是自己构建了一个,以便学习如何以优雅且简单的方式管理全局应用程序状态。

摘要

在本章中,我们学习了如何处理身份验证和管理应用程序的全局状态。

我们从对身份验证系统及其工作原理的概述开始。然后,我们实现了登录、注销和获取认证用户信息等身份验证功能。我们还构建了Protected组件,该组件根据用户的认证状态控制用户是否可以查看页面。

然后,我们构建了一个 toast 通知系统,用户可以从应用程序的任何地方触发和显示通知。构建它的主要目的是介绍 Zustand,这是一个非常简单且易于使用的全局状态管理库,用于处理全局应用程序状态。

在下一章中,我们将学习如何使用单元测试、集成测试和端到端测试来测试应用程序。

第八章:测试

我们终于完成了应用程序的开发。在我们将其发布到生产之前,我们想确保一切按预期工作。

在本章中,我们将学习如何使用不同的测试方法来测试我们的应用程序。这将给我们信心重构应用程序,构建新功能,修改现有功能,而不用担心破坏当前应用程序的行为。

我们将涵盖以下主题:

  • 单元测试

  • 集成测试

  • 端到端测试

到本章结束时,我们将知道如何使用不同的方法和工具来测试我们的应用程序。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们需要在计算机上安装以下内容:

  • Node.js 版本 16 或更高版本以及 npm 版本 8 或更高版本

安装 Node.js 和 npm 有多种方法。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)目前是 JavaScript/TypeScript 最受欢迎的编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 集成良好,我们可以通过扩展来扩展其功能。可以从 code.visualstudio.com/ 下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的正确代码库阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第八章,因此如果我们想跟随,可以选择 chapter-08-start,或者选择 chapter-08 来查看本章的最终结果。

一旦选择了章节,所有跟随该章节所需的文件将显示出来。

关于设置细节的更多信息,请查看 README.md 文件。

单元测试

单元测试是一种测试方法,其中应用程序单元在隔离状态下进行测试,不依赖于其他部分。

对于单元测试,我们将使用 Jest,这是测试 JavaScript 应用程序最流行的框架。

在我们的应用程序中,我们将对通知存储进行单元测试。

让我们打开 src/stores/notifications/__tests__/notifications.test.ts 文件并添加以下内容:

import {
  notificationsStore,
  Notification,
} from '../notifications';
const notification = {
  id: '123',
  title: 'Hello World',
  type: 'info',
  message: 'This is a notification',
} as Notification;
describe('notifications store', () => {
  it('should show and dismiss notifications', () => {
    // 1
    expect(
      notificationsStore.getState().notifications.length
    ).toBe(0);
    // 2
    notificationsStore
      .getState()
      .showNotification(notification);
    expect(
      notificationsStore.getState().notifications
    ).toContainEqual(notification);
    // 3
    notificationsStore
      .getState()
      .dismissNotification(notification.id);
    expect(
      notificationsStore.getState().notifications
    ).not.toContainEqual(notification);
  });
});

通知测试工作如下:

  1. 我们断言 notifications 数组最初是空的。

  2. 然后,我们触发showNotification动作,并测试新创建的通知是否存在于notifications数组中。

  3. 最后,我们调用dismissNotification函数来取消通知,并确保通知已从notifications数组中移除。

要运行单元测试,我们可以执行以下命令:

npm run test

单元测试的另一个用例将是各种实用函数和可重用组件,包括可以单独测试的逻辑。然而,在我们的案例中,我们将主要使用集成测试来测试我们的组件,这将在下一节中看到。

集成测试

集成测试是一种测试方法,其中测试应用程序的多个部分。集成测试通常比单元测试更有帮助,并且大多数应用程序测试应该是集成测试。

集成测试更有价值,因为它们可以增加我们对应用程序的信心,因为我们正在测试不同部分的功能、它们之间的关系以及它们如何进行通信。

对于集成测试,我们将使用 Jest 和 React Testing Library。这是一种测试应用程序功能的好方法,就像用户使用它一样。

src/testing/test-utils.ts中,我们可以定义一些我们可以在测试中使用的实用工具。我们还应该从这里重新导出 React Testing Library 提供的所有实用工具,这样我们就可以在测试中需要时轻松访问它们。目前,除了 React Testing Library 提供的所有函数外,我们还导出以下实用工具:

  • appRender是一个函数,它调用 React Testing Library 中的render函数,并将AppProvider作为wrapper。我们需要这样做,因为在我们进行集成测试时,我们的组件依赖于在AppProvider中定义的多个依赖项,例如 React Query 上下文、通知等。提供AppProvider作为wrapper将在我们测试期间渲染组件时使其可用。

  • checkTableValues是一个函数,它遍历表格中的所有单元格,并将每个值与提供的数据中的相应值进行比较,确保所有信息都显示在表格中。

  • waitForLoadingToFinish是一个函数,它在我们可以继续进行测试之前等待所有加载旋转器消失。这在我们必须等待某些数据被获取后才能断言值时很有用。

另一个值得提及的文件是src/testing/setup-tests.ts,在那里我们可以配置不同的初始化和清理动作。在我们的案例中,它帮助我们初始化和重置测试之间的模拟 API。

我们可以根据页面拆分我们的集成测试,并测试每个页面上的所有部分。想法是在以下部分对我们的应用程序进行集成测试:

  • 仪表板工作页面

  • 仪表板工作页面

  • 创建工作页面

  • 登录页面

  • 公共工作页面

  • 公共组织页面

仪表板工作页面

仪表板作业页面的功能基于当前登录用户。在这里,我们正在获取用户组织的所有作业并在作业表中显示它们。

让我们从打开src/__tests__/dashboard-jobs-page.test.tsx文件并添加以下内容开始:

import DashboardJobsPage from '@/pages/dashboard/jobs';
import { getUser } from '@/testing/mocks/utils';
import { testData } from '@/testing/test-data';
import {
  appRender,
  checkTableValues,
  screen,
  waitForLoadingToFinish,
} from '@/testing/test-utils';
// 1
jest.mock('@/features/auth', () => ({
  useUser: () => ({ data: getUser() }),
}));
describe('Dashboard Jobs Page', () => {
  it('should render the jobs list', async () => {
    // 2
    await appRender(<DashboardJobsPage />);
    // 3
    expect(screen.getByText(/jobs/i)).toBeInTheDocument();
    // 4
    await waitForLoadingToFinish();
    // 5
    checkTableValues({
      container: screen.getByTestId('jobs-list'),
      data: testData.jobs,
      columns: ['position', 'department', 'location'],
    });
  });
});

测试工作如下:

  1. 由于加载作业依赖于当前登录用户,我们需要模拟useUser钩子以返回正确的用户对象。

  2. 然后,我们渲染页面。

  3. 然后,我们确保作业页面的标题显示在页面上。

  4. 要获取已加载的作业,我们需要等待它们加载完成。

  5. 最后,我们断言表格中的作业值。

仪表板作业页面

仪表板作业页面的功能是我们希望加载作业数据并在页面上显示它。

让我们从打开src/__tests__/dashboard-job-page.test.tsx文件并添加以下内容开始:

import DashboardJobPage from '@/pages/dashboard/jobs/
  [jobId]';
import { testData } from '@/testing/test-data';
import {
  appRender,
  screen,
  waitForLoadingToFinish,
} from '@/testing/test-utils';
const job = testData.jobs[0];
const router = {
  query: {
    jobId: job.id,
  },
};
// 1
jest.mock('next/router', () => ({
  useRouter: () => router,
}));
describe('Dashboard Job Page', () => {
  it('should render all the job details', async () => {
    // 2
    await appRender(<DashboardJobPage />);
    await waitForLoadingToFinish();
    const jobPosition = screen.getByRole('heading', {
      name: job.position,
    });
    const info = screen.getByText(job.info);
    // 3
    expect(jobPosition).toBeInTheDocument();
    expect(info).toBeInTheDocument();
  });
});

测试工作如下:

  1. 由于我们是根据jobId URL 参数加载作业数据,我们需要模拟useRouter钩子以返回正确的作业 ID。

  2. 然后,我们渲染页面并等待数据加载,通过等待页面上所有加载器消失来实现。

  3. 最后,我们检查作业数据是否显示在页面上。

作业创建页面

作业创建页面包含一个表单,当提交时,它调用 API 端点在后端创建一个新的作业。当请求成功时,我们将用户重定向到仪表板作业页面并显示关于成功创建作业的通知。

让我们从打开src/__tests__/dashboard-create-job-page.test.tsx文件并添加以下内容开始:

import DashboardCreateJobPage from '@/pages/dashboard/jobs/
  create';
import {
  appRender,
  screen,
  userEvent,
  waitFor,
} from '@/testing/test-utils';
const router = {
  push: jest.fn(),
};
// 1
jest.mock('next/router', () => ({
  useRouter: () => router,
}));
const jobData = {
  position: 'Software Engineer',
  location: 'London',
  department: 'Engineering',
  info: 'Lorem Ipsum',
};
describe('Dashboard Create Job Page', () => {
  it('should create a new job', async () => {
    // 2
    appRender(<DashboardCreateJobPage />);
    const positionInput = screen.getByRole('textbox', {
      name: /position/i,
    });
    const locationInput = screen.getByRole('textbox', {
      name: /location/i,
    });
    const departmentInput = screen.getByRole('textbox', {
      name: /department/i,
    });
    const infoInput = screen.getByRole('textbox', {
      name: /info/i,
    });
    const submitButton = screen.getByRole('button', {
      name: /create/i,
    });
    // 3
    userEvent.type(positionInput, jobData.position);
    userEvent.type(locationInput, jobData.location);
    userEvent.type(departmentInput, jobData.department);
    userEvent.type(infoInput, jobData.info);
    // 4
    userEvent.click(submitButton);
    // 5
    await waitFor(() =>
      expect(
        screen.getByText(/job created!/i)
      ).toBeInTheDocument()
    );
  });
});

测试工作如下:

  1. 首先,我们需要模拟useRouter钩子以包含push方法,因为提交后它用于导航到作业页面。

  2. 然后,我们渲染页面组件。

  3. 之后,我们将所有输入值插入到它们中。

  4. 然后,我们通过模拟提交按钮上的点击事件来提交表单。

  5. 提交后,我们需要等待文档中显示作业已创建的通知。

公共组织页面

对于组织页面,由于我们是在服务器上渲染它,我们需要在服务器上获取数据并在页面上显示。

让我们从打开src/__tests__/public-organization-page.test.tsx文件并定义测试套件的骨架开始,如下所示:

import PublicOrganizationPage, {
  getServerSideProps,
} from '@/pages/organizations/[organizationId]';
import { testData } from '@/testing/test-data';
import {
  appRender,
  checkTableValues,
  screen,
} from '@/testing/test-utils';
const organization = testData.organizations[0];
const jobs = testData.jobs;
describe('Public Organization Page', () => {
  it('should use getServerSideProps that fetches and
    returns the proper data', async () => {
  });
  it('should render the organization details', async () => {
  });
  it('should render the not found message if the
    organization is not found', async () => {
  });
});

现在,我们将关注测试套件中的每个测试。

首先,我们想要测试getServerSideProps函数获取正确的数据并将其作为 props 返回,这些 props 将在页面上提供:

it('should use getServerSideProps that fetches and returns
  the proper data', async () => {
  const { props } = await getServerSideProps({
    params: {
      organizationId: organization.id,
    },
  } as any);
  expect(props.organization).toEqual(organization);
  expect(props.jobs).toEqual(jobs);
});

在这里,我们正在调用getServerSideProps函数并断言返回的值包含相应的数据。

在第二个测试中,我们想要验证提供给PublicOrganizationPage组件的 props 是否正确渲染:

it('should render the organization details', async () => {
  appRender(
    <PublicOrganizationPage
      organization={organization}
      jobs={jobs}
    />
  );
  expect(
    screen.getByRole('heading', {
      name: organization.name,
    })
  ).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: organization.email,
    })
  ).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: organization.phone,
    })
  ).toBeInTheDocument();
  checkTableValues({
    container: screen.getByTestId('jobs-list'),
    data: jobs,
    columns: ['position', 'department', 'location'],
  });
});

在这个测试中,我们正在渲染页面组件并验证所有值是否显示在页面上。

在测试套件的第三个测试中,我们想要断言如果组织不存在,我们想要显示未找到消息:

it('should render the not found message if the organization is not found', async () => {
  appRender(
    <PublicOrganizationPage
      organization={null}
      jobs={[]}
    />
  );
  const notFoundMessage = screen.getByRole('heading', {
    name: /not found/i,
  });
  expect(notFoundMessage).toBeInTheDocument();
});

在这里,我们正在渲染PublicOrganizationPage组件,并使用null的组织值,然后验证未找到消息是否在文档中。

公共职位页面

对于公共职位页面,由于我们在服务器上渲染它,我们需要在服务器上获取数据并在页面上显示它。

让我们从打开src/__tests__/public-job-page.test.tsx文件并定义测试的框架开始:

import PublicJobPage, {
  getServerSideProps,
} from '@/pages/organizations/[organizationId]/jobs/[jobId]';
import { testData } from '@/testing/test-data';
import { appRender, screen } from '@/testing/test-utils';
const job = testData.jobs[0];
const organization = testData.organizations[0];
describe('Public Job Page', () => {
  it('should use getServerSideProps that fetches and
    returns the proper data', async () => {
  });
  it('should render the job details', async () => {
  });
  it('should render the not found message if the data does
    not exist', async () => {
  });
});

现在,我们可以专注于测试套件中的每个测试。

首先,我们需要测试getServerSideProps函数,它将获取数据并通过 props 将数据返回到页面:

it('should use getServerSideProps that fetches and returns
  the proper data', async () => {
  const { props } = await getServerSideProps({
    params: {
      jobId: job.id,
      organizationId: organization.id,
    },
  } as any);
  expect(props.job).toEqual(job);
  expect(props.organization).toEqual(organization);
});

在这里,我们调用getServerSideProps并断言返回值是否与预期数据匹配。

现在,我们可以测试PublicJobPage,我们想要确保提供的数据显示在页面上:

it('should render the job details', async () => {
  appRender(
    <PublicJobPage
      organization={organization}
      job={job}
    />
  );
  const jobPosition = screen.getByRole('heading', {
    name: job.position,
  });
  const info = screen.getByText(job.info);
  expect(jobPosition).toBeInTheDocument();
  expect(info).toBeInTheDocument();
});

在这里,我们渲染页面组件并验证提供的职位数据是否显示在页面上。

最后,我们要断言getServerSideProps提供的数据不存在的情况:

it('should render the not found message if the data does not exist', async () => {
  const { rerender } = appRender(
    <PublicJobPage organization={null} job={null} />
  );
  const notFoundMessage = screen.getByRole('heading', {
    name: /not found/i,
  });
  expect(notFoundMessage).toBeInTheDocument();
  rerender(
    <PublicJobPage
      organization={organization}
      job={null}
    />
  );
  expect(notFoundMessage).toBeInTheDocument();
  rerender(
    <PublicJobPage organization={null} job={job} />
  );
  expect(notFoundMessage).toBeInTheDocument();
  rerender(
    <PublicJobPage
      organization={organization}
      job={{ ...job, organizationId: '123' }}
    />
  );
  expect(notFoundMessage).toBeInTheDocument();
});

由于存在多个数据可能被视为无效的情况,我们使用了rerender函数,它可以使用不同的 props 集重新渲染组件。我们断言如果数据未找到,则未找到消息将在页面上显示。

登录页面

登录页面渲染登录表单,当成功提交时,将用户导航到仪表板。

让我们从打开src/__tests__/login-page.test.tsx文件并添加以下内容开始:

import LoginPage from '@/pages/auth/login';
import {
  appRender,
  screen,
  userEvent,
  waitFor,
} from '@/testing/test-utils';
// 1
const router = {
  replace: jest.fn(),
  query: {},
};
jest.mock('next/router', () => ({
  useRouter: () => router,
}));
describe('Login Page', () => {
  it('should login the user into the dashboard', async () => {
    // 2
    await appRender(<LoginPage />);
    const emailInput = screen.getByRole('textbox', {
      name: /email/i,
    });
    const passwordInput =
      screen.getByLabelText(/password/i);
    const submitButton = screen.getByRole('button', {
      name: /log in/i,
    });
    const credentials = {
      email: 'user1@test.com',
      password: 'password',
    };
    // 3
    userEvent.type(emailInput, credentials.email);
    userEvent.type(passwordInput, credentials.password);
    userEvent.click(submitButton);
    // 4
    await waitFor(() =>
      expect(router.replace).toHaveBeenCalledWith(
        '/dashboard/jobs'
      )
    );
  });
});

测试工作如下:

  1. 我们需要模拟useRouter钩子,因为它被用来在成功提交时将用户导航到仪表板。

  2. 接下来,我们渲染页面。

  3. 然后,我们将凭据输入到表单中并提交。

  4. 最后,我们期望在路由器上的replace方法被调用,并带有/dashboard/jobs值,如果登录提交成功,则应将用户导航到仪表板。

要运行集成测试,我们可以执行以下命令:

npm run test

如果我们想观察测试中的变化,我们可以执行以下命令:

npm run test:watch

端到端测试

端到端测试是一种将应用程序作为一个完整实体进行测试的测试方法。通常,这些测试包括以自动化方式运行整个应用程序,包括前端和后端,并验证整个系统是否正常工作。

在端到端测试中,我们通常想要测试成功路径,以确认一切按预期工作。

为了测试我们的应用程序端到端,我们将使用 Cypress,这是一个非常流行的测试框架,它通过在无头浏览器中执行测试来工作。这意味着测试将在真实的浏览器环境中运行。除了 Cypress 之外,由于我们已经熟悉了 React Testing Library,我们将使用 Cypress 的 Testing Library 插件来与页面交互。

对于我们的应用程序,我们想要测试两个应用程序的流程:

  • 仪表板流程

  • 公共流程

仪表板流程

仪表板流程是组织管理员想要测试用户认证以及访问和交互仪表板不同部分的流程。

让我们从打开cypress/e2e/dashboard.cy.ts文件并添加我们的测试骨架开始:

import { testData } from '../../src/testing/test-data';
const user = testData.users[0];
const job = testData.jobs[0];
describe('dashboard', () => {
  it('should authenticate into the dashboard', () => {
  });
  it('should navigate to and visit the job details page', () => {
  });
  it('should create a new job', () => {
  });
  it('should log out from the dashboard', () => {
  });
});

现在,让我们来实现测试。

首先,我们想要认证进入仪表板:

it('should authenticate into the dashboard', () => {
  cy.clearCookies();
  cy.clearLocalStorage();
  cy.visit('http://localhost:3000/dashboard/jobs');
  cy.wait(500);
  cy.url().should(
    'equal',
    'http://localhost:3000/auth/login?redirect=/dashboard/
      jobs'
  );
  cy.findByRole('textbox', {
    name: /email/i,
  }).type(user.email);
  cy.findByLabelText(/password/i).type(
    user.password.toLowerCase()
  );
  cy.findByRole('button', {
    name: /log in/i,
  }).click();
  cy.findByRole('heading', {
    name: /jobs/i,
  }).should('exist');
});

在这里,我们想要清除 cookies 和localStorage。然后,我们必须尝试导航到仪表板;然而,应用程序将重定向我们到登录页面。我们必须在登录表单中输入凭据并提交。之后,我们将被重定向到仪表板职位页面,在那里我们可以看到职位标题。

现在我们处于仪表板职位页面,我们可以通过访问职位详情页面来进一步操作:

it('should navigate to and visit the job details page', () => {
  cy.findByRole('row', {
    name: new RegExp(
      `${job.position} ${job.department} ${job.location}
        View`,
      'i'
    ),
  }).within(() => {
    cy.findByRole('link', {
      name: /view/i,
    }).click();
  });
  cy.findByRole('heading', {
    name: job.position,
  }).should('exist');
  cy.findByText(new RegExp(job.info, 'i')).should(
    'exist'
  );
});

在这里,我们点击了其中一个职位的查看链接,并导航到职位详情页面,以验证所选的职位数据是否显示在页面上。

现在,让我们测试职位创建过程:

it('should create a new job', () => {
  cy.go('back');
  cy.findByRole('link', {
    name: /create job/i,
  }).click();
  const jobData = {
    position: 'Software Engineer',
    location: 'London',
    department: 'Engineering',
    info: 'Lorem Ipsum',
  };
  cy.findByRole('textbox', {
    name: /position/i,
  }).type(jobData.position);
  cy.findByRole('textbox', {
    name: /department/i,
  }).type(jobData.department);
  cy.findByRole('textbox', {
    name: /location/i,
  }).type(jobData.location);
  cy.findByRole('textbox', {
    name: /info/i,
  }).type(jobData.info);
  cy.findByRole('button', {
    name: /create/i,
  }).click();
  cy.findByText(/job created!/i).should('exist');
});

由于我们处于职位详情页面,我们需要导航回仪表板职位页面,在那里我们可以点击创建职位链接。这将带我们到创建职位页面。在这里,我们填写表格并提交。当提交成功时,应该会显示职位已创建的通知。

现在我们已经测试了仪表板的所有功能,我们可以从仪表板注销:

it('should log out from the dashboard', () => {
  cy.findByRole('button', {
    name: /log out/i,
  }).click();
  cy.wait(500);
  cy.url().should(
    'equal',
    'http://localhost:3000/auth/login'
  );
});

点击注销按钮将用户注销并重定向到登录页面。

公共流程

应用程序的公共流程对访问它的每个人都是可用的。

让我们从打开cypress/e2e/public.cy.ts文件并添加测试的骨架开始:

import { testData } from '../../src/testing/test-data';
const organization = testData.organizations[0];
const job = testData.jobs[0];
describe('public application flow', () => {
  it('should display the organization public page', () => {
  });
  it('should navigate to and display the public job details
    page', () => {
  });
});

现在,让我们开始实现测试。

首先,我们想要访问组织页面:

it('should display the organization public page', () => {
  cy.visit(
    `http://localhost:3000/organizations/${organization.id}`
  );
  cy.findByRole('heading', {
    name: organization.name,
  }).should('exist');
  cy.findByRole('heading', {
    name: organization.email,
  }).should('exist');
  cy.findByRole('heading', {
    name: organization.phone,
  }).should('exist');
  cy.findByText(
    new RegExp(organization.info, 'i')
  ).should('exist');
});

在这里,我们正在访问组织详情页面,并检查显示的数据是否与组织匹配。

现在我们处于组织详情页面,我们可以查看组织的职位:

it('should navigate to and display the public job details
  page', () => {
  cy.findByTestId('jobs-list').should('exist');
  cy.findByRole('row', {
    name: new RegExp(
      `${job.position} ${job.department} ${job.location}
        View`,
      'i'
    ),
  }).within(() => {
    cy.findByRole('link', {
      name: /view/i,
    }).click();
  });
  cy.url().should(
    'equal',
    `http://localhost:3000/organizations/$
      {organization.id}/jobs/${job.id}`
  );
  cy.findByRole('heading', {
    name: job.position,
  }).should('exist');
  cy.findByText(new RegExp(job.info, 'i')).should(
    'exist'
  );
});

在这里,我们点击了职位的查看链接,然后导航到职位详情页面,在这里我们断言职位数据。

要运行端到端测试,我们需要首先通过运行以下命令来构建应用程序:

npm run build

然后,我们可以通过打开浏览器来开始测试:

npm run e2e

或者,我们可以以无头模式运行测试,因为它对资源的需求较少,这对于 CI 来说非常好:

npm run e2e:headless

摘要

在本章中,我们学习了如何测试我们的应用程序,使其准备好投入生产。

我们首先通过为我们的通知存储实现单元测试来学习单元测试。

由于集成测试非常有价值,因为它们提供了更多的信心,表明某些东西正在正常工作,我们使用了这些测试来测试页面。

最后,我们为公共和仪表板流程创建了端到端测试,其中我们测试了每个流程的整个功能。

在下一章中,我们将学习如何准备和发布我们的应用程序到生产环境。我们将使用这些测试并将它们集成到我们的 CI/CD 管道中,如果任何测试失败,我们将不允许应用程序发布到生产环境。这将使我们的用户更加满意,因为出现错误最终进入生产环境的可能性更小。