테스트의 종류

테스트에는 많은 종류가 있다.

  • 유닛 테스트(Unit Testing) : 컴포넌트, 함수 단위의 작은 코드를 테스트
  • 엔드 투 엔드 테스트(End to End Testing) : 사용자 관점에서 취할만한 행동에 따라 각각의 서비스가 정해진 대로 동작하는지 테스트
  • 부하 테스트(Load Testing), 스트레스 테스트(Stress Testing) : 얼마나 많은 동시 접속자를 처리할 수 있는지 테스트 (서버 관점에서의 테스트)

Nest.js에서는 기본적으로 E2E(End To End) 테스트 파일을 만들어주고, 컨트롤러, 서비스 각각을 제너레이터를 사용하여 생성하면 유닛 테스트 파일도 자동으로 만들어준다.

E2E 테스트는 test 폴더의 app.e2e-spec.ts에 있고, 유닛 테스트는 src 폴더 내부에 .spec.ts의 파일로 사용한다.

유닛 테스트

기본적으로 describe it expect만 사용해서도 테스트할 수 있고, 원활한 사용을 위해서는 beforeEach beforeAll afterEach afterAll도 같이 쓴다.

테스트 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe('MoviesService', () => {
let service: MoviesService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [MoviesService],
}).compile();

service = module.get<MoviesService>(MoviesService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

beforeEach에 대해서는 테스트 구조 설명 후 알아보겠다.

먼저 테스트할 서비스를 불러오고, 서비스가 정의된 객체인지 판별한다. 테스트에 통과했다면 이제 서비스에 구현한 각각의 함수(Unit)에 대해 테스트를 수행하면 된다. describe 내부에 describe를 써서 상위, 하위 개념으로 작성한다.

1
2
3
4
5
6
describe('getAll', () => {
it('should be return an array', () => {
const result = service.getAll();
expect(result).toBeInstanceOf(Array);
});
});

getAll 메소드는 배열 타입을 반환해야 하는데, 이를 테스트한 것이다. expect는 말그대로 “기대”이다. expect(a).toEqual(b)라면 “a가 b와 같기를 기대해” 라는 의미이고, toEqual, toBeDefined, toBeGreaterThanexpect에서 사용할 수 있는 다양한 메소드를 활용해서 테스트를 진행한다.

하나 더 예를 들면, 서비스가 404(Not Found)를 뱉어주기를 원한다면 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
it('should return a 404', () => {
try {
service.deleteOne(999);
} catch (e) {
expect(e).toBeInstanceOf(NotFoundException);
}
});

물론 서비스에서도 핸들링을 통해 NotFoundException을 뱉어야 한다. 이런 저명(?)한 예외 처리는 @nestjs/common에 왠만하면 다 들어가있어서 별도로 에러 객체를 구현하지 않아도 된다.

beforeEach, beforeAll, afterEach, afterAll

describe 내부에 넣는 하나의 메소드인데, 말 그대로 테스트 전/후로 각각 테스트/전체 테스트에 메소드를 실행하는 것이다.

예를 들어 beforeEachbeforeAll은 테스트할 서비스 객체를 가져오거나 DB 연결을 초기화하는 등의 작업을 수행할 수 있고, afterEachafterAll로는 테스트 후 DB를 정리하는 등의 작업을 할 수 있다. 대부분의 테스트는 DB와 함께 동작할텐데, 이 때 필요한 동작을 번거로움없이 해결 할 수 있다.

테스트 수행

1
2
$ yarn test # npm run test
$ yarn test:watch # npm run test:watch

기본적으로 test 명령어로 테스트를 실행하고, :watch를 사용하면 파일이 저장되어 바뀔 때마다 새롭게 테스트를 실행할 수 있다.

E2E 테스트

E2E 테스트도 유닛 테스트와 구조는 다를 게 없다. 테스트 하는 대상의 범위만 다를 뿐이다. 유닛 테스트가 톱니바퀴에 나사가 잘 끼워졌는지, 윤활은 잘 되어있는지 등을 검사한다면 E2E 테스트는 톱니바퀴가 잘 돌아가는 그림 정도를 본다.

테스트 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe('AppController (e2e)', () => {
let app: INestApplication;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
// 실제 app의 pipe를 test에도 별도로 적용해야 데코레이터가 적용됨
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
});

it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(200).expect('Welcome to API');
});

