3.4 自动化测试

112 阅读6分钟

通常来说,自动化测试分为端到端(E2E)和单元测试(unit)两种。前者需要有个真实的环境(前端是浏览器,后端是服务容器),也可以叫集成测试,而后者只需要代码层面的模拟测试即可。对二者不太熟悉的,可以参看这篇文章《持续集成之测试篇》。

下来我们将简单谈下这两种测试的方案。

E2E测试

E2E测试的优势是有真实的环境,如果代码换个语言或框架重构,可以很容易验证重构的结果。缺点也是启动测试服务可能会非常复杂,你必须保障测试的环境是全新的,不能有旧数据,否则会影响准确性。

对于我们的项目而言,只用到了MongoDB数据库,而在真实的开发业务中,接入redis、kafka及其它第三方服务更常见。

第一步需要有一个MongoDB的测试库。

第二步是连接这个测试库,启动服务。

第三步可以是调用我们自己的测试代码进行测试,也可以是使用第三方工具。

自测代码

我们怎么自己调用呢?很简单,Deno原生支持fetch API,用它来调用我们自己的接口就行。

如果在CICD中使用,则需要在测试完成时关闭我们的服务,这有2种方案,一是启动服务也由我们程序调用,那么关闭服务就由我们程序自己关,另一种是我们服务再暴露一个接口来关闭服务,为了安全,这个接口必须依赖于某个环境变量或者配置才能工作。

服务自启动

import_map.json中新增一条:

"std/": "https://deno.land/std@0.118.0/",

修改src/main.ts,把服务启动代码封装到一个函数中:

export const abortController = new AbortController();

export async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  ...
  const { signal } = abortController;
  await app.listen({ port: globals.port, signal });
}

if (import.meta.main) {
  bootstrap();
}

新建tests/e2e/app_test.ts:

import { assertEquals } from "std/testing/asserts.ts";

export async function testVersion() {
  const result = await fetch("http://localhost:8000/version");
  const version = await result.text();
  assertEquals(version, "0.1.1");
}

新建tests/e2e/mod.ts:

import { abortController, bootstrap } from "@/main.ts";
import { testVersion } from "./app_test.ts";

bootstrap();

setTimeout(async () => {
  await testVersion();
  // 其它测试代码
  abortController.abort();
}, 1000);

上面代码延迟一秒,才进行测试,原因是服务代码是不能await的,只有服务停止后这个Promise才会结束,所以我们不知道服务是什么时候运行成功的。

测试代码可以分布在不同的ts文件中,具体怎么组装代码就看你自己了。

这里停止服务使用了 abortController.abort,其实更暴力可以使用Deno.exit。

修改deno.jsonc文件,增加一个e2e的task:

"test": "deno test --allow-net --allow-env --allow-write --allow-read --import-map import_map.json --unstable",

最后,使用deno task e2e进行测试。

暴露接口关闭服务

修改src/app.controller.ts,增加一个退出方法:

@Get("exit")
exit() {
    Deno.exit(0);
}

修改tests/e2e/mod.ts:

import { testVersion } from "./app_test.ts";

function exitApp() {
  return fetch("http://localhost:8000/exit").catch((_) => null);
}

setTimeout(async () => {
  await testVersion();
  exitApp();
}, 1000);

使用deno task dev启动服务,再另开一个控制台执行deno task e2e,当它运行完毕时,上一个服务也关闭了。

这种方式当然并不推荐,一定要确保这个接口在生产环境无法调用(比如使用环境变量控制)。

第三方软件

也可以使用第三方软件,比如有名的PostMan,也安利一款国产软件Apifox。既可以记录接口,也可用来测试,甚至也可以与CICD集成。

单元测试

我们使用oak_nest自带的测试函数来帮助我们单元测试,核心思想是将下层的方法Mock以测试上层的逻辑。更详细的Deno测试方法可以参看《Deno单元测试:让你的代码更加健壮》。

测试Controller

以src/user/user.controller.ts为例,假设我们要测试getAllUsers和currentUserInfo方法:

export class UserController {
  constructor(
    private readonly userService: UserService,
    private readonly sessionService: SessionService,
    private readonly logger: Logger,
  ) {} 
 @Get("user")
 @UseGuards(SSOGuard)
 async getAllUsers() {
    return await this.userService.getAll();
 }
  
 @Get("currentUserInfo")
 @UseGuards(SSOGuard)
 currentUserInfo(context: Context) {
    const user = { ...context.state.locals?.user };
    delete user.password;
    return user;
  }
}

在同一个文件夹下创建一个新文件src/user/user.controller_test.ts,内容如下:

import { createTestingModule } from "oak_nest";
import { assertEquals } from "std/testing/asserts.ts";
import { SessionService } from "../session/session.service.ts";
import { UserController } from "./user.controller.ts";
import { UserService } from "./user.service.ts";

Deno.test("getAllUsers", async (t) => {
  const callStacks: number[] = [];
  const userService = {
    getAll() {
      callStacks.push(1);
      return Promise.resolve([]);
    },
  };
  const moduleRef = await createTestingModule({
    controllers: [UserController],
  })
    .overrideProvider(UserService, userService)
    .overrideProvider(SessionService, {})
    .compile();
  const userController = await moduleRef.get(UserController);
  
  await t.step("getAllUsers", async () => {
    const users = await userController.getAllUsers();
    assertEquals(users, []);
    assertEquals(callStacks, [1]);
  });

  await t.step("currentUser", () => {
    const userInfo = userController.currentUserInfo({
      state: {
        locals: {
          user: {
            name: "test",
            password: "123456",
          },
        },
      },
      // deno-lint-ignore no-explicit-any
    } as any);
    assertEquals(userInfo, { name: "test" });
  });
});

因为整个Controller里用到了UserService和SessionService这两个依赖数据库的Service,如果不使用overrideProvider将它们Mock,那测试代码启动就会照常验证数据库,这样就报错了。我们把UserService的getAll方法重写后,就能用它来验证Controller中的逻辑了。

这里getAllUsers因为没有其它业务处理,所以显得简单些,好像没什么用处。而后面的currentUser代码中删除了password,避免显示给页面,这里的验证就很有用处,将来你或者别人修改代码时误把这个逻辑去掉,单元测试就失败了。

从上面也看得出,Deno自带的test方法很方便,要组合同段的测试逻辑,可以使用t.step分段,注意需要在外面await。

我们在deno.jsonc中增加一段:

"unit": "deno test --allow-write --allow-read --unstable",

执行deno task unit进行测试。

测试Service

又比如src/user/user.service.ts,假设我们要验证getAll和addUser:

@Injectable()
export class UserService {
  constructor(@InjectModel(User) private readonly userModel: Model<User>) {}

  async getAll() {
    return this.userModel.findMany({});
  }

  async addUser(user: User) {
    const id = await this.userModel.insertOne(user);
    return id.toString();
  }
}

这种没有注入其它service的更好验证,直接Mock一个userModel对象就行了。新建src/user/user.service_test.ts:

import { assertEquals } from "std/testing/asserts.ts";
import { Gender } from "./user.schema.ts";
import { UserService } from "./user.service.ts";

Deno.test("user service", async (t) => {
  const callStacks: number[] = [];
  const model = {
    findMany() {
      callStacks.push(1);
      return Promise.resolve([]);
    },
    insertOne() {
      callStacks.push(2);
      return 1;
    },
  };
  // deno-lint-ignore no-explicit-any
  const userService = new UserService(model as any);

  await t.step("getAllUsers", async () => {
    const users = await userService.getAll();
    assertEquals(users, []);
    assertEquals(callStacks, [1]);

    callStacks.length = 0;
  });

  await t.step("addUser", async () => {
    const id = await userService.addUser({
      name: "test",
      password: "",
      avatar: "",
      gender: Gender.Man,
      bio: "",
    });
    assertEquals(id, "1");
    assertEquals(typeof id, "string");

    assertEquals(callStacks, [2]);
    callStacks.length = 0;
  });
});

其实,像getAll这种没有业务逻辑的方法测试并没有多大意义,而addUser这种要保障返回值类型为字符串的,才是我们的重点关注对象。

作业

通过上面的学习,相信你已经知道怎么写测试代码了。

这两种测试应该怎么抉择呢?E2E的测试效果更权威些,更看重整体功能测试,而unit偏代码层面的逻辑测试,也更简单些。

优先推荐使用unit保障你的业务代码,尤其是底层的工具函数。有余力能做E2E就更好了。

要避免为提升测试覆盖率而写测试用例。时刻记着,你的目的是要保障代码的健壮性,而不仅仅是完成KPI。

你可以尝试着写几个单元测试。