yup 基础使用以及 jest 测试

yup 基础使用以及 jest 测试

写在前面的一些碎碎念……与具体功能无关,想要跳过的话可以直接跳到下下个 section 进入实现。这次尝试用了 vite 而不是 webpack 的 cra,发现开发过程中真的是快很多,也许下下个 initiative 确实会从 webpack 的 cra 转变到 vite

initiative

好久没有写前端部分的东西了……主要是因为之前项目重构到现在一直都处在比较稳健的功能实施的阶段,没有什么新的业务需求。不过最近客户那边有了比较新的需求,就是希望能够在前端部分增加更多的验证

之前的实现是,用户输入数据之后,前端的验证处理的比较有限——主要就是数字部分会有一点的验证,比如说同样都是 0.99 这个数字,欧洲那边的标准是 0,99,而除了欧洲之外的其他地方都是 0.99,我们可能是更多的基于这些前端必须做的特异化需求进行的部分验证

不过现在如果要做更加彻底的验证,那么比起手写所有的验证,使用市面上已经比较流行的库显然是一个更好的选择,这个过程中需要选择的库有两个:yup & zod

二者的使用方式是差不多的,在这个过程中,其实整体来说 zod 的支持会比 yup 好很多,包括 zod 直接实现了对于 enum 的支持,使用语法为: z.enum(VALUES);,对比 yup 并没有实现 enum 的支持,其用法为:yup.mixed().oneOf(['jimmy', 42]);。当然,数据类型确定的话也可以使用 yup.string().oneOf([]) 或是 yup.num().oneOf([])

另一方面就是针对 insert 和 update 这两个 case,目前基于 yup 来说,我们还需要写一个 util function 去把 insert 和 update 两个 schema 分开来,但是 zod 的实现方式就比较简单了:

// Insert schema: all fields are required
const insertSchema = baseSchema;

// Update schema: all fields are optional
const updateSchema = baseSchema.partial();

那为什么还选择用 yup 而不是 zod 的原因,主要是因为可变性。我们一些需求——尤其是众多的 enum,是要基于不同的选项去渲染不同的 enum 值,而且这个操作会基于一些 API 的实现,因此它必须是要异步执行的

这种情况下,yup 可以直接 schema = schema.shape({...}),而 zod 使用 schema = z.refine({...}) 就会因为 schema 进行了 mutate,而导致类型不符则抛出异常

虽然使用 schema: any 可以解决这个问题,不过要用 any 了为啥还用 typescript……总体来说 zod 方面的解决方案还在探索阶段,但是 yup 这里算是可以暂时绕过这个问题,进到下一步

另一个想要转变成使用 schema validation 的原因是因为目前的代码实现实在是太过麻烦,首先需要一个 type,其次还要定义一个 column object 以供表单去消化,大体实现如下:

class Example {
  public num1: number;
  public num2: number;
  public str1: string;
  public enum1: string;
  public date1: string;
  // ...
}

const exampleTableStructure = {
  num1: { type: "num", accessor: "num1" },
  num2: { type: "num", accessor: "num2" },
  str1: { type: "str", accessor: "str1" },
  enum1: { type: "enum", options: SOME_OPTION, accessor: "enum1" },
  date1: { type: "date", accessor: "date1" },
};

其实这样重复的代码还是很多的,而且 TS 也没有办法准确获得当前数据的类型,除非大量手动实现各种各样的 getter/setter,所以也是想说有没有可能实现了 schema 后,只需要提供当前表单/表格所需要的数据类型,就可以动态的生成这样一个 exampleTableStructure,减少一些代码量。

配置

主要是添加了一些配置方面的东西,感觉 vite 快的另一个原因也是因为没有添加一些额外的包……比如说测试这种……?

vite config 更新

vite.config.ts 的更新:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  // otherwise process.env is not found
  define: { "process.env": {} },
});

这里主要是添加 define: { "process.env": {} },,否则在 ts 文档里获取 process 会抛出异常,显示 process 不存在

package.json

这里更新一下 package.json,主要是添加测试(jest):

