NestJS에서 단위테스트 작성하기

윤학·2025년 1월 1일
0

Nestjs

목록 보기
14/14
post-thumbnail

평소 개발을 하면 코드가 정상적으로 동작하는지 테스트코드를 통해 1차로 확인한다.
단위테스트와 통합테스트를 주로 작성하는데, 단위테스트의 비율이 훨씬 높은 것 같다.

개인적으로 단위테스트를 수행할 때 어떤 생각으로 작성하는지 기록을 남기고자 글을 작성한다.

Jest의 설정법이나 실제 테스트를 진행하기보단 예시 로직을 가지고 진행해보자.

사용자가 쿠폰 코드를 입력해서 쿠폰을 등록한다고 가정한다.

coupon.ts

export class Coupon {
  userId: number;
  couponCode: string;
  expiredAt: Date;
  
  constructor(userId: number, couponCode: string, expiredAt: Date) {
    this.userId = userId;
    this.couponCode = couponCode;
    this.expiredAt = expiredAt;
  };
  
  registered(
    userId: number, 
    registeredAt: Date = new Date()
  ) {
    if( this.userId ) 
      throw new ConflictException('이미 다른 사용자가 등록함');
    if( registeredAt > this.expiredAt )
      throw new ConflictException('유효기간 끝남');
    this.userId = userId;
  }
}

coupon.service.ts


@Injectable()
export class CouponService {
  constructor(
    private readonly couponRepository: CouponRepository,
    private readonly logger: Logger
  ) {};

  async registerCoupon(userId: number, couponCode: string) {
    const coupon = await this.couponRepository.findByCode(couponCode);
    
    if( !coupon ) {
      throw new NotFoundException('입력 코드랑 일치하는 쿠폰 없음');
    }

    coupon.registered(userId);

    await this.couponRepository.update(coupon);

    this.logger.log(`User Registered Coupon | data: ${JSON.stringify({ userId, couponCode })}`);
  }
};

로직의 흐름은 다음과 같다.

  1. 입력한 코드랑 일치하는 값이 있는지 확인 후 없으면 에러를 던진다.
  2. 쿠폰을 등록할 때 유효성 검사를 진행한 후 쿠폰에 사용자 id를 등록한다.
  3. 쿠폰을 업데이트한다
  4. 로그를 남긴다.

도메인 요구사항 테스트

먼저, 도메인(Entity) 객체부터 테스트를 해보자.

도메인 객체는 보통 핵심 요구사항들이 모여있기 때문에 유효성 검사가 존재하면 적절한 에러를 던지는지, 데이터를 업데이트하면 알맞게 업데이트하는지 필수적으로 테스트를 진행한다.

그럼 쿠폰을 등록할 때 요구사항은 뭘까?
1. 이미 다른 사용자가 등록한 쿠폰이라면 등록이 돼서는 안된다.
2. 유효기간이 지났다면 등록이 돼서는 안된다.
3. 정상적으로 등록되면 쿠폰에 사용자 ID가 등록되어야 한다.

이 사항들을 전부 테스트로 옮긴다.

describe('Coupon Unit Test', () => {
	describe('registered()', () => {
		describe('등록이 되지 않는 경우 테스트', () => {
			it('이미 다른 사용자가 등록한 상태면 409 에러를 던진다.', () => {
				const registeredUserId = 1;
				const coupon = new Coupon(registeredUserId, 'test_coupon', new Date(2099, 1, 1));
                
				const result = () => coupon.registered(100);
              
				expect(result).toThrowError(new ConflictException('이미 다른 사용자가 등록함');
			});
          
            it('등록 가능한 유효기간이 지난 경우 409 에러를 던진다.', () => {
				const expiredAt = new Date(1999, 1, 1);
				const coupon = new Coupon(null, 'test_coupon', expiredAt);

				const result = () => coupon.registered(1);
				
				expect(result).toThrowError(new ConflictException('유효기간 끝남');
			});
		});

		it('정상적으로 등록되는 경우 사용자의 id가 설정된다.', () => {
          	const [userId, expiredAt] = [1, new Date(2099, 1, 1);
            const coupon = new Coupon(null, 'test_coupon', expiredAt);
          
			coupon.registered(userId);
          	
          	expect(coupon.userId).toBe(userId);	
		});
	});
  });
});

예외가 발생했을 때 log를 보고 파악하는데 메시지가 잘못 남아있으면 크게 방황할 수 있기 때문에 무조건 메시지까지 함께 테스트해준다.

서비스 레이어 테스트

서비스 레이어의 단위 테스트는 서비스 레이어만의 유효성 검사나 다른 컴포넌트의 호출을 위주로 테스트한다.

그럼 테스트하지 못한 부분이 뭐가 있을까?
1. 입력한 쿠폰코드와 일치하는 쿠폰이 DB에 없는 경우 에러를 던진다.
2. 모든 유효성 검사를 통과하면 쿠폰 객체의 최종 상태가 변경되어야 하고, log가 남겨져야 한다.

describe('CouponService Unit Test', () => {
  let couponRepository: CouponRepository,
    couponService: CouponService,
    logger: Logger;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      providers: [
        { 
          provide: CouponRepository,
          useValue: {
            findByCode: jest.fn()
          }
        },
        {
          provide: Logger,
          useValue: {
            log: jest.fn()
          }
        }
        CouponService
      ]
    }).compile();

    couponRepository = module.get(CouponRepository);
    couponService = module.get(CouponService);
    logger = module.get(Logger);
  })

  describe('registerCoupon()', () => {
    it('코드와 일치하는 쿠폰이 없으면 404 에러', () => {
      jest
        .spyOn(couponRepository, 'findByCode')
        .mockResolvedValueOnce(null);
      
      const result = async () => await couponService.registerCoupon(1, 'TESTCOUPON12');

      expect(result)
        .rejects
          .toThrowError(new NotFoundException('입력 코드랑 일치하는 쿠폰 없음'));
    });

    it('정상적으로 등록된 경우 최종상태 확인', async () => {
      const [registerUserId, inputCode] = [1, 'TESTCOUPON12']
      const coupon = new Coupon(null, new Date(2099, 1, 1));

      jest
        .spyOn(couponRepository, 'findByCode')
        .mockResolvedValueOnce(coupon);
      
      const logSpy = jest.spyOn(logger, 'log');

      await couponService.registeredCoupon(registerUserId, inputCode);
		
      expect(coupon.userId).toBe(registerUserId);
      expect(logSpy).toHaveBeenCalledWith(
        `User Registered Coupon | data: ${JSON.stringify({
          userId: registerUserId,
          couponCode: inputCode
        })}`
      )
    });
  })
});

위와 같이 비즈니스 로직의 흐름을 나타낼 수 있는 log들은 에러를 추적할 때나 비즈니스 로직들의 진행 단계를 파악할 수 있어 중요하다고 생각하여 테스트하는 편이다.

에러메시지들을 따로 관리하더라도 생각보다 개발할 때 잘못 작성하고 테스트에서 잡는 경우가 많았다.

또한, 서비스 레이어의 단위 테스트는 외부 의존성들을 어떻게 격리시켜서 진행하는지가 많이 나뉘는 것 같은데, 위와 같은 방식으로 단순히 mocking 해서 진행하기도 하고, 테스트용 stub 객체를 따로 만들어서 진행하기도 하는 것 같은데 해당 부분은 별도의 포스팅으로 정리하려 한다.

개선점

근데 만약 앞선 예시에서 쿠폰 클래스가 더 많은 데이터를 가지고 있다면 앞에 작성했던 테스트가 편할까?

예시로 아무 속성이나 채워보자.

export class Coupon {
  userId: number;
  name: string;
  couponCode: string;
  discountAmount: number;
  expiredAt: Date;
  usageCount: number;
  
  constructor(
    userId: number, 
    name: string,
    couponCode: string, 
    discountAmnount: number,
    expiredAt: Date,
    usageCount: number
  ) {
    this.userId = userId;
    this.name = name;
    this.couponCode = couponCode;
    this.discountAmount = discountAmnount;
    this.expiredAt = expiredAt;
    this.usageCount = usageCount;
  };
  
  registered(
    userId: number, 
    registeredAt: Date = new Date()
  ) {
    if( this.userId ) 
      throw new ConflictException('이미 다른 사용자가 등록함');
    if( registeredAt > this.expiredAt )
      throw new ConflictException('유효기간 끝남');
    this.userId = userId;
  }
}
it('이미 다른 사용자가 등록한 상태면 409 에러를 던진다.', () => {
    const registeredUserId = 1;
    const coupon = new Coupon(
      registeredUserId,
      'velog 쿠폰',
      'test_coupon',
      10000,
      new Date(2099, 1, 1),
      3
   	);

    const result = () => coupon.registered(100);

    expect(result).toThrowError(new ConflictException('이미 다른 사용자가 등록함');
});

처음 수행했던 테스트를 다시 보면 실제로 테스트에 필요한 데이터는 userId뿐인데 나머지 값들을 다 채워야 한다.

더 나아가서 다른 도메인에서 테스트하는데 쿠폰 데이터들이 필요하다고 생각하면 테스트 데이터들을 준비하는 것만으로도 머리가 아파진다.

이런 경우 별도의 클래스나 함수로 분리해서 테스트하고 싶은 데이터만 넘겨주고 나머지는 기본값들로 채우는 방식으로 진행하고 있다.

coupon.fixture.ts

export class CouponFixture {
  static create({
    userId = null,
    name = 'velog 쿠폰',
    couponCode = 'test_coupon',
    discountAmount = 10000,
    expiredAt = null,
    usageCount = 0
  }) {
    return new Coupon(
      userId,
      name,
      couponCode,
      discountAmount,
      expiredAt,
      usageCount
    );
  }
}

위처럼 분리하면 테스트하고 싶은 데이터들로만 채워 빠르게 테스트하고 싶은 대상을 구성할 수 있다.

it('이미 다른 사용자가 등록한 상태면 409 에러를 던진다.', () => {
    const registeredUserId = 1;
	const registeredCoupon = CouponFixture.create({ userId: registeredUserId });

    const result = () => registeredCoupon.registered(100);

    expect(result).toThrowError(new ConflictException('이미 다른 사용자가 등록함');
});

테스트 코드는 코드 검증도 있겠지만 처음 서비스를 이해하는 입장에서 특정 도메인에 어떤 요구사항들이 있는지 한눈에 파악하기 좋은 것 같다.

다음에는 개인적으로 통합테스트를 작성하는 방식을 공유해보려 한다.

0개의 댓글

관련 채용 정보