DEV Community

Henrique Weiand
Henrique Weiand

Posted on • Edited on

Applying Unit Tests on NestJS with Jest and GitHub Actions

Hello fellow coders! In this post we are still going to talk about tests, but, instead of e2e we are going to implement unit tests, I’d say they are kind of siblings, however, each one does different things, and they have a taste in common, but they deliver value on their way. Ok! I will stop playing with the concept and focus on the details.

I am not going to rewrite the thousands of definitions that we can find on the internet, basically, unit tests will help your project to guarantee the consistency of some parts, and methods. As the proper name says, it is a unit, something simple and important for the whole process at the same time. To have a better vision of unit tests, we can compare them to e2e, where we are running the whole nestjs application basically, with unit tests we are going to run a module, a service, and pieces of the system, right?

Some references
bliki: UnitTest
Definition of a Unit Test - The Art of Unit Testing

The project

We are going to use a base project that was built previously on our last post about e2e, just because I don’t want to spend more time setting up etc.
GitHub - nestjsninja/nestjs-auth-flow-blog-post-with-e2e

Setting up

As the same we did for e2e tests, we have just a few details to do in order to make sure the unit tests will work well. First, let’s create another jest configuration file, it will be called jest-unit.json

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": "./src/",
  "testEnvironment": "node",
  "testRegex": ".spec-unit.ts$",
  "transform": {
    "^.+\\\\.(t|j)s$": ["@swc/jest"]
  }
}
Enter fullscreen mode Exit fullscreen mode

I’m creating this configuration because I am applying different criteria to recognize the unit files to e2e.

"testRegex": ".spec-unit.ts$",
Enter fullscreen mode Exit fullscreen mode

In our package.json I added one more command

"test:unit": "jest --config ./jest-unit.json",
Enter fullscreen mode Exit fullscreen mode

That’s all 😎

Creating first unit test

In our example, we are going to do unit tests for our two services

  • auth.service.ts

  • user.service.ts

It doesn’t mean that you can apply unit tests for something else, OK? You need to be aware of what you expect to guarantee the logic or something and then I’d suggest you apply tests on them.

Applying unit tests into the auth.service

The first unit test that we are going to implement will be inside the auth.service, and the first step that we need to do is to create a new test file I will do it just beside the auth.service.ts file with the name auth.service.spec-unit.ts. I will post the whole file ready and then I will break it down little by little. OK?

💡 Remember spec-unit.ts is an important part of the name because the jest will only run unit tests for those files that have this in their names.

import { TestingModule, Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import { UnauthorizedException } from '@nestjs/common';

describe('AuthService', () => {
    let authService: AuthService;
    let usersServiceMock: UsersService;
    let jwtServiceMock: JwtService;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                AuthService,
                {
                    provide: UsersService,
                    useValue: {
                        validateCredentials: jest.fn(),
                        create: jest.fn(),
                    },
                },
                {
                    provide: JwtService,
                    useValue: {
                        signAsync: jest.fn(),
                    },
                },
            ],
        }).compile();

        authService = module.get<AuthService>(AuthService);
        usersServiceMock = module.get<UsersService>(UsersService);
        jwtServiceMock = module.get<JwtService>(JwtService);
    });

    it('should sign in a user and return an access token', async () => {
        const user = {
            id: 1,
            username: 'testuser',
            password: 'password',
        };

        usersServiceMock.validateCredentials.mockResolvedValueOnce(user);
        jwtServiceMock.signAsync.mockResolvedValueOnce('token');

        const response = await authService.signIn('testuser', 'password');

        expect(response).toEqual({ access_token: 'token' });
    });

    it('should throw an UnauthorizedException if the user cannot be signed in', async () => {
        usersServiceMock.validateCredentials.mockResolvedValueOnce(null);

        await expect(
            authService.signIn('testuser', 'password'),
        ).rejects.toThrowError(UnauthorizedException);
    });

    it('should sign up a user and return the user', async () => {
        const user = {
            id: 1,
            username: 'testuser',
            password: 'password',
        };

        usersServiceMock.create.mockResolvedValueOnce(user);

        const response = await authService.signUp('testuser', 'password');

        expect(response).toEqual(user);
        expect(response.password).toBeUndefined();
    });

    it('should throw an InternalServerErrorException if the user cannot be signed up', async () => {
        usersServiceMock.create.mockRejectedValueOnce(new Error());

        await expect(
            authService.signUp('testuser', 'password'),
        ).rejects.toThrowError(Error);
    });
});
Enter fullscreen mode Exit fullscreen mode

