테스트 코드를 짜다보면 보이는 것들.. (이론/실전편)

개발 끄적끄적 .. ✍️·2024년 4월 21일
0
post-thumbnail

왜 테스트 코드를 짜라고 하는거야.. (이론편)

테스트 코드를 짜는 이유.. 를 검색해보면 너무나도 많은 블로그와 글에서 테스트코드의 중요성을 전달하고 있습니다.

그 내용을 요약하면 아래와 같고, 많은 분들이 이미 알고있는 내용이라서 상세하게 풀지는 않겠습니다.

테스트 코드를 짜야하는 이유

  1. 디버깅 비용 절감
  2. 코드 변경에 대한 불안감 해소
    • 개발 초기 단계에 문제를 발견하게 도와줌
    • 리팩토링 / 라이브러리 업그레이드에서 기존 기능이 올바르게 작동하는지 알 수 있음
  3. 단위 테스트는 시스템에 대한 실제 문서를 제공
    • 단위 테스트 자체가 문서로 사용 가능
  4. 결합도와 의존성이 낮은 코드 지향

나도 알긴 아는데..

🤔 너도나도 테스트 코드를 작성해야한다고 하고..
🤔 막상 들어보면 틀린말도 아니며..
🤔 TDD, TDD.. 하는데 나도 한 번 짜볼까 하지만..

막상 일을 하다보면 테스트 코드는 늘 후순위가 되곤 했습니다. 그래도 주변 사람들이 좋다고 하니..
해볼까 하는 생각에, 몇 개의 프로젝트에서만 테스트 코드를 일부 적용해보기로 했습니다.

왜 테스트 코드를 짜라고 하는거야.. (실전편)

막상 테스트 코드를 작성하고 프로젝트에 적용해보니, 저는 아래의 경험을 얻게 되었습니다.

1. 디버깅 비용 절감 (어떻게 다들 기능 테스트 하시나요 ?)

이전까지 저의 개발 플로우는 아래와 같았습니다.

  1. 개발 - 2. 서버 시동 - 3. 포스트맨 호출 - 4. 디버깅 - 5. 오류 수정 - 6. 서버 재시동 - 7. 포스트맨 호출 … 8. (반복) ...

여기에 특정 값에 의해 결과가 달라져야한다면, DB의 값을 추가 / 변경 하곤 했었습니다. 이미 저에게는 익숙한 플로우라 개발을 하는데 불편함은 없었지만 늘 고민은 개발 - 테스트 - 완료 한 사이클을 진행하는데 너무 많은 시간 비용을 투자하는 것이었습니다.

이런 저의 개발 플로우에 테스트코드를 적용해보니 아래와 같이 변경되었습니다.

  1. 개발 - 2. 테스트 코드 실행 - 3. 디버깅 - 4. 오류 수정 - 5. 테스트 코드 실행 … 6. (반복)

뭐가 크게 달라졌는가 싶지만, 위의 개발 - 테스트 - 완료 한 사이클을 진행하는데 시간이 절반 이하로 줄어들었습니다.

  1. 서버를 재시동 할 필요도 없고
  2. 포스트 맨을 통해 호출할 필요가 없었으며
  3. 무엇보다 테스트 대상의 mocking value만 변경하면 되니, db를 손대지 않아도 되니, 그 시간이 크게 줄었습니다

개발의 한 사이클의 시간을 줄이고 나니까, 그 시간에 여러 값을 mocking 해볼 수 있었고, 안보이던 버그나 오류들도 찾아낼 수 있었습니다.

2. 단위 테스트는 시스템에 대한 실제 문서를 제공 (with. python)

어떻게 메서드의 역할과 요구사항을 잘 전달하려고 하시나요 ?

비즈니스 사이드의 요청 사항은 늘 가변적이며, 그 히스토리를 파악하기 어렵습니다. 그래서 일반적으로 리뷰어나 다른 사람에게 요구 사항을 잘 전달하기 위해서는

  1. docstring을 추가하거나
  2. in-line 코멘트에 그 역할과 주의 사항을 적어놓거나
  3. method signature를 잘 작성하거나

