likes
comments
collection
share

React Router V6.4 (router 测试篇)

作者站长头像
站长
· 阅读数 36

测试能帮我们更好的理解重要库 @remix-run/router 的设计与验证。

本文主要是梳理测试思路。router 主要服务于 React 技术栈,使用 jest 作为测试工具,下面从测试的配置开始。

jest 配置

之前写过一篇 Jest 配置解读 的文章,不熟悉的同学可以看看或者直接阅读Jest 官方文档学习, 如果你熟悉 vite 用 vites 做测试,他们的api 基本一样,当然你也选择其他自己拿手的测试工具。

module.exports = {
  testMatch: ["**/__tests__/*-test.(js|ts)"], // 要测试的文件
  transform: {
    "\\.[jt]sx?$": "./jest-transformer.js", // 自定 jest 转换器
  },
  setupFiles: ["./__tests__/setup.ts"], // 安装文件
  moduleNameMapper: {
    "^@remix-run/router$": "<rootDir>/index.ts", // 模块映射
  },
};

转换器使用 babel-jest,需要 babel关于 react 的插件以及套餐的支持

const babelJest = require("babel-jest");

module.exports = babelJest.createTransformer({
  presets: [
    ["@babel/preset-env", { loose: true }],
    "@babel/preset-react",
    "@babel/preset-typescript",
  ],
  plugins: ["babel-plugin-dev-expression"],
});

setup.ts 运行之前运行文件,本质就是替换一些全局环境的变量

import { fetch, Request, Response } from "@remix-run/web-fetch";

if (!globalThis.fetch) {
  globalThis.fetch = fetch;
  globalThis.Request = Request;
  globalThis.Response = Response;
}

Jest 扩展 expect 属性

因为这里用到扩展方法,所以还是要提出来,可以看官网示例,如何在 expect 对象上扩展一个属性:使用 extend api 进行扩展,router 测试中扩展了 trackedPromise 方法,用于追踪 promise 状态:data/error/aborted 三种不同的状态。

expect.extend({
  trackedPromise(received, data, error, aborted = false) {
    let promise = received as TrackedPromise;
    let isTrackedPromise =
      promise instanceof Promise && promise._tracked === true;

    if (data != null) {
      let dataMatches = promise._data === data;
      return {
        message: () => `expected ${received} to be a resolved deferred`,
        pass: isTrackedPromise && dataMatches,
      };
    }

    if (error != null) {
      let errorMatches =
        error instanceof Error
          ? promise._error.toString() === error.toString()
          : promise._error === error;
      return {
        message: () => `expected ${received} to be a rejected deferred`,
        pass: isTrackedPromise && errorMatches,
      };
    }

    if (aborted) {
      let errorMatches = promise._error instanceof AbortedDeferredError;
      return {
        message: () => `expected ${received} to be an aborted deferred`,
        pass: isTrackedPromise && errorMatches,
      };
    }

    return trackedPromise
  },
});

trackedPromise 第一参数是 expect 函数的参数,后面的参数是 trackedPromise 自己调用时候传递的。

根据这两个条件进行判断,返回不同的数据,数据结果如下:

{
    message: () => string,
    pass: boolean
};

测试目录结果

.
├── TestSequences
│   ├── EncodedReservedCharacters.ts
│   ├── GoBack.ts
│   ├── GoForward.ts
│   ├── InitialLocationDefaultKey.ts
│   ├── InitialLocationHasKey.ts
│   ├── Listen.ts
│   ├── ListenPopOnly.ts
│   ├── PushMissingPathname.ts
│   ├── PushNewLocation.ts
│   ├── PushRelativePathname.ts
│   ├── PushRelativePathnameWarning.ts
│   ├── PushSamePath.ts
│   ├── PushState.ts
│   ├── ReplaceNewLocation.ts
│   ├── ReplaceSamePath.ts
│   └── ReplaceState.ts
├── browser-test.ts
├── create-path-test.ts
├── custom-environment.js
├── hash-base-test.ts
├── hash-test.ts
├── memory-test.ts
├── resolveTo-test.tsx
├── router-test.ts
└── setup.ts

