Lsiron

Nest.js에서 E2E 테스팅 해보기 본문

백엔드/Nest.js

Nest.js에서 E2E 테스팅 해보기

Lsiron 2024. 8. 30. 04:40

movies.controller.ts

import { Controller, Get, Post, Delete, Patch, Param, Body } from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';
import { UpdateMovieDto } from './dto/update-movie.dto';

@Controller('movies')
export class MoviesController {
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[] {
        return this.moviesService.getAll();
    }

    @Get('/:id')
    getOne(@Param('id') movieId: number): Movie {
        console.log(typeof movieId)
        return this.moviesService.getOne(movieId);
    }

    @Post()
    create(@Body() movieData: CreateMovieDto) {
        return this.moviesService.create(movieData);
    }

    @Delete('/:id')
    remove(@Param('id') movieId: number) {
        return this.moviesService.delete(movieId);
    }

    @Patch('/:id')
    patch(@Param('id') movieId: number, @Body() updateData: UpdateMovieDto) {
        return this.moviesService.update(movieId, updateData)
    }
}

movie.controller.ts ( 루트 파일 )

import { Controller, Get } from '@nestjs/common';

@Controller('')
export class AppController {
    @Get()
    home() {
        return 'Welcome to my Movie API';
    }
}

 

기본 테스트 구성

 

테스트는 test 폴더 내의 app.e2e-spec.ts 파일에서 이루어진다.

 

app.e2e-spec.ts 파일은 E2E 테스트의 기본 구조를 제공한다.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

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

    app = moduleFixture.createNestApplication();
    await app.init();
  });

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

 

describe, beforeEach 등, 유닛 테스팅을 할때의 spec파일과 구성이 유사하다.

 

기본으로 작성 돼 있는 테스트 코드를 분석 해 보자.

 

이 테스트는 애플리케이션의 루트 경로('/')로 GET 요청을 보냈을 때, 응답으로 200 상태 코드와 "Welcome to my Movie API"라는 메시지를 기대하는 것이다.

 

이는 가장 기본적인 형태의 E2E 테스트로, 애플리케이션이 정상적으로 동작하는지 확인하는 첫 단계라 할 수 있다.

 

즉, url에 대한 요청을 테스팅하기 때문에, Controller, Service, Pipe의 결과에 대해 모든 테스트를 하고 있다는 뜻이기도 하다.

 

E2E 테스트 실행하기

E2E 테스트를 한번 시작 해 보자.

 

먼저 아래와 같이 명령어를 입력하여, E2E 테스트를 실행시켜보자.

$ npm run test:e2e

 

200응답과 'Welcome to my Movie API' 응답 조건이 Pass 된 것을 확인 할 수 있다.

 

GET 요청 테스트

이제 아래와 같이 코드를 작성하여, getAll() 함수 부터 테스트 해 보자. movies 엔드포인트에 GET 요청을 보내고, 빈 배열을 반환하는지 확인하는 테스트를 작성 해보자.

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
      .get('/movies')
      .expect(200)
      .expect([]);
    })
  })

 

200응답과 빈 배열의 반환 조건이 Pass 된 것을 확인 할 수 있다.

 

NestJS에서 E2E(End-to-End) 테스트를 작성할 때, 일반적으로 개발자들은 두 개의 데이터베이스를 사용한다.

 

하나는 애플리케이션이 실제로 작동할 때 사용하는 운영용 데이터베이스이고, 다른 하나는 테스트 전용 데이터베이스이다.

 

우리의 경우, 테스트 전용 데이터베이스를 사용하며, 이 데이터베이스는 테스트를 시작할 때마다 초기화되어 빈 상태에서 시작한다. 이를 통해 각 테스트가 독립적으로 실행되고, 이전 테스트의 데이터에 영향을 받지 않도록 보장할 수 있다.

 