To give an overview of this test, let’s start looking at the beginning because this unit test was almost the same that we did with e2e, I mean, both tests have the beforeAll part, where we’re declaring the vars that are going to be used and the test module indeed.

let authService: AuthService;
let usersServiceMock: UsersService;
let jwtServiceMock: JwtService;

beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
        providers: [
            AuthService,
            {
                provide: UsersService,
                useValue: {
                    validateCredentials: jest.fn(),
                    create: jest.fn(),
                },
            },
            {
                provide: JwtService,
                useValue: {
                    signAsync: jest.fn(),
                },
            },
        ],
    }).compile();

    authService = module.get<AuthService>(AuthService);
    usersServiceMock = module.get<UsersService>(UsersService);
    jwtServiceMock = module.get<JwtService>(JwtService);
});
Enter fullscreen mode Exit fullscreen mode

IMPORTANT: We are testing the main methods of the auth.service, which means, we don’t need to task the behaviors of the libraries that it is using. For those libraries, we can mock (it was what I did) and force a return as it was done well or not, but remember, you have to focus on your unit test. These lines create a mock of UserService and JWTService.

{
    provide: UsersService,
    useValue: {
        validateCredentials: jest.fn(),
        create: jest.fn(),
    },
},
{
    provide: JwtService,
    useValue: {
        signAsync: jest.fn(),
    },
},
Enter fullscreen mode Exit fullscreen mode

Once you have a mock of the function, you can take advantage of it and force a response on the defined methods. It is also important to know that they are dependencies for the service that we are applying our unit test, which makes us put them inside our providers.

First test

it('should sign in a user and return an access token', async () => {
    const user = {
        id: 1,
        username: 'testuser',
        password: 'password',
    };

    usersServiceMock.validateCredentials.mockResolvedValueOnce(user);
    jwtServiceMock.signAsync.mockResolvedValueOnce('token');

    const response = await authService.signIn('testuser', 'password');

    expect(response).toEqual({ access_token: 'token' });
});
Enter fullscreen mode Exit fullscreen mode

This is a simple test, and I’d say that even though we have two mocks inside this test, which is almost everything for this function, I still think that this is one valid test because we are expecting to have always the same outcome in order to not break possible functions that are going to use this method

expect(response).toEqual({ access_token: 'token' });
Enter fullscreen mode Exit fullscreen mode

Let’s see one more from this service.

it('should sign up a user and return the user', async () => {
    const user = {
        id: 1,
        username: 'testuser',
        password: 'password',
    };

    usersServiceMock.create.mockResolvedValueOnce(user);

    const response = await authService.signUp('testuser', 'password');

    expect(response).toEqual(user);
    expect(response.password).toBeUndefined();
});
Enter fullscreen mode Exit fullscreen mode

One of the values from this unit test, for example, is to guarantee that we are not passing the user’s password outside of the response of the signUp method, besides having the other information.

Unit tests for users.service

Create a file called users.service.spec-unit.ts close to the users.service indeed and there we are going to have something similar to the previous test.

import { UsersService } from './users.service';
import { Users } from './users.entity';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { HttpException } from '@nestjs/common';

export class userRepositoryMock {
    findOne = jest.fn();
    create = jest.fn();
    save = jest.fn();
}

