DEV_BBAK
← 포스트

무엇을 테스트할지는 추상화가 정한다

·19 min read·로딩중...

무엇을 테스트할지는 추상화가 정한다

최근에 리뷰를 하다가 이런 코멘트를 봤다.

굳이 우리가 이런 것까지는 테스트할 필요 없지 않나.

이 모달이 닫히면 어떤 사이드이펙트가 일어나는지 확인하는 테스트였다. 그 코드는 선언적으로 쓰여 있어서 술술 읽혔다. 나도 수긍했다.

그런데 수긍하면서도 남는 게 있었다. 내부에서 일어나는 일은 여전히 테스트가 필요해 보였다. 그럼 어디까지가 테스트할 내부이고, 어디부터는 안 해도 되는 걸까.

에이전트는 쓰고, 나는 읽는다

이 질문이 자주 떠오르는 건 에이전트랑 개발을 많이 하면서부터다. 테스트코드를 직접 작성하는 일은 많이 줄었고, IDE도 잘 켜지 않는다. 대신 에이전트가 테스트를 무지막지하게 많이 쓴다.

코드를 쓰지 않게 됐다고 확인까지 던지는 건 아니다. 제품의 최종 결정권자는 여전히 개발자 본인이다. 확인은 한다. 내가 작성한 코드라도 PR로 확인하는 경우가 꽤 많은데, PR을 열면 테스트부터 읽는다. 검증하려고 읽는 게 아니라 이 작업이 뭔지 파악하려고 읽는다. 그런데 테스트가 너무 많으면 이 파악이 잘 안 된다. 뭘 테스트하고 있는 건지 확인하는 것부터 노이즈가 꽤 된다.

사라질 테스트와 남을 테스트

테스트는 크게 두 종류다.

UI 집약적인 테스트는 요구사항이 조금만 바뀌어도 금방 사라질 테스트다. 모달의 위치가 바뀌고, 플로우가 재배치되고, 문구가 수정되는 순간 고쳐 쓰는 게 아니라 지우게 된다.

반면 추상화된 레이어에 대한 테스트는 훌륭하다. 입력과 출력이 정해진 계산, 상태 전이, 외부와의 약속. 이런 테스트는 UI가 아무리 바뀌어도 잘 바뀌지 않는다. 차이는 테스트가 어디에 붙었느냐다.

그 리뷰도 이 차이를 짚고 있었다. "이 모달이 닫히면 어떤 사이드이펙트가 일어난다"에서 테스트할 가치가 있던 건 모달이 아니라 사이드이펙트 쪽이다. 모달은 요구사항이 바뀌면 함께 바뀌는 부분이고, 사이드이펙트는 그 안에서 일어나는 내부다.

같은 화면의 두 테스트

구독 취소 모달을 예로 보자.

<CancelModal open={isOpen} onClose={close} onConfirm={cancelSubscription}>
  <Modal.Title>구독을 취소할까요?</Modal.Title>
  <Modal.Description>취소하면 남은 기간은 일 단위로 환불됩니다.</Modal.Description>
  <Modal.Actions>
    <Button onClick={close}>계속 쓸게요</Button>
    <Button variant="danger" onClick={cancelSubscription}>구독 취소</Button>
  </Modal.Actions>
</CancelModal>

이 정도로 선언적으로 쓰인 화면은 술술 읽는다. 무엇이 뜨는지, 어떤 문구가 적혀 있는지, 각 버튼이 무슨 일을 하는지가 JSX 그대로다. 읽히는 코드는 코드 자체가 명세다.

여기에 붙는 테스트는 대략 이런 모습이 된다.

test('닫기 버튼을 누르면 모달이 닫힌다', async () => {
  render(<CancelModal open onClose={close} onConfirm={cancelSubscription} />)

  await user.click(screen.getByRole('button', { name: '계속 쓸게요' }))

  expect(close).toHaveBeenCalled()
})

이 테스트가 확인하는 건 '계속 쓸게요'를 누르면 close가 호출된다는 사실이다. 그런데 그 사실은 위 JSX에 이미 적혀 있다. 테스트는 이미 말한 걸 다른 문법으로 한 번 더 쓴 것에 가깝다. 문구가 바뀌면 셀렉터가 부러지고, 플로우가 재배치되면 통째로 지운다.

