麻烦的格式符
最近在尝试开发一个 Tailwind CSS 小插件。在为插件编写测试的过程中,经常需要验证插件生成的 CSS 字符串和目标 CSS 字符串是否相同:
const generatedCss = `
.clay {
background-color: #f87171;
}
`
// 测试会通过吗?
expect(generatedCss).toBe(`.clay { background-color: #f87171; }`)
虽然上述代码中两个字符串对 CSS 而言是相同的,但因为空格等格式符,二者在字符串层面是不同的,也就无法通过测试:
当然,我们可以在进行验证前将两字符串格式化,把格式符移除:
const generatedCss = `
.clay {
background-color: #f87171;
}
`
expect(generatedCss.trim().replace(/[;\s]+/g, ""))
.toBe(`.clay { background-color: #f87171; }`.trim().replace(/[;\s]+/g, ""))
但“验证生成的 CSS 字符串”是为该 Tailwind CSS 插件编写测试的基本操作,要给所有 CSS 对应字符串都带上一条“小尾巴”着实有些罗嗦……
要是存在这样一种 matcher
(匹配器)就好了:若两字符串在移除空格等格式符后相等,则认为两字符串相等。
const generatedCss = `
.clay {
background-color: #f87171;
}
`
// 通过!
expect(generatedCss).toMatchCss(`.clay { background-color: #f87171; }`)
虽然这个匹配器的逻辑并不复杂,可我使用的测试框架 Vitest 提供的原生匹配器似乎并不能直接满足该需求……不过好在 Vitest 开放了扩展匹配器!
还是先介绍一下 Vitest 吧
Vitest 官网给出的简介是:“由 Vite 提供支持的极速单元测试框架”。也就是说 Vitest
- Vite 原生
- 快
对我个人而言,相较于“快”,感知更明显的其实是“Vite 原生”。因为原生,Vitest 可以直接使用 Vite 配置文件,像是配置好的 resolve.alias
可以开箱即用。这就实现了一个配置文件既配置了构建,又配置了测试:
// vite.config.ts
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { resolve } from "path"
import { defineConfig } from "vitest/config"
export default defineConfig({
// Vite 构建部分
build: {
lib: {
entry: resolve(__dirname, "src/main.ts"),
formats: ["cjs"],
fileName: "index",
},
outDir: "lib",
},
// 路径别名可以共用
resolve: {
alias: { "~": resolve(__dirname, "src") },
},
// Vitest 测试部分
test: {
restoreMocks: true,
},
})
除此之外,Vitest 另一大特点是与 Jest 兼容:像是 describe
、test
、expect
等 Jest API 在 Vitest 中都可以直接使用。一些 Jest 相关的库,如Testing Library、eslint-plugin-jest,也可以接入 Vitest 使用。
import { act, renderHook } from '@testing-library/react-hooks'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should increment counter', () => {
const { result } = renderHook(() => useCounter())
act(() => { result.current.increment() })
expect(result.current.count).toBe(1)
})
})
但毕竟 Vitest 是一个全新的项目,所以对一些“历史包袱”,Vitest 就采取了更为“激进”的态度:比如提倡使用 ESM 而不是 CJS、完全基于 promise
处理异步、默认非全局等等……所以如果是原先使用 Jest 的项目迁移到 Vitest,还是需要进行一定处理的。
开始自定义匹配器
大致介绍了 Vitest,接下来就开始编写自定义匹配器吧!首先是核心的匹配逻辑:
// 把两个 CSS 字符串中的“分号”和“空白”去掉后进行比对,若相等则表示匹配成功
const removeFormat = (str: string) => str.trim().replace(/[;\s]+/g, "")
const pass = removeFormat(received) === removeFormat(expected)
接下来将匹配逻辑转化为匹配器:
// 自定义匹配器约定的返回类型
interface MatcherResult {
// 匹配结果
pass: boolean
// 匹配失败时给出的相关信息
message: () => string
}
// `expect(received).toMatchCss(expected)`
export const cssMatcher = (received: string, expected: string): MatcherResult => {
const pass = removeFormat(received) === removeFormat(expected)
const message = () => `expected ${received}${pass ? " not" : ""} to match ${expected}`
return { pass, message }
}
const removeFormat = (str: string) => str.trim().replace(/[;\s]+/g, "")
然后我们将编写的匹配器注册到 expect
:
expect.extend({ toMatchCss: cssMatcher })
自此我们就可以使用自定义匹配器 toMatchCss()
了:
// 故意给出不同的 CSS 字符串让自定义匹配器给出匹配失败信息
const generatedCss = `
.clay {
color: #f87171;
}
`
expect(generatedCss).toMatchCss(`.clay { background-color: #f87171; }`)
增强🔥
完成了核心逻辑后,我们再对 cssMatcher()
进行一定的增强:
支持 not
对 cssMatcher()
返回对象中的 message
方法,有的朋友可能会有一点疑问:
// `pass` 为 `true` 表示两 CSS 字符串相等,测试通过,此时应该不需要给出匹配失败信息
const pass = removeFormat(received) === removeFormat(expected)
// 但为什么匹配结果无论是 `true` 还是 `false` 都需要对应返回匹配失败信息?
const message = () => `expected ${received}${pass ? " not" : ""} to match ${expected}`
return { pass, message }
上述代码中对 message
处理主要是为了支持 not
。而 not
到底有什么用,我们先来看如下代码:
// 原有接口返回 ["葡式蛋挞", "波纹薯条", "嫩牛五方"]
// 接口修改后返回 ["葡式蛋挞", "波纹薯条"]
// 正向 接口返回数据中应当包括"葡式蛋挞"和"波纹薯条"
expect(KfcCrazyThursday).toContain("葡式蛋挞")
expect(KfcCrazyThursday).toContain("波纹薯条")
// 反向 接口返回数据中不再包括"嫩牛五方"
expect(KfcCrazyThursday).not.toContain("嫩牛五方") // 我不能接受!
哈哈哈,上面举的例子可能不太恰当,我实际想说明的是所谓“正难则反”:当正向处理问题较为复杂时,可以尝试从反向进行处理。这在测试编写中是一个十分常用的技巧。
使用 not
之后,匹配器原来表示通过测试(即 pass
结果为 true
)的逻辑此时应视为不通过,也就应当通过 message
对应给出匹配失败的相关信息。
自动给出不同
当 expected
(目标 CSS 字符串)和 received
(Tailwind CSS 插件生成 CSS 字符串)存在不同时,当前 cssMatcher()
给出的匹配失败信息只是将 expected
和 received
直接打印到终端上。至于两个 CSS 字符串到底哪里不同,就需要开发者自己一点点比对看啦。
那可不可以在匹配失败信息中自动给出不同呢?当然是可以的!只不过得引入 jest-matcher-utils 和一点点修改:
import {
matcherErrorMessage,
matcherHint,
printDiffOrStringify,
printExpected,
printReceived,
printWithType
} from "jest-matcher-utils"
export const cssMatcher = (received: string, expected: string) => {
const receivedWithoutFormat = removeFormat(received)
const expectedWithoutFormat = removeFormat(expected)
const pass = receivedWithoutFormat === expectedWithoutFormat
// 利用 `jest-matcher-utils` 生成更有可读性的匹配失败信息
const diffMessage = printDiffOrStringify(
expectedWithoutFormat,
receivedWithoutFormat,
"Expected CSS",
"Received CSS",
true
)
const passMessage = matcherHint(".not.toMatchCss") +
"\n\n" +
"Two CSS classes should not be equal while ignoring white-space and semicolon (using ===):\n" +
diffMessage
const failMessage = matcherHint(".toMatchCss") +
"\n\n" +
"Two CSS classes should be equal while ignoring white-space and semicolon (using ===):\n" +
diffMessage
return { pass, message: () => (pass ? passMessage : failMessage) }
}
const removeFormat = (str: string) => str.trim().replace(/[;\s]+/g, "")
支持 TS
接下来我们将 expect(received).toMatchCss(expected)
对应的类型信息合并到 expect
中,这样在使用 toMatchCss()
的时候就能够触发自动补全和类型检查了:
// Vitest 提供了对 Jest 兼容,而且我觉得 Jest 提供的自定义匹配器合并接口更加简洁,所以此处为 `jest` 而不是 `Vi`
// 这里有一个小坑:文档中给出的例子是 `declare global { namespace jest { ... } }`
// 但因为 Vitest 默认非全局,所以默认情况下不需要 `global`
declare namespace jest {
/**
* R 匹配器返回数据类型
*/
interface Matchers<R> {
toMatchCss: (expected: string) => R
}
}
这样就行了……吗?眼尖的朋友可能已经发现了:之前编写匹配器逻辑时,cssMatcher(received: string, expected: string)
要求匹配双方的类型都为 string
,但 cssMatcher()
以 toMatchCss(expected: string)
形式注册到 expect
后,对 expect(received: string)
部分的类型检查就丢失了。
解决该问题需要一点 TS 魔法小技巧:
declare namespace jest {
/**
* R 匹配器返回数据类型
* T `expect(received)` 中 `received` 数据类型
*/
interface Matchers<R, T = unknown> {
// `expect(received)` 中 `received` 数据类型是否为 `string`?
toMatchCss: T extends string | Promise<string>
// 若是则要求 `toMatchCss(expected)` 中 `expected` 数据类型也为 `string`
? (expected: string) => R
// 若不是则抛出类型错误信息
// (比起没头没脑的 `"Type 'never' has no call signatures."`,我个人更偏好模拟抛出 `Type-level Error` 来提供更多信息)
// 关于 TS 抛出 `Type-level Error` 的提案,可见 https://github.com/microsoft/TypeScript/pull/40468
: `Type-level Error: Received value must be "string" but received is "${T}"`
}
}
现在使用 expect(received).toMatchCss(expected)
就会同时对 received
和 expected
进行类型检查了!
总结
以上就是自定义 Jest matcher(而且是 Vitest)的全部内容啦!完整代码会在下方附上。如果你有什么想法的话,欢迎评论交流哦!
import {
matcherErrorMessage,
matcherHint,
printDiffOrStringify,
printExpected,
printReceived,
printWithType
} from "jest-matcher-utils"
export const cssMatcher = (received: string, expected: string) => {
for (const element of [received, expected]) {
if (typeof element !== "string") {
throw new Error(
matcherErrorMessage(
matcherHint(".toMatchCss"),
`both received and expected must be string`,
printWithType("Expected", expected, printExpected) +
"\n" +
printWithType("Received", received, printReceived)
)
)
}
}
const receivedWithoutFormat = removeFormat(received)
const expectedWithoutFormat = removeFormat(expected)
const pass = receivedWithoutFormat === expectedWithoutFormat
const diffMessage = printDiffOrStringify(
expectedWithoutFormat,
receivedWithoutFormat,
"Expected CSS",
"Received CSS",
true
)
const passMessage = matcherHint(".not.toMatchCss") +
"\n\n" +
"Two CSS classes should not be equal while ignoring white-space and semicolon (using ===):\n" +
diffMessage
const failMessage = matcherHint(".toMatchCss") +
"\n\n" +
"Two CSS classes should be equal while ignoring white-space and semicolon (using ===):\n" +
diffMessage
return { pass, message: () => (pass ? passMessage : failMessage) }
}
const removeFormat = (str: string) => str.trim().replace(/[;\s]+/g, "")