RooTrip 테스트 작성기

영슈·2023년 10월 12일
0

깃허브 주소

RooTrip-Factoring

RooTrip-Clone

진행 일지

1.[rootrip-factoring:test] create-local-user , verification-email-code 테스팅 코드 작성

기록할 점

  • create-local-user , verficiation-user-code 테스팅 코드 작성

Testing

현재 , 개발 프로세스에서 테스팅은 매우 중요하다. 대략적인 장점으론 ,
1. 버그 발견 및 예방
2. 유지 보수 와 리팩토링
3. 자동화 된 테스트
4. 문서화
등이 있는거 같다.
지엽적인 내용이므로 생략하고 , 핵심은 테스팅 코드를 어떻게 , 어떤 측면으로 짜는지가 핵심이다.

다양한 테스팅 코드를 구경하면서 접목시키려고 했는데, 코드들 마다 관점이 다 달라서 꽤나 혼란스러웠다.
깨달은 점은 어떤 의도로 테스팅 코드를 작성하는지에 따라 달라지는 것 같다.
( 코드를 작성하기 전 , 미리 테스팅 코드를 작성하고 본 코드를 작성하는지 )
( 상세 과정은 포함하지 않고 , 결과 만 테스팅 하는지 )
( 안에 들어있는 로직들을 전부 검사하고 결과도 같이 테스팅 하는지 )

=> 나는 , 2번안 과 3번안의 그 사이를 준수하며 작성한 거 같다.
로직을 전부 검사하지 않고 , 실행이 되었는지 검사만 하여 , 동작이 하는 것을 기대했다.
+ 추가로 , kafka 나 , redis 같은 Provider 들도 같이 추가하여 , 테스트 코드를 작성하여 생긴 고민도 같이 적겠다.

Testing Code

create-local-user.controller.spec.ts