같은 화면의 다른 쪽을 보자. 구독 취소를 누르면 환불액을 정산해야 한다. 이 일은 화면 어디에도 선언적으로 드러나지 않는다.

function calculateRefund(monthlyFee: number, usedDays: number) {
  const dailyFee = monthlyFee / 30
  const usedFee = Math.min(monthlyFee, dailyFee * usedDays)
  return Math.floor(monthlyFee - usedFee)
}

30일 기준으로 일 단위 정산을 하고, 쓴 만큼 빼고, 남은 만큼 돌려준다. 규칙은 이 세 줄 안에 다 있지만 이 규칙이 맞는지는 읽는 것만으로 알 수 없다. 실행해 보기 전까지 알 수 없는 것들, 그러니까 계산과 상태 전이와 사이드이펙트에는 테스트가 곧 명세가 된다.

test('사용하지 않았으면 전액을 환불한다', () => {
  expect(calculateRefund(9900, 0)).toBe(9900)
})

test('며칠 쓰다가 취소하면 남은 기간만큼 환불한다', () => {
  expect(calculateRefund(9900, 10)).toBe(6600)
})

test('31일을 썼어도 환불액은 음수가 되지 않는다', () => {
  expect(calculateRefund(9900, 31)).toBe(0)
})

이 테스트들을 읽으면 환불 정책이 뭔지 코드보다 먼저 알 수 있다. 그래서 PR을 열면 이런 테스트부터 읽게 된다. 회귀를 걱정해서가 아니라 이 작업이 뭔지 파악하려고. 그리고 이런 테스트는 모달이 몇 번 다시 디자인되어도 잘 바뀌지 않는다.

술술 읽히는 쪽은 코드가 명세고, 읽어도 알 수 없는 쪽은 테스트가 명세다.

상태 전이는 표가 된다

주문이나 결제처럼 상태 전이가 복잡한 도메인은 어떨까. 결제 대기, 결제 완료, 배송 시작, 취소, 환불. 상태와 이벤트가 늘어나면 조합이 금방 커진다. 그래도 종류가 달라지는 건 아니다. 상태 전이도 함수다. 현재 상태와 이벤트를 받아서 다음 상태를 돌려주는.

전이 규칙이 화면의 이벤트 핸들러에 흩어져 있으면 읽기도 테스트도 안 된다. 모으면 하나의 머신이 된다.

function nextOrderState(order: Order, event: OrderEvent): Order {
  switch (event.type) {
    case 'PAYMENT_SUCCEEDED':
      return { ...order, status: 'PAID' }

    case 'CANCEL_REQUESTED':
      if (order.status === 'SHIPPED' || order.status === 'DELIVERED') {
        return order
      }
      return { ...order, status: 'CANCELLED' }

    case 'TIMEOUT':
      return order.status === 'PAYMENT_PENDING'
        ? { ...order, status: 'CANCELLED' }
        : order
  }
}

그러면 테스트가 표가 된다.

test.each`
  from                 | event                 | to
  ${'PAYMENT_PENDING'} | ${'CANCEL_REQUESTED'} | ${'CANCELLED'}
  ${'PAID'}            | ${'CANCEL_REQUESTED'} | ${'CANCELLED'}
  ${'SHIPPED'}         | ${'CANCEL_REQUESTED'} | ${'SHIPPED'}
  ${'PAYMENT_PENDING'} | ${'TIMEOUT'}          | ${'CANCELLED'}
`('$from 상태에서 $event를 받으면 $to가 된다', ({ from, event, to }) => {
  expect(nextOrderState(orderIn(from), eventOf(event)).status).toBe(to)
})

이 테스트를 읽으면 어느 상태에서 무엇을 할 수 있고 무엇을 할 수 없는지가 줄로 나온다. 제일 가치 있는 줄은 무효한 전이다. 배송이 시작된 주문은 취소할 수 없다는 규칙은 UI 어디에도 보이지 않는다. 시간 의존도 이벤트로 만들면 표에 들어온다. 결제 대기 30분 타임아웃은 TIMEOUT 이벤트 하나고, 시계만 주입하면 테스트할 수 있다.

