Deno单元测试:让你的代码更加健壮

230 阅读8分钟

断言

Deno内置了测试方法——Deno.test,结合断言可以很容易地进行测试。

import { assert, assertEquals, assertNotEquals, assertStrictEquals} from "https://deno.land/std@0.194.0/testing/asserts.ts";

Deno.test("Hello Test", () => {
  assert("Hello");
  assertEquals("Hello", "Hello");
  assertNotEquals(1, 2);
  assertStrictEquals("Hello", "Hello");
});

这个断言模块提供了14种断言方式:

assert(expr: unknown, msg = ""): asserts expr
assertEquals(actual: unknown, expected: unknown, msg?: string): void
assertExists(actual: unknown, msg?: string): void
assertNotEquals(actual: unknown, expected: unknown, msg?: string): void
assertStrictEquals(actual: unknown, expected: unknown, msg?: string): void
assertAlmostEquals(actual: number, expected: number, epsilon = 1e-7, msg?: string): void
assertInstanceOf(actual: unknown, expectedType: unknown, msg?: string): void
assertStringIncludes(actual: string, expected: string, msg?: string): void
assertArrayIncludes(actual: unknown\[], expected: unknown\[], msg?: string): void
assertMatch(actual: string, expected: RegExp, msg?: string): void
assertNotMatch(actual: string, expected: RegExp, msg?: string): void
assertObjectMatch( actual: Record\<PropertyKey, unknown>, expected: Record\<PropertyKey, unknown>): void
assertThrows(fn: () => void, ErrorClass?: Constructor, msgIncludes?: string | undefined, msg?: string | undefined): Error
assertRejects(fn: () => Promise<unknown>, ErrorClass?: Constructor, msgIncludes?: string | undefined, msg?: string | undefined): Promise<void>

如果你习惯使用Node.js的Chai库的话,在Deno中也可以通过CDN的方式引用:

import chai from "https://cdn.skypack.dev/chai@4.3.4?dts";

const foo = "bar";
const beverages = { tea: ["chai", "matcha", "oolong"] };

Deno.test("we can make chai assertions", () => {
  const assert = chai.assert;

  assert.typeOf(foo, "string"); // without optional message
  assert.typeOf(foo, "string", "foo is a string"); // with optional message
  assert.equal(foo, "bar", "foo equal `bar`");
  assert.lengthOf(foo, 3, "foo`s value has a length of 3");
  assert.lengthOf(beverages.tea, 3, "beverages has 3 types of tea");
});

Deno.test("we can make chai expectations", () => {
  const expect = chai.expect;

  expect(foo).to.be.a("string");
  expect(foo).to.equal("bar");
  expect(foo).to.have.lengthOf(3);
  expect(beverages).to.have.property("tea").with.lengthOf(3);
});

Deno.test("we can use chai should style", () => {
  chai.should();

  foo.should.be.a("string");
  foo.should.equal("bar");
  foo.should.have.lengthOf(3);
  beverages.should.have.property("tea").with.lengthOf(3);
});

自定义断言函数

你可以依赖于现有的断言模块,创建自定义断言函数可以提高可读性并减少代码量。

import { AssertionError } from "https://deno.land/std@0.194.0/testing/asserts.ts";

export function assertPowerOf(actual: number, expected: number, msg?: string): void {
  let received = actual;
  while (received % expected === 0) received = received / expected;
  if (received !== 1) {
    if (!msg) {
      msg = `actual: "${actual}" expected to be a power of : "${expected}"`;
    }
    throw new AssertionError(msg);
  }
}

使用:

Deno.test("Test Assert PowerOf", () => {
  assertPowerOf(8, 2);
  assertPowerOf(11, 4);
});

测试消毒剂(Test Sanitizers)

Deno提供了3种测试消毒剂。消毒剂这个名词你可能会觉得怪怪的,看了下面的例子就能理解了。

资源消毒剂