핵심은 Command 를 통해 실행되는 로직을
어떻게 테스팅 할 지와 throw 하는 Error 를 어떻게 테스팅 할지 방법에 대해서 고민하였다.
테스팅 모듈 과 공통 변수 선언부터 설명하겠다.

  let controller: CreateLocalUserController;
  let commandBus: CommandBus;
  let commandResult: Result<
    string,
    EmailAlreadyExistError | NicknameAlreadyExistError
  >;
  let expectedResult:
    | ResponseBase<{ id: string }>
    | EmailAlreadyExistError
    | NicknameAlreadyExistError;
  let createUserProps: CreateLocalUserProps;
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [CreateLocalUserController],
      providers: [
        {
          provide: CommandBus,
          useValue: {
            execute: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get<CreateLocalUserController>(
      CreateLocalUserController,
    );
    commandBus = module.get<CommandBus>(CommandBus);

    createUserProps = typia.random<CreateLocalUserProps>();
  });
  • commandResult 와 expectedResult 는 기존 Controller Code 의 Type 과 동일하게 하였다.
    ( 이에 따라 묶여져 있는 리턴타입들을 타입으로 하나로 만들까 했으나 ,
    두 곳들에서만 사용되는 것을 위해 또 새로운 타입을 만드는 것은 비효율 적이라 생각하여 만들지 않았다 )
  • typia.random 은 Generic Type 으로 들어가있는 타입에 일치하는 값을 랜덤으로 생성해주므로 , 매우 간결하게 테스팅 가능
  it('0. Command must be created', () => {
    const command = new CreateLocalUserCommand(createUserProps);
    expect(typia.is<CreateLocalUserCommand>(command)).toBeTruthy();
  });
  • 우선 , Command 가 제대로 생성되었는지 확인
  • typia.is 는 들어온 인자가 Generic Type 의 타입과 일치한지 검사
  it('1. Should create a new user', async () => {
    commandResult = Ok('user_id'); // Replace with the expected command result
    expectedResult = match(commandResult, {
      Ok: (id: string) => new ResponseBase({ id }),
    });
    jest.spyOn(commandBus, 'execute').mockResolvedValue(commandResult);
    const result = await controller.create(createUserProps);
    expect(result).toEqual(expectedResult);
  });
  • command 의 결과를 미리 만들어 놓은 후 , commandBus 가 실행 될 시 , 해당 값을 반환하도록 mocking
  • expectedResult 에 , 미리 controller 가 실행 될 시 , 나오는 결과 값을 저장
  • 결과 값과 기대값이 같은지 비교

  • Ok , match 라는 oxide ts 에서 제공해주는 기능을 사용했는데 , 이거 때문에 어떻게 테스팅을 해야할지 고민했는데 ,
    미리 두개의 result 를 만들어 놓고 비교하는게 , 제일 바람직한 테스팅 코드 인 거 같다.
  it('2. Should handle email already exists throw error ', async () => {
    commandResult = Err(new EmailAlreadyExistError());
    jest.spyOn(commandBus, 'execute').mockResolvedValue(commandResult);
    try {
      match(commandResult, {
        Err: (error: EmailAlreadyExistError) => {
          throw error;
        },
      });
    } catch (err: any) {
      expectedResult = err;
      return;
    }
    const result = await controller.create(createUserProps);
    expect(result).toEqual(expectedResult);
  });
  • 위와 매우 동일하나 , 고민한 점은 해당 사항은 error 를 throw 하고 , error 는 exception filter 에서 catch 해서 가공한 후 , return 한다.
    이에 따라 , throw Error 를 하나 , match 함수에서 throw error 를 하면 , 테스팅 코드가 오류가 나버린다.
    따라서 , 위와 같이 try-catch 문으로 error 를 catch 해서 return 하는 exception filter 와 유사한 테스팅 결과를 유도했다.

create-local-user.service.spec.ts

위와 동일하나 CommandBus 처럼 실행이 되는 주체를 mocking 할 필요가 없다.
해당 코드는 , CommandHandler 를 통해 , 이미 실행이 되었다고 가정하기에 , 기능 검증만 하면 된다.

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        {
          provide: USER_REPOSITORY,
          useValue: {
            findByEmail: jest.fn(),
            findByNickname: jest.fn(),
          },
        },
        CreateUserCommandHandler,
        EventEmitter2,
      ],
    }).compile();
    eventEmitter = module.get<EventEmitter2>(EventEmitter2);
    userRepository = module.get<UserRepositoryPort>(USER_REPOSITORY);
    createUserHandler = module.get<CreateUserCommandHandler>(
      CreateUserCommandHandler,
    );
    command = new CreateLocalUserCommand(typia.random<CreateLocalUserProps>());
  });
  • command 가 타당한지도 이미 controller 단에서, 했으므로 검증 X
  it('should return Ok with two events emitting', async () => {
    jest.spyOn(eventEmitter, 'emitAsync');
    const result = await createUserHandler.execute(command);
    expect(result.isOk()).toBe(true);
    expect(eventEmitter.emitAsync).toHaveBeenCalledTimes(2);
  });
  • eventEmitter 를 mocking 한 이유는 , 받는 결과값이 mocking 된 값이 아니므로 에러가 발생한다.
  • 실행 후 성공적으로 완료시 , result 는 Controller 에서 처럼 Ok 로 결과값이 나올것이므로 검사
  • 두 개의 이벤트를 emit 할 것이므로 BeenCalledTimes(2) 로 검사

고찰점

여기서 많이 시간을 썼는데 , eventEmitter 가 어떤 DomainEvent 를 확인하는지로 검사를 해보고 싶었는데 ,
toHaveBeenCalledWith(SendVertificationEmailDomainEvent) 이렇게 검사를 해도 ,

    Expected: [Function SendVertificationEmailDomainEvent]
    Received
           1: "SendVertificationEmailDomainEvent", {"aggregatedId": "8f4279de-ae0e-4421-83a9-fb1d0224b9ab", "email": "uzglhdfwy", "id": "86b7dc37-6df8-42b9-9501-c0addd189fb1", "metadata": {"causationId": undefined, "correlationId": undefined, "timestamp": 1697113329935, "userId": undefined}, "nickname": "ocazcfcll", "redirectCode": "6e36c09af35af7b8b77a3a8ef2aea6a3"}
  • 실제 생성되는 객체를 유추할 수 있는 방법을 찾지 못해서 , 이와 같이 구현했다.
  • 추가로 , 발생하는 이벤트가 더욱 늘어나는 경우에 , 해당 부분을 인식 못할수도 있는데 이렇게 2번인지 검증하면 , 에러를 띄워서 Testing 에 의의도 맞는거 같아서 이렇게 구현했다. ( 해당 부분은 아직 정확하게 모르겠음 )
  it('should handle email already exists return error ', async () => {
    jest
      .spyOn(userRepository, 'findByEmail')
      .mockResolvedValue({ id: 'user_id' });
    const result = await createUserHandler.execute(command);
    expect(result.isErr()).toBe(true);
    expect(result.unwrapErr()).toBeInstanceOf(EmailAlreadyExistError);
  });
  • 해당 부분은 매우 간단하게 구현