운영용 데이터베이스는 실제 애플리케이션이 사용자와 상호작용할 때 사용하는 데이터베이스이다. 보안, 백업, 고가용성 등의 설정이 중요하며, 주로 클라우드 서비스와 연동되어 운영된다.

 

테스트용 데이터베이스는 애플리케이션의 테스트를 위해 별도로 구성한 데이터베이스이다. 이 데이터베이스는 주로 개발, 테스트 환경에서 사용되며, 각종 기능 테스트를 수행할 때만 사용된다.

 

테스트 중 데이터의 생성, 수정, 삭제가 빈번하게 일어나기 때문에, 운영 데이터베이스와 분리해서 사용해야 한다. 즉, 데이터베이스 초기화와 특정 상태로의 데이터 세팅이 용이하게 이루어져야 한다.

 

클라우드 연동은 보통 운영용 데이터베이스에만 적용하고, 테스트용 데이터베이스는 보통 로컬에서 실행되거나 CI/CD 파이프라인에서 사용할 수 있는 별도의 인스턴스로 관리하는 경우가 많다.

 

POST 요청 테스트

다음으로 create() 메서드를 테스트해보자. 새로운 영화를 생성하는 POST 요청을 보내고, 201 상태 코드가 반환되는지 확인하는 테스트를 작성 해보자.

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
      .get('/movies')
      .expect(200)
      .expect([]);
    })
    it('POST', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
        })
        .expect(201);
    });
  })

 

describe에 작성한 movies에서 GET과 POST 모두 테스트 했으며, 201응답 조건이 Pass 된 것을 확인 할 수 있다.

 

DELETE 요청 테스트

delete() 메서드를 테스트해보자. 특정 영화의 삭제 요청이 404 응답을 반환하는지 확인하는 코드를 작성 해보자.

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
      .get('/movies')
      .expect(200)
      .expect([]);
    })
    it('POST', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
        })
        .expect(201);
    });
    it('DELETE', () => {
      return request(app.getHttpServer())
        .delete('/movies')
        .expect(404);
    });
  })

 

'404 Not Found 또는 요청한 페이지를 찾을 수 없습니다.' 에러인 404응답을 반환하는 조건을 Pass 된 것을 확인 할 수 있다.

 

movies.controller.ts 파일에서 DELETE 요청의 엔드포인트는 '/movies/:id'로 정의되어 있다. 따라서 '/movies'로 요청을 보낼 경우, 해당 요청은 적절한 엔드포인트와 매칭되지 않아 처리되지 않는다.

 

ID 파라미터가 포함된 GET 요청 테스트

이제 getOne() 메서드를 테스트해보자. 영화의 ID를 포함한 요청을 보냈을 때, 정상적인 응답이 반환되는지 확인해보자.

 

이를 위해, beforeEach 대신 beforeAll을 사용하여 데이터베이스가 비어 있지 않도록 해주자. 이렇게 하면, create() 테스트를 통해 삽입된 데이터가 이후 테스트들에서도 유지된다.

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

    app = moduleFixture.createNestApplication();
    await app.init();
  });

 

이제 아래와 같이 코드를 작성하여 getOne() 을 테스트 해 보자.

  describe('/movies/:id', () => {
    it('GET 200', () => {
      return request(app.getHttpServer())
        .get('/movies/1')
        .expect(200);
    });

 

세상에 에러가 발생한다. 왜 그런 것일까? 왜 404에러가 나오는 것 일까? 

 

Transform 적용 문제 해결하기

이 경우는 main.ts 파일을 보면 정답이 나온다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({   
      whitelist: true, // 아무 decorator도 없는 object를 걸러준다.
      forbidNonWhitelisted:true, // 잘못된 데이터를 요청하면, request를 막아준다.
      transform: true,  // 요청으로 들어오는 데이터를 우리가 기대하는 DTO의 타입으로 자동 변환 해줌
    }),
  );
  await app.listen(3000);
}
bootstrap();

 