Deno.test({
  name: "leaky resource test",
  async fn() {
    await Deno.open("hello.txt");
  },
  sanitizeResources: false,
});

这个例子,如果不将sanitizeResources设置为false,则会报错:

error: Leaking resources:
- A file (rid 3) was opened during the test, but not closed during the test. Close the file handle by calling `file.close()`.

这个很好理解,打开了资源并没有关闭。

操作消毒剂

Deno.test({
  name: "leaky operation test",
  fn() {
    crypto.subtle.digest(
      "SHA-256",
      new TextEncoder().encode("a".repeat(100000000)),
    );
  },
  sanitizeOps: false,
});

crypto.subtle.digest是个异步函数,返回值是个Promise。如果不设置sanitizeOps为false,会报错:

error: Leaking async ops:
  - 1 async operation to digest data was started in this test, but never completed. This is often caused by not awaiting the result of a `crypto.subtle.digest` call.
To get more details where ops were leaked, run again with --trace-ops flag.

这时还有另一种方式是将fn设置为async等待这个函数完成。

Deno.test({
  name: "leaky operation test",
  async fn() {
    await crypto.subtle.digest(
      "SHA-256",
      new TextEncoder().encode("a".repeat(100000000)),
    );
  }
});

退出消毒剂

Deno.test({
  name: "false success",
  fn() {
    Deno.exit(0);
  },
  sanitizeExit: false,
});

// This test never runs, because the process exits during "false success" test
Deno.test({
  name: "failing test",
  fn() {
    throw new Error("this test fails");
  },
});

如果不设置sanitizeExit,在使用Deno.exit后,默认会报错,同时也不会阻塞后面测试用例的执行:

error: AssertionError: Test case attempted to exit with exit code: 0
  Deno.exit(0);

综上,你应该已经明白所谓『消毒剂』就是为你改变Deno默认测试行为的特殊方案。

测试方法

Deno.test通常有2种写法:

Deno.test({
  name: "test name",
  fn() {
    
  }
});

等价于:

Deno.test("test name", () => {
    
});

如果遇到异步的测试:

Deno.test("test name", async () => {
    
});

套用子测试用例,注意t.step之前加await。

Deno.test("User age", async (t) => {
  const user = new User("Kyle");

  await t.step("getAge", () => {
    assertThrows(() => user.getAge(), Error, "Age unknown");
    user.age = 18;
    assertEquals(user.getAge(), 18);
  });

  await t.step("setAge", () => {
    user.setAge(18);
    assertEquals(user.getAge(), 18);
  });
});

更复杂点儿的用例,你可能需要在每个样例前后执行固定代码,比如beforeEach、afterEach,那么可以结合另一个官方库bdd使用:

import {
  afterEach,
  beforeEach,
  describe,
  it,
} from "https://deno.land/std@0.194.0/testing/bdd.ts";

describe("User", () => {
  it("users initially empty", () => {
    assertEquals(User.users.size, 0);
  });

  describe("age", () => {
    let user: User;

    beforeEach(() => {
      user = new User("Kyle");
    });

    afterEach(() => {
      User.users.clear();
    });

    it("getAge", function () {
      assertThrows(() => user.getAge(), Error, "Age unknown");
      user.age = 18;
      assertEquals(user.getAge(), 18);
    });

    it("setAge", function () {
      user.setAge(18);
      assertEquals(user.getAge(), 18);
    });
  });
});

describe方法也能平铺开,不是很推荐,有兴趣的在这里自己看看,就不介绍了。

Mock

测试间谍是函数的替身,用于断言函数的内部行为是否符合预期。方法的测试间谍保留原始行为,但允许你测试方法的调用方式和返回值。测试存根是测试间谍的扩展,还可以替换原始方法的行为。

上面的介绍可能绕口些,简单来说,就是对于函数内部逻辑的测试,官方提供了一种方便的测试方案。

Spying 间谍

比如下面有2个函数:

export function multiply(a: number, b: number): number {
  return a * b;
}

export function square(
  multiplyFn: (a: number, b: number) => number,
  value: number,
): number {
  return multiplyFn(value, value);
}

我们想要测试multiply这个函数被square当作参数时有没有被执行,可以使用spy将这个函数包装:

import {
  assertSpyCall,
  assertSpyCalls,
  spy,
} from "https://deno.land/std@0.194.0/testing/mock.ts";
import { assertEquals } from "https://deno.land/std@0.194.0/testing/asserts.ts";

Deno.test("square calls multiply and returns results", () => {
  const multiplySpy = spy(multiply);

  assertEquals(square(multiplySpy, 5), 25);

  // asserts that multiplySpy was called at least once and details about the first call.
  assertSpyCall(multiplySpy, 0, {
    args: [5, 5],
    returned: 25,
  });

  // asserts that multiplySpy was only called once.
  assertSpyCalls(multiplySpy, 1);
});

另一种场景可能更为常见,multiply仅是被square内部直接调用,那么可以把multiply当作一个对象(_internals)的属性暴露出去:

export function multiply(a: number, b: number): number {
  return a * b;
}

export function square(value: number): number {
  return _internals.multiply(value, value);
}

export const _internals = { multiply };

将_internals包装下:

Deno.test("square calls multiply and returns results", () => {
  const multiplySpy = spy(_internals, "multiply");

  try {
    assertEquals(square(5), 25);
  } finally {
    // unwraps the multiply method on the _internals object
    multiplySpy.restore();
  }

  // asserts that multiplySpy was called at least once and details about the first call.
  assertSpyCall(multiplySpy, 0, {
    args: [5, 5],
    returned: 25,
  });

  // asserts that multiplySpy was only called once.
  assertSpyCalls(multiplySpy, 1);
});

之所以这样麻烦,是因为multiply不能被直接劫持,而对象可以。

另一点需要注意的是,例子中调用square后显式执行multiplySpy.restore(),从命名上看,它就是恢复为初始状态。

如果没有这一句,换成以下代码:

Deno.test("square calls multiply and returns results", () => {
  const multiplySpy = spy(_internals, 'multiply');
  assertEquals(square(5), 25);
  assertSpyCalls(multiplySpy, 1);

  // 再执行一遍
  assertEquals(square(5), 25);
  assertSpyCalls(multiplySpy, 2);
});

multiplySpy的调用记录就还在。

Stubbing 存根

再看下面的函数,randomInt的返回值是随机的:

export function randomInt(lowerBound: number, upperBound: number): number {
  return lowerBound + Math.floor(Math.random() * (upperBound - lowerBound));
}

export function randomMultiple(value: number): number {
  return value * _internals.randomInt(-10, 10);
}

export const _internals = { randomInt };

换用上面的方法你怎么测试?你可能照猫画虎写到下面的代码就知道不对劲了:

const multiplySpy = spy(_internals, "randomInt");

assertEquals(randomMultiple(5), 25);

这时需要一种方法,Mock掉randomInt这个函数的返回值,这就是stub函数和returnsNext。

import {
  assertSpyCall,
  assertSpyCalls,
  returnsNext,
  stub,
} from "https://deno.land/std@0.194.0/testing/mock.ts";

Deno.test("randomMultiple uses randomInt to generate random multiples between -10 and 10 times the value", () => {
  const randomIntStub = stub(_internals, "randomInt", returnsNext([-3, 3]));

  try {
    assertEquals(randomMultiple(5), -15);
    assertEquals(randomMultiple(5), 15);
  } finally {
    // unwraps the randomInt method on the _internals object
    randomIntStub.restore();
  }

  // asserts that randomIntStub was called at least once and details about the first call.
  assertSpyCall(randomIntStub, 0, {
    args: [-10, 10],
    returned: -3,
  });
  // asserts that randomIntStub was called at least twice and details about the second call.
  assertSpyCall(randomIntStub, 1, {
    args: [-10, 10],
    returned: 3,
  });

  // asserts that randomIntStub was only called twice.
  assertSpyCalls(randomIntStub, 2);
});