{
  "name": "react-yup",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "test": "jest"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "yup": "^1.4.0"
  },
  "devDependencies": {
    "@types/jest": "^29.5.12",
    "@types/node": "^20.14.10",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "@typescript-eslint/eslint-plugin": "^7.13.1",
    "@typescript-eslint/parser": "^7.13.1",
    "@vitejs/plugin-react-swc": "^3.5.0",
    "eslint": "^8.57.0",
    "eslint-plugin-react-hooks": "^4.6.2",
    "eslint-plugin-react-refresh": "^0.4.7",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "ts-jest": "^29.2.2",
    "ts-node": "^10.9.2",
    "typescript": "^5.2.2",
    "vite": "^5.3.1"
  }
}

因为主要就是为了测试 yup,所以没有加 React Testing Library

jest 配置

在根目录下面增添 jest.config.ts,配置如下:

// jest.config.ts

export default {
  preset: "ts-jest",
  testEnvironment: "jest-environment-jsdom",
  transform: {
    "^.+\\.tsx?$": "ts-jest",
    // process `*.tsx` files with `ts-jest`
  },
  moduleNameMapper: {
    "\\.(gif|ttf|eot|svg|png)$": "<rootDir>/test/__ mocks __/fileMock.js",
  },
};

这回没用上 mock,直接用的 enum 里面的值进行的返回……大概是跟 webpack 的 CRA 的设置不太一样吧,同样的代码 webpack 的 CRA 就直接找到了 mock 的文件而不是原有的文件

代码实现

这里就是不包含测试的部分

enum

export enum TestEnum1 {
  A = "Example A",
  B = "Example B",
}

export enum TestEnum2 {
  C = "Example C",
  D = "Example D",
}

export const getTestEnum = () => {
  if (process.env.REACT_APP_USE_SERVICE) return TestEnum1;

  return TestEnum2;
};

yup schema 实现

import { InferType, boolean, number, object, string } from "yup";
import { getTestEnum } from "../const/enums";

const enumField = process.env.REACT_APP_USE_SERVICE ? "A" : "C";

export const demoSchema = object({
  // string
  description: string().default("demo").required(),
  // enum
  enumField: string()
    .required()
    .default(enumField)
    .oneOf(Object.keys(getTestEnum() || [])),
  // optional field with special key
  optionalField: string().nullable().default(null),
  hasOptionalField: boolean()
    .default(false)
    .when("optionalField", ([optionalField], schema) => {
      if (optionalField && optionalField.trim() !== "") {
        return schema.oneOf(
          [true],
          "hasOptionalField must be true when optionalField is not empty"
        );
      }

      return schema.oneOf(
        [false],
        "hasOptionalField must be false when optionalField is empty"
      );
    }),
  numField: number().required().default(0).min(1).max(10000),
});

export interface Demo extends InferType<typeof demoSchema> {}

注意:Object.keys(getTestEnum() || []) 这里其实是为了 jest 的 mock 做的准备。因为这个代码是被 jest mock 了,所以返回值有可能是 null

我觉得更合适的方法应该是琢磨一下 mock 这方面,找到正确的 inject 方式,不过这样也可以运行……就先用 || 或者 ?? 顶一下吧

运行结果

这里讲一下运行结构,首先是使用 yup.cast({}),效果如下:

在这里插入图片描述

可以填充默认值还是挺好的,但是如果有 required,又没有提供 default,就会报错:

在这里插入图片描述

不过这里的 default 并不能保证一定会有合法的值,比如说 numField: number().required().default(0).min(1).max(10000),,提供的默认值是 0,但是合理的值在 1-10000

使用 validate 后:

const value = demoSchema.cast({});
demoSchema
  .validate(value)
  .then((res) => {
    console.log(res);
  })
  .catch((e) => {
    if (e instanceof ValidationError) {
      console.log(e.path, ",", e.message);
    }
  });

运行结果为:

在这里插入图片描述

测试代码

先丢完整代码:

import { string } from "yup";
import { Demo, demoSchema } from "../model/demo";
import * as enumTypes from "../const/enums";

// Mock the module and the getTestEnum function
jest.mock("../const/enums", () => ({
  ...jest.requireActual("../const/enums"),
  getTestEnum: jest.fn(),
}));

describe("Demo schema with basic tests", () => {
  beforeEach(() => {
    jest.resetModules();
    jest.clearAllMocks();
    (enumTypes.getTestEnum as jest.Mock).mockReturnValue(enumTypes.TestEnum2);
  });

  const createSchema = () => {
    return demoSchema.shape({
      enumField: string()
        .required()
        .default("C")
        .oneOf(Object.keys(enumTypes.getTestEnum())),
    });
  };

  it("Should validate object and passes", async () => {
    const schema = createSchema();

    const validObject: Demo = {
      description: "description",
      enumField: "C",
      optionalField: null,
      hasOptionalField: false,
      numField: 100,
    };

    await expect(schema.validate(validObject)).resolves.toEqual(validObject);
  });

  it("Should invalidate the object since num is not in the correct range", async () => {
    const schema = createSchema();

    // lower bound
    const invalidObject: Demo = {
      description: "description",
      enumField: "C",
      optionalField: null,
      hasOptionalField: false,
      numField: 0,
    };

    await expect(schema.validate(invalidObject)).rejects.toThrow();

    // upper bound
    invalidObject.numField = 10001;
    await expect(schema.validate(invalidObject)).rejects.toThrow();
  });

  it("Should invalidate the object with conflicted has flag condition", async () => {
    const schema = createSchema();

    // lower bound
    let invalidObject: Demo = {
      description: "description",
      enumField: "C",
      optionalField: null,
      hasOptionalField: true,
      numField: 100,
    };

    // has option flag to be true but option field doesn't have value
    await expect(schema.validate(invalidObject)).rejects.toThrow();

    invalidObject = {
      ...invalidObject,
      optionalField: "has value",
      hasOptionalField: false,
    };

    // has option flag to be false but option field has value
    await expect(schema.validate(invalidObject)).rejects.toThrow();
  });
});

describe("Demo schema with dynamic enum value", () => {
  beforeEach(() => {
    jest.resetAllMocks();
    jest.clearAllMocks();
  });

  const createSchema = (defaultValue: string) => {
    return demoSchema.shape({
      enumField: string()
        .required()
        .default(defaultValue)
        .oneOf(Object.keys(enumTypes.getTestEnum())),
    });
  };

  it("Should pick enum in TestEnum1", async () => {
    (enumTypes.getTestEnum as jest.Mock).mockReturnValue(enumTypes.TestEnum1);

    const validObject: Demo = {
      description: "description",
      enumField: "A",
      optionalField: null,
      hasOptionalField: false,
      numField: 1,
    };

    const modifiedSchema = createSchema("A");

    await expect(modifiedSchema.validate(validObject)).resolves.toEqual(
      validObject
    );

    validObject.enumField = "B";
    await expect(modifiedSchema.validate(validObject)).resolves.toEqual(
      validObject
    );

    validObject.enumField = "C";
    await expect(modifiedSchema.validate(validObject)).rejects.toThrow();
  });
});

在这里插入图片描述

整体来说没有什么特别难的地方

唯一需要注意的地方就在于 schema 是什么时候被初始化的

简单来说 jest 对 schema 是有引用的需求,所以 schema 必须在 jest 存在之前存在。而 yup/zod 本身又是不可变的,因此想要修改其中的值,即被 mock 的 oneOf(Object.keys(enumTypes.getTestEnum())),就需要在 jest 的 ut 运行前重新获得一个新的,属性已经被正常更新的 schema

顺便如果真的是想要好好地折腾一下 ut,可以考虑一下 wallaby,效果还蛮好的:

在这里插入图片描述

在这里插入图片描述

被其他免费的插件搞得心力交瘁,就觉得……付费软件还是有付费软件的用途的……

不过我的 license 已经过期了,没办法用最新版的功能……看什么时候现在用的版本 crash 了再续费一年吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值