[NestJS] 테스트

kimhayeon·2024년 10월 31일
0

NestJS

목록 보기
1/1

세팅

NestJS CLI로 프로젝트를 생성하면 Jest와 SuperTest를 사용한 테스트 환경이 설정된다.
추가적으로 모의 객체를 간단하게 생성하고 타입 안정성을 높이기 위해 jest-mock-extended를 설치했다.

npm install jest-mock-extended --save-dev

단위 테스트

Service

테스트 모듈 설정

describe('AuthService', () => {
  let authService: AuthService; // 테스트할 서비스 인스턴스
  
  beforeEach(async () => {
    // TestingModule 인스턴스 반환
    const module = await Test.createTestingModule({
      // 의존성 주입
      providers: [AuthService]
    }).compile();
    
    userService = module.get(AuthService); // AuthService 인스턴스 할당
  });
});

의존성

describe('UserService', () => {
  let jwtService: DeepMockProxy<JwtService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        { provide: JwtService, useValue: mockDeep<JwtService>() },
        { provide: ConfigService, useValue: mockDeep<ConfigService>() }
      ]
    }).compile();
    
    jwtService = module.get(JwtService); // JwtService 모의 객체 할당
  });
});

JwtService는 모의 객체를 사용하기 때문에 imports가 아닌 providers에 추가했다.

AuthService는 ConfigService에 의존하고 있기에 providers에 추가해야 한다.

// AuthService.ts
this.jwtService.signAsync(payload, {
  secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET'),
  expiresIn: expSec
});

AuthService.spec.ts 내에선 ConfigService가 직접적으로 사용되지 않는다.

// JwtService 모의
const module = await Test.createTestingModule({
  providers: [
    { provide: JwtService, useValue: mockDeep<JwtService>() }
  ]
}).compile();
// jwtService 사용
jwtService.signAsync.mockResolvedValue(accessToken);

따라서 별도로 configService 변수를 정의하지 않았다.

authService 검증

it('should be defined', () => {
  // null이나 undefined가 아닌지 검증
  expect(authService).toBeDefined();
});

토큰 재발급 검증

성공하는 시나리오와 실패하는 시나리오를 모두 검증한다.

describe('reissueToken', () => {
  it('should return an object of type ReissuedToken', async () => {
    // 호출 시 항상 특정 값을 반환하도록 설정
    kakaoLoginService.reissueToken.mockResolvedValue(newKakaoToken);
    authService.createAccessToken = jest.fn().mockResolvedValue(accessTokenInfo);
    
    const result = await authService.reissueToken(auth.refreshToken, auth.userId);
    
    // reissueToken 실행 시, 정상적인 값이 반환되는지 검증
    expect(result).toEqual(reissuedToken);
  });
});

authService는 실제 테스트할 서비스의 인스턴스다.
따라서 authService의 메서드가 반환할 값을 설정하기 위해서는 먼저 jest.fn()로 모의해야 한다.

describe('reissueToken', () => {
  it('should throw UnauthorizedException when refreshToken is invalid or expired', () => {
    authService.findOne = jest.fn().mockResolvedValue(null);
    // rejects: Promise의 rejected 이유 추출
    // toThrow: 에러가 발생하는지 / 특정 에러가 발생하는지 검증
    return expect(authService.reissueToken('invalid-token', BigInt(1234567890))).rejects.toThrow(
      new UnauthorizedException('Invalid or expired refresh token')
    );
  });
});

rejects 사용 시 Promise를 테스트하기 때문에, 테스트 또한 Promise다.
따라서 Jest가 기다리게 해야 한다.
return 대신 async/await를 사용할 수도 있다.

it('should throw UnauthorizedException when refreshToken is invalid or expired', async () => {
  authService.findOne = jest.fn().mockResolvedValue(null);
  await expect(authService.reissueToken('invalid-token', BigInt(1234567890))).rejects.toThrow(
    new UnauthorizedException('Invalid or expired refresh token')
  );
});

왜 모의하는가?

단위 테스트란?

코드의 가장 작은 기능적 단위를 테스트하는 프로세스

모의의 목적

각 단위 테스트가 한 가지 기능을 테스트할 수 있도록 돕는다.
예를 들어 로그인 메서드를 테스트 시, '로그인 함수가 호출되면 유저 정보가 반환되야 한다'라는 목표가 있다고 가정하자.
토큰 발급 로직이나 데이터베이스와의 연동도 검증하려 한다면, 단위 테스트의 핵심 목적에서 벗어나게 된다.

Controller

controller의 테스트 코드도 service와 크게 다르지 않다.

테스트 모듈 설정

describe('AuthController', () => {
  let controller: AuthController;
  let service: DeepMockProxy<AuthService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [AuthController],
      providers: [
        { provide: AuthService, useValue: mockDeep<AuthService>() }
      ]
    }).compile();

    controller = module.get(AuthController);
    service = module.get(AuthService);
  });
});

HTTP 응답 모의

let response: DeepMockProxy<Response>;

beforeEach(async () => {
  response = mockDeep<Response>();
});

express의 Response는 의존성이 아니기 때문에 module에 추가하지 않았다.