모달이 close를 부르는 얇은 연결이었듯 결제 화면은 이벤트를 만드는 얇은 연결이 된다. 화면은 현재 상태를 선언적으로 보여주고 전이 규칙은 함수가 맡는다. 상태가 복잡할수록 밀어넣을 게 많아질 뿐 방식은 같다.

다만 상태의 주인이 누구인지는 먼저 정해야 한다. 주문 상태의 주인은 대개 서버다. 그러면 프론트는 서버 상태를 투영해서 보여주고 사용자의 의도를 요청으로 보내는 일만 남는다. 프론트가 테스트할 내부는 그 사이의 것들이다. 취소 가능 여부를 판단하는 규칙, 상태별 화면 매핑, 금액 계산.

테스트하기 좋다는 건 추상화의 신호다

"테스트하기 좋은 코드가 잘 작성된 코드다"라는 말이 있다. 요즘은 이 말을 조금 다르게 읽는다. 테스트하기 좋다는 건 테스트하려는 대상이 이미 경계를 갖고 있다는 뜻이다. 입력과 출력이 정리되어 있고, 요구사항 변화에서 한 발 떨어져 있다. 반대로 무엇을 테스트할지 모르겠다면 그건 테스트 기술이 부족해서가 아니라 추상화가 아직 안 됐다는 신호다.

탁월한 추상화는 테스트하기 좋다. 이 문장은 반대로도 읽힌다. 잘 추상화된 코드는 테스트하기 좋고, 테스트하기 좋아지고 싶은 코드는 추상화부터 다시 해야 한다.

그리고 나눈 조각은 다시 읽혀야 한다. es-toolkit의 flowAsync 같은 함수로 잇는 방법이 있다.

const cancelSubscription = flowAsync(
  fetchSubscription,
  calculateRefund,
  requestRefund,
)

이 핸들러는 왼쪽에서 오른쪽으로 읽힌다. 구독을 가져오고, 환불액을 계산하고, 환불을 요청한다. 연결까지 읽히면 연결을 위한 테스트는 줄어든다. 남는 건 조각마다의 유닛 테스트와 전체가 실제로 도는지 볼 통합 테스트다.

다만, 통합 테스트는 어떻게?

유닛 테스트는 읽기가 쉽다. 테스트를 읽으면 함수가 어떻게 돌아가는지 알 수 있다. calculateRefund의 테스트 세 줄이 그랬다. 통합 테스트도 같은 질문을 한다. 이 테스트를 읽으면 앱이 어떻게 돌아가는지 알 수 있는가.

첫 답은 이미 위에 있다. flowAsync로 합쳐 둔 cancelSubscription을 그냥 호출하면 된다.

test('구독을 취소하면 남은 기간만큼 환불된다', async () => {
  server.use(
    subscriptionOf({ monthlyFee: 9900, usedDays: 10 }),
    cancelRefundsTo({ status: 'CANCELLED', refund: 6600 }),
  )

  const result = await cancelSubscription('sub_1')

  expect(result).toEqual({ status: 'CANCELLED', refund: 6600 })
})

화면을 렌더하지 않는다. 가짜 서버를 상대로 가져오고, 계산하고, 요청하는 순서가 실제로 맞는지를 본다. 셀렉터도 클릭도 없으니 빠르고 잘 안 깨진다. 요청이 정확했는지까지 보려면 가짜 서버를 실제 서버처럼 행동하게 만들면 된다. 받은 요청을 확인하고 답하는 서버. 그러면 검증은 여전히 결과 한 곳에 있다.

이 테스트가 못 보는 건 연결이다. 이 함수를 확인 버튼이 진짜 부르는지, 응답을 화면이 진짜 보여주는지. 그런데 그 연결이 onConfirm={cancelSubscription} 한 줄이면 읽히는 코드고, 읽히는 쪽은 코드가 명세니까 테스트하지 않는다.