Faking time 假时间

还有种常见的情况是,我们在代码中可能会使用setInterval定时器:

export function secondInterval(cb: () => void): number {
  return setInterval(cb, 1000);
}

如果使用实际时间,这可能会导致测试花费的时间比应有的时间长得多。现在我们可以使用FakeTime和tick方法,来推进时间线的演进:

import {
  assertSpyCalls,
  spy,
} from "https://deno.land/std@0.194.0/testing/mock.ts";
import { FakeTime } from "https://deno.land/std@0.194.0/testing/time.ts";

Deno.test("secondInterval calls callback every second and stops after being cleared", () => {
  const time = new FakeTime();

  try {
    const cb = spy();
    const intervalId = secondInterval(cb);
    assertSpyCalls(cb, 0);
    time.tick(500);
    assertSpyCalls(cb, 0);
    time.tick(500);
    assertSpyCalls(cb, 1);
    time.tick(3500);
    assertSpyCalls(cb, 4);

    clearInterval(intervalId);
    time.tick(1000);
    assertSpyCalls(cb, 4);
  } finally {
    time.restore();
  }
});

快照测试

Deno 标准库配备了一个快照模块,使开发人员能够编写测试来比较一个值与参考快照。这个参考快照是原始值的序列化表示,并与测试文件一起存储。快照测试在许多情况下都很有用,因为它可以用非常少的代码来捕获各种错误。它特别有助于在难以精确表达应该断言什么的情况下使用,而不需要大量的代码,或者测试所做的断言预计经常更改的情况下。因此,它特别适用于前端和 CLI 的开发。

使用assertSnapshot,与测试目录的文件作对比:

import { assertSnapshot } from "https://deno.land/std@0.194.0/testing/snapshot.ts";

Deno.test("isSnapshotMatch", async function (t): Promise<void> {
  const a = {
    hello: "world!",
    example: 123,
  };
  await assertSnapshot(t, a);
});

测试快照文件:

export const snapshot = {};

snapshot[`isSnapshotMatch 1`] = `
{
  example: 123,
  hello: "world!",
}
`;

如果你手动修改了example的值,那么测试就会失败。

当然,我们不可能手动编写这个快照文件,而是用命令deno test --allow-all -- --update进行生成和更新。

上面的样例是个最简单的用例,你可能看不出它的威力。假如你用了Deno的颜色库:

import {
  red,
} from "https://deno.land/std@0.194.0/fmt/colors.ts";

Deno.test("Color test", async (t) => {
  const color = red("Hello, World!");
  await assertSnapshot(t, color);
});

这里的color变量打印出来是这样的: image.png

但快照文件是这样的:

export const snapshot = {};

snapshot[`Color test 1`] = `"\\x1b[31mHello, World!\\x1b[39m"`;

如果你正常写断言可能是这样的:

Deno.test("Color test", async (t) => {
  const color = red("Hello, World!");
  assertEquals(color, "[31mHello, World![39m");
});

遗憾的是会报错,让你怀疑人生: image.png

其实应该是这样的:

assertEquals(color, "Hello, World!");

image.png

而用快照,则不用关注这些细节,只需要让测试框架知道我的代码输出是什么样的,不允许随意变化就是了。 还有更复杂的用例和配置,因为大部分情况都用不到,这里就不介绍了,有兴趣的同学直接看官方文档吧。

总结

本文介绍了Deno标准模块中的断言与测试方法,以及如何使用资源、操作、退出三种消毒剂来跳过内置规则。此外,本文还介绍了如何在测试中Mock函数内部的调用,以及一个并不常用的快照模块。这些模块已经覆盖了测试的大部分场景,读者可以灵活运用这些内容,编写完善的测试用例,提高代码的健壮性。