send-vertification-email.domain-event.spec.ts

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [ProviderModule],
      providers: [
        {
          provide: 'PRODUCER',
          useValue: {
            send: jest.fn(),
          },
        },
        SendVertificationEmailEventListener,
      ],
    }).compile();
    producer = module.get<Producer>(PRODUCER);
    await producer.connect();
    listener = module.get<SendVertificationEmailEventListener>(
      SendVertificationEmailEventListener,
    );
    event = new SendVertificationEmailDomainEvent(
      typia.random<DomainEventProps<SendVertificationEmailDomainEvent>>(),
    );
  });
  • PRODUCER 를 주입받고 , 해당 기능에서 사용하는 send Method 를 모킹했다.
  • event 도 이와 동일하게 생성
  it('should send a verification email', async () => {
    const expectedMessage = {
      email: event.email,
      code: event.redirectCode,
      nickname: event.nickname,
    };
    const expectedResult: RecordMetadata[] = [
      {
        errorCode: 10,
        partition: 1,
        topicName: SEND_VERTIFICATION_EMAIL,
      },
    ];
    jest.spyOn(producer, 'send')
    //.mockResolvedValue(expectedResult);
    listener.handleSendVertificationEmailEvent(event);
    expect(producer.send).toHaveBeenCalledWith({
      messages: [
        {
          value: JSON.stringify(expectedMessage),
        },
      ],
      topic: SEND_VERTIFICATION_EMAIL,
      acks: 1,
    });
  });
  • send Method 를 spyOn 으로 mocking , resolvedValue 를 하지 않은 이유는 결과를 가지고 추가적인 로직이 없어서 하지 않음
  • toHaveBeenCalledWith 을 통해 , 해당 메소드가 제대로 호출을 실행하는지 검증

결론

처음 테스팅 코드를 작성해봤는데 , 단순 구현을 위한 코드와 완벽한 테스팅 호환 가능한 코드 작성은 매우 다른것을 느꼈다.
1. 특히나 , 각 Event 들이나 , Entity 등에는 현재 실행중인 current execution context 에서 받는 request 같은것들이 포함되는데 ,
테스팅때는 포함이 될리가 없으므로 전부 에러가 발생했다.

import { RequestContext } from 'nestjs-request-context';

export class RequestContextService {
  static getContext(): AppRequestContext {
    const ctx: AppRequestContext = RequestContext.currentContext?.req;
    return ctx;
  }
}
  • nestjs-request-context 는 오픈소스 library 로 , current execution context 에 대한 값 제공
  • 원래는 ? 연산자를 사용하지 않았으나 , testing 단위에서 에러 발생하여 추가!
    ( if 문 또는 삼항 연산자를 이용해서 없을때 추가 req 를 만들거나 , 아니면 Testing 인지 검증하고 하는 방법으로 추후 개선해나가야 할 듯 )
  1. Redis 또는 Kafka 같은 실제 어플리케이션에 연결을 해서 테스팅을 해야하는 부분에 대해서도 고민이였다.
    connect 및 send 같은 모든 부분을 mocking 해서 , 실제 구동을 하지 않아도 테스팅이 가능하게 할 지 , 구동시에만 테스팅을 가능 여부였는데,
    400*300
  • nestjs 오픈 단톡방에 물어보니 , 유닛테스트 단위에서는 적절치 않다는 의견이 있었으나 ,
    그냥 사용하는 분도 계셔서 조금 더 생각해봐야 할 거 같다.
  1. 아직까지 테스팅 코드를 이렇게 짜는 것이다 라는 확신이 없어서 좀 더 많은 테스팅 코드를 보고 , 내 방법이 맞는지 확인해야 할 거 같다.
    테스트 시에 적어야 하는 내용도 좀 더 찾아봐야겠다.

작성코드

https://github.com/youngsu5582/RooTrip-Factoring/tree/develop/src/modules/user/test

Writed By Obisidan
profile
https://youngsu5582.life/ 로 블로그 이동중입니다~

0개의 댓글