Nest가 자동으로 E2E 테스트의 틀은 짜준다. 여기서 채워넣으면 되는데, 주의할 점이 있다. 테스트 전에 실행하는 메소드인 beforeAll 내부를 보면 app을 다시 생성한다. 따라서 main.ts에서 별도로 app에 global pipe를 추가했거나 다른 파이프를 연결한 경우 필요에 따라 다시 app에 미들웨어를 끼워줘야 한다. 이러지 않으면 실제 구동할 때와 테스트할 때 app의 속성이 달라서 당황할 수도 있다.

GET 테스트

/에 GET 요청이 들어왔을 때의 E2E 테스트를 보자.

1
2
3
it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(200).expect('Welcome to movie API');
});

beforeAll에서 초기화한 app을 사용하기 위해 request() 메소드를 사용한다. request(app.getHttpServer())를 통해 GET, POST 등의 요청을 보낼 수 있다. 그 후 똑같이 expect를 테스트 목적에 맞게 이어주면 된다.

POST 테스트

POST 요청에 대한 테스트로 하나 더 예를 들자면,

1
2
3
4
5
6
7
8
9
10
it('POST 201', () => {
return request(app.getHttpServer())
.post('/movies')
.send({
title: 'Test',
year: 2020,
genres: ['test'],
})
.expect(201);
});

GET과 방식은 유사한데, send 메소드를 통해 Body에 내용을 실어서 보낸다. 그리고 정상적으로 POST되었다는 응답인 201 상태를 expect에 걸어주면 된다.

1
2
3
4
5
6
7
8
9
10
11
it('POST 400', () => {
return request(app.getHttpServer())
.post('/movies')
.send({
title: 'Test',
year: 2020,
genres: ['test'],
other: 'thing',
})
.expect(400);
});

Validator에 대한 검증을 위해 성공하면 안되는 경우를 테스트할 때는 위처럼 400 상태를 expect에 걸어주면 된다.

uri에 대한 테스트 묶기

그리고 일반적으로 한 uri에 포함된 GET, POST, DELETE 등의 테스트들은 한 개의 describe에 묶어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
describe('/movies/:id', () => {
it('GET 200', () => {
return request(app.getHttpServer()).get('/movies/1').expect(200);
});
it('GET 404', () => {
return request(app.getHttpServer()).get('/movies/999').expect(404);
});
it('PATCH 200', () => {
return request(app.getHttpServer())
.patch('/movies/1')
.send({ title: 'Updated Test' })
.expect(200);
});
it('DELETE 200', () => {
return request(app.getHttpServer()).delete('/movies/1').expect(200);
});
it('DELETE 404', () => {
return request(app.getHttpServer()).delete('/movies/999').expect(404);
});
});

위 같은 방식으로 묶어주면 테스트 화면에서

이렇게 가독성 좋은 결과를 출력할 수 있다.

테스트 수행

1
$ yarn test:e2e # npm run test:e2e

말 그대로 test:e2e를 붙여주면 E2E 테스트를 수행할 수 있다.

마치며

테스트 커버리지 보기

테스트에 대한 커버리지를 보고 싶다면 다음과 같이 입력하자.

1
$ yarn test:cov # npm run test:cov

정상적으로 테스트를 모든 유닛에 대해 적용했다면 100이 나온다.

테스트의 중요성

Nest에서 기본적으로 제공하는 Jest를 활용한 테스트를 알아보았다. 실제 실무에서는 비즈니스 로직에 대한 코드 작성보다 테스트를 위한 코드 작성에 시간을 더 할애하는 경우도 많다고 한다. 그만큼 테스트가 중요하다는 말이다.