연결이 한 줄이 아닐 때만 화면을 지나는 테스트를 쓴다. 여러 단계를 걸쳐야 하거나, 조건에 따라 갈리거나, 중간 상태 자체가 사용자에게 의미가 있을 때다. 그럴 때는 이렇게 쓴다.

test('구독을 취소하면 남은 기간만큼 환불된다', async () => {
  // Given: 구독 중이고, 취소하면 서버는 남은 기간만큼 환불한다
  server.use(
    subscriptionOf({ monthlyFee: 9900, usedDays: 10 }),
    cancelRefundsTo({ status: 'CANCELLED', refund: 6600 }),
  )

  renderSubscriptionPage()

  // When: 사용자가 구독을 취소한다
  await user.click(await screen.findByRole('button', { name: '구독 취소하기' }))
  await user.click(screen.getByRole('button', { name: '구독 취소' }))

  // Then: 화면은 취소된 구독과 환불액을 보여준다
  expect(await screen.findByRole('status')).toHaveTextContent('6,600원')
})

이 테스트는 앞에서 봤던 '닫기 버튼을 누르면 모달이 닫힌다'와 같은 자리에 있지만 붙는 지점이 다르다.

시작의 두 줄은 서버와의 계약을 정한다. 구독 상태와, 취소했을 때 서버가 무엇을 돌려주는지다. 환불이 실제로 이뤄지는 건 서버의 행동이라 이 테스트의 검증 대상이 아니라 전제가 된다.

버튼을 찾아 누르는 두 줄은 사용자와 같은 경로다. 화면 내부를 직접 만지는 대신 누르고, 내부 함수는 하나도 갈아끼우지 않는다. 그래서 요청을 보내고 응답을 받아 화면에 옮기기까지의 코드가 전부 진짜로 돈다.

마지막 줄은 화면에서 확인한다. 프론트엔드의 행동은 번역이다. 사용자의 의도를 서버로 옮기고, 서버의 결과를 사용자에게 옮긴다. 백엔드 통합 테스트는 서버의 상태를 들여다보지만, 프론트엔드에서 그 결과를 받는 사람은 사용자다.

이렇게 쓰면 테스트가 한 문장으로 읽힌다.

구독 중인 사용자가 취소하면 화면은 환불액을 보여준다.

그래도 '이런 것도 테스트해야 해요?'라는 질문은 사라지지 않는다. 그 질문은 유지 비용에 대한 것이고, 비용을 물을 권리는 누구나 있다. 달라지는 건 답이다. 이 테스트는 왜 있어야 하냐는 질문에 그 한 문장으로 답할 수 있다. 그 시나리오가 비용을 지불할 만한지는 그 문장을 놓고 정하면 된다. 요청의 모양을 검증하는 테스트는 답할 문장 자체가 없다.

그리고 이 테스트가 확인하는 것은 계산이 아니다. 몫이 나뉘어 있다.

  • 6,600원이 맞는 금액인지는 calculateRefund 테스트가 보증한다.
  • 언제 전액 환불이고 언제 일할 계산인지는 그 규칙의 유닛 테스트가 보증한다.
  • 그 계산과 규칙이 이 화면에 연결돼 있는지는 이 테스트가 확인한다.

경우의 수를 이쪽으로 가져오지 않는 한 시나리오마다 하나씩이면 충분하다.

이 테스트의 죽는 지점은 문구다. 버튼의 '구독 취소하기'도 결과의 '6,600원'도 바뀔 수 있다. 다만 6,600원이라는 값은 서버와의 계약에서 온 것이니 문구보다는 오래 산다.

잘 쓴다는 건 안 죽게 만드는 게 아니라 수리 비용을 작게 두는 것이다. 쿼리는 role로 찾고, 문구는 결과의 본질만 보고, 개수를 적게 둔다.

이제 리뷰에서 테스트를 보면 어디에 붙어 있나부터 본다. 오래갈 곳에 붙어 있으면 남기고, 요구사항과 함께 바뀔 곳에 붙어 있으면 걷어낸다.

처음 그 코멘트를 달았던 리뷰어도 아마 같은 걸 보고 있었을 거다.