등의 방법으로 요구사항과 히스토리를 전달합니다.

어떻게 남이 짜놓은 메서드를 파악하시나요 ?

이 기능이 어떤 기능인지, 어떤 맥락을 가지고 있는지를 확인하기 위해서는 코드 라인 한줄 한줄 따라 가야 합니다.

  1. 한 개의 메서드가 100줄, 200줄이라면 ?
  2. 만약 이런 메서드가… 100개.. 200개.. 1000개 라면 ?

기능하나를 이해하는데 러닝 커브가 기하급수적으로 증가하게 됩니다.

물론 대부분의 PR Conversation에 작업의 히스토리가 담겨있거나, 좀 더 체계적인 조직에서는 관련 문서가 모두 정리되어 있습니다. 하지만 이 모든 것을 갖춘 환경은 찾기 어렵고, 체계적으로 히스토리가 관리되어 있지는 않습니다.

아래 메서드에 담긴 히스토리는 무엇일까요 ?

아래 코드는 프로모션 할인 금액을 계산하는 코드입니다. 최대한 비즈니스 요구사항과, 맥락, 기능을 전달하기 위해 in-line 코멘트를 작성했습니다. in-line 코멘트에도 적혀있다시피 구 프로모션과의 관계, 구 프로모션과 신규 프로모션이 겹칠 경우, 결제 취소가 되는 경우 등등 요구 사항이 매우 복잡합니다.

def get_promotion_discount(self) -> int:

    PROMOTION_FREE_BENEFIT = 5

    # 3월 20일 까지는 구 프로모션만 적용한다.
    if settings.PROD_DB and datetime.date(2024, 3, 20) >= datetime.date.today():
        return self.get_legacy_es_union_promotion_discount()

    # 구 프로모션 혜택을 받을 수 있는 유저의 경우, 구 프로모션을 혜택만 적용한다.
    if self.user.extra.get('launching_promo_2312') and (discount_amount := self.get_legacy_es_union_promotion_discount()):
        return discount_amount

    # 론칭 프로모션 혜택을 받았던 라이딩이라면, 이후 결제 취소가 되더라도 재결제 시 동일한 론칭 프로모션 혜택을 받는다.
    if self.riding.extra.get('launching_promo_2403'):
        return self.calculate_discount(self.riding.pricing, PROMOTION_FREE_BENEFIT)

    # 유저 생애 최초 3회 라이딩의 경우, 신규 프로모션 혜택을 받는다.
    if self.user.ridings.exclude(id=self.riding.id).count() >= 3:
        return 0

    # 프로모션 혜택 받은 라이딩이라면, 별도의 슬러그를 추가한다.
    self.riding.extra['launching_promo_2403'] = True
    self.riding.save(update_fields=('extra', 'modified_at'))

    return self.calculate_discount(self.riding.pricing, PROMOTION_FREE_BENEFIT)

여러가지 장치로 메서드의 기능을 확인하기는 용이할지몰라도, 도메인 / 비즈니스 요구사항을 파악하기는 상대적으로 어렵습니다. 하지만 테스트 코드를 본다면 비즈니스를 이해하기 상대적으로 용이합니다.

테스트 코드를 보면 코드의 히스토리를 알기 편하다 !

def test_구_프로모션_기간_첫_이용_후_30일_이내에_시작한_하루_최초_라이딩은_신규_프로모션_혜택을_받을_수_있다()
    ...

def test_구_프로모션_기간_첫_이용_후_30일_이후에_시작한_하루_최초_라이딩은_신규_프로모션_혜택을_받을_수_없다()
    ...

def test_구_프로모션_기간_중_하루_두번_째_라이딩은_신규_프로모션_혜택을_받을_수_없다()
    ...

def test_신규_프로모션_이후_생애_첫_라이딩을_시작했고_총_라이딩_횟수가_3회_이하라면_첫_세번_무료_혜택만_받는다()
    ...

