[개발일지] 22년 37주차 - Unit test와 E2E test

FeRo 페로·2022년 9월 18일
0

노마드 nestJS 강의를 다 들었다. 마지막 챕터의 주제는 테스트 코드였다. 이전에 JEST로 프론트 쪽에서 아주 간단하게 테스트를 해본 적이 있는데 백엔드 쪽에서 테스트 코드를 작성한 적은 이번이 처음이었다. 오늘은 간단하게 나마 니꼬 선생님에게 배운 테스트 코드에 대해 정리해 볼까 한다.

준비는 이미 다 돼 있다

package.json을 열어서 확인해보면 이미 test와 관련된 명령어가 있다. 테스트는 spec.ts 파일을 기준으로 진행되는데, 이 spec.ts 파일 역시 모두 준비가 돼 있다. 이 말은 nest new를 입력하면 JEST와 함께 명령어와 테스트 파일도 다 준비가 된다는 것이다.

그래서 따로 무언갈 설정해 줄 필요도 없었다. 이번에 사용해 본 명령어는 test:watch와 test:cov이다.

test:watch

test:watch는 watch에서 유추할 수 있겠지만 테스트를 계속 follow 할 수 있다. 매번 새로 테스트 명령어를 입력할 필요 없이 파일이 save 되면 알아서 테스트를 새로 진행해준다. webpack에서 watch의 역할을 해준다고 생각하면 된다.

test:cov


test:cov의 cov는 coverage의 약자인데, spec.ts로 테스트하고 위 사진처럼 몇퍼센트나 테스트 완료되는지 보여준다.

하나 씩 살펴보는 Unit test

이번에 해 본 테스트는 unit test와 End-to-End test가 있다. 먼저 유닛 테스트를 살펴보자.

유닛 테스트는 함수 하나하나에 대한 test 코드를 작성하는 것이다. 영화 검색api를 통해 확인해보자.

// movie.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateMovieDto } from './dto/create-movie.dto';
import { UpdateMovieDto } from './dto/update-movie.dto';
import { Movie } from './entities/movie.entity';

@Injectable()
export class MoviesService {
  private movies: Movie[] = [];

  // 영화 하나를 조회
  getOne(id: number): Movie {
    const movie = this.movies.find((movie) => movie.id === id);
    if (!movie) {
      throw new NotFoundException(`Movie with ID ${id} not found.`);
    }
    return movie;
  }
}

// movie.service.spec.ts
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { MoviesService } from './movies.service';

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

  // getOne메소드에 대한 유닛 테스트 코드
  describe('getOne', () => {
    it('should be return movie', () => {
      // 해당 테스트 코드는 로컬 환경에서 진행한다. 
      // 따로 테스트DB 구축을 하지 않았기 때문에 getOne 메소드를 작동시키기 전에 
      // 영화 데이터 하나를 create해준다.
      service.create({
        title: 'Test Movie',
        genres: ['test'],
        year: 2022,
      });
      const movie = service.getOne(1); // getOne메소드를 통해 영화 데이터 하나를 가지고 온다.
      expect(movie).toBeDefined(); // undefined가 아니어야 하고,
      expect(movie.id).toEqual(1); // 조회된 영화의 id가 1이어야 한다.
    });
    // 예외처리
    it('should throw 404 error', () => {
      try {
        // 아예 없는 id를 조회하면 나오는 결과는 NotFoundExpection인스턴스를 반환해야 한다. 
        // 즉 에러가 잘 발생하는지 확인을 하는 것이다.
        service.getOne(999);
      } catch (err) {
        expect(err).toBeInstanceOf(NotFoundException);
        expect(err.message).toEqual('Movie with ID 999 not found.');
      }
    });
  });
});

코드를 보면 몇 가지 반복되는 형식들이 있다. describe와 it이다. describe를 통해서 테스트 할 그룹을 지정하고 그 안에서 it(indivisual test)으로 각각의 유닛에 대한 테스트 명칭을 정해준다.

그리고 함수를 실행했을 때 undefined가 아닌지(toBeDefined), 특정한 수치가 나오는지(toEqual), 여기엔 없지만 특정 수치보다 크고 작은지(toBeGreaterThan, toBeLessThan) 등 정량적으로 확인할 수 있는 여러 메소드를 이용해서 결괏값이 정확한 지 테스트할 수 있다.

이처럼 유닛 테스트는 함수 각각에 대한 테스트를 작성하고, 각 테스트 안에서 여러 서브 테스트를 진행할 수 있다.

서비스 흐름에 대한 E2E test