response를 beforeEach 함수 내에서 할당한 이유는 각 테스트 실행 전 초기화하기 위함이다.
초기화하지 않으면 테스트에서 response를 변경할 경우, 다른 테스트에 영향을 미칠 수 있다.
const로 선언하더라도 response는 객체이기 때문에, 변경될 위험이 있다.

// cookie가 호출되었는지, 인자가 예상한 값과 일치하는지 검증
expect(response.cookie).toHaveBeenCalledWith('access_token', token.accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  expires: token.exp
});

response도 결국 객체이며, cookie 또한 함수(메서드)이기 때문에 다른 검증 로직과 다르지 않다.

Guard

CanActivate를 implements한 Guard를 테스트할 것이다.

테스트 모듈 설정

describe('AccessTokenGuard', () => {  
  let guard: AccessTokenGuard;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        AccessTokenGuard,
        { provide: ConfigService, useValue: mockDeep<ConfigService>() }
      ]
    }).compile();
    
    guard = module.get(AccessTokenGuard);
  });
});

ConfigService 등 다른 의존성 모듈을 사용하기 위해선, 테스트할 모듈인 AccessTokenGuard 또한 의존성에 추가해야 한다.

canActivate

describe('canActivate', () => {
  it('should return true when access token is valid', async () => {
    const accessToken = 'accessToken';
    // 필요한 구조만 정의하여 타입 단언 사용
    const context = {
      switchToHttp: () => ({
        getRequest: () => ({
          cookies: { access_token: accessToken }
        })
      })
    } as ExecutionContext;
  });
});

context는 여러 테스트에서 사용되지만, 각 테스트마다 필요한 구조가 다르기 때문에 beforeEach에서 모의하지 않았다.

describe('canActivate', () => {
  it('should return true when access token is valid', async () => {
    // result가 true인지 검증
    expect(result).toBe(true);
  });
});
  • toBe: 원시 값을 비교하거나, 객체의 참조가 동일한지 비교할 때 사용된다.
  • toEqual: 객체의 모든속성을 재귀적으로 비교한다.

더 다양한 matcher 함수를 알고 싶다면 공식 문서를 확인하자.

통합 테스트

세팅

NestJS가 기본적으로 설정해 주는 E2E 테스트를 따라했다.

script 추가

"test:integration": "jest --config ./test/jest-integration.json"

설정 파일 추가

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": "../",
  "testEnvironment": "node",
  "testRegex": ".*\\.e2e-spec\\.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  "moduleNameMapper": {
    "^@/(.*)$": "<rootDir>/src/$1"
  }
}

프로젝트에서 절대 경로를 사용하는 컨벤션을 따르기 때문에, rootDir을 프로젝트 루트 디렉터리로 지정했다. 또한, 경로 별칭을 사용하고 있기 때문에 moduleNameMapper를 추가하여 별칭을 올바르게 매핑하도록 설정했다.

API

Jest와 SuperTest를 함께 사용하는 통합 테스트는 요청을 보내는 부분을 제외하면, 단위 테스트에서 Jest를 사용하던 방식과 크게 다르지 않다.

세팅

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';

import { AuthModule } from '@/auth/auth.module';

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

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

    app = moduleFixture.createNestApplication(); // Nest 애플리케이션 생성
    await app.init(); // 애플리케이션 초기화
  });
});

httpAdapter를 사용하기 위해 Nest 애플리케이션을 생성한다.

HTTP 요청

describe('/api/auth/token/reissue (POST)', () => {
  it('204', async () => {
    const { refreshTokenCookie } = await createUser();
    
    const { header } = await request(app.getHttpServer())
    .post('/api/auth/token/reissue')
    .set('Cookie', [refreshTokenCookie]) // 요청 헤더에 쿠키 추가
    .send({ id: Number(id) }) // 요청 본문
    .expect(204); // 상태 코드 검증
    
    await deleteUser();
});

HTTP 요청이 실행 중인 Nest 애플리케이션으로 라우팅되도록,
request 인수로 Nest 애플리케이션의 HTTP 서버 리스너에 대한 참조를 전달하고 있다.
토큰 재발급 요청을 보내기에 앞서 유저를 생성하고, 마지막에 생성된 유저 데이터를 정리했다.

describe('/api/auth/token/reissue (POST)', () => {
  it('204', async () => {
    const cookies = header['set-cookie'] as unknown as string[];

    const accessTokenCookie = cookies.find(cookie => cookie.startsWith('access_token='));
    expect(accessTokenCookie).toBeDefined();
    expect(accessTokenCookie).toContain('HttpOnly');
    expect(accessTokenCookie).toContain('Secure');
    expect(accessTokenCookie).toContain('SameSite=Strict');
  });
});

Response의 header 프로퍼티의 타입은 다음과 같다.

{ [index: string]: string }

그러나 실제 테스트를 실행할 때 header['set-cookie'] 값의 타입은 string[]이었다.
따라서 타입 캐스팅을 했다. 안전을 위해 전역 타입을 캐스팅하진 않았다.
...가 자세히 보니 asunknown을 사용하지 않고 올바른 타입으로 쿠키를 가져올 수 있었다.

{
  get(header: string): string | undefined;
  get(header: "Set-Cookie"): string[] | undefined;
}

0개의 댓글