def test_신규_프로모션_이후_생애_첫_라이딩을_시작했고_총_라이딩_횟수가_3회_초과라면_어떤_혜택도_받지_않는다()
    ...

def test_신규_프로모션_이후_생애_첫_라이딩을_시작했고_전체환불의_경우도_횟수에_총_라이딩_횟수에_포함되기_때문에_3회_이상_라이딩을_했다면_혜택을_받을_수_없다()
    ...

def test_구_프로모션과_신규_프로모션_중복_대상자의_하루_첫_라이딩에는_구_프로모션_혜택을_받는다()
    ...

def test_구_프로모션과_신규_프로모션_중복_대상자의_하루_첫_라이딩이_아니라면_신규_프로모션_혜택을_받는다()
    ...

유닛 테스트 케이스를 보면,

  1. 이 코드가 어떤 역할을 해야 하는지
  2. 어떤 예외 사항을 처리 해야 하는지
  3. 어떤 맥락과 히스토리를 가지고 있는지

별도의 문서를 찾아보지 않더라도 코드의 역할을 쉽게 이해할 수 있습니다.

3. 결합도와 의존성이 낮은 코드 지향. 더 나은 코드를 위해 (with. golang)

어떻게 코드를 유지보수 하시나요 ? 어떤 관점으로 리팩토링 하시나요 ?

아래 코드는

  • 정책 옵션을 조회하여
  • 외부 쿠폰 플랫폼에게 grpc 요청을 통해 특정 기간 동안 사용한 쿠폰의 개수를 조회한 후
  • 정책 상 최대 사용 개수와 실제 사용 개수를 비교한 후
  • 다음 쿠폰을 발급할지를

결정하는 메서드입니다.

// checkMaxUsedCountIssuable 정책에 정의된 기간 내에 사용한 쿠폰의 최대 개수를 초과하지 않았는지 확인
func (svc CouponPolicyService) checkMaxUsedCountIssuable(memberId int64, policyId string, rule repository.Rule) (bool, error) {
	// 정책 옵션 조회
	policyOption, err := svc.getMaxUsedCountPolicyOption(rule)
	if err != nil {
		return false, err
	}
	
    // grpcClient 생성
	conf := config.NewConfig().GetConfig()
	grpcClient := client.NewGRPCClient(
		client.WithAddress(conf.CouponPlatformHost, "443"),
		client.WithInsecure(false),
		client.WithToken(conf.CouponPlatformGRPCToken),
	)

	location, _ := util.KSTLocation()
	interval := util.GetInterval(time.Now(), option.day, location)
    
    // 사용된 쿠폰 개수 조회
	res, requestErr := grpcClient.RequestListUsedCoupons(memberId, policyId, interval)
	if requestErr != nil {
		return false, requestErr
	}
   
    // 최대 사용 가능 개수와 실제 사용된 쿠폰 개수 비교
	issuable := option.count >= int32(len(res.Coupons))

	return issuable, nil
}

위 코드에 대한 통합 테스트(integration test)를 진행하려고 하니, 테스트가 불가했습니다. memberId int64, policyId string, rule repository.Rule과 같은 파라미터는 mocking 할 수 있지만 아래 코드는 mocking이 불가능 했기 때문입니다.

// grpcClient 생성
conf := config.NewConfig().GetConfig()
grpcClient := client.NewGRPCClient(
    client.WithAddress(conf.CouponPlatformHost, "443"),
    client.WithInsecure(false),
    client.WithToken(conf.CouponPlatformGRPCToken),
)

checkMaxCountIssuable() 매서드 내부에서 grpcClient를 생성하다보니, 강한 결합 때문에 실제 통신을 하지 않는다면 통합테스트는 불가했습니다. 하지만 테스트의 경우, 어떤 외부 요소의 영향을 받아서는 안되는 독립적으로 실행해야하기 때문에 원본 코드를 수정해야 했었습니다.

외부에서 의존성 주입하기 !

어떻게 수정할까 고민하다가 생성자에서 grpcClient를 주입해주었습니다. 이 때의 타입은 client 패키지에 정의되어 있는 IGPRCClient 인터페이스 입니다.

