Contents
Preface
Recently I was writing unit testing (Unit Testing) and e2e testing (End-to-End Testing, referred to as e2e testing) for a nestjs project. This is my first time writing tests for a back-end project. I found that the same as before Writing tests for front-end projects is still different, which makes it difficult to start when writing tests at the beginning. Later, after reading some examples, I figured out how to write tests, so I decided to write an article to record and share it to help people who have the same confusion as me.
At the same time, I also wrote a demo project. The relevant unit tests and e2e tests have been written. If you are interested, you can take a look. The code has been uploaded to Github: nestjs-interview-demo .
The difference between unit testing and E2E testing
Unit testing and e2e testing are both methods of software testing, but their goals and scope are different.
Unit testing is the examination and verification of the smallest testable unit of software. For example, a function or a method can be a unit. In a unit test, you give the function the expected output for its various inputs and verify the functionality’s correctness. The goal of unit tests is to quickly find bugs within functions, and they are easy to write and fast to execute.
E2e testing usually tests the entire application by simulating real user scenarios. For example, the front-end is usually tested using a browser or a headless browser, and the back-end is tested by simulating API calls.
In a nestjs project, a unit test may test a method of a service or a controller, such as testing update
whether the method in the Users module can correctly update a user. An e2e test might test a complete user flow, such as creating a new user, then updating their password, and then deleting that user. This involves multiple services and controllers.
Write unit tests
Writing unit tests for a utility function or a method that does not involve an interface is very simple. You only need to consider various inputs and write corresponding test code. But once interfaces are involved, the situation becomes complicated. Use code as an example:
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 successful, 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 ; }
The above code is auth.service.ts
a method in the file validateUser
, which is mainly used to verify whether the account and password entered by the user when logging in are correct. The logic it contains is as follows:
- According to
username
check whether the user exists, if not, throw a 401 exception (it can also be a 404 exception) - Check whether the user is locked. If locked, throw a 401 exception and related prompt text.
- Compare
password
the encrypted password with the password in the database. If it is wrong, a 401 exception will be thrown (if you fail to log in three times in a row, your account will be locked for 5 minutes) - If the login is successful, the count records of previous login failures will be cleared (if any) and returned to the user
id
andusername
go to the next stage.
It can be seen that validateUser
the method contains 4 processing logics. We need to write corresponding unit test code for these 4 points to ensure that the entire validateUser
method function is normal.
First test case
When we start writing unit tests, we will encounter a problem. findOne
The method needs to interact with the database. It needs to username
find whether the corresponding user exists in the database. But if every unit test has to interact with the database, it will be very troublesome to test. So this can be achieved by mocking fake data.
For example, if we have registered a woai3c
user, then when the user logs in, the user data validateUser
can be obtained through in the method const entity = await this.usersService.findOne({ username });
. So as long as you make sure that this line of code can return the desired data, there will be no problem even if it doesn’t interact with the database. And this, we can achieve through mock data. Now let’s take a look at validateUser
the relevant test code for the method:
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... }); });
We get user data by calling usersService
the method, so we need to mock the method fineOne
in the test code :usersService
fineOne
beforeEach ( async () => { usersService = { findOne : jest. fn (), // Mock the findOne method here }; const module = await Test . createTestingModule ({ providers : [ AuthService , // The real AuthService, because we want to test its methods { provide : UsersService , // Use mock usersService instead of real usersService useValue : usersService, }, ], }). compile (); authService = module . get < AuthService >( AuthService ); });
By using jest.fn()
return a function instead of real usersService.findOne()
. If called at this time, usersService.findOne()
there will be no return value, so the first unit test case will pass:
it ( 'should throw an UnauthorizedException if user is not found' , async () => { await expect ( authService.validateUser ( TEST_USER_NAME , TEST_USER_PASSWORD ) , ) .rejects.toThrow ( UnauthorizedException ) ; });
Because what is called in the validateUser
method is a fake function of mock and has no return value, so the 2-4 lines of code in the method can be executed:const entity = await this.usersService.findOne({ username });
findOne
validateUser
if (!entity) { throw new UnauthorizedException ( 'User not found' ); }
Throws a 401 error, as expected.
Second test case
validateUser
The second processing logic in the method is to determine whether the user is locked. The corresponding code is as follows:
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); }
It can be seen that if there is a lock time in the user data lockUntil
and the lock end time is greater than the current time, it can be judged that the current account is locked. So you need to mock a lockUntil
user data with field:
it ( 'should throw an UnauthorizedException if the account is locked' , async () => { const lockedUser = { _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 ) ; await expect ( authService.validateUser ( TEST_USER_NAME , TEST_USER_PASSWORD ) , ) .rejects.toThrow ( UnauthorizedException ) ; });
In the above test code, an object is first defined lockedUser
. This object has the fields we want lockUntil
, and then uses it as findOne
the return value. This usersService.findOne.mockResolvedValueOnce(lockedUser);
is achieved through . Then validateUser
when the method is executed, the user data inside is the data from the mock, thus successfully passing the second test case.
unit test coverage
I won’t write the remaining two test cases, the principles are the same. If the remaining two tests are not written, then validateUser
the unit test coverage of this method will be 50%. If all 4 test cases are written, then validateUser
the unit test coverage of this method will reach 100%.
Unit test coverage (Code Coverage) is a metric used to describe how much of an application’s code is covered or tested by unit tests. It is usually expressed as a percentage, indicating how many of all possible code paths are covered by test cases.
Unit test coverage usually includes the following types:
- Line coverage (Lines): How many lines of code are covered by the test.
- Function coverage (Funcs): How many functions or methods are covered by the test.
if/else
Branch coverage (Branch): How many branches of code (for example, statements) are covered by the test .- Statement Coverage (Stmts): How many code statements are covered by the tests.
Unit test coverage is an important indicator of unit test quality, but it is not the only indicator. High coverage can help detect errors in code, but it does not guarantee code quality. Low coverage may mean that there is untested code and possibly undiscovered bugs.
The following figure shows the unit test coverage results of the demo project:
For files like service and controller, it is generally better to have unit test coverage as high as possible. For files like module, there is no need to write unit tests, and it is impossible to write them, which is meaningless. The above picture represents the overall indicator of the entire unit test coverage. If you want to view the test coverage of a certain function, you can open coverage/lcov-report/index.html
the file in the project root directory to view it. For example, I want to view validateUser
the specific test status of the method:
It can be seen that validateUser
the unit test coverage of the original method is not 100%, and there are still two lines of code that have not been executed, but it does not matter, it does not affect the four key processing nodes, and do not pursue high test coverage one-sidedly.
Writing E2E tests
In the unit test, we show how to validateUser()
write unit tests for each function point of , and use the mock data method to ensure that each function point can be tested. In e2e testing, we need to simulate real user scenarios, so we need to connect to the database for testing. Therefore, the methods in the module tested this time auth.service.ts
will interact with the database.
auth
The module mainly has the following functions:
- register
- Log in
- Refresh token
- Read user information
- change Password
- delete users
e2e testing needs to test all six functions from 注册
start to 删除用户
finish. During testing, we can create a special test user for testing, and then delete this test user after the test is completed, so that useless information will not be left in the test database.
beforeAll ( async () => { const moduleFixture : TestingModule = await Test . createTestingModule ({ imports : [ AppModule ], }). compile () app = moduleFixture. createNestApplication () await app. init () //Perform login to get token const response = await request ( app.getHttpServer ()) . post ( '/auth/register' ) . send ({ username : TEST_USER_NAME , password : TEST_USER_PASSWORD }) .expect ( 201 ) accessToken = response. body . access_token refreshToken = response. body . refresh_token }) afterAll ( async () => { await request (app. getHttpServer ()) . delete ( '/auth/delete-user' ) . set ( 'Authorization' , `Bearer ${accessToken} ` ) .expect ( 200 ) await app. close () })
beforeAll
The hook function will be executed before all tests start, so we can register a test account here TEST_USER_NAME
. afterAll
The hook function will be executed after all tests are completed, so TEST_USER_NAME
it is more appropriate to delete the test account here, and you can also test the registration and deletion functions.
In the unit testing in the previous section, we wrote validateUser
relevant unit tests for the method. In fact, this method is executed when logging in and is used to verify whether the user account password is correct. So this time the e2e test will also use the login process to show how to write e2e test cases.
The entire login test process contains a total of five small tests:
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 () => { // ... }) })
The five tests are:
- Login successful, return 200
- If the user does not exist, throw a 401 exception
- If no password or username is provided, a 400 exception is thrown
- Log in with wrong password, throw 401 exception
- If the account is locked, a 401 exception is thrown
Now we start writing e2e tests:
// Login successful it ( '/auth/login (POST)' , () => { return request (app. getHttpServer ()) . post ( '/auth/login' ) . send ({ username : TEST_USER_NAME , password : TEST_USER_PASSWORD }) .expect ( 200 ) }) // If the user does not exist, a 401 exception should be thrown it ( '/auth/login (POST) with user not found' , () => { return request (app. getHttpServer ()) . post ( '/auth/login' ) . send ({ username : TEST_USER_NAME2 , password : TEST_USER_PASSWORD }) . expect ( 401 ) // Expect an unauthorized error })
The e2e test code is relatively simple to write, just call the interface directly and then verify the results. For example, for a successful login test, we only need to verify whether the return result is 200.
The first four tests are relatively simple. Now let’s look at a slightly more complex e2e test, which is to verify whether the account is locked.
it ( '/auth/login (POST) account lock after multiple failed attempts' , async () => { const moduleFixture : TestingModule = await Test . createTestingModule ({ imports : [ AppModule ], }). compile () const app = moduleFixture. createNestApplication () await app. init () const registerResponse = await request (app. getHttpServer ()) . post ( '/auth/register' ) . send ({ username : TEST_USER_NAME2 , password : TEST_USER_PASSWORD }) const accessToken = registerResponse. body . access_token const maxLoginAttempts = 3 // lock user when the third try is failed for ( let i = 0 ; i < maxLoginAttempts; i++) { await request (app. getHttpServer ()) . post ( '/auth/login' ) . send ({ username : TEST_USER_NAME2 , password : 'InvalidPassword' }) } // The account is locked after the third failed login attempt await request (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.' , ) }) await request (app. getHttpServer ()) . delete ( '/auth/delete-user' ) . set ( 'Authorization' , `Bearer ${accessToken} ` ) await app. close () })
When a user fails to log in three times in a row, the account will be locked . So in this test, we cannot use the test account TEST_USER_NAME
, because if the test is successful, the account will be locked and cannot continue with the following tests. We need to register a new user TEST_USER_NAME2
specifically to test account locking, and then delete this user after the test is successful. So you can see that this e2e test has a lot of code and requires a lot of pre- and post-processing work. In fact, the real test code only has these lines:
// Log in three times in a row for ( let i = 0 ; i < maxLoginAttempts; i++) { await request (app. getHttpServer ()) . post ( '/auth/login' ) . send ({ username : TEST_USER_NAME2 , password : 'InvalidPassword' }) } // Test whether the account is locked await request (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.' , ) })
It can be seen that writing e2e test code is relatively simple. There is no need to consider mock data or test coverage, as long as the operation of the entire system process meets expectations.
Should you write tests?
If possible, I recommend that you write tests. Because writing tests can improve the robustness, maintainability and development efficiency of the system.
Improve system robustness
When we generally write code, we focus on the program flow under normal input to ensure that the core functions operate normally. But there are some edge cases, such as abnormal input, which we may often ignore. But when we start writing tests, the situation is different, which forces you to think about how to handle and provide corresponding feedback to avoid program crashes. It can be said that writing tests actually indirectly improves system robustness.
Improve maintainability
When you take on a new project, it will be a happy thing if the project contains complete tests. They are like a guide to the project, helping you quickly grasp each functional point. You can easily understand the expected behavior and boundary conditions of each function by just looking at the test code, without having to go through the code of each function line by line.
Improve development efficiency
Imagine that a project that has not been updated for a long time suddenly receives new requirements. After changing the code, you may worry about introducing bugs. If there are no tests, you need to manually test the entire project again – a waste of time and inefficiency. With complete testing, a single command can tell whether code changes affect existing functionality. Even if something goes wrong, you can quickly locate and find the problem.
When is it not recommended to write tests?
It is not recommended to write tests for short-term projects or projects with very fast demand iterations . For example, some activity projects are useless after the activity is over. Such projects do not need to write tests. In addition, don’t write tests for projects that require very fast iterations. I just said that writing tests can improve development efficiency with a prerequisite. That is, when the function iteration is relatively slow, writing tests can improve development efficiency . If your function was just written today, and the requirements change after a day or two and the function needs to be changed, then the relevant test code will have to be rewritten. So just stop writing it and rely on the testers in the team to test it, because writing tests is very time-consuming, so there is no need to ask for trouble.
According to my experience, there is no need to write tests for the vast majority of domestic projects (especially government and enterprise projects) because the requirements iterate too fast and the previous requirements are always overturned, and the code has to be written overtime. Write tests when you have free time.
Summarize
After explaining in detail how to write unit tests and e2e tests for Nestjs projects, I still want to reiterate the importance of testing, which can improve the robustness, maintainability and development efficiency of the system. If you don’t have the opportunity to write tests, I suggest that you create a practice project to write on your own, or participate in some open source projects and contribute code to these projects, because open source projects generally have strict code requirements. Contributing code may require writing new test cases or modifying existing test cases.
Finally, I would like to recommend my other articles. If you are interested, you may wish to read them:
- Get you started with front-end engineering
- Implement a toy browser rendering engine from scratch
- Teach you step by step how to write a simple micro front-end framework
- Analysis of some technical points and principles of front-end monitoring SDK
- Analysis of some technical points and principles of the visual drag-and-drop component library
- Analysis of some technical points and principles of the visual drag-and-drop component library (2)
- Analysis of some technical points and principles of the visual drag-and-drop component library (3)
- Analysis of some technical points and principles of the visual drag-and-drop component library (4)
- Exploration and practice of low-code and large language models
- 24 suggestions for front-end performance optimization (2020)
- Teach you step by step how to write a scaffold
- Teach you step by step how to write a scaffold
- NestJS : A framework for building efficient, scalable Node.js server-side applications.
- MongoDB : A NoSQL database used for data storage.
- Jest : A testing framework for JavaScript and TypeScript.
- Supertest : A library for testing HTTP servers.