写一个可以跑在浏览器上的"mini-vitest"

前言

mini-vue 断断续续写了快了大半年了,reactivity 部分其实我很早就完成了,但是runtime-core这部分我之前写了1周就弃坑了。

原因是什么?大概是 reactivityvitest 的单元测试帮我把关,让我重构和后续功能的实现都比较有把握。

但是为什么 runtime-core 不继续这样呢?原因是 vitest 运行在 nodejs 环境,这样子很多windowsapi都无法使用。

动机

如果你看过vue源码你大概知道会有 custom-renderer 这东西,然后配合 happy-dom 去把 runtime-dom 部分重新实现一遍就好了。又或者学习一下vue源码通过序列化的方式去做测试。

可能你会问:你既然都知道了怎么还不去弄?

我的回答是:我后面学完了才知道的。

我当初的决定是自己仿一个简单的 vitest 去做我 runtime-core 部分的测试。

功能列表

  • it
    • only
    • skip
  • expect
    • not
    • toBe
    • toEqual
    • toContain
    • toHaveBeenCalled
    • toHaveBeenCalledTimes
  • beforeEach
  • afterEach
  • vi
    • fn

暂时不支持嵌套和mock,因为没想做的太复杂,目前就这些暂时够我用了。

具体实现

最主要原理就是要收集it注册的测试用例,然后通过一个开关启动。如果expect的结果不正确就抛错,告诉用户期望的和实际的值是什么。

it & expect & runner

大致的框架如下

// test-runner.js


// 存放测试用例
const testCases = [];


/**
 * 注册测试用例
 * @param {*} name 用例名
 * @param {*} cb 回调函数
 * @param {*} options 选项
 */
export function it(
  name,
  cb,
  options = {
    only: false,
    skip: false,
  }
) {
  testCases.push({
    name,
    cb,
    options,
  });
}


/**
 * 断言
 * @param {any} value
 */
export const expect = (value) => {

  return {
    toBe(val) {
      if (value !== val) {
        throw new Error(`expect "${val}" but got "${value}"`);
      }
    },
    // ... 其他的断言函数
};


/**
 * 启动器
 * @param {*} name 用例名
 * @param {*} cb 回调函数
 * @param {*} options 选项
 */
export async function runner(unitName) {
    let successNums = 0;
    let failNums = 0;
    let total = runCases.length;
    console.log(`======== ${unitName} start ========`);
    for (const cases of testCases) {
      const { name, cb } = cases;
      console.log(` ===== ${name} start ====`);
      try {
        await cb();
        successNums++;
        console.log(" | > 结果:测试通过");
      } catch (error) {
        console.error(error);
        failNums++;
      } finally {

      }
      console.log(` ===== ${name} end ====`);
    }
    console.log(` ===== ${name} end ====`);
    console.log(`共成功用例 ${successNums} / ${total}`);
}

用法

使用 live-server 打开

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div id="app"></div>
  </body>
  <script type="module" src="test-add.js"></script>
</html>


// test-add.js
import {it, expect, runner} from "./test-runner.js";

function add(a,b) {
  return a + b;
}



it("test-add success", () => {
  const c = add(1,2);
  expect(c).toBe(3);
});


it("test-add fail", () => {
  const c = add(1,2);
  expect(c).toBe(4);
});


runner("test add");


image.png

beforeEach & afterEach

大差不差,也是一个全局变量去收集

const testCases = [];
// 收集beforeEach和afterEach
const beforeEachCb = [];
const afterEachCb = [];



export async function runner(unitName) {


  let successNums = 0;
  let failNums = 0;
  let total = runCases.length;


  console.log(`======== ${unitName} start ========`);

  for (const cases of testCases) {
    const { name, cb } = cases;
    console.log(` ===== ${name} start ====`);
    
    //  每个case之前执行
    beforeEachCb.forEach((fn) => {
      fn();
    });

    try {
      await cb();
      console.log(" | > 结果:测试通过");
      successNums++;
    } catch (error) {
      console.error(error);
      failNums++;
    } finally {
     
      // 放到finally中就可以完成后置
      afterEachCb.forEach((fn) => {
        fn();
      });
    }

    console.log(` ===== ${name} end ====`);
  }

  console.log(`======== ${unitName} end ========`);
  console.log(`共成功用例 ${successNums} / ${total}`);
  cleanup();
  console.log("\n");
}

export const beforeEach = (cb) => {
  beforeEachCb.push(cb);
};


export const afterEach = (cb) => {
  afterEachCb.push(cb);
};

it.skip & it.only

这边其实只是给case加些标志方便区分。




export async function runner(unitName) {
  
  // 把那些需要skip掉的cases排出去
  let runCases = testCases.filter((c) => c.options.skip !== true);
  
  // 过滤出那些only的cases
  let onlyCases = testCases.filter((c) => c.options.only === true);
  
  // 如果有设置only,就执行的用例就是那些设置了only的
  if (onlyCases.length > 0) {
    runCases = onlyCases;
  }


  let successNums = 0;
  let failNums = 0;
  let total = runCases.length;


  console.log(`======== ${unitName} start ========`);


  for (const cases of runCases) {
    const { name, cb } = cases;
    console.log(` ===== ${name} start ====`);

    beforeEachCb.forEach((fn) => {
      fn();
    });

    try {
      await cb();
      console.log(" | > 结果:测试通过");
      successNums++;
    } catch (error) {
      console.error(error);
      failNums++;
    } finally {
      afterEachCb.forEach((fn) => {
        fn();
      });
    }


    console.log(` ===== ${name} end ====`);
  }

  console.log(`======== ${unitName} end ========`);
  console.log(`共成功用例 ${successNums} / ${total}`);
  cleanup();
  console.log("\n");
}


export function it(
  name,
  cb,
  options = {
    only: false,
    skip: false,
  }
) {
  testCases.push({
    name,
    cb,
    options,
  });
}

it.only = function (name, cb) {
  it(name, cb, {
    only: true,
    skip: false,
  });
};

it.skip = function (name, cb) {
  it(name, cb, {
    only: false,
    skip: true,
  });
};

expect.not

这个也简单,只需要用到 getter 的时候做个标志即可

export const expect = (value) => {
  //  是否开启not
  let isSetNot = false;


  return {
    get not() {
      isSetNot = true;
      return this;
    },
    toBe(val) {
      if (!isSetNot && value !== val) {
        throw new Error(`expect "${val}" but got "${value}"`);
      } else if (isSetNot && value === val) {
        throw new Error(`expect not to be "${val}" but got "${value}"`);
      }
    },
}

vi.fn & expect.toHaveBeenCalled & toHaveBeenCalledTimes

这个vi.fn我做的比较简单,就是记录了这个fn被调用了多少次。实现起来其实做层包了个函数,并且在他被调用的时候去做记录。

// 用来存放vi.fn注册的cb
// 为什么用weakmap, 一是因为它用地址做记录不会重复,二是会自动销毁
const spyMap = new WeakMap();


export const vi = {
  fn(cb) {
    if (!spyMap[cb]) {
      spyMap[cb] = 0;
    }
    return function (...args) {
      // 被调用了就+1
      spyMap[cb]++;
      cb(...args);
    };
  },
};


export const expect = (value) => {
  //  是否开启not
  let isSetNot = false;


  return {
    get not() {
      isSetNot = true;
      return this;
    },
    toHaveBeenCalled() {
      const callTimes = spyMap[value] || 0;
      if (!isSetNot && callTimes <= 0) {
        throw new Error(`expect function called but not`);
      } else if (isSetNot && callTimes > 0) {
        throw new Error(`expect function not to be called but it does`);
      }
    },
    toHaveBeenCalledTimes(times) {
      const callTimes = spyMap[value] || 0;
      if (!isSetNot && callTimes !== times) {
        throw new Error(
          `expect function called "${times}" times but called "${callTimes}" times actually`
        );
      } else if (isSetNot && callTimes === times) {
        throw new Error(
          `expect function not to be called "${times}" times but it called "${callTimes}" times actually`
        );
      }
    },
  };

cleanup

最后别忘了做清除,因为你的测试用例可能分散到多个文件里,存放的cases都是在同一个全局变量中

//  清除
function cleanup() {
  testCases.length = 0;
  beforeEachCb.length = 0;
  afterEachCb.length = 0;
}





export async function runner(unitName) {
  // ...省略

  console.log(`======== ${unitName} end ========`);
  console.log(`共成功用例 ${successNums} / ${total}`);
  cleanup();
  console.log("\n");
}

结语

以上就是我自己实现的 mini-vitest,希望对各位有帮助。另外这个东西在我的 eazy-vue3 中只是一个单独的文件存在,有需要的可以看一下我的github仓库。

image.png

image.png

github链接:

eazy-vue3/test-runner

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYkVo7eC' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片