출처: https://uxgjs.tistory.com/102 [UX 공작소]
스퀘어랩 기술블로그 로고
Engineering

Testing 101

전지훈|Jul 24, 2023

어떤 사람이든 자신의 코드에 대해 한번에 100% 확신을 할 수 있는 사람은 없을 것 같습니다. 저 또한 제가 짠 코드, 제가 생각한 구조에 대해 의심을 많이 하는 편인데요, 저 같은 사람들을 돕기 위한 개발 기법, 이를 위한 툴들이 많이 나와 있습니다. 오늘은 어떤 방법으로 소프트웨어의 안정성을 향상시킬 수 있을지, 스퀘어랩에서는 어떻게 하고 있는지 이야기해 보려고 합니다.

오늘 이야기는 테스팅에 익숙하지 않은 사람들을 위해 주로 제가 Extreme Programming, TDD의 개념을 처음 접했을 당시의 경험, 근 5~6년간 실무에서 Unit testing, CI/CD 등을 이용해 안정성 확보를 위해 해왔던 노력들을 기반으로 최대한 쉽게 이야기 해보도록 할게요.

Stability

Stability

어떤 소프트웨어가 Stable하다고 말할 수 있을까요?

그 조건을 단순하게 이야기하면 어떠한 상황에서도 의도한 대로 동작한다 일 것 같습니다. 상황이란 코드의 수정, 예상 밖의 리소스 프레셔, 과도한 트래픽 등 여러가지가 있을 수 있을텐데 오늘은 코드가 수정되었을 때에도 의도대로 동작하도록 하는 시스템적인 방법에 대해 이야기 해보려고 합니다.

코드는 작성되는 순간 계속해서 수정되어가야 한다는 운명을 가지고 태어납니다. 지금 아무리 잘 만들어둔 코드여도 시간이 지나고 기능적으로 다듬어져감에 따라 어느 순간에는 수정될 가능성이 아주 높죠. 이 과정에서도 항상 필요한 기능을 수행할 수 있어야 안정적인 소프트웨어라고 할 수 있을텐데요, 지금 코드를 작성한 내가 자주 조금씩 수정해 나간다면 안정성을 유지해 나가는데 별 문제가 없을 수도 있지만, 어떤 형태로든 팀에 속해있다면 내가 방금 짠 코드의 유지보수를 앞으로도 계속 내가 하지 않을 확률은 100%에 가깝습니다. 내가 방금 짠 코드의 컨텍스트를 미래의 나조차 잃어버릴 확률 또한 100%에 가깝구요. 그렇기 때문에 안정성 확보 여부가 사람에게 달려있지 않도록 도구와, 이를 사용하는 시스템이 꼭 필요합니다.

날고긴다는 사람들 다 모아서 만든 우주선마저 소프트웨어 결함으로 문제가 생기는걸 보면
천재들만 모아놔도 알아서 안정성이 확보되는건 아닌가봅니다.

그런데 소프트웨어의 기능적인 안정성은 실제로 코드를 실행시켜 보지 않고는 확인 자체를 할 수가 없습니다. 그 대상이 서버든, 앱이든, 웹이든, 임베디드 시스템이든 심지어 자동차와 같은 하드웨어의 경우에도 방법이 조금씩 다르겠지만 이 사실 자체는 변하지 않습니다. 그렇다고 코드를 작성할 때마다 코드를 일일이 실행시키고 로직을 전부 수행해 보는 것 또한 쉬운 일이 아니죠. 프로젝트를 진행하면서 많은 케이스들을 접하다 보면 직접 테스트 해보기가 어려운 경우가 실제로도 많이 있습니다.

다행히 이런 경우 우리는 여러 가지 방법으로 자동화된 소프트웨어 테스트를 할 수가 있습니다.

Testing

테스트라는 개념 자체는 사실 우리에게 아주 익숙합니다.

조명의 전구를 갈아끼우고 전기를 연결해 켰다껐다 해보거나, 새로 산 이어폰을 귀에 꽂아본다거나 하는 모든 것들이 테스트입니다. 그리고 이러한 현실세계의 테스트들은 앞서 말했듯 직접 수행을 전제로 합니다.

그런데 스퀘어랩에서 여행 서비스를 만들다 보면 이런 경우를 만나게 됩니다.