// coupon policy service 생성자
func NewCouponPolicyService(
	repository repository.ICouponPolicyRepository, 
    client client.IGPRCClient
) ICouponPolicyService {
	return CouponPolicyService{
    	Repo: repository, 
        Client: client
  	}
}

그리고 원본 코드를 수정하니 아래와 같이 변경되었습니다.

// checkMaxUsedCountIssuable 정책에 정의된 기간 내에 사용한 쿠폰의 최대 개수를 초과하지 않았는지 확인
func (svc CouponPolicyService) checkMaxUsedCountIssuable(memberId int64, policyId string, rule repository.Rule) (bool, error) {
	// 정책 옵션 조회
	option, err := svc.getMaxUsedCountPolicyOption(rule)
	if err != nil {
		return false, err
	}
	
    // grpcClient 호출
	client := svc.Client.GetGRPCClient()

	location, _ := util.KSTLocation()
	interval := util.GetInterval(time.Now(), option.day, location)
	
    // 사용된 쿠폰 개수 조회
	res, requestErr := client.RequestListUsedCoupons(memberId, policyId, interval)
	if requestErr != nil {
		return false, requestErr
	}

	issuable := option.count >= int32(len(res.Coupons))

	return issuable, nil
}

이제

  1. 테스트 코드에서 grpcClient를 mocking하여 RequestListUsedCoupons() 의 값을 변경 하면서 테스트 할 수 있습니다.
  2. 또한 객체의 메서드를 직접 호출하는 것이 아니라 인터페이스에 정의된 메서드를 통해서만 통신하다보니 상대적으로 느슨한 결합을 할 수 있었고,
  3. 외부에 의존하지 않고 기능으로만 테스트가 가능해졌습니다.

왜 테스트 코드를 작성해야 할까요 ?

  • 좋은 코드는 테스트하기 쉽다
  • 외부의 영향을 받거나 내부적으로 의존성을 가진 코드는 변화에 유연하지 못하다
  • 재사용하기 어렵다
  • “만약 내가 작성한 코드가 테스트하기 어렵다면 냄새나는 코드일 가능성이 높다”

테스트코드를 작성해야하는 이유를 다룬 여러 아티클에서 얻은 인사이트입니다. 실제 테스트 코드를 작성하다보니 내 코드가 얼마나 테스트 하기 어렵고, 내부의 의존성이 존재하고, 재사용하기 어려운지 한 마디로 냄새나는 코드였는지 한 번 더 알게 되었습니다.

안정성이 테스트 코드의 모든 이유는 아니다 !

이전에는 테스트코드의 목적이자 유일한 이유는 안정성이라고만 생각을 했었습니다. 하지만 실제 테스트 코드를 짜다보니 개발 주기, 문서화, 확장성 있는 유연한 코드 등 여러 이점이 있다는 사실을 이번 기회에 알게 되었습니다.

TDD,,, TDD,, Test Driven Development

이번에는 개발 - 테스트코드 - 리팩토링의 사이클을 경험했었는데요, 만약 테스트코드를 먼저 짜보았더라면 개발 사이클을 테스트코드-개발로 줄이면서 보다 빠르고 안전하며 확장성 있게 개발 할 수 있었지 않을까 싶은 생각이 들었습니다. 그리고 이게 사람들이 말하는 TDD가 아닐까 싶었습니다

억지인데요.. 내테내코 ! (내돈내산? 내테내코!)

내돈내산이라는 말이 있습니다. 내 돈으로 내가 산 것의 약자로 나의 실제 후기를 가장 잘 나타내는 말입니다.
이 글은 내가 테스트 짜보면서 내 코드를 개선 시킨 내용입니다. 이전까지는 저 역시도 테스트 코드를 왜 짜야하는지를 잘 몰랐습니다. 하지만 한 두개씩 테스트 코드를 작성하다보니 많은 이점이 있는 것을 알게되었습니다.

내테내코.. 라는 억지와 함께 글을 마무리 해보겠습니다.. 🙇‍♂️

0개의 댓글