describe('UsersService', () => {
    let usersService: UsersService;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UsersService,
                {
                    provide: getRepositoryToken(Users),
                    useClass: userRepositoryMock,
                },
            ],
        }).compile();

        usersService = module.get<UsersService>(UsersService);
    });

    describe('findOneByUsername', () => {
        it('should return a user if found', async () => {
            const user = new Users();
            user.username = 'testuser';
            user.password = 'password';
            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                user,
            );
            const foundUser = await usersService.findOneByUsername('testuser');
            expect(foundUser).toEqual(user);
        });

        it('should return undefined if user not found', async () => {
            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                undefined,
            );
            const foundUser = await usersService.findOneByUsername('testuser');
            expect(foundUser).toBeUndefined();
        });
    });

    describe('validateCredentials', () => {
        it('should return a user if credentials are valid', async () => {
            const user = new Users();
            user.username = 'testuser';
            user.password = 'password';

            jest.spyOn(usersService, 'comparePasswords');

            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                user,
            );
            (usersService as any).comparePasswords.mockResolvedValueOnce(true);

            const validatedUser = await usersService.validateCredentials({
                username: 'testuser',
                password: 'password',
            });
            expect(validatedUser).toEqual(user);
        });

        it('should throw a 401 error if user not found', async () => {
            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                undefined,
            );
            await expect(
                usersService.validateCredentials({
                    username: 'testuser',
                    password: 'password',
                }),
            ).rejects.toThrowError(HttpException);
        });

        it('should throw a 401 error if password is invalid', async () => {
            const user = new Users();
            user.username = 'testuser';
            user.password = 'password';
            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                user,
            );
            await expect(
                usersService.validateCredentials({
                    username: 'testuser',
                    password: 'incorrect-password',
                }),
            ).rejects.toThrowError(HttpException);
        });
    });

    describe('create', () => {
        it('should create a new user if username is not already in use', async () => {
            const user = new Users();
            user.username = 'testuser';
            user.password = 'password';
            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                undefined,
            );
            (usersService as any).userRepository.create.mockReturnValue(user);
            const createdUser = await usersService.create({
                username: 'testuser',
                password: 'password',
            });
            expect(createdUser).toEqual(user);
        });

        it('should throw a 400 error if username is already in use', async () => {
            const user = new Users();
            user.username = 'testuser';
            user.password = 'password';
            (usersService as any).userRepository.findOne.mockResolvedValueOnce(
                user,
            );
            await expect(
                usersService.create({
                    username: 'testuser',
                    password: 'password',
                }),
            ).rejects.toThrowError(HttpException);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

This time we are testing only the methods from user.service, so we can get one example

describe('findOneByUsername', () => {
    it('should return a user if found', async () => {
        const user = new Users();
        user.username = 'testuser';
        user.password = 'password';
        (usersService as any).userRepository.findOne.mockResolvedValueOnce(
            user,
        );
        const foundUser = await usersService.findOneByUsername('testuser');
        expect(foundUser).toEqual(user);
    });

    it('should return undefined if user not found', async () => {
        (usersService as any).userRepository.findOne.mockResolvedValueOnce(
            undefined,
        );
        const foundUser = await usersService.findOneByUsername('testuser');
        expect(foundUser).toBeUndefined();
    });
});
Enter fullscreen mode Exit fullscreen mode

Important to remember that we don’t want to test the repository functionality, and that’s why we are mocking the repository functions that are used inside of the service.

This part of the test is making sure that any service or place that uses the findOneByUsername method from user.service, can return nothing or a user, which helps the project to have a guarantee of the function’s behavior for the future. And yes, we have to think a lot about the future team, and everything that we implement today to avoid problems in the future. It looks like unnecessary, but it is necessary.

Two more interesting tests

it('should throw a 401 error if user not found', async () => {
      (usersService as any).userRepository.findOne.mockResolvedValueOnce(
          undefined,
      );
      await expect(
          usersService.validateCredentials({
              username: 'testuser',
              password: 'password',
          }),
      ).rejects.toThrowError(HttpException);
  });

  it('should throw a 401 error if password is invalid', async () => {
      const user = new Users();
      user.username = 'testuser';
      user.password = 'password';
      (usersService as any).userRepository.findOne.mockResolvedValueOnce(
          user,
      );
      await expect(
          usersService.validateCredentials({
              username: 'testuser',
              password: 'incorrect-password',
          }),
      ).rejects.toThrowError(HttpException);
  });
Enter fullscreen mode Exit fullscreen mode

This time we are validating the function’s behavior when something does not go well, like the wrong password or when the user is not found by using the usersService.validateCredentials method.

Adding tests on the GitHub Actions pipeline

As easy as it was on the e2e case, let’s open the cy.yml and edit it

name: Run integration testing

    on:
      push:
        branches: [main]
      pull_request:
        branches: [main]

    env:
      NODE_VERSION: 19.0.1

    jobs:
      unit-test:
        runs-on: ubuntu-latest

        steps:
        - name: Checkout repository
          uses: actions/checkout@v2

        - name: Use Node.js ${{ env.NODE_VERSION }}
          uses: actions/setup-node@v3
          with:
            node-version: ${{ env.NODE_VERSION }}

        - name: Install Dependencies
          run: yarn install

        - name: Unit testing
          run: |
            yarn test:unit
Enter fullscreen mode Exit fullscreen mode

What I changed here was, that I removed the Postgres service and changed the command that is going to run on the pipeline to execute the tests.

Conclusion

You can get the entire code from this repo
GitHub - nestjsninja/nestjs-auth-flow-blog-post-with-unit-tests

Ok, fellow coders, we finished one more post about tests, I hope you can get something from it, and I strongly recommend you if you don’t have any tests, to think a bit more about implementing, because as I mentioned before, maybe it can save some hours of debugging the software in the future because you simply changed the behavior of one method that was being referenced from other scripts.

Top comments (0)