React 应用架构实战 0x7:测试

2023-05-17 21:03:41 浏览数 (1)

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

# 单元测试

单元测试是在应用程序单元在不依赖于其他部分的情况下进行独立测试。

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

下面以 notification store 为例:

代码语言:javascript复制
// src/stores/notification/__tests__/notification.test.ts
import { notificationsStore, Notification } from "../notifications";

const notification = {
  id: "1",
  type: "info",
  title: "Test",
  message: "Test message",
} as Notification;

describe("Notifications store", () => {
  it("should show and dismiss notification", () => {
    expect(notificationsStore.getState().notifications.length).toBe(0);

    notificationsStore.getState().showNotification(notification);

    expect(notificationsStore.getState().notifications).toContainEqual(notification);

    notificationsStore.getState().dismissNotification(notification.id);

    expect(notificationsStore.getState().notifications).not.toContainEqual(notification);
  });
});

运行测试 pnpm jest

代码语言:javascript复制
$ pnpm test

> next-jobs-app@0.1.0 test D:studycodenext-jobs-app
> jest

 PASS  src/stores/notifications/__tests__/notifications.test.ts
  Notifications store
    √ should show and dismiss notification (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.603 s
Ran all test suites.

更多代码细节请参考 Github (opens new window)。

# 集成测试

集成测试是一种测试方法,其中多个应用程序部分一起进行测试。集成测试通常比单元测试更有用,大多数应用程序测试应该是集成测试。

集成测试更有价值,因为它们可以更有全面地测试应用程序,我们会测试不同部分的功能、它们之间的关系以及它们的通信方式。

对于集成测试,我们将使用 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 是一个函数,在我们进行测试之前,它会等待所有加载提示消失
    • 可应用于当我们必须等待某些数据被获取后才能断言值时
代码语言:javascript复制
// src/testing/test-utils.ts
import type { ReactElement } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { AppProvider } from "@/providers/app";
import type { Entity } from "@/types";

export const appRender = (ui: ReactElement) => {
  return render(ui, {
    wrapper: AppProvider,
  });
};

export const checkTableValues = <T extends Entity>({
  container,
  data,
  columns,
}: {
  container?: HTMLElement;
  data: T[];
  columns: Array<keyof T>;
}) => {
  data.forEach((entry, index) => {
    const selector = container ? within(container) : screen;
    const row = selector.getByTestId(`table-row-${index}`);

    columns.forEach((column) => {
      const cell = within(row).getByRole("cell", {
        name: String(entry[column]),
      });

      expect(cell).toBeInTheDocument();
    });
  });
};

export const waitforLoadingToFinish = () => {
  return waitFor(
    () => {
      const loaders = [
        ...screen.queryAllByTestId(/loading/i),
        ...screen.queryAllByText(/loading/i),
      ];

      loaders.forEach((loader) => {
        expect(loader).not.toBeInTheDocument();
      });
    },
    {
      timeout: 5000,
    }
  );
};

export * from "@testing-library/react";
export { userEvent };

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

代码语言:javascript复制
// src/testing/setup-tests.ts
import "@testing-library/jest-dom/extend-expect";

import { queryClient } from "@/lib/react-query";
import { seedDb } from "./mocks/seed-db";
import { server } from "./mocks/server";

beforeAll(() => {
  server.listen({
    onUnhandledRequest: "error",
  });
  seedDb();
});

afterEach(async () => {
  queryClient.clear();
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

我们可以按页面进行集成测试,并在每个页面上测试所有的部分。

# 看板职位列表页面

代码语言:javascript复制
// 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";

jest.mock("@/features/auth", () => ({
  useUser: () => ({
    data: getUser(),
  }),
}));

describe("Dashboard Jobs Page", () => {
  it("should render the jobs list", async () => {
    await appRender(<DashboardJobsPage />);

    expect(screen.getByText(/jobs/i)).toBeInTheDocument();

    await waitforLoadingToFinish();

    checkTableValues({
      container: screen.getByTestId("jobs-list"),
      data: testData.jobs,
      columns: ["position", "department", "location"],
    });
  });
});

# 看板职位页面

代码语言:javascript复制
// 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,
  },
};

jest.mock("next/router", () => ({
  useRouter: () => router,
}));

describe("Dashboard Job Page", () => {
  it("should render the job details", async () => {
    await appRender(<DashboardJobPage />);

    await waitforLoadingToFinish();

    const jobPosition = screen.getByRole("heading", {
      name: job.position,
    });

    const info = screen.getByText(job.info);

    expect(jobPosition).toBeInTheDocument();
    expect(info).toBeInTheDocument();
  });
});

# 创建职位页面

代码语言:javascript复制
// src/__tests__/dashboard-create-job-page.test.tsx
import DashboardCreateJobPage from "@/pages/dashboard/jobs/create";

import { appRender, screen, userEvent, fireEvent, waitFor } from "@/testing/test-utils";

const router = {
  push: jest.fn(),
};

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 () => {
    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,
    });

    fireEvent.change(positionInput, {
      target: {
        value: jobData.position,
      },
    });
    fireEvent.change(locationInput, {
      target: {
        value: jobData.location,
      },
    });
    fireEvent.change(departmentInput, {
      target: {
        value: jobData.department,
      },
    });
    fireEvent.change(infoInput, {
      target: {
        value: jobData.info,
      },
    });

    await waitFor(() => {
      // 检查输入
      expect(positionInput).toHaveValue(jobData.position);
      expect(departmentInput).toHaveValue(jobData.department);
      expect(locationInput).toHaveValue(jobData.location);
      expect(infoInput).toHaveValue(jobData.info);

      userEvent.click(submitButton);

      expect(router.push).toHaveBeenCalledWith("/dashboard/jobs");
    });
  });
});

# 公开组织页面

代码语言:javascript复制
// 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 to fetch data", async () => {
    const { props } = await getServerSideProps({
      params: { organizationId: organization.id },
    } as any);

    expect(props.organization).toEqual(organization);
    expect(props.jobs).toEqual(jobs);
  });

  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 does not exist", async () => {
    appRender(<PublicOrganizationPage organization={null} jobs={[]} />);

    expect(screen.getByText(/not found/i)).toBeInTheDocument();
  });
});

# 公开职位页面

代码语言:javascript复制
// 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 to fetch data", async () => {
    const { props } = await getServerSideProps({
      params: { organizationId: organization.id, jobId: job.id },
    } as any);

    expect(props.job).toEqual(job);
    expect(props.organization).toEqual(organization);
  });

  it("should render the job details", async () => {
    appRender(<PublicJobPage job={job} organization={organization} />);

    const jobPosition = screen.getByRole("heading", { name: job.position });
    const info = screen.getByText(job.info);

    expect(jobPosition).toBeInTheDocument();
    expect(info).toBeInTheDocument();
  });

  it("should render the not found message if the data does not exist", async () => {
    const { rerender } = appRender(<PublicJobPage job={null} organization={null} />);

    const notFoundMsg = screen.getByRole("heading", {
      name: /not found/i,
    });
    expect(notFoundMsg).toBeInTheDocument();

    rerender(<PublicJobPage job={job} organization={null} />);
    expect(notFoundMsg).toBeInTheDocument();

    rerender(<PublicJobPage job={null} organization={organization} />);
    expect(notFoundMsg).toBeInTheDocument();

    rerender(
      <PublicJobPage
        job={{
          ...job,
          organizationId: "invalid-id",
        }}
        organization={organization}
      />
    );
    expect(notFoundMsg).toBeInTheDocument();
  });
});

# 登录页面

代码语言:javascript复制
// src/__tests__/login-page.test.tsx
import LoginPage from "@/pages/auth/login";

import { appRender, screen, fireEvent, userEvent, waitFor } from "@/testing/test-utils";

const router = {
  replace: jest.fn(),
  query: {},
};

jest.mock("next/router", () => ({
  useRouter: () => router,
}));

describe("Login Page", () => {
  it("should login the user into the dashboard", async () => {
    await appRender(<LoginPage />);

    const emailInput = screen.getByRole("textbox", { name: /email/i });
    const passwordInput = screen.getByLabelText(/password/i);
    const submitBtn = screen.getByRole("button", { name: /log in/i });

    const credentials = {
      email: "user1@test.com",
      password: "password",
    };

    fireEvent.change(emailInput, { target: { value: credentials.email } });
    fireEvent.change(passwordInput, { target: { value: credentials.password } });

    userEvent.click(submitBtn);

    await waitFor(() => {
      expect(router.replace).toHaveBeenCalledWith("/dashboard/jobs");
    });
  });
});

# E2E 测试

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

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

# 安装及配置 Cypress

  • 安装 Cypress
代码语言:javascript复制
pnpm add -D cypress @testing-library/cypress

  • 配置 Cypress

cypress.config.ts

代码语言:javascript复制
import { defineConfig } from "cypress";

export default defineConfig({
  video: false,
  videoUploadOnPasses: false,
  e2e: {
    setupNodeEvents(on, config) {
      return require("./cypress/plugins/index")(on, config);
    },
  },
});

cypress/plugins/index.ts

代码语言:javascript复制
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************

// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)

/**
 * @type {Cypress.PluginConfig}
 */
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
};

cypress/tsconfig.json

代码语言:javascript复制
{
  "compilerOptions": {
    "esModuleInterop": true,
    "target": "es5",
    "lib": ["es5", "dom"],
    "types": ["node", "cypress", "@testing-library/cypress"]
  },
  "include": ["**/*.ts"]
}

cypress/support/e2e.ts

代码语言:javascript复制
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************

// Import commands.js using ES2015 syntax:
import "./commands";

// Alternatively you can use CommonJS syntax:
// require('./commands')

cypress/support/commands.ts

代码语言:javascript复制
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

import "@testing-library/cypress/add-commands";

# 编写测试

下面以管理面板为例,测试用户身份验证及访问:

代码语言:javascript复制
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", () => {
    console.log("start test");
    cy.clearCookies();
    cy.clearLocalStorage();

    console.log("visit");

    cy.visit("http://localhost:3000/dashboard/jobs");

    cy.wait(1000);

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

为了运行端到端测试,我们需要启动应用程序,然后运行 Cypress:

代码语言:javascript复制
pnpm dev
pnpm run cypress

0 人点赞