End-to-end(e2e) test는 유저 입장에서 해보는 테스트이다. 페이지 간의 이동을 테스트 하는데, 즉 서비스의 흐름을 테스트 한다고 볼 수 있다. e2e 테스트를 위해서는 test 디렉토리가 필요하다. 유닛 테스트에서의 spec.ts와 파일 구성은 거의 비슷하지만 superset 라이브러리 패키지가 들어있다는 부분이 좀 다르다.

아래는 이번에 만들어 본 영화 api에 대한 e2e 테스트 코드이다. e2e 테스트의 전체적인 흐름들은 유닛 테스트와 유사하다. 조금 다른 점이 있는데, 이것을 바탕으로 살펴보도록 하자.

// app.e2e-spec
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,
        forbidNonWhitelisted: true,
      }),
    );
    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 201', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          // data를 같이 보내야 하기 때문에~
          title: 'Test',
          year: 2020,
          genres: ['test'],
        })
        .expect(201);
    });
    it('POST 400', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2020,
          genres: ['test'],
          other: 'thing',
        })
        .expect(400);
    });
    it('DELETE', () => {
      return request(app.getHttpServer()).delete('/movies').expect(404);
    });
  });

  describe('/movies/:id', () => {
    it.todo('GET 200', () => {
      return request(app.getHttpServer()).get('/movies/1').expect(200);
    });
    it.todo('GET 404', () => {
      return request(app.getHttpServer()).get('/movies/1000').expect(404);
    });
    it.todo('PATCH', () => {
      return request(app.getHttpServer())
        .patch('/movies/1')
        .send({
          title: 'update teset',
        })
        .expect(200);
    });
    it.todo('DELETE', () => {
      return request(app.getHttpServer()).delete('/movies/1').expect(200);
    });
  });
});

superset에서 가지고 오는 request

앞서 superset을 import 한다고 했는데 그 이유가 바로 request 메소드를 사용하기 위함이다. e2e에서는 request메소드로 HTTP 테스트 시뮬레이션을 진행한다. http request가 우리가 실행 중인 nest 어플리케이션으로 라우팅 되길 원하므로 메소드 파라미터로 app.getHttpServer를 넘겨준다.

http request를 바탕으로 한 테스트

유닛 테스트와 다르게 e2e 테스트는 페이지 간의 이동을 테스트 한다. 그렇기 때문에 유닛 테스트에는 보이지 않던 get, post와 같은 http request들이 보인다.
즉, 약속된 값을 해당 api로 특정 http method를 보냈을 때 200이 나오는지, 그리고 에러가 났을 땐 400이 나오는지, 값이 잘 추가될 땐 201이 나오는지를 테스트 하는 것이다.

동일한 환경에서 테스트

서비스 전체적인 부분을 테스트를 하기 때문에 배포용 app과 테스트용 app의 환경이 같아야 정확한 테스트를 할 수 있다. 그렇기 때문에 배포용 app에서 설정해 둔 validationPipe와 같은 설정을 테스트용 app에서도 동일하게 설정해줘야 한다.

// 위 예제의 상단 부분에 위치한 코드이다.
beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true
    }))
    await app.init();
  });

추가적으로 여기서 beforeAll와 같은 메소드는 테스트 전후로 필요한 작업들을 할 수 있다. 이와 유사한 메소드들이 있는데 syntax 그대로 받아들이면 된다.

afterEach : 각각의 e2e 테스트 이후에 할 동작
beforeEach : 각각의 e2e 테스트 전에 할 동작
afterAll : 모든 e2e 테스트 후에 할 동작 ( 클린업 코드를 짜기 좋은 부분 )
beforeAll : 모든 e2e 테스트 전에 할 동작 ( 테스트용 app를 설정하기 좋은 부분 )

마무리

간단하게 배운 TS를 조금 더 사용해 보고 싶어서 선택한 nestJS였는데, TS도 조금 더 써볼 수 있었지만 무엇보다 nestJS라는 매력적인 프레임워크를 배울 수 있었던 즐거운 시간이었다.
혼자라면 인강만 보고 넘어갈 수 있었지만 함께 스터디를 하면서 서로 더 깊은 지적 탐구를 할 수 있도록 자극이 되어준 희정님께 큰 감사를 표한다.
이렇게 시작한 TS와 nestJS가 간단한 경험에서 그치지 않도록 토이 프로젝트라도 해보면서 더 깊은 경험을 해볼 수 있도록 노력해야 겠다.

profile
주먹펴고 일어서서 코딩해

0개의 댓글