最近,我一直在为一个NestJS项目编写单元测试和E2E测试。这是我第一次为后端项目编写测试,我发现这个过程与我在前端测试的经验不同,使得开始时充满挑战。在看了一些例子之后,我对如何进行测试有了更清晰的了解,所以我打算写一篇文章来记录和分享我的学习,以帮助其他可能面临类似困惑的人。
此外,我还整理了一个演示项目,其中包含相关单元并已完成端到端测试,您可能会感兴趣。代码已上传至Github:https://github.com/woai3c/nestjs-demo。
单元测试和端到端测试都是软件测试的方法,但它们有不同的目标和范围。
单元测试涉及检查和验证软件中的最小可测试单元。例如,函数或方法可以被视为一个单元。在单元测试中,您为函数的各种输入提供预期输出并验证其操作的正确性。单元测试的目标是快速识别功能内的错误,并且它们易于编写和快速执行。
另一方面,E2E测试通常会模拟真实的用户场景来测试整个应用程序。例如,前端通常使用浏览器或无头浏览器进行测试,而后端则通过模拟 API 调用来进行测试。
在 NestJS 项目中,单元测试可能会评估特定服务或控制器的方法,例如验证 Users 模块中的更新方法是否正确更新用户。然而,端到端测试可能会检查完整的用户旅程,从创建新用户到更新密码再到最终删除用户,这涉及多个服务和控制器。
为不涉及接口的实用函数或方法编写单元测试相对简单。您只需要考虑各种输入并编写相应的测试代码即可。然而,一旦接口发挥作用,情况就会变得更加复杂。我们以代码为例:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
上面的代码是auth.service.ts文件中的validateUser方法,主要用于验证用户登录时输入的用户名和密码是否正确。它包含以下逻辑:
可以看出,validateUser方法包含四个处理逻辑,我们需要针对这四个点编写相应的单元测试代码,以保证整个validateUser函数运行正确。
当我们开始编写单元测试时,遇到一个问题:findOne方法需要与数据库交互,它通过username在数据库中查找对应的用户。但是,如果每个单元测试都必须与数据库进行交互,那么测试就会变得非常繁琐。因此,我们可以模拟假数据来实现这一点。
例如,假设我们注册了一个名为 woai3c 的用户。然后,在登录过程中,可以在 validateUser 方法中通过 constentity = wait this.usersService.findOne({ username }); 检索用户数据。只要这行代码能够返回想要的数据,就没有问题,即使没有数据库交互。我们可以通过模拟数据来实现这一点。现在,我们看一下 validateUser 方法的相关测试代码:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
我们通过调用usersService的findOne方法来获取用户数据,所以我们需要在测试代码中模拟usersService的findOne方法:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
我们使用 jest.fn() 返回一个函数来替换真正的 usersService.findOne()。如果现在调用 usersService.findOne() ,将不会有返回值,因此第一个单元测试用例将通过:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
由于 constentity 中的 findOne = wait this.usersService.findOne({ username }); validateUser 方法是一个模拟的假函数,没有返回值,validateUser 方法中的第 2 到第 4 行代码可以执行:
it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); });
抛出 401 错误,符合预期。
validateUser方法中的第二个逻辑是判断用户是否被锁定,对应代码如下:
if (!entity) { throw new UnauthorizedException('User not found'); }
可以看到,如果用户数据中有锁定时间lockUntil,并且锁定结束时间大于当前时间,我们就可以判断当前账户被锁定。因此,我们需要使用 lockUntil 字段来模拟用户数据:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
上面的测试代码中,首先定义了一个对象lockedUser,其中包含我们需要的lockUntil字段。然后,它被用作findOne的返回值,通过usersService.findOne.mockResolvedValueOnce(lockedUser);实现。这样,当执行 validateUser 方法时,其中的用户数据就是模拟数据,成功地让第二个测试用例通过。
单元测试覆盖率(代码覆盖率)是用于描述单元测试覆盖或测试了多少应用程序代码的指标。它通常以百分比表示,表示所有可能的代码路径中有多少已被测试用例覆盖。
单元测试覆盖率通常包括以下类型:
单元测试覆盖率是衡量单元测试质量的重要指标,但不是唯一指标。高覆盖率可以帮助检测代码中的错误,但并不能保证代码的质量。覆盖率低可能意味着存在未经测试的代码,可能存在未检测到的错误。
下图显示了演示项目的单元测试覆盖率结果:
对于services、controller之类的文件,一般单元测试覆盖率越高越好,而对于module之类的文件则不需要写单元测试,也不可能写,因为没有意义。上图表示整个单元测试覆盖率的总体指标。如果要查看特定功能的测试覆盖率,可以打开项目根目录下的coverage/lcov-report/index.html文件。比如我想看看validateUser方法的具体测试情况:
可以看到,validateUser方法原来的单元测试覆盖率并不是100%,还有两行代码没有执行。不过没关系,不会影响四个关键处理节点,也不宜一维追求高测试覆盖率。
在单元测试中,我们演示了如何为 validateUser() 函数的每个功能编写单元测试,使用模拟数据来确保每个功能都可以被测试。在端到端测试中,我们需要模拟真实的用户场景,因此连接数据库进行测试是必要的。因此,我们将测试的 auth.service.ts 模块中的方法都与数据库交互。
auth模块主要包含以下功能:
端到端测试需要对这六个功能进行一一测试,从注册开始,到删除用户结束。在测试过程中,我们可以创建一个专门的测试用户来进行测试,完成后删除这个测试用户,以免在测试数据库中留下任何不必要的信息。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
beforeAll钩子函数在所有测试开始之前运行,因此我们可以在这里注册一个测试帐户TEST_USER_NAME。 afterAll钩子函数在所有测试结束后运行,所以这里适合删除测试账号TEST_USER_NAME,也方便测试注册和删除功能。
在上一节的单元测试中,我们围绕 validateUser 方法编写了相关的单元测试。实际上,该方法是在登录时执行,以验证用户的帐号和密码是否正确。因此,本次e2E测试也将使用登录流程来演示如何编写e2E测试用例。
整个登录测试过程包括五个小测试:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
这五个测试如下:
现在让我们开始编写 e2E 测试:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
编写 e2E 测试代码相对简单:只需调用接口,然后验证结果即可。比如登录测试成功,我们只需要验证返回结果是否为200即可。
前四个测试非常简单。现在我们来看一个稍微复杂一点的端到端测试,就是验证账户是否被锁定。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
当用户连续3次登录失败时,账户将被锁定。因此,在本次测试中,我们不能使用测试帐户TEST_USER_NAME,因为如果测试成功,该帐户将被锁定,无法继续进行后续测试。我们需要注册另一个新用户TEST_USER_NAME2专门用于测试账户锁定,测试成功后删除该用户。所以,正如你所看到的,这个 e2E 测试的代码相当庞大,需要大量的设置和拆卸工作,但实际的测试代码只有这几行:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
编写 e2E 测试代码相对简单。您不需要考虑模拟数据或测试覆盖率。只要整个系统进程按预期运行就足够了。
如果可能的话,我通常建议编写测试。这样做可以增强系统的健壮性、可维护性和开发效率。
在编写代码时,我们通常会关注正常输入下的程序流程,以确保核心功能正常工作。然而,我们可能经常忽略一些边缘情况,例如异常输入。编写测试改变了这一点;它迫使您考虑如何处理这些情况并做出适当的响应,从而防止崩溃。可以说,编写测试间接提高了系统的健壮性。
接手一个包含全面测试的新项目是非常令人愉快的。它们充当指南,帮助您快速了解各种功能。只需查看测试代码,您就可以轻松掌握每个函数的预期行为和边界条件,而无需逐行查看函数代码。
想象一下,一个有一段时间没有更新的项目突然收到了新的需求。进行更改后,您可能会担心引入错误。如果没有测试,您将需要再次手动测试整个项目——浪费时间且效率低下。通过完整的测试,单个命令可以告诉您代码更改是否影响了现有功能。即使出现错误,也可以快速定位并解决。
对于短期项目以及需求迭代非常快的项目,不建议编写测试。例如,某些用于活动的项目在活动结束后将毫无用处,不需要测试。另外,对于需求迭代非常快的项目,我说编写测试可以提高开发效率,但前提是函数迭代很慢。如果你刚刚完成的功能在一两天内发生变化,相关的测试代码就必须重写。所以,最好根本不写测试,而是依赖测试团队,因为写测试非常耗时,不值得付出努力。
在详细解释了如何为NestJS项目编写单元测试和e2E测试之后,我仍然想重申一下测试的重要性。可以增强系统的健壮性、可维护性和开发效率。如果你没有机会编写测试,我建议你自己启动一个实践项目或者参与一些开源项目并为其贡献代码。开源项目通常有更严格的代码要求。贡献代码可能需要您编写新的测试用例或修改现有的测试用例。
以上是如何为 NestJS 应用程序编写单元测试和 Etest的详细内容。更多信息请关注PHP中文网其他相关文章!