2023년 7월 7일 0시0분0초 까지 무료취소가 가능한 호텔 예약이 있다면, 7월 7일 0시0분0초가 지나는 순간 무료 취소가 불가능해야 되겠죠. 이게 정말인지 알려면 호텔에 예약을 한 뒤에 무료 취소 기한이 끝나기 직전, 정시, 직후에 직접 취소 요청을 해보고 어떻게 되는지 확인해 봐야 할거에요. 이런 경우라면 테스트 조건을 맞추는 것 자체도 거의 불가능 할테고 무료 취소가 되지 않는 경우에 대해서도 테스트를 해야 할테니 실제 예약을 하고 테스트를 해보고 취소 수수료를 무는 과정을 반복해야겠군요. 아니 한 번 테스트할 때마다 돈을 내야하다니…

사실상 이런 케이스를 직접 테스트하기는 불가능에 가깝습니다.

하지만 우리의 코드는 다행히도 소프트웨어 세상 안에 있죠. 이런 저런 상황의 시뮬레이션 또한 현실보다 훨씬 쉽습니다. 취소 수수료를 물어야 하는 예약 또한 실제로 예약을 하지 않고도 한 것처럼 만들어줄 수 있고요! 이를 위해 우리가 이야기해볼 것은 바로 유닛 테스트입니다.

Test Driven Development

TDD

TDD라는 개념을 아시나요? 제가 처음으로 이 개념을 접한 것은 Extreme Programming이라는 것이 붐이 되기 시작한 2007년 정도였던 것 같습니다. 지금은 우리에게 너무나 친숙한 애자일 방법론 또한 이 때부터 유명해 지기 시작했고, 제가 있던 팀에서도 이 때부터 데일리 스크럼이나 스프린트 단위의 개발 등을 도입했었습니다. TDD, Test Driven Development도 그 중 하나였구요.

TDD는 말 그대로 테스트를 중심으로 하는 개발 방법론입니다. 어떤 로직은 항상 거기에 대한 테스트를 수반해야 하고, 테스트는 어떠한 로직의 수정이 있더라도 실패해서는 안됩니다. 심지어 로직을 작성하기 전에 테스트를 먼저 모두 작성해 두고, 이 테스트들이 성공하는걸 목표로 로직을 작성하기도 합니다.

테스트는 쉽게 말해
“cancelPayment 함수를 실행한 뒤에는 항상 Payment가 canceled상태로 바뀌어 있어야 해”
라는 조건을 강제하는 것과 같습니다.
cancelPayment 내부에서 무슨 일이 일어나든 함수가 정상적으로 수행된 뒤에 Payment는 canceled 상태여야 하는 것이죠.

테스트를 중심으로 개발하자는 방법론의 핵심은 잘 작성된 테스트가 계속해서 코드에 남아 우리 코드가 어떤 모습으로 바뀌든 특정한 결과물을 내도록 강제하고, 그럼으로써 어플리케이션의 품질을 유지하도록 도와준다는 것입니다.

Unit test

이제 어떻게 하면 유닛 테스트를 잘 작성할 수 있는지 알아보도록 하죠.

테스트는 크게 유닛테스트(Unit test)와 통합테스트(Integration test)로 나뉩니다.
오늘은 그 중 특정 모듈의 기능을 독립적으로 테스트하는 유닛테스트의 기초적인 기법들에 대해 이야기해 보겠습니다

Unittest

유닛테스트가 지켜야 하는 조건으로 보통 아래 세 가지가 거론됩니다.

  1. 작고 단순한 하나의 로직을 대상으로 해야 하고
  2. 빠르게 실행되어야 하고
  3. 독립적이어야 한다

음… 와닿으시나요? 실제로 학계에서도 로직이 얼마나 작아야 하는지, 얼마나 독립적이어야 하는지에 대한 해석에 따라 유닛테스트를 대하는 일종의 파벌이 나뉘기도 합니다(고전파와 런던파).

오늘은 너무 깊이 들어가지는 않기로 했으니 기초적인 예시를 이용해 유닛 테스트를 이해해 보도록 하죠. 우선 앞서 들었던 cancelPayment 함수의 예를 아래와 같이 타입스크립트로 간단하게 작성해 보겠습니다.

async cancelPayment(paymentId: string, amount: number): Promise<Payment> {
	// paymentId에 대한 validation
  const payment = await this.paymentRepository.findById(paymentId)
  if (!payment) {
    throw Exception(`No payment with id: ${paymentId}`)
  }

  // amount에 대한 validation
  if (payment.amount < amount) {
    throw Exception(`Cannot cancel ${amount} over payment amount ${payment.amount}`)
  }

  // Payment 업데이트
  const updated = await this.paymentRepository.update(
      paymentId,
      {
        status: 'canceled',
        cancelAmount: amount,
      },
  )
  if (!updated) {
    throw Exception(`Payment update failed for id: ${paymentId}`)
  }

  return updated
}

Test double