history 支持 hash/browser/memory 三种不同的模式。所以很多同样的测试函数就可以抽象出来。TestSequences 文件就是存放了可以通用测试用例。

  • rowser-test.ts:BrowserHistory 相关的测试
  • hash-test.ts:HashHistory 相关测试
  • memory-test: MemoryHistory 相关的测试
  • esolveTo-test.tsx: 决策路径测试
  • create-path-test.ts: 不同的参数传递创建路径测试
  • router-test.ts: Router 对象测试

hash/browser/memory 三种不同 history 测试思路

  • 顶层测试套件 describe 函数,包含一组与之相关的测试用例
describe("a browser history", () => {})
describe("a hash history", () => {})
describe("a memory history", () => {})
  • 使用 beforeEach 为每一个测试用例定义一个 hisotry 对象
// history
let history: BrowserHistory;

beforeEach(() => {
    const dom = new JSDOM(`<!DOCTYPE html><p>History Example</p>`, {
      url: "https://example.org/",
    });
    dom.window.history.replaceState(null, "", "/");
    history = createBrowserHistory({ window: dom.window as unknown as Window });
});
// hash
let history: HashHistory;
beforeEach(() => {
    const dom = new JSDOM(`<!DOCTYPE html><p>History Example</p>`, {
      url: "https://example.org/",
    });
    dom.window.history.replaceState(null, "", "#/");
    history = createHashHistory({ window: dom.window as unknown as Window });
});
  
// memoery
let history: MemoryHistory;
beforeEach(() => {
    history = createMemoryHistory();
});

使用 it (别名 test )函数包裹测试用例, 其次 describe 可以嵌套,表示测试套件里面还有测试套件(套件可以理解为组)

测试 history api

history.createHref 多种形式的测试

  • 测试 createHref 函数参数对象形式
  • 测试 createHref 函数参数字符串形式
  • 测试 createHref 函数参数?/# 书写不完全正的情况是否会自动补全

history.listen 函数测试

使用 jest.fn()定义间谍函数调试监听函数是否被调用

let spy = jest.fn();
let unlisten = history.listen(spy);
expect(spy).not.toHaveBeenCalled();
unlisten();

测试默认 location 对象默认值以及 location 发生变化location 对象

  • history.push 对location变化的影响
    • push 新的路径
    • push 相同的路径
    • push history state
    • push 没有path(有search和hash)
    • push 相对路径
  • history.replace 替换
    • 替换一个新的路径
    • 替换一个相同的路径
    • 替换一个 state
  • location 对象编码与解码
  • history.back/history.go/historyforward

针对 memoery hisotry 需要测试 index 索引

it("has an index property", () => {
    expect(typeof history.index).toBe("number");
  });
expect(history.location.key).toBe("default");

使用 toMatchObject 匹配 location 变化,以 push 为例

export default function PushNewLocation(history: History) {
  expect(history.location).toMatchObject({
    pathname: "/",
  });

  history.push("/home?the=query#the-hash");
  expect(history.action).toBe("PUSH");
  expect(history.location).toMatchObject({
    pathname: "/home",
    search: "?the=query",
    hash: "#the-hash",
    state: null,
    key: expect.any(String),
  });
}

history 测试示例

示例部分重点关注 listen 和 state, 这个可能更加容易被忽略

listen

import type { History } from "../../history";

export default function Listen(history: History) {
  let spy = jest.fn();
  let unlisten = history.listen(spy);

  expect(spy).not.toHaveBeenCalled();

  unlisten();
}

listen 中主要注意的是要会理解并会使用间谍函数,间谍函数主要断言是否被调用过,这个指标。listen 函数调用之后返回值是移除 listen 效果,这个看情况移除。

state

import type { History } from "../../history";

export default function PushState(history: History) {
  expect(history.location).toMatchObject({
    pathname: "/",
  });

  history.push("/home?the=query#the-hash", { the: "state" });
  expect(history.action).toBe("PUSH");
  expect(history.location).toMatchObject({
    pathname: "/home",
    search: "?the=query",
    hash: "#the-hash",
    state: { the: "state" },
  });
}

state 是在通过 push 方法第二个参数进行调控的,所以在测试时候 api 会更加熟悉。

router 测试

history 作为 router 的基础,有了基础,理解 router 有了基石。router 测试代码很多,完全研读需要花费很长时间,这里我们只关注重点,router 测试也是嵌套的测试套件:

注意:在 router 测试中使用 memoryHistory 因为这样能用在 nodejs 的环境中,使用内存,而不需要浏览器或者native环境。

router 顶层套件

套件说明
顶层套件describe("a router", () => {})

router 第二层套件

套件说明
初始化 initdescribe("init", () => {})
普通导航describe("normal navigation", () => {})
应该重新验证describe("shouldRevalidate", () => {})
没有路由匹配describe("no route match", () => {})
在导航时发生错误describe("errors on navigation", () => {})
POP navigationsdescribe("POP navigations", () => {})
提交导航describe("submission navigations", () => {})
action 错误describe("action errors", () => {})
导航 statedescribe("navigation states", () => {})
打断describe("interruptions", () => {})
新的导航describe("navigation (new)", () => {})
数据读取describe("data loading (new)", () => {})
重定向describe("redirects", () => {})
滚动存储describe("scroll restoration", () => {})
路由重新雁阵describe("router.revalidate", () => {})
路由丢弃describe("router.dispose", () => {})
fetchersdescribe("fetchers", () => {})
延迟数据describe("deferred data", () => {})
服务端渲染describe("ssr", () => {})

测试重点关注点

  • 配置带有 loader/actions 路由以及数据获取和定义
  • router.navigate 多种使用方法以及返回值
  • 表单定义
  • router.state state 对象复杂度提升

router 测试示例

测试一个 memoryHistory 的 router.state 对象进行匹配。

it("with initial values", async () => {
      let history = createMemoryHistory({ initialEntries: ["/"] });
      let router = createRouter({
        routes: [
          {
            id: "root",
            path: "/",
            hasErrorBoundary: true,
            loader: () => Promise.resolve(),
          },
        ],
        history,
        hydrationData: {
          loaderData: { root: "LOADER DATA" },
          actionData: { root: "ACTION DATA" },
          errors: { root: new Error("lol") },
        },
      });
      expect(router.state).toEqual({
        historyAction: "POP",
        loaderData: {
          root: "LOADER DATA",
        },
        actionData: {
          root: "ACTION DATA",
        },
        errors: {
          root: new Error("lol"),
        },
        location: {
          hash: "",
          key: expect.any(String),
          pathname: "/",
          search: "",
          state: null,
        },
        matches: [
          {
            params: {},
            pathname: "/",
            pathnameBase: "/",
            route: {
              hasErrorBoundary: true,
              id: "root",
              loader: expect.any(Function),
              path: "/",
            },
          },
        ],
        initialized: true,
        navigation: {
          location: undefined,
          state: "idle",
        },
        preventScrollReset: false,
        restoreScrollPosition: null,
        revalidation: "idle",
        fetchers: new Map(),
      });
    });

routes 中带有 loader 从router 中去 loaderdData

it("preserves non-revalidated loaderData on navigations", async () => {
      let count = 0;
      let history = createMemoryHistory();
      let router = createRouter({
        history,
        routes: [
          {
            path: "",
            id: "root",
            loader: () => `ROOT ${++count}`,

            children: [
              {
                path: "/",
                id: "index",
                loader: (args) => "SHOULD NOT GET CALLED",
                shouldRevalidate: () => false,
              },
            ],
          },
        ],
        hydrationData: {
          loaderData: {
            root: "ROOT 0",
            index: "INDEX",
          },
        },
      });
      router.initialize();
      await tick();

      // Navigating to the same link would normally cause all loaders to re-run
      router.navigate("/");
      await tick();
      expect(router.state.loaderData).toEqual({
        root: "ROOT 1",
        index: "INDEX",
      });

      router.navigate("/");
      await tick();
      expect(router.state.loaderData).toEqual({
        root: "ROOT 2",
        index: "INDEX",
      });

      router.dispose();
    });

小结

  • 对于测试最好自己写,写多就习惯了,你说业务变化很快,router 变化不快,router 还是比较稳定的,就学习测试和深入理解 router 而言是值得的。
  • 当然 router 有很多的 utils 支持函数,这里不再探索

相关文件推荐

接下来

进入 React 阶段,React 组件化,React Hook 钩子函数化

help

作者正在参加投票活动,如果文章真的能帮助到您,不妨发财的小手点一点,投一票是对作者最大鼓励丫。

转载自:https://juejin.cn/post/7180700844826837052
评论
请登录