Unit Testing

유닛 테스트 과정

Fake UsersService를 만들어서 실행할 계획이다. 정상적으로 애플리케이션을 실행하면 DI 안에 많은 종속성을 넣어야 한다. 우린 새롭게 테스트를 위한 DI를 만드는데, 내부에는 Userss Service의 모든 메서드를 실행하는 클래스를 담는다. 이로서 어떤 단위(ex. Authentication, sign in 등)을 테스트하는데 종속성 주입에서 자유로워질 수 있다.

Test 설정하기

users 디렉토리에 auth.service.spec.ts 파일을 만들자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// auth.service.spec.ts
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from './users.service';

it('can create an instance of auth service', async () => {
    // 새 DI container 생성
    // 하지만 AUthService를 위한 종속성을 제공하지 않았으니 실행하면 오류가 뜰것이다.
    constmudle = await Testing.createTestingModule({
        providers:[AuthService]
    }).compile();
    
    const service = module.get(AuthService);
    
    expect(service).toBeDefined();
});

터미널에서 npm run test:watch를 입력한다. 3개가 failed라고 뜰텐데, p를 누르고 auth.service.spec 을 입력한다. 그럼 이 파일만 테스트하여 1 failed라고 뜬다.

다시 말하지만, AuthService를 위한 종속성을 제공하지 않았으니 당연히 실패할 수 밖에 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// auth.service.spec.ts
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from './users.service';

it('can create an instance of auth service', async () => {
	// 추가한 코드
    // Create a fake copy of the users service 
    const fakeUsersService = {
      find: () => Promise.resolve([]),
        create: (email: string, password: string) => 
          Promise.resolve({ id: 1, email, password }) 
    };
    
    const moudle = await Testing.createTestingModule({
        providers:[
            AuthService,
        {
            provide: UsersService, // 이걸 제공할 때
            useValue: fakeUsersService // 이걸로 주세요. 라는 코드
        }
      ],
    }).compile();
    
    const service = module.get(AuthService);
    
    expect(service).toBeDefined();
});

다시 터미널에서 npm run test:watch를 실행하면 test가 통과하는 것을 확인할 수 있다. 코드에 대해 자세히 알아보자

테스트 속도를 올리려면, package.json 파일의 script로 가서

"test:watch": "jest --watch",

"test:watch": "jest --watch --maxWorkers=1",

로 바꿔주자

코드 이해하기

providers는 우리가 주입할 수 있는 다른 클래스들이다. DI가 클래스를 얻으면 클래스의 모든 종속성 인스턴스도 생성할 수 있다.

DI에서는 providers로 주어진 Auth Service를 통해 Users Service 까지 생성할 수 있다. 하지만 다음 코드로 인해 UsersService가 fakeUsersService로 전달된다. 이 클래스는 우리가 정의한대로 findcreate 메서드를 가지고 있다. 즉 여기서 Auth Service는 원래 우리가 알던 UsersService가 주입되는 것이 아니라, findcreate를 메서드로 가진 새로운 종속성(근데 UsersService인 척 하는)을 제공한다는 의미이다.

그렇다면 createfind 딱 두 메서드만 만든 이유는 무엇일까?

우리가 테스트하는 AuthService는 signup()signin() 을 메서드로 가진다. 이 둘의 로직을 잘 생각해보자. signup()은 email을 찾아서(find) 있으면 예외처리를 하고, 없으면 만들어(create)준다. signin()은 찾아서(find) 로그인한다. 따라서 다른 메서드들은 생성해도 호출될 일이 없다.

현재 useValue로 주어진 fakeUsersService 대신 아무거나(ex. 123)을 넣어도 typescript는 오류를 잡아내지 못한다. typescript가 우리가 만든 create, find 메서드가 제대로 만들어졌는지 확인하도록 코드를 수정해보자.