위 코드에서 cancelPayment는 paymentRepository의 기능들에 의존성을 가지고 있습니다. paymentRepository의 내부에서 에러가 발생했을 때 cancelPayment 또한 실패하리라는 것도 쉽게 알아챌 수 있죠. 어라 유닛테스트는 cancelPayment의 로직만을 독립적으로 테스트해야 하는데..?

이 때 아래 두 가지 버전으로 이러한 상황에 대한 해석을 할 수 있을 것 같네요.

  1. 안에서 어떤 일이 일어나든 cancelPayment는 성공해야 하니까 paymentRepository에서 에러가 발생했을 때 cancelPayment가 실패하는 것은 정상이야
  2. paymentRepository의 에러는 paymentRepository의 문제지 cancelPayment의 문제는 아니니 이 둘은 격리해야 해

1번은 paymentRepository 객체를 실제로 생성해서 cancelPayment 테스트를 해야 한다는 입장, 2번은 paymentRepository를 테스트 대역으로 대체해서 cancelPayment 테스트를 해야 한다는 입장입니다. 둘 다 일리가 있는 주장이므로 뭐가 맞고 틀린지는 이야기하지 않도록 하고, 오늘은 테스트 대역에 대한 설명을 해야 하니 2번에 좀더 무게를 두고 상황을 바라보도록 해요.

테스트 대역이란 유닛테스트의 독립성을 확보하도록 해주는 아주 중요한 개념입니다.

크게는 Mock, Stub의 두 가지 유형으로 나눠볼 수 있는데요, Mock(과 spy)은 테스트대상 로직이 호출하는 외부로 나가는 의존성에 대한 모방으로 실제로 어떤 값으로 호출이 되었는지를 확인하는 방식으로 사용되고, Stub(과 fake, dummy object)은 테스트대상 로직으로 들어오는 의존성에 대한 모방으로 어떠한 값이 들어왔다고 가정해야할 때 사용됩니다.

cancelPayment 예시에서는 paymentRepository의 동작에 따라 어떠한 로직이 동작할지 분기해야 하므로 paymentRepository의 동작을 stub 하는 방식으로 테스트를 작성해볼 수 있겠습니다. 이렇게 하면 paymentRepository가 어떻게 동작할지는 임의로 가정해 두고 cancelPayment의 로직만 온전히 테스트할 수 있습니다.

여기서는 언어는 javascript를, stub을 위해 Sinon.js, 테스트케이스 실행을 위해 mocha를 사용합니다.

describe("cancelPayment", () => {
  it(`should throw when payment doesn't exist with paymentId`, async () => {
    // paymentRepository.findById가 undefined를 리턴한다면
    sandbox.stub(paymentRepository, "findById").returns(undefined);

    // cancelPayment는 No payment with id: paymentId 에러를 throw해야 한다
    expect(() => cancelPayment("paymentId", 10)).throws(
      "No payment with id: paymentId"
    );
  });
});

paymentId에 해당하는 payment가 없어서 paymentRepository.findById가 undefined를 리턴한다면, cancelPayment 입장에서는 취소할 payment가 없으니 Exception을 throw해 줘야 할테고, 위 테스트를 통해 실제로 Exception이 발생하는지 확인해볼 수 있습니다.

Stub을 사용하지 않았다면 paymentRepository가 실제로 특정 paymentId에 대해 undefined를 리턴하도록 DB를 세팅해 두고 테스트를 했어야 할테니 테스트 환경 구축이 좀더 번거로웠을 텐데 Sinon이라는 좋은 툴이 있어 정말 다행이라는 생각이 드네요.

AAA

유닛테스트를 구성하는 패턴으로 AAA라는 것이 있습니다.

테스트는 Arrange(테스트 환경 구성), Act(테스트 대상 로직 실행), Assert(결과 검증)의 순서로 작성되어야 한다는 뜻인데요, 위 테스트는 아래와 같이 생각해볼 수 있겠어요.

  • Arrange: paymentRepository.findById가 undefined를 리턴하도록 함
  • Act: cancelPayment(’paymentId’, 10)을 호출
  • Assert: “No payment with id: paymentId”라는 에러를 냈는지 검증

모든 테스트를 이러한 패턴에 맞춰 작성하게 되면 테스트들이 단순하고 균일한 구조를 갖게 해주므로, 테스트를 읽을 때에도 좀더 쉽게 이해할 수 있습니다. 이렇게 되면 결국 테스트 전체의 유지 보수 비용이 줄어드는 효과도 낼 수 있겠죠.

많이들 사용하는 Given-When-Then 패턴과도 동일합니다

Caution

유닛테스트에는 피해야 하는 이른바 안티패턴들이 있습니다.

