自定义 Jest matcher 但是 Vitest

903 阅读6分钟

麻烦的格式符

最近在尝试开发一个 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

  1. 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 兼容:像是 describetestexpect 等 Jest API 在 Vitest 中都可以直接使用。一些 Jest 相关的库,如Testing Libraryeslint-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; }`)

初步.png

增强🔥

完成了核心逻辑后,我们再对 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() 给出的匹配失败信息只是将 expectedreceived 直接打印到终端上。至于两个 CSS 字符串到底哪里不同,就需要开发者自己一点点比对看啦。

问题-终.png

那可不可以在匹配失败信息中自动给出不同呢?当然是可以的!只不过得引入 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, "")

diff.png

支持 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
  }
}

type-base.png

这样就行了……吗?眼尖的朋友可能已经发现了:之前编写匹配器逻辑时,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) 就会同时对 receivedexpected 进行类型检查了!

type-improved.png

总结

以上就是自定义 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, "")