id를 URL 파라미터로 받아올 때, 기본적으로 문자열(string)로 처리되기 때문에, 이를 숫자(number)로 변환하는 로직이 필요하다.

 

이를 피하기 위해 나는 transform 옵션을 true로 설정하여, id를 자동으로 숫자 타입으로 변환하도록 설정했었다.

 

실제 애플리케이션에서는 이 설정이 잘 작동하지만, 테스트 파일에서는 transform이 적용되지 않아 404 오류가 발생할 수 있다.

 

그 이유는 테스트 파일에서 애플리케이션 인스턴스를 별도로 생성하기 때문에, 테스트 환경에서도 실제 애플리케이션과 동일한 설정을 적용해야 하기 때문이다.

 

이는 E2E 테스트나 유닛 테스트를 작성할 때 반드시 고려해야 할 중요한 점이다.

 

따라서 app.useGlobalPipes를 테스트 파일에서도 적용하여, 실제 애플리케이션과 동일한 환경을 만들어 보자.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

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

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({   
        whitelist: true, // 아무 decorator도 없는 object를 걸러준다.
        forbidNonWhitelisted:true, // 잘못된 데이터를 요청하면, request를 막아준다.
        transform: true,  // 요청으로 들어오는 데이터를 우리가 기대하는 DTO의 타입으로 자동 변환 해줌
      }),
    );
    await app.init();
  });

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

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
      .get('/movies')
      .expect(200)
      .expect([]);
    })
    it('POST', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
        })
        .expect(201);
    });
    it('DELETE', () => {
      return request(app.getHttpServer())
        .delete('/movies')
        .expect(404);
    });
  })

  describe('/movies/:id', () => {
    it('GET 200', () => {
      return request(app.getHttpServer())
        .get('/movies/1')
        .expect(200);
    });
  })
  
});

 

이제 다시 테스트를 해 보자.

 

테스트가 잘 작동하며 200응답을 받는 조건이 Pass 된 것을 확인 할 수 있다.

 

다음으로 터무니 없는 id값을 받았을 때, 정상적으로 404 응답을 주는지 테스트 해 보자.

 

200응답을 주는지 확인하는 테스트 코드 밑에 아래와 같이 코드를 입력 해 보자.

    it('GET 404', () => {
      return request(app.getHttpServer())
        .get('/movies/999')
        .expect(404);
    });

 

모두 성공적으로 테스트가 되는 것을 확인 할 수 있다.

 

PATCH와 DELETE 요청 테스트

이제 마지막으로 PATCH와 DELETE를 테스트 해보자.

 

특정 영화를 업데이트하고 삭제하는 요청이 올바르게 처리되는지 아래 코드를 작성하여 확인해보자.

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

 

업데이트 할 데이터는 title을 업데이트 해 주었고, 모두 200 응답을 반환하는 지 조건을 걸었다.

 

테스트가 모두 성공적으로 완료되었다.

 

이번 테스트를 통해 유저가 애플리케이션에서 이용할 수 있는 모든 기능을 E2E 테스트로 확인했다!

 

테스트 과정은 다음과 같다.

웹사이트 방문 -> 영화 목록 조회 -> 영화 생성 -> 영화 삭제 -> 특정 영화 조회 -> 영화 업데이트 -> 특정 영화 삭제.

app.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

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

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({   
        whitelist: true, // 아무 decorator도 없는 object를 걸러준다.
        forbidNonWhitelisted:true, // 잘못된 데이터를 요청하면, request를 막아준다.
        transform: true,  // 요청으로 들어오는 데이터를 우리가 기대하는 DTO의 타입으로 자동 변환 해줌
      }),
    );
    await app.init();
  });

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

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
      .get('/movies')
      .expect(200)
      .expect([]);
    })
    it('POST', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
        })
        .expect(201);
    });
    it('DELETE', () => {
      return request(app.getHttpServer())
        .delete('/movies')
        .expect(404);
    });
  })

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

 

참조: 노마드코더