대부분 테스트 대상 로직이 하나여야 한다는 제약사항을 생각해 보면 쉽게 피할 수 있는 것들인데 몇 가지 예시를 들어 보겠습니다.

너무 작은, 너무 큰 로직에 대한 테스트

이는 테스트 자체의 안티패턴은 아니지만, 테스트의 대상이 되는 로직이 너무 작거나 너무 큰 경우에는 아예 테스트를 작성하지 말아야 한다는 기준입니다.

너무 작은 로직의 경우는 테스트 자체가 별로 의미가 없는 경우가 많기 때문이고, 너무 큰 로직의 경우는 보통 내부에서 수행하는 일이 다양하기 때문인데 이는 테스트가 하나의 로직만 검증해야 한다는 일관성을 깹니다. 개인적으로는 로직이 조금 크더라도 수행하는 일이 한가지라면 테스트 대상에 포함시키는 것이 맞다고는 생각합니다.

테스트 내부의 분기처리

테스트 내부에 if문이 있는 것은 대표적인 안티패턴입니다. 테스트는 항상 분기가 없는 아주 간단한 일련의 코드여야 하는데 반해, if문이 있다는 것은 테스트가 너무 많은 것을 한번에 검증하려 한다는 뜻이고, 이런 경우는 여러 개의 테스트로 분리되어야 합니다.

여러개의 AAA 단계들

Arrange - Act - Assert - more Act - another Assert 와 같은 테스트를 볼 때가 있습니다. 물론 이 것 자체가 잘못된 것은 아니지만, 이 테스트는 더이상 유닛테스트가 아닌 통합테스트입니다. 로직 자체를 검증하는 것이 아닌 여러 개의 동작단위를 검증하기 때문인데요, 유닛테스트를 작성할 때에는 피하는 것이 좋습니다.

유닛테스트를 작성하려 하는데 위와 같은 패턴이 보인다면 하나의 동작을 하나의 테스트로 도출하도록 리팩토링하는 것이 좋습니다.

테스트 꼭 있어야 하나요?

앞서 말한 몇 가지 경우를 제외한 대부분의 경우 테스트는 꼭 있어야 합니다.

하지만 꼭 유닛테스트일 필요도, 꼭 통합테스트일 필요도 없고, 지금 이 코드의 안정성을 어떻게 하면 보강해 줄까에 대한 시스템적인 답이면 됩니다. 적어도 코드를 올리기 전에 어떻게 하면 안정성을 보강해 줄 수 있을지 한 번 더 생각하는 것 만으로 좋은 시작이라고 생각해요. 아, 그리고 코드가 복잡해서 테스트를 작성하기 너무 어렵다고 생각된다면 그보다 좋은 리펙토링 시그널은 없으니 감사하게 생각하며 리펙토링을 하면 됩니다.

가령 쿠폰 조합에 따라 할인액을 계산해 주는 함수는 그 자체의 계산 로직이 중요하므로 유닛테스트를, 예약을 동시다발적으로 일으켰을 때 DB의 무결성이 깨지지 않는지 확인하기 위해서는 실제 DB를 붙인 통합테스트를 수행하는 식으로요. 외부 서버의 API를 호출해주는 프록시 서버라면 에러를 쉽게 확인할 수 있도록 로깅 정도만 해두고 테스트는 굳이 작성하지 않기로 할 수도 있겠죠.

물론 작성할 할 코드들은 많고 지금 내가 테스트했을 때 잘 돌아가는 코드라면, 굳이 테스트 코드를 작성하지 않고 얼른 다음 할일로 넘어가고 싶은 마음이 저 또한 굴뚝같습니다. 하지만 저도 믿지 못하는 내일의 저를 위해… 그리고 나중에 이 복잡한 코드를 볼 미래의 우리 팀원을 위해 아무래도 테스트는 작성하는게 맞는 것 같습니다.

오늘은 기초적인 내용을 다루었기 때문에 빨리빨리 넘어간 부분도 있고 좀더 심도있게 이야기 하면 또 하나의 포스트를 쓸 수 있을만한 내용도 가볍게 언급만 하고 넘어가기도 했습니다. 테스팅에 대한 개념은 알고 있지만 직접 작성해본 경험이 없는 분들이 이 포스트를 보고 한번 시도해 봤으면 하는 마음에서요.

특히 테스팅의 부차적인 이점들, 유닛테스트와 통합테스트의 구분, 좋은 테스트를 작성하는 방법 등 좀더 이야기해보고 싶은 주제들은 꽤 있으니 관심 있는 분들은 좀더 자세한 내용을 찾아보시길 권하고 싶습니다.

다른건 모르겠고 제가 생각하는 테스트의 가장 큰 장점은 "이게 있어야 마음이 편하다" 인거 같습니다