You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
asyncvalidateUser(username: string,password: string,): Promise<UserAccountDto>{constentity=awaitthis.usersService.findOne({ username });if(!entity){thrownewUnauthorizedException('User not found');}if(entity.lockUntil&&entity.lockUntil>Date.now()){constdiffInSeconds=Math.round((entity.lockUntil-Date.now())/1000);letmessage=`The account is locked. Please try again in ${diffInSeconds} seconds.`;if(diffInSeconds>60){constdiffInMinutes=Math.round(diffInSeconds/60);message=`The account is locked. Please try again in ${diffInMinutes} minutes.`;}thrownewUnauthorizedException(message);}constpasswordMatch=bcrypt.compareSync(password,entity.password);if(!passwordMatch){// $inc update to increase failedLoginAttemptsconstupdate={$inc: {failedLoginAttempts: 1},};// lock account when the third try is failedif(entity.failedLoginAttempts+1>=3){// $set update to lock the account for 5 minutesupdate['$set']={lockUntil: Date.now()+5*60*1000};}awaitthis.usersService.update(entity._id,update);thrownewUnauthorizedException('Invalid password');}// if validation is sucessful, then reset failedLoginAttempts and lockUntilif(entity.failedLoginAttempts>0||(entity.lockUntil&&entity.lockUntil>Date.now())){awaitthis.usersService.update(entity._id,{$set: {failedLoginAttempts: 0,lockUntil: null},});}return{userId: entity._id, username }asUserAccountDto;}
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',()=>{letauthService: AuthService;// Use the actual AuthService typeletusersService: Partial<Record<keyofUsersService,jest.Mock>>;beforeEach(async()=>{usersService={findOne: jest.fn(),};constmodule=awaitTest.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()=>{awaitexpect(authService.validateUser(TEST_USER_NAME,TEST_USER_PASSWORD),).rejects.toThrow(UnauthorizedException);});// other tests...});});
it('should throw an UnauthorizedException if user is not found',async()=>{awaitexpect(authService.validateUser(TEST_USER_NAME,TEST_USER_PASSWORD),).rejects.toThrow(UnauthorizedException);});
if(!entity){thrownewUnauthorizedException('User not found');}
抛出 401 错误,符合预期。
第二个测试用例
validateUser 方法中的第二个处理逻辑是判断用户是否锁定,对应的代码如下:
if(entity.lockUntil&&entity.lockUntil>Date.now()){constdiffInSeconds=Math.round((entity.lockUntil-Date.now())/1000);letmessage=`The account is locked. Please try again in ${diffInSeconds} seconds.`;if(diffInSeconds>60){constdiffInMinutes=Math.round(diffInSeconds/60);message=`The account is locked. Please try again in ${diffInMinutes} minutes.`;}thrownewUnauthorizedException(message);}
it('should throw an UnauthorizedException if the account is locked',async()=>{constlockedUser={_id: TEST_USER_ID,username: TEST_USER_NAME,password: TEST_USER_PASSWORD,lockUntil: Date.now()+1000*60*5,// The account is locked for 5 minutes};usersService.findOne.mockResolvedValueOnce(lockedUser);awaitexpect(authService.validateUser(TEST_USER_NAME,TEST_USER_PASSWORD),).rejects.toThrow(UnauthorizedException);});
describe('login',()=>{it('/auth/login (POST)',()=>{// ...})it('/auth/login (POST) with user not found',()=>{// ...})it('/auth/login (POST) without username or password',async()=>{// ...})it('/auth/login (POST) with invalid password',()=>{// ...})it('/auth/login (POST) account lock after multiple failed attempts',async()=>{// ...})})
这五个测试分别是:
登录成功,返回 200
如果用户不存在,抛出 401 异常
如果不提供密码或用户名,抛出 400 异常
使用错误密码登录,抛出 401 异常
如果账户被锁定,抛出 401 异常
现在我们开始编写 e2e 测试:
// 登录成功it('/auth/login (POST)',()=>{returnrequest(app.getHttpServer()).post('/auth/login').send({username: TEST_USER_NAME,password: TEST_USER_PASSWORD}).expect(200)})// 如果用户不存在,应该抛出 401 异常it('/auth/login (POST) with user not found',()=>{returnrequest(app.getHttpServer()).post('/auth/login').send({username: TEST_USER_NAME2,password: TEST_USER_PASSWORD}).expect(401)// Expect an unauthorized error})
it('/auth/login (POST) account lock after multiple failed attempts',async()=>{constmoduleFixture: TestingModule=awaitTest.createTestingModule({imports: [AppModule],}).compile()constapp=moduleFixture.createNestApplication()awaitapp.init()constregisterResponse=awaitrequest(app.getHttpServer()).post('/auth/register').send({username: TEST_USER_NAME2,password: TEST_USER_PASSWORD})constaccessToken=registerResponse.body.access_tokenconstmaxLoginAttempts=3// lock user when the third try is failedfor(leti=0;i<maxLoginAttempts;i++){awaitrequest(app.getHttpServer()).post('/auth/login').send({username: TEST_USER_NAME2,password: 'InvalidPassword'})}// The account is locked after the third failed login attemptawaitrequest(app.getHttpServer()).post('/auth/login').send({username: TEST_USER_NAME2,password: TEST_USER_PASSWORD}).then((res)=>{expect(res.body.message).toContain('The account is locked. Please try again in 5 minutes.',)})awaitrequest(app.getHttpServer()).delete('/auth/delete-user').set('Authorization',`Bearer ${accessToken}`)awaitapp.close()})
前言
最近在给一个 nestjs 项目写单元测试(Unit Testing)和 e2e 测试(End-to-End Testing,端到端测试,简称 e2e 测试),这是我第一次给后端项目写测试,发现和之前给前端项目写测试还不太一样,导致在一开始写测试时感觉无从下手。后来在看了一些示例之后才想明白怎么写测试,所以打算写篇文章记录并分享一下,以帮助和我有相同困惑的人。
同时我也写了一个 demo 项目,相关的单元测试、e2e 测试都写好了,有兴趣可以看一下。代码已上传到 Github: nestjs-demo。
单元测试和 E2E 测试的区别
单元测试和 e2e 测试都是软件测试的方法,但它们的目标和范围有所不同。
单元测试是对软件中的最小可测试单元进行检查和验证。比如一个函数、一个方法都可以是一个单元。在单元测试中,你会对这个函数的各种输入给出预期的输出,并验证功能的正确性。单元测试的目标是快速发现函数内部的 bug,并且它们容易编写、快速执行。
而 e2e 测试通常通过模拟真实用户场景的方法来测试整个应用,例如前端通常使用浏览器或无头浏览器来进行测试,后端则是通过模拟对 API 的调用来进行测试。
在 nestjs 项目中,单元测试可能会测试某个服务(service)、某个控制器(controller)的一个方法,例如测试 Users 模块中的
update
方法是否能正确的更新一个用户。而一个 e2e 测试可能会测试一个完整的用户流程,如创建一个新用户,然后更新他们的密码,然后删除该用户。这涉及了多个服务和控制器。编写单元测试
为一个工具函数或者不涉及接口的方法编写单元测试,是非常简单的,你只需要考虑各种输入并编写相应的测试代码就可以了。但是一旦涉及到接口,那情况就复杂了。用代码来举例:
上面的代码是
auth.service.ts
文件里的一个方法validateUser
,主要用于验证登录时用户输入的账号密码是否正确。它包含的逻辑如下:username
查看用户是否存在,如果不存在则抛出 401 异常(也可以是 404 异常)password
加密后和数据库中的密码进行对比,如果错误则抛出 401 异常(连续三次登录失败会被锁定账户 5 分钟)id
和username
到下一阶段可以看到
validateUser
方法包含了 4 个处理逻辑,我们需要对这 4 点都编写对应的单元测试代码,以确定整个validateUser
方法功能是正常的。第一个测试用例
在开始编写单元测试时,我们会遇到一个问题,
findOne
方法需要和数据库进行交互,它要通过username
查找数据库中是否存在对应的用户。但如果每一个单元测试都得和数据库进行交互,那测试起来会非常麻烦。所以可以通过 mock 假数据来实现这一点。举例,假如我们已经注册了一个
woai3c
的用户,那么当用户登录时,在validateUser
方法中能够通过const entity = await this.usersService.findOne({ username });
拿到用户数据。所以只要确保这行代码能够返回想要的数据,即使不和数据库交互也是没有问题的。而这一点,我们能通过 mock 数据来实现。现在来看一下validateUser
方法的相关测试代码:我们通过调用
usersService
的fineOne
方法来拿到用户数据,所以需要在测试代码中 mockusersService
的fineOne
方法:通过使用
jest.fn()
返回一个函数来代替真实的usersService.findOne()
。如果这时调用usersService.findOne()
将不会有任何返回值,所以第一个单元测试用例就能通过了:因为在
validateUser
方法中调用const entity = await this.usersService.findOne({ username });
的findOne
是 mock 的假函数,没有返回值,所以validateUser
方法中的第 2-4 行代码就能执行到了:抛出 401 错误,符合预期。
第二个测试用例
validateUser
方法中的第二个处理逻辑是判断用户是否锁定,对应的代码如下:可以看到如果用户数据里有锁定时间
lockUntil
并且锁定结束时间大于当前时间就可以判断当前账户处于锁定状态。所以需要 mock 一个具有lockUntil
字段的用户数据:在上面的测试代码里,先定义了一个对象
lockedUser
,这个对象里有我们想要的lockUntil
字段,然后将它作为findOne
的返回值,这通过usersService.findOne.mockResolvedValueOnce(lockedUser);
实现。然后validateUser
方法执行时,里面的用户数据就是 mock 出来的数据了,从而成功让第二个测试用例通过。单元测试覆盖率
剩下的两个测试用例就不写了,原理都是一样的。如果剩下的两个测试不写,那么这个
validateUser
方法的单元测试覆盖率会是 50%,如果 4 个测试用例都写完了,那么validateUser
方法的单元测试覆盖率将达到 100%。单元测试覆盖率(Code Coverage)是一个度量,用于描述应用程序代码有多少被单元测试覆盖或测试过。它通常表示为百分比,表示在所有可能的代码路径中,有多少被测试用例覆盖。
单元测试覆盖率通常包括以下几种类型:
if/else
语句)。单元测试覆盖率是衡量单元测试质量的一个重要指标,但并不是唯一的指标。高的覆盖率可以帮助检测代码中的错误,但并不能保证代码的质量。覆盖率低可能意味着有未被测试的代码,可能存在未被发现的错误。
下图是 demo 项目的单元测试覆盖率结果:
像 service 和 controller 之类的文件,单元测试覆盖率一般尽量高点比较好,而像 module 这种文件就没有必要写单元测试了,也没法写,没有意义。上面的图片表示的是整个单元测试覆盖率的总体指标,如果你想查看某个函数的测试覆盖率,可以打开项目根目录下的
coverage/lcov-report/index.html
文件进行查看。例如我想查看validateUser
方法具体的测试情况:可以看到原来
validateUser
方法的单元测试覆盖率并不是 100%,还是有两行代码没有执行到,不过也无所谓了,不影响 4 个关键的处理节点,不要片面的追求高测试覆盖率。编写E2E 测试
在单元测试中我们展示了如何为
validateUser()
的每一个功能点编写单元测试,并且使用了 mock 数据的方法来确保每个功能点都能够被测试到。而在 e2e 测试中,我们需要模拟真实的用户场景,所以要连接数据库来进行测试。因此,这次测试的auth.service.ts
模块里的方法都会和数据库进行交互。auth
模块主要有以下几个功能:e2e 测试需要将这六个功能都测试一遍,从
注册
开始,到删除用户
结束。在测试时,我们可以建一个专门的测试用户来进行测试,测试完成后再删除这个测试用户,这样就不会在测试数据库中留下无用的信息了。beforeAll
钩子函数将在所有测试开始之前执行,所以我们可以在这里注册一个测试账号TEST_USER_NAME
。afterAll
钩子函数将在所有测试结束之后执行,所以在这删除测试账号TEST_USER_NAME
是比较合适的,还能顺便对注册和删除两个功能进行测试。在上一节的单元测试中,我们编写了关于
validateUser
方法的相关单元测试。其实这个方法是在登录时执行的,用于验证用户账号密码是否正确。所以这一次的 e2e 测试也将使用登录流程来展示如何编写 e2e 测试用例。整个登录测试流程总共包含了五个小测试:
这五个测试分别是:
现在我们开始编写 e2e 测试:
e2e 的测试代码写起来比较简单,直接调用接口,然后验证结果就可以了。比如登录成功测试,我们只要验证返回结果是否是 200 即可。
前面四个测试都比较简单,现在我们看一个稍微复杂点的 e2e 测试,即验证账户是否被锁定。
当用户连续三次登录失败的时候,账户就会被锁定。所以在这个测试里,我们不能使用测试账号
TEST_USER_NAME
,因为测试成功的话这个账户就会被锁定,无法继续进行下面的测试了。我们需要再注册一个新用户TEST_USER_NAME2
,专门用来测试账户锁定,测试成功后再删除这个用户。所以你可以看到这个 e2e 测试的代码非常多,需要做大量的前置、后置工作,其实真正的测试代码就这几行:可以看到编写 e2e 测试代码还是相对比较简单的,不需要考虑 mock 数据,不需要考虑测试覆盖率,只要整个系统流程的运转情况符合预期就可以了。
应不应该写测试
如果有条件的话,我是比较建议大家写测试的。因为写测试可以提高系统的健壮性、可维护性和开发效率。
提高系统健壮性
我们一般编写代码时,会关注于正常输入下的程序流程,确保核心功能正常运作。但是一些边缘情况,比如异常的输入,这些我们可能会经常忽略掉。但当我们开始编写测试时,情况就不一样了,这会逼迫你去考虑如何处理并提供相应的反馈,从而避免程序崩溃。可以说写测试实际上是在间接地提高系统健壮性。
提高可维护性
当你接手一个新项目时,如果项目包含完善的测试,那将会是一件很幸福的事情。它们就像是项目的指南,帮你快速把握各个功能点。只看测试代码就能够轻松地了解每个功能的预期行为和边界条件,而不用你逐行的去查看每个功能的代码。
提高开发效率
想象一下,一个长时间未更新的项目突然接到了新需求。改了代码后,你可能会担心引入 bug,如果没有测试,那就需要重新手动测试整个项目——浪费时间,效率低下。而有了完整的测试,一条命令就能得知代码更改有没有影响现有功能。即使出错了,也能够快速定位,找到问题点。
什么时候不建议写测试?
短期项目、需求迭代非常快的项目不建议写测试。比如某些活动项目,活动结束就没用了,这种项目就不需要写测试。另外,需求迭代非常快的项目也不要写测试,我刚才说写测试能提高开发效率是有前提条件的,就是功能迭代比较慢的情况下,写测试才能提高开发效率。如果你的功能今天刚写完,隔一两天就需求变更了要改功能,那相关的测试代码都得重写。所以干脆就别写了,靠团队里的测试人员测试就行了,因为写测试是非常耗时间的,没必要自讨苦吃。
根据我的经验来看,国内的绝大多数项目(尤其是政企类项目)都是没有必要写测试的,因为需求迭代太快,还老是推翻之前的需求,代码都得加班写,那有闲情逸致写测试。
总结
在细致地讲解了如何为 Nestjs 项目编写单元测试及 e2e 测试之后,我还是想重申一下测试的重要性,它能够提高系统的健壮性、可维护性和开发效率。如果没有机会写测试,我建议大家可以自己搞个练习项目来写,或者说参加一些开源项目,给这些项目贡献代码,因为开源项目对于代码要求一般都比较严格。贡献代码可能需要编写新的测试用例或修改现有的测试用例。
最后,再推荐一下我的其他文章,如果你有兴趣,不妨一读:
参考资料
The text was updated successfully, but these errors were encountered: