본문 바로가기
프로젝트/웹

(NestJS) TDD를 하며 배운 것들

by WOOSERK 2022. 11. 24.

들어가며


이번 프로젝트에서 백엔드의 목표는 아키텍처 성능 개선TDD였다.

 

그 중 아키텍처 성능 개선은 오버 엔지니어링을 피하기 위해 필수 기능에 대한 개발이 끝난 후 고민하기로 했다.

 

그래서 필수 기능을 개발하면서 세울 수 있는 도전적인 목표가 무엇이 있을지 생각해보다가 멘토님이 해주셨던 이야기가 떠올랐다. 이번 주 미팅때 멘토님께서 TDD가 현업에 가면 거의 이루어지기 힘들다고 하시면서 코드 커버리지가 70%만 되어도 대단한 거라고 말씀하셨었다.

 

따라서 우리는 이번에 개발 목표를 TDD로 정했다. 그리고 기준점은 코드 커버리지 80%로 잡았다. 80%는 우리 프로젝트의 규모가 실제 서비스들보다 작기 때문에 충분히 달성할 수 있다고 생각했다.

TDD의 어려움


처음엔 뭐 어렵겠어?하고 호기롭게 TDD를 목표로 선언했지만, 어려웠다.

TDD는 Test Driven Development의 줄임말로, 테스트 주도적인 개발을 의미한다.

여기서 중요한 것은 테스트 주도적이다. 즉, 테스트를 우선적으로 구현하고 테스트 케이스를 통과하기 위한 짧은 코드를 작성한다. 그리고 이를 반복하면서 기능을 구현하는 방식이다.

 

테스트 코드를 짜고, 기능 구현 코드를 짠다. 그리고 테스트를 통과하면 다시 테스트 코드를 짜고, 기능 구현 코드를 짠다.

이 일련의 과정이 말은 쉽지만 정말 어렵다. 일단 테스트 코드를 가볍게 짜고 나면 기능을 개발할 때까지 기능 구현 코드를 짜게 된다. 건방진 손과 뇌가 테스트 코드로 다시 돌아가기를 거부한다.

 

제일 큰 문제는 심리적 압박감이다. 아무래도 주차별로 구현해야 할 기능을 정해놓고, FE의 요구사항도 반영해야 하다보니 조급함에 사로잡혀 기능 구현 전에 테스트 코드를 손 댈 여력이 없었다.

 

이런 문제점을 팀원인 태훈이도 동일하게 느끼고 있었고, 목표를 약간 수정했다. TDD에서 TDD로.

변경된 TDD는 Test DeunDeun Development로, 테스트가 든든하게 짜여진 개발이다. 방금 지어냈다.

테스트 든든 디벨롭먼트


테스트를 우선적으로 짜지는 못해도 다양한 케이스를 커버하도록 테스트를 짜기로 정했다. 그렇다면 그 범위는 어디까지 정해야 할까?

 

테스트도 여러 테스트가 있다. 그 중 단위 테스트는 자바 스프링을 공부하면서 JUnit으로 몇 번 작성해본 적은 있지만 본격적으로 프로젝트에서 적용해본 적은 없었다.

 

우선 컨트롤러-서비스 레이어로 나누어진 우리의 백엔드 구조에서 단위 테스트는 뗄래야 뗄 수 없다. 어떤 레이어에서 버그가 발생했는지 빠르게 찾을 수 있기 때문이다.

 

우선 서비스 레이어에서 단위 테스트가 필수라는 사실은 명백하다. 서비스 레이어는 핵심 비즈니스 로직을 담당하기에 메서드 하나가 곧 기능 하나에 해당한다. 따라서 서비스 레이어의 단위 테스트는 필수적으로 구현하기로 했다.

 

그런데 컨트롤러 레이어에서 단위 테스트가 필요할까? 물론 있으면 좋겠지만 굳이?라는 느낌이다. 클라이언트의 요청을 받으면 서비스를 호출하고 응답을 반환하는 너무나 단조로운 흐름이기 때문이다.

 

그래서 보통 백엔드에서 어떤 테스트를 하는지 검색을 좀 해봤더니, E2E 테스트를 종종 한다는 글이 꽤 있었다.

E2E 테스트는 End to End 테스트로, 양 끝단 간의 테스트다. 즉, 클라이언트로부터 요청(컨트롤러) - 비즈니스 로직(서비스) - 데이터베이스(레포지토리) - 다시 서비스 - 응답의 과정을 테스트하는 것이다.

 

단위 테스트와는 다르게 처음 접하는 테스트이기도 하고, 다양한 시나리오(ex. 유저가 로그인하고 검색을 함)를 테스트해볼 수 있을 것 같아서 적용하기로 했다.

그래서 이번 프로젝트에서 백엔드는 서비스 레이어의 단위 테스트E2E 테스트를 목표로 삼게 되었다.

테스트에 대한 기록


테스트 코드를 작성하면서 기록해두면 좋을 것 같다고 생각한 부분이 몇 개 있었기에, 아래 순서대로 문서를 진행하고자 한다.

우선 나는 테스트 라이브러리로 jest를 사용했다. E2E 테스트는 인메모리 데이터베이스인 sqlite를 사용했다.

 

jest의 SpyOn()

jest에는 mocking에 사용되는 메서드가 2개 존재한다. SpyOnfn이 그것이다.

둘의 차이가 무엇인지 명확히 몰랐었는데 공식문서를 참고하면 다음처럼 서술되어 있다.

jest.fn()

Returns a new, unused mock function. Optionally takes a mock implementation.

jest.SpyOn()

Creates a mock function similar to jest.fn but also tracks calls to object[methodName].

 

즉, SpyOnfn처럼 mock 메서드를 만들 수 있지만 대상이 되는 메서드의 호출도 추적할 수 있다. 따라서 SpyOn은 주로 mocking보다는 실제 메서드의 호출 정보를 알기 위해 사용된다.

 

원래는 내가 작성한 테스트 케이스를 첨부하려고 했는데, 이제 보니 제대로 사용한 것 같지 않다. 그래서 실제 구현된 SpyOn 코드를 함께 해부하여 깊이 이해해보고자 한다.

spyOn(object: any, methodName: any, accessType?: string): any {
    if (accessType) {
      return this._spyOnProperty(object, methodName, accessType);
    }

    if (typeof object !== 'object' && typeof object !== 'function') {
      throw new Error(
        'Cannot spyOn on a primitive value; ' + this._typeOf(object) + ' given',
      );
    }

    const original = object[methodName];

    if (!this.isMockFunction(original)) {
      if (typeof original !== 'function') {
        throw new Error(
          'Cannot spy the ' +
            methodName +
            ' property because it is not a function; ' +
            this._typeOf(original) +
            ' given instead',
        );
      }

      object[methodName] = this._makeComponent({type: 'function'}, () => {
        object[methodName] = original;
      });

      object[methodName].mockImplementation(function() {
        return original.apply(this, arguments);
      });
    }

    return object[methodName];
  }

위 코드를 자세히 살펴보면 우선 3번째 인자가 들어오면 다른 메서드로 넘긴다. 3번째 인자는 optional로, 'get'이나 'set'이 올 수 있으며 각각 getter와 setter를 감시하는 데에 유용하다고 한다.

 

그 이후에는 객체의 타입 검사가 이루어진다. 그리고 대상이 mock 메서드라면 그대로 반환하고, 아니라면 메서드의 타입 검사가 이루어진다.

 

그러고나면 mock 메서드를 만든다. 두 번째 인자로 입력된 메서드는 _makeComponent 메서드 내의 아래 로직에서 _spyState라는 Set에 추가된다.

if (typeof restore === 'function') {
  this._spyState.add(restore);
}

즉 실제 메서드를 _spyState 내부에 저장해두고, mock 메서드로 변환한 뒤 apply를 호출하여 실제 메서드의 호출 결과값을 반환하도록 한다.

 

결론적으로 원래 메서드의 결과값을 반환하는 mock 메서드를 만드는 것이다.

따라서 원래 메서드와 동작의 차이가 없다.

정말로 SpyOn은 실제 메서드의 호출을 추적하기 위해 사용됨을 알 수 있다. 실제 구현 코드를 보고 싶다면 아래를 참고하자.

 

https://github.com/facebook/jest/blob/e9aa321e0587d0990bd2b5ca5065e84a1aecb2fa/packages/jest-mock/src/index.js#L674-L708

 

GitHub - facebook/jest: Delightful JavaScript Testing.

Delightful JavaScript Testing. Contribute to facebook/jest development by creating an account on GitHub.

github.com

 


beforeAll, beforeEach, afterEach, afterAll

테스트 코드를 작성하다보면 이 메서드들로 인해 혼란스러운 상황이 나온다.

각 메서드의 이름이 명확하긴 하지만 호출되는 타이밍이나 순서를 모르면 의도와 다른 테스트 코드가 작성될 수 있어 주의해야 한다.

 

우선 이 메서드들을 알아보기 전에, describe에 대해 알아야 한다.

describe는 테스트들을 그룹화하는 데에 사용된다. 예를 들면 다음과 같다.

describe('글 작성', () => {
  it('글 작성 성공', async () => {
    console.log(userRepository.findOneBy());
    await service.write(1, writeDto);

    expect(postRepository.save).toBeCalledTimes(1);
  });

  it('유저를 찾지 못해서 글 작성 실패', async () => {
    userRepository.findOneBy = jest.fn(() => null);

    try {
      await service.write(1, writeDto);
      throw new Error();
    } catch (err) {
      expect(err).toBeInstanceOf(UserNotFoundException);
    }
  });

  it('그 외 에러로 글 작성 실패', async () => {
    userRepository.findOneBy = jest.fn(() => new User());

    postRepository.save = jest.fn(() => {
      throw new PostNotWrittenException();
    });

    try {
      await service.write(1, writeDto);
      throw new Error();
    } catch (err) {
      expect(err).toBeInstanceOf(PostNotWrittenException);
    }
  });
});

위 코드에서 describe로 글 작성 기능에 대한 그룹을 만들고, 각 테스트 케이스들을 it를 이용하여 정의하고 있다.

 

describe는 nested가 가능하여, 그룹 안에 그룹을 만드는 것도 가능하다.

 

이제 각 메서드들을 알아보자. 편의상 All과 Each로 나누는 것이 이해가 쉬울 것 같다.

beforeAll, afterAll

우선 ~All은 그 이름 그대로 일회성 설정에 사용된다. 각각 테스트의 시작과 끝에서 수행될 동작을 정의하는 데에 사용된다.

 

예를 들면 beforeAll맨 처음에 데이터베이스를 초기화하는데 사용될 수 있고, afterAll마지막으로 데이터베이스를 비우는데 사용될 수 있다.

 

이 메서드들은 기본적으로 전역 범위를 가진다. 즉, 파일의 모든 테스트에 적용된다.

아래 ~Each도 마찬가지지만 앞서 말한 describe와 함께 사용된다면 해당 블록 내로 범위가 국한된다.

beforeEach, afterEach

~Each는 반복적인 설정에 사용된다.

앞선 예를 여기에 적용해보면 beforeEach각 테스트 케이스 시작 전에 데이터베이스를 초기화하는데 사용될 수 있고, afterEach각 테스트 케이스가 끝난 후에 데이터베이스를 비우는데 사용될 수 있다.

하나하나 보면 명확한 메서드명 덕분에 순서가 헷갈리지 않지만, 이들이 여러 그룹에 사용되면 순서를 추적하기는 여간 쉬운 일이 아니다.

아래는 공식 문서에서 가져온 코드이다.

beforeAll(() => console.log('1 - beforeAll'));      // -- 1
afterAll(() => console.log('1 - afterAll'));        // -- 12
beforeEach(() => console.log('1 - beforeEach'));    // -- 2, 6
afterEach(() => console.log('1 - afterEach'));      // -- 4, 10

test('', () => console.log('1 - test'));            // -- 3

describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'));    // -- 5
  afterAll(() => console.log('2 - afterAll'));      // -- 11
  beforeEach(() => console.log('2 - beforeEach'));  // -- 7
  afterEach(() => console.log('2 - afterEach'));    // -- 9

  test('', () => console.log('2 - test'));          // -- 8
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

정말 어지럽다. 알 수 있는 것은 ~All은 한 번만 수행된다는 것. 괘씸한 ~Each는 여러 번 수행될 수 있다.

 

beforeEach는 먼저 적힌 순서대로 수행되고, afterEach는 나중에 적힌 순서대로 수행된다. 즉, 전자는 큐처럼 쌓이고, 후자는 스택처럼 쌓인다.

 

그럼 아래 응용 문제를 각자 풀어보자.

Jasmine: why beforeEach() works in nested describe but beforeAll() doesn't?

 

Jasmine: why beforeEach() works in nested describe but beforeAll() doesn't?

I am struggling to understand why my code won't work and why the tests fail when I use a simple beforeAll() instead of a beforeEach() in a nested describe suite of tests? Here is a small example to

stackoverflow.com


에러를 동반한 테스트

이번에 단위 테스트를 작성하면서 에러를 발생시키는 상황에 대한 테스트를 작성했었다. 테스트 케이스가 로직대로 잘 동작하기에 대수롭지 않게 넘겼었는데, 후에 알고보니 테스트가 이루어지지 않고 있었다.

이건 잘 모르면 누구든지 겪을 수 있는 문제라고 생각해서 해당 상황을 공유하고자 정리한다.

 

경위는 이렇다. 나는 글 작성 기능을 구현한 뒤 테스트 코드를 작성했다. 지금은 수정됐지만, 당시에는 해당 기능에 다음과 같은 코드가 있었다.

const manager = queryRunner.manager;

const userEntity = await manager.findOneBy(User, {
  id: userId,
});

if (userEntity === null) {
  throw new UserNotFoundException();
}

데이터베이스에서 유저의 정보를 찾고, 존재하지 않는 유저면 커스텀 예외를 발생시킨다.

 

언젠가 마주칠 수 있는 상황이기에 에러를 발생시키게 구현했고, 테스트 케이스를 다음처럼 작성했다.

it('유저를 찾지 못해서 글 작성 실패', async () => {
    jest.spyOn(qr.manager, 'findOneBy').mockImplementation(async (entity) => {
    if (entity typeof User) {
      return null;
    }

    return {};
  });

  try {
    await service.write(1, writeDto);
  } catch (err) {
    expect(err).toBeInstanceOf(UserNotFoundException);
    expect(qr.rollbackTransaction).toBeCalledTimes(1);
    expect(qr.release).toBeCalledTimes(1);
  }
});

로직대로라면 userRepositoryfindOneBy 메서드가 호출됐을 때 null이 반환될 것이고, 곧이어 UserNotFoundException이 던져져야 한다.

 

그리고 예상대로 테스트는 성공했다. 그래서 안심하고 배포를 진행했는데, 서비스 코드를 리팩토링하는 과정에서 테스트에 결함이 있다는 것을 알게 됐다.

 

검색을 좀 하고 나니 내가 모르는 사실이 몇 가지 있었다는 것을 깨달았다. 우선 인자로 들어오는 entity가 User라면 entity typeof User는 true일 줄 알았는데, 아니었다. User가 클래스이기 때문에 function 타입이고, 그렇기 때문에 {}를 반환한다.

 

따라서 서비스 로직은 막힘없이 진행되고, 에러를 던지지 않은 채 종료된다. 에러가 발생하지 않아서 catch문에 도달하지 않는다. 당연한 사실인데 간과했다.

 

따라서 에러를 동반한 테스트 케이스를 작성할 경우에는 예외없이 완료되는 경우를 대비하여 다른 타입의 에러라도 던져줘야 한다.

 

https://jojoldu.tistory.com/656

 

Jest로 Error 검증시 catch 보다는 expect

Jest를 통한 테스트를 작성하다보면 Exception에 대한 검증을 작성해야할 때가 있다. 이럴때 보통 2가지 방법 중 하나를 선택한다. try ~ catch expect.rejects.toThrowError 실제 코드로는 다음과 같다. // try ~ c

jojoldu.tistory.com