通常来说,自动化测试分为端到端(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。
你可以尝试着写几个单元测试。