const fakeUsersService = { 부분을 const fakeUsersService: Partial<UsersService> = { 로 바꿔주자. 이로서 UsersService에 다르게 User entity로 반환하지 않으면 오류가 감지된다. 따라서 받는 부분도 as User 로 해주고, entity에서 User도 import하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// auth.service.spec.ts
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from './users.service';
import { User } from './user.entity';

it('can create an instance of auth service', async () => {
    // Create a fake copy of the users service 
    const fakeUsersService: Partial<UsersService> = {
      find: () => Promise.resolve([]),
        create: (email: string, password: string) => 
          Promise.resolve({ id: 1, email, password } as User), 
    };
    
    const moudle = await Testing.createTestingModule({
        providers:[
            AuthService,
        {
            provide: UsersService,
            useValue: fakeUsersService
        }
      ],
    }).compile();
    
    const service = module.get(AuthService);
    
    expect(service).toBeDefined();
});

구조 개선하기

파일 구조를 체계적으로 바꿔보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//auth.service.spec.ts
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { UsersService } from './users.service';
import { User } from './user.entity';

describe('AuthService', () => {
    let service: AuthService;

beforeEach(async () => {
   // Create a fake copy of the users service 
    const fakeUsersService: Partial<UsersService> = {
      find: () => Promise.resolve([]),
        create: (email: string, password: string) => 
          Promise.resolve({ id: 1, email, password } as User), 
    };
    
    const moudle = await Testing.createTestingModule({
        providers:[
            AuthService,
        {
            provide: UsersService,
            useValue: fakeUsersService
        }
      ],
    }).compile();
    
    service = module.get(AuthService); 
});
})

순차적으로 fakeService를 만들고 authservice를 테스트하고,이 전체 코드가 하나의 테스트이므로 묶어준 것이다.

계정 생성 테스트

계정 생성이 제대로 되는지 확인하는 단위 테스트를 만들자.

1
2
3
4
5
6
7
8
9
10
// auth.service.spec.ts
it('creates a new user with a salted and hashed password', async () => {
    const user = await service.signup('asdf@asdf.com', 'asdf');
    
    expect(user.apssword).not.toEqual(asdf);
    const [salt, hash] = user.password.split('.');
    expect(salt).toBeDefined();
    expect(hash).toBeDefined();
});

테스트를 실행해 확인해보자.

중복 이메일 테스트

위에서 fakeUsersService를 만들것이다. 하지만 문제가 있다. 바로 위 계정 생성 테스트에서, fakeUsersService 는 find 메서드를 사용한다. 이 때, email로 user를 탐색해서 없어야 한다.

위 계정 생성 테스트는 회원가입을 테스트하는 것이고, 회원 가입이 작동해야 한다. 그렇기 위해선 email로 user를 탐색했을 때 빈 배열이 반환되야 한다. emial은 고유값이기 때문에 이미 존재하는 email이면 강비할 수 없기 때문이다. 이 테스트는 find 메서드의 결과로 빈 배열이 나오길 원하는 테스트이다.

반면 이번 중복 이메일 테스트는 중복되는 이메일을 걸러낼 수 있는지 찾아내는 테스트이다. 따라서 find 메서드의 결과로 최소한 길이가 1인(길이가 존재하는) 배열이 나오길 원하는 테스트이다. 중복된 경우를 설정하고 테스트하는데 빈 배열이 나온다면, 중복된 값을 못찾아낸다는 의미이므로 테스트가 실패하기 때문이다.

즉 이 fakeUsersService는 같은 메서드를 사용해야 하는데, 정 반대의 결과를 바라고 있다. 다음과 같이 해결하자

1
2
3
4
5
6
7
8
9
// auth.service.spec.ts
...
describe('AuthService', () => {
    let service: AuthService;
    let fakeUsersService: Partial<UsersService>;
    
    beforeEach(async () => {
        fakeUsersService = {
            ...

Partial 덕분에 완전히 일치하지는 않아도 되도록 바꿔주었다.

1
2
3
4
5
6
// auth.service.spec.ts
it('throws an error if user signs up with email that is in used', async () => {
    fakeUsersService.find = () => Promise.resolve([{ id: 1, eamil: 'a', password: '1'} as User])
 	await expect(service.signup('asdf@asdf.com', 'asdf')).rejects.toThrow(BadRequestException,
  );
});