How to write unit tests and E2E tests for Nestjs

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 updatewhether 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.tsa 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:

  1. According to usernamecheck whether the user exists, if not, throw a 401 exception (it can also be a 404 exception)
  2. Check whether the user is locked. If locked, throw a 401 exception and related prompt text.
  3. Compare passwordthe 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)
  4. If the login is successful, the count records of previous login failures will be cleared (if any) and returned to the user idand usernamego to the next stage.

It can be seen that validateUserthe method contains 4 processing logics. We need to write corresponding unit test code for these 4 points to ensure that the entire validateUsermethod function is normal.

First test case

When we start writing unit tests, we will encounter a problem. findOneThe method needs to interact with the database. It needs to usernamefind 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 woai3cuser, then when the user logs in, the user data validateUsercan 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 validateUserthe 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 usersServicethe method, so we need to mock the method fineOnein the test code :usersServicefineOne

 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 validateUsermethod 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 });findOnevalidateUser

if (!entity) {
   throw  new  UnauthorizedException ( 'User not found' );
}

Throws a 401 error, as expected.

Second test case

validateUserThe 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 lockUntiland 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 lockUntiluser 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 findOnethe return value. This usersService.findOne.mockResolvedValueOnce(lockedUser);is achieved through . Then validateUserwhen 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 validateUserthe unit test coverage of this method will be 50%. If all 4 test cases are written, then validateUserthe 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/elseBranch 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:

Insert image description here

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.htmlthe file in the project root directory to view it. For example, I want to view validateUserthe specific test status of the method:

Insert image description here

It can be seen that validateUserthe 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.tswill interact with the database.

authThe 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 ()
})

beforeAllThe hook function will be executed before all tests start, so we can register a test account here TEST_USER_NAMEafterAllThe hook function will be executed after all tests are completed, so TEST_USER_NAMEit 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 validateUserrelevant 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:

  1. Login successful, return 200
  2. If the user does not exist, throw a 401 exception
  3. If no password or username is provided, a 400 exception is thrown
  4. Log in with wrong password, throw 401 exception
  5. 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_NAME2specifically 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:

Leave a Reply

Your email address will not be published. Required fields are marked *