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 了再续费一年吧