단위 테스트로 복잡한 도메인의 프론트 프로젝트 정복하기(feat. Jest)

Aug.18.2022 이찬호

QA/Testing WEB Web Frontend

최근 저는 복잡한 도메인의 서비스를 개발하는 개발자라면 공감할 만한 문제를 겪고 있었습니다.

찬호 님, 서비스가 비마트일 때, 공급사 반품 상세페이지에서 공급사 계정으로 로그인한 사용자가 역분개한 상품의 마감일을 변경할 수 있나요?

개발을 하다 보면 PM으로부터 이런 질문이 자주 들어오는데, 예전 같으면 제가 아는 선에서 바로 대답할 수 있었을 간단한 질문에도 최소 10분은 하던 일을 멈추고 확인해야 할 만큼 플랫폼이 복잡해졌습니다. 위 예시는 서비스가 복잡해지면 생기는 여러 문제 중 하나라는 건 공감하실 거라 생각합니다. 확인뿐 아니라 기존 코드를 리팩토링하는 것도, 그 코드에 새 기능을 추가해달라는 것도 개발 초기보다 훨씬 어려워졌죠.

이 글에서는 실제 개발 중인 서비스에 단위 테스트를 도입해 본 경험을 공유합니다. 프론트 테스트에 대해 검색하면 너무 간단한 예시들만 있어서, 현재 서비스에 작성된 실제 코드를 예시로 글을 쓰고 싶었습니다. (무려 팀장님 허락받고 실제 운영 중인 코드를 예시로 사용했습니다!) 제가 작성한 옛 코드를 단위 테스트로 검증하고, 검증된 테스트를 등에 업고 기존 코드를 리팩토링하는 과정을 단계별로 작성했습니다. 실제 운영 서비스를 예시로 사용하다 보니 역분개와 같은 전문용어가 나오지만, 글의 맥락을 이해하는 데엔 영향을 주지 않습니다. 저와 같은 고민을 하고 계시는 분들에게 많은 도움이 되길 바랍니다!

SCM을 개발할수록 걱정이 늘어간다

SCM(Supply Chain Management)은 공급망 관리 플랫폼으로, 공급자로부터 고객에게 이르는 전체적인 연결고리 관계와 구매/재고/물류/정산관리 등의 여러 기능을 포함하고 있는 서비스입니다. SCM을 플랫폼 화하여 우아한형제들 안에 있는 여러 서비스들(배민상회, 비마트, 배민문방구 등)에서 사용하고 있습니다.

SCM이라는 도메인 자체가 복잡도가 높은데, 이를 플랫폼화하면서 복잡도가 더 높아졌습니다. 기존에 각 서비스에서 운영하던 것을 하나로 합치다 보니 플랫폼이지만 서비스만을 위한 기능들이 불가피하게 들어가는 경우가 생겼기 때문입니다.

위 이미지는 SCM 내에서 가장 복잡도가 높은 발주서 상세 페이지(a.k.a 대마왕...) 중 상품 정보를 보여주는 컴포넌트입니다. 이 글에서는 상품 정보에 있는 마감일 열을 구현한 코드에 테스트를 추가하고, 리팩토링을 진행해 보려고 합니다.

코드를 작성하기에 앞서 발주서 상세 페이지에서 개발할 때 파악해야 하는 조건들을 살펴보겠습니다.

서비스 사용자 발주/반품 발주 유형 발주아이템상태
배민상회 내부사용자(MD) 발주 구매발주 발주요청
비마트 외부사용자(공급사) 판매발주 입고진행
배민문방구 외부사용자(물류사) 입고완료
외부사용자(공급사+물류사) 발주마감
발주대기
발주취소
공급사 반품 구매발주 반품 반품요청
판매발주 반품 반품진행
반품완료
반품마감
반품취소

각 열에 해당하는 것이 발주서 상세 페이지에서 확인해야 하는 조건들입니다. 각 상황마다 할 수 있는 동작이 다르거나, 보이는 화면이 달라져야 합니다. 조건에 대해 설명하면 다음과 같습니다.

  1. 서비스: 배민상회, 배민문방구, B마트 등 사용자가 어느 서비스의 사용자인지 확인하는 조건입니다.
  2. 사용자: 사용자의 Role도 다양합니다. 내부 사용자(MD), 외부 사용자(공급사, 물류사, 공급사+물류사). 각 사용자의 Role마다 해당 페이지에서 할 수 있는 기능이 다릅니다.
  3. 발주/반품: 발주서가 발주용 발주서인지, 공급사 반품용 발주서인지도 다르고요.
  4. 발주 유형: 이 발주 유형은 발주/반품에 따라 구매발주/판매발주, 구매발주 반품/판매발주 반품으로 나뉩니다.
  5. 발주 아이템 상태: 마지막으로 하나의 발주서엔 여러 개의 상품이 들어가는데, 이 발주 아이템마다 상태가 존재합니다. 발주요청, 발주 대기, 입고 진행, 입고 완료, 마감 완료. 그리고 각 상품의 상태별로 사용자가 상품에서 변경할 수 있는 값이 다 다릅니다.
WarehouseInfo ManagementButtons

화면에 보이는 컴포넌트는 간단한 센터 정보와, 상품 정보 테이블뿐이지만, 상품 정보에 들어가는 코드는(모달 제외) 총 1700줄이 넘습니다. 너무 코드가 길어 관리 열에 해당하는 코드를 ManagementButtons.tsx라는 컴포넌트로 분리했습니다.

이렇게 도메인도 복잡해지고, 코드도 복잡해지다 보니 걱정거리가 생겼습니다.

  1. 발주 상세 페이지에 새로운 기능을 추가하기가 두려워졌습니다.
  2. 리팩토링을 하고 싶은데 복잡해서 리팩토링할 엄두가 나질 않았습니다.
  3. PM 분들이 어떻게 동작하는 건지 질문을 해와도 파악하는 데 시간이 오래 걸렸습니다.
  4. 저 말고 다른 사람이 이 코드를 보면서 작업할 수 있을지 두려웠습니다.

서비스가 살아있고 요구사항은 계속 생기는데, 코드가 복잡해졌다고 개발자가 걱정하고 두려워하고만 있으면 안 되겠죠. 테스트를 통해 이 두려움을 뚫어봅시다!

단위 테스트와 적용 방식

출처: Why Test-Driven Development (TDD), marsner, (https://marsner.com/blog/why-test-driven-development-tdd)

TDD를 구글에 검색해 보면 이런 이미지를 쉽게 접할 수 있습니다.

  • Red: 먼저 테스트를 작성합니다.
// sum이 없으니 error가 날 것이다.
it("a+b", () => {
    expect(sum(1, 3)).toBe(4); // sum이 없으니 테스트 실패!
}
  • Green: 그 테스트가 동작하는 코드를 작성합니다.
// 일단 테스트가 통과하게 만들기
function sum(a, b){
    return 4;
}

it("a+b", () => {
    expect(sum(1, 3)).toBe(4); // sum은 무조건 4를 return해서 테스트 통과!
}
  • Refactor: 코드를 리팩토링합니다.
// 정상적으로 동작하게 코드 수정하기
function sum(a, b){
    return a + b;
}

it("a+b", () => {
    expect(sum(1, 3)).toBe(4);
}

위 방법은 TDD로 개발할 때 적합한 순서입니다. 그러나 제가 지금 하려는 것은 이미 기존에 동작하는 코드에 테스트를 추가하고, 추가한 테스트를 기반으로 코드를 리팩토링하거나 기능을 추가하는 것이므로 Red가 아닌 Green부터 시작합니다. 이 단계를 충분하게 지나고 나면, 나중엔 TDD까지 할 수 있길 기대해 봅니다.

기존에 작성된 코드에 테스트를 추가하는 방식은 다음과 같습니다.

  1. 기존에 동작하는 코드를 기반으로 사용자 시나리오를 작성합니다.
    a. context(상황)에 따라 어떻게 동작하는지를 전부 분류합니다.
    b. 기존에 작성해 준 조건들이 빈약하거나 고려하지 못했던 에지(edge) 케이스를 발견할 수 있습니다. 추가로 정리해 줍니다.
  2. 사용자 시나리오를 기반으로 테스트 코드를 작성합니다.
    a. 1-b처럼 고려 못 했던 에지(edge) 케이스를 발견한 게 아니라면, 이미 동작하는 코드를 기반으로 테스트를 작성하는 거라 전부 성공 케이스가 나옵니다.
  3. 리팩토링
    a. 컴포넌트를 분리하고, 코드를 분리하고, 로직을 간단하게 변경합니다.
    b. 여기서 테스트 코드의 강력함을 경험할 수 있습니다.

이 순서로 실제 운영하는 SCM의 코드에 테스트를 작성해 보겠습니다.

SCM에도 테스트를 작성해 보자!

0. 현재 사항 파악(화면, 코드) 및 코드 분리

내부 사용자일 때 외부 사용자일 때

오늘 테스트를 작성해 볼 예시는 상품 정보 테이블 안에 있는 마감일입니다. 두 이미지에 보이는 것처럼 같은 발주서에 같은 상태임에도 불구하고 사용자에 따라 다른 화면을 보여주고 있습니다.

코드도 확인해 볼까요?

테이블에서 마감일을 나타내는 코드

마감일은 renderClosedDate라는 함수를 호출해 렌더됩니다.

// 마감일
  function renderClosedDate(item: OrderItemDetailSearchDto) {
    if (item.closedDate === undefined || isCancel(item)) return '-'
    const dataIndex = 'closedDate'
    const result = []

    if (
      isRollBacked(item) &&
      (isReceived(item) || isReturnCarried(item)) &&
      isEmployee() &&
      editingInfo[item.orderItemId]?.status !== 'editing'
    ) {
      result.push(
        <DatePicker
          key="date-picker"
          format="YYYY-MM-DD HH:mm:ss"
          ...생략
        />
      )
    }

    if (
      result.length === 0 &&
      ((isEmployee() && isRollBacked(item)) ||
        isClosed(item) ||
        isReturnClosed(item))
    ) {
      result.push(<div key="closed-date">{item[dataIndex]}</div>)
    }

    if (
      isEmployee() &&
      item.orderItemClosedDateChangeHistoryCount > 0 &&
      !isCancel(item)
    )
      result.push(
        <div key="history" style={{ marginTop: 5 }}>
          <Typography.Text
            type="secondary"
            ...생략
          >
            (이력보기)
          </Typography.Text>
        </div>
      )

    return <>{result}</>
  }

renderClosedDate 함수를 살펴보면 3개의 조건으로 나뉘어 있습니다. 각 컴포넌트가 특정 조건에 맞을 때 result에 배열로 추가해서 리턴하는 방식입니다.

마감일을 수정할 수 있는 DatePicker 마감일을 text로 출력 (이력보기) 출력

renderClosedDate 함수를 테스트하기 편하도록 ClosedDateColumn.tsx 컴포넌트로 분리하겠습니다.

ClosedDateColumn.tsx 와 ClosedDateColumn.test.tsx

테스트를 작성해야 하니 ClosedDateColumn.test.tsx까지 만들어 주면 이제 테스트를 작성할 준비가 되었습니다.

1. 기존에 동작하는 코드를 기반으로 사용자 시나리오 작성

컴포넌트로 분리한 코드를 보면서 어떻게 동작하는지 사용자 시나리오를 정리합니다.

// 마감일 / 마감일 수정 표기
1. 내부 사용자일 때
    1-1. 마감일이 있을 때
        1-1-1. 역분개를 했을 때
            - DatePicker를 보여준다.
            - 마감일을 수정할 수 있다.
        1-1-2. 역분개를 하지 않았을 때
            - 마감일을 텍스트로 보여준다.
    1-2. 마감일이 없을 때
        - '-'를 텍스트로 보여준다.
2. 외부 사용자일 때
    2-1. 마감일이 있을 때
        - 마감일을 텍스트로 보여준다.
    2-2. 마감일 없을 때
        - '-'를 텍스트로 보여준다.

// (이력 보기) 표기
1. 내부 사용자일 때
    1-1. 변경이력이 있을 때
        - '(이력 보기)'를 보여준다.
    1-2. 변경이력이 없을 때
        - 아무것도 보여주지 않는다.
2. 외부 사용자일 때
    - 아무것도 보여주지 않는다.

코드에서 if 문이 있듯, 시나리오를 작성할 때도 특정 조건을 기준으로 분기하며 작성해 줍니다. 코드에서 if 문 안에 여러 조건이 복잡하게 작성되어 있는 것을 하나씩 풀어서 분류해 봅니다. 이 과정을 통해 내가 작성했던 코드의 맹점들을 발견하게 될 수도 있습니다. 조건을 풀어서 나열해보면 제 조건문이 MECE(mutually exclusive and collectively exhaustive. 상호배제와 전체포괄. 즉 중복이 없고, 누락이 없다.)한지 검증하기도 좋습니다.

작성한 코드를 기반으로 사용자 시나리오가 정리됐다면, 실제 서비스에서 작성한 시나리오를 토대로 제대로 동작하는지 확인합니다. 문제가 없다면 확인을 마치면 이제 테스트를 작성해도 됩니다. (문제가 있다면 hotfix…)

2. 사용자 시나리오를 기반으로 테스트 코드 작성

1에서 작성한 사용자 시나리오를 바탕으로 테스트를 작성합니다. if문 조건을 기준으로 context를 나누고, 해당 context 안에서 실행되어야 하는 테스트를 작성해서 하나씩 테스트합니다.

context가 여러 개로 분리되어 있다는 말은 실제로 직접 확인하려면 복잡한 조건들을 다 맞춰가면서 확인을 해야 한다는 것인데, 여기에서 테스트의 강력함이 나타납니다. Prop drilling으로 전달받는 변수나, 전역에서 사용하는 상태관리 라이브러리(여기선 Redux)에서 사용하는 값들을 제가 원하는 상황에 맞게 바꿔서 렌더링할 수 있다는 점입니다. FIXTURE라는 이름으로 테스트에 사용할 더미 데이터를 만든 후, 원하는 상태에 맞게 바꿔가면서 컴포넌트를 렌더한 후 테스트가 가능합니다.

describe('ClosedDateColumn', () => {
  context('1. 내부사용자 일 때', () => {
    context('1-1. 마감일이 있을 때', () => {
      context('1-1-1. 역분개를 쳤을 때 (발주상태가 입고완료일 때)', () => {
        it('마감일을 수정 할 수 있는 인풋이 렌더링 된다.', () => {
          renderClosedDate(
            {
              item: {
                ...FIXTRUE.OrderItem,
                closedDate: '2022-06-06 12:12:12',
                orderStatus: 'RECEIVED'
              },
              editingInfo: {},
              setEditingInfo: jest.fn()
            },
            FIXTRUE.InternalUser
          )
          expect(screen.getByRole('textbox')).toBeInTheDocument()
        })

        it('날짜를 선택하여 마감일을 수정 할 수 있다', async () => {
          const handleUpdateClosedDate = jest.fn()
          const user = userEvent.setup()

          renderClosedDate(
            {
              item: {
                ...FIXTRUE.OrderItem,
                                closedDate: '2022-06-06 12:12:12',
                orderStatus: 'RECEIVED'
              },
              editingInfo: {},
              setEditingInfo: handleUpdateClosedDate
            },
            FIXTRUE.InternalUser

          )

          expect(handleUpdateClosedDate).not.toBeCalled()

          await user.click(screen.getByPlaceholderText('날짜 선택'))
          await user.click(screen.getByText(/현재 시각/i))

          expect(handleUpdateClosedDate).toBeCalled()
        })
      })

      context('1-1-2. 역분개를 치치 않았을 때 (발주상태가 발주마감일 때)', () => {
        it('마감일을 텍스트로 보여준다.', () => {
          renderClosedDate(
            {
              item: { ...FIXTRUE.OrderItem,
                            closedDate: '2022-06-06 12:12:12' },
              editingInfo: {},
              setEditingInfo: handleUpdateClosedDate
            },
            FIXTRUE.InternalUser
          )
          expect(screen.queryByText('2022-06-06 12:12:12')).toBeInTheDocument()
        })
      })
    })

    context('1-2. 마감일이 없을 때', () => {
      it('- 만 렌더링 된다', () => {
        renderClosedDate(
          {
            item: { ...OrderItems[0], closedDate: undefined },
            editingInfo: {},
            setEditingInfo: handleUpdateClosedDate
          },
          FIXTRUE.InternalUser
        )
        expect(screen.queryByText('-')).toBeInTheDocument()
      })
    })
  })

    ... 이하 생략 ...

})

이렇게 테스트코드를 작성하고 실행합니다.

테스트 전부 통과!

이미 작성된 코드를 검증하는 테스트코드라 실패하는 것 없이 잘 통과했습니다. (기쁘군요) 이제 두려울 것이 없어졌습니다. 이 테스트를 믿고 복잡해 보이는 코드를 리팩토링해 보겠습니다.

3. 리팩토링

3-1. 마감일 코드 분리하기

이 두개를 ClosedDate.tsx로 분리해 보자

시나리오를 작성한 것을 보면, 마감일 / DatePicker는 같은 시나리오로 묶을 수 있고 이력 보기는 테스트도 따로 분리할 수 있었습니다. 이 기준에 따라 컴포넌트도 작게 분리해 보겠습니다.

const ClosedDate: React.FC<Props> = ({ item, editingInfo, setEditingInfo }) => {
  const { isEmployee } = useUser()

  if (
    isEmployee() &&
    isRollBacked(item) &&
    editingInfo[item.orderItemId]?.status !== 'editing'
  ) {
    return (
      <DatePicker
        format="YYYY-MM-DD HH:mm:ss"
        ...(생략)
      />
    )
  }

  return <div>{item.closedDate || '-'}</div>
}

export default ClosedDate

마감일 / DatePicker를 보여주는 컴포넌트 ClosedDate.tsx로 필요한 코드만 가져오고 복잡한 조건문 테스트를 통과도록 간소화합니다.

위 코드에 해당하는 테스트 코드도 ClosedDate.test.tsx 파일로 분리합니다.

ClosedDate.tsx를 ClosedDateColumn.tsx에 적용

새로 작성한 컴포넌트 ClosedDate.tsxClosedDateColumn.tsx 컴포넌트에 있던 기존 코드를 대체해서 넣고 테스트가 제대로 동작하는지 확인합니다.

ClosedDateColumn.test.tsx ClosedDate.test.tsx

둘 다 잘 동작합니다. 변경한 코드가 기존 코드와 정확하게 동일한 동작을 한다는 것을 테스트로 보증받았습니다. 기쁜 마음으로 커밋을 하고 다음 리팩토링으로 넘어갑니다.

3-2. 이력보기 코드 분리하기

(이력보기) 컴포넌트를 DateChangeHistory.tsx로 옮겨보자

다음은 (이력보기)에 해당하는 코드를 컴포넌트 DateChangeHistory.tsx로 분리하고, 테스트 코드를 옮겨옵니다.

const DateChangeHistory: React.FC<Props> = ({ item }) => {
  const dispatch = useDispatch()
  const { isEmployee } = useUser()

  return (
    <Typography.Text
      type="secondary"
      style={{ cursor: 'pointer' }}
      onClick={() =>
        dispatch(
          showModal({
            modalType: 'ORDER_CLOSED_DATE_HISTORY',
            component: OrderClosedDateHistoryModal,
            props: { orderItemId: item.orderItemId }
          })
        )
      }
    >
      (이력보기)
    </Typography.Text>
  )
}

export default DateChangeHistory

이번엔 테스트 코드의 위력을 보기 위해 일부러 조건문을 제외하고, (이력보기)를 안에 있는 내용물만 옮겼습니다. 이 상태로 작성해 둔 테스트코드를 확인해 보면?

red가 보이면 테스트가 저를 지켜주는 느낌이라 기분이 좋습니다.

세상에! 이렇게 아름다울 수가! 실제 동작을 화면에서 일일이 확인하지 않아도 테스트 코드가 지금 시나리오대로 동작하지 않는다고 이야기하네요. 잘못된 코드가 있으니 테스트를 통과하도록 조건문을 추가하겠습니다.

먼저 외부 사용자일 땐 (이력 보기)가 출력되지 않는다고 했으니 외부 사용자일 때 null을 리턴하는 코드를 추가하고 테스트를 확인합니다.

코드 추가 테스트 결과

외부사용자일 때 이력보기가 출력되지 않는다는 테스트가 정상적으로 통과했습니다.

다음은 내부사용자 일때, 변경이력이 없는 경우 아무것도 노출되지 않는 상황을 위해 코드를 추가하고 테스트를 확인합니다.

코드 추가 테스트 결과

테스트가 전부 통과했으니 기쁜 마음으로 커밋을 합니다. 이제 이 코드는 정상적으로 동작하니 DateChangeHistory.tsx 컴포넌트를 ClosedDateColumn.tsx 컴포넌트에 반영합니다.

DateChangeHistory.tsx를 적용

처음보다 매우 간결해졌네요! (짝짝짝)

const ClosedDateColumn: React.FC<Props> = ({
  item,
  editingInfo,
  setEditingInfo
}) => {
  const result = []

  result.push(
    <ClosedDate
      key="closedDate"
      item={item}
      editingInfo={editingInfo}
      setEditingInfo={setEditingInfo}
    />
  )

  result.push(<DateChangeHistory key="changeHistory" item={item} />)

  return <>{result}</>
}

export default ClosedDateColumn

마지막으로 굳이 이 컴포넌트들을 배열에 담아 리턴할 필요가 없으니 돔 형태로 수정합니다.

const ClosedDateColumn: React.FC<Props> = ({
  item,
  editingInfo,
  setEditingInfo
}) => {
  return (
    <Vertical gap={4} style={{ alignItems: 'center' }}>
      <ClosedDate
        item={item}
        editingInfo={editingInfo}
        setEditingInfo={setEditingInfo}
      />
      <DateChangeHistory item={item} />
    </Vertical>
  )
}

export default ClosedDateColumn

완성!

이렇게 테스트 코드를 반영하여 복잡했던 코드를 안전하고 깔끔하게 리팩토링했습니다! (짝짝짝)

효과는 굉장했다! 예상대로 좋았던 부분

  1. 사용자 시나리오를 테스트하여 검증하기가 정말 수월해졌습니다.
    • 기존엔 크롬 시크릿 창까지 동원해서 다른 권한을 가진 사용자 ID로 로그인해서 직접 눈으로 보면서 동작을 확인해야 했었는데 이젠 테스트 코드에서 FIXTURE를 통해 원하는 상황에 대해 테스트 코드로 다 검증이 가능해졌습니다.
  2. 기존에 가지고 있던 두려움 해소!
    1. 발주 상세 페이지에 새로운 기능을 추가하기가 두려워졌습니다.
      • 새로운 기능을 추가하기가 두려운 이유는 정리되지 않고 복잡한 코드에 새로운 걸 추가해야 해서 두려웠던 건데 테스트하면서 컴포넌트도 복잡하지 않게 분리가 되었고, 테스트 코드가 기존 동작에 대해서 안정성을 보장해 주니 새 기능을 추가하다 발생하는 사이드 이펙트 걱정을 덜 수 있었습니다.
    2. 리팩토링하고 싶은데 복잡해서 리팩토링할 엄두가 나질 않았습니다.
      • 이젠 오히려 테스트를 작성하면서 기존 코드들에 안정성을 불어넣고 싶은 마음이 더 커졌습니다.
    3. PM 분들이 어떻게 동작하는 건지 질문을 해와도 파악하는 데 시간이 오래 걸렸습니다.
      • 복잡한 코드 볼 필요 없이 테스트 시나리오로 금방 파악할 수 있습니다.
    4. 저 말고 다른 사람이 이 코드를 보면서 작업할 수 있을지 두려웠습니다.
      • 테스트 시나리오가 곧 명세가 되니 기본적인 도메인 지식만 익히고 나면 시나리오만으로도 어떻게 동작하는지 파악하면서 작업할 수 있을 겁니다 (희망 편)

사실 테스트 코드를 작성하면, 제가 기존에 가지고 있던 이런 문제들을 해결해 줄 수 있을 거라 기대하고 있었고 실제로 그런 효과를 경험했는데요.

실제로 테스트 코드를 작성하는 것은 제가 예상했던 것 외로 좋은 효과들이 있었습니다.

효과는 엄청났다! 예상하지 못했던 부분

  1. 컴포넌트를 분리해야 하는 명확한 기준과 근거가 생겼습니다.
    • 기존에는 아 컴포넌트가 너무 크니까 적당하게 나눠야겠다.라고 생각하고 나눴다면 (ManagementButtons.tsx), 이제는 테스트하기 좋은 코드를 기준으로 나누게 되었습니다.
    • 컴포넌트를 작게 해라, 컴포넌트가 하나의 동작만 하게 해라. 등등의 조언들이 왜 그래야 하는지 이해가 되었습니다.
    • 그리고 이 기준은 같이 일하는 팀원들과 논의할 때도 공통의 기준으로 논의를 할 수 있게 되었습니다. 다른 기준을 가지고 어떤 기준이 더 적합한가? 가 아니라, 같은 기준안에서 어떻게 하면 더 테스트하기 좋은 코드일까?를 고민하게 됐다는 점이 좋았습니다.
  2. 복잡한 도메인에 대한 문서화를 할 필요가 없어졌습니다.
    • 나중에 이 부분에 대한 코드를 나 말고 다른 사람이 이해할 수 있을까? 이 고민이 항상 있었는데, 테스트 코드가 곧 명세가 되어버리니 따로 코드를 설명하기 위해 문서화할 필요가 없어졌습니다.
  3. 코드가 아름다워진다 – 읽기 좋은 코드가 된다.
    • 코드가 작아지니 길고 복잡한 코드일 땐 눈에 들어오지 않았던 의미 없는 코드들이 금방 파악되고 필요 없는 코드를 제거해서 더 간결하고 깔끔해졌습니다.
    • 테스트를 작성하기 위해 컴포넌트를 나누게 되는데, 이때 어디까지가 같은 역할을 하는 코드인가?를 고민하게 되고 이 고민은 자연스럽게 단일 책임 원칙을 지키는 방향으로 이루어집니다.
    • 결과적으로 더 나은 구조로 컴포넌트를 분리해서 사용하게 되었습니다.
  4. MECE하게 시나리오를 작성하다 보니 숨겨진 에지(edge) 케이스를 찾아내게 됐습니다.
    • 처음 기능을 구현할 때부터 고려하고 작성했으면 좋겠지만(그러지 못한 저를 반성합니다), 그러지 못했던 코드에 대해 테스트 코드를 작성하면서 이런 상황일 땐? 하고 고려하지 못한 케이스를 찾아내게 됩니다.

코드를 작성하면서도 기능이 잘 구현되는 것이 가장 중요하지만, 기능이 잘 돌아가고 나면 그다음은 같이 일하는 사람들과 지속해서 협업하기 좋고 관리하기 좋은 코드를 고민하게 되는데요. 테스트 코드가 없을 땐 막연하게 이렇게 하는 게 더 좋겠지 하고 혼자만의 기준으로 고민했다면, 이제는 테스트가 그 기준이 되어준다는 점이 예상하지 못한 큰 효과였습니다.

마치며

찬호님, 서비스가 비마트일 때, 공급사 반품 상세페이지에서 공급사 계정으로 로그인한 사용자가 역분개한 상품의 마감일을 변경할 수 있나요?

정답은 아니요입니다.

테스트를 작성하기 전엔 마감일 관련 코드를 하나씩 보면서 답을 해야 했는데, 이제는 테스트 시나리오에 내부 사용자만 수정을 할 수 있다라는 내용만으로도 답을 할 수 있게 됐습니다. (같은 질문 또 해주실 날을 손꼽아 기다리고 있습니다)

이 글에서 단위 테스트를 통해 화면 렌더링과 동작을 테스트하는 것은 여러 테스트 방식 중에서도 정말 간단하고 작은 부분입니다. 그러나 이 작은 테스트만으로도 개발자 입장에서 얻을 수 있는 정말 수많은 이점이 있다는 것을 공유하고 싶었습니다.

이 글이 저와 비슷한 고민을 가지신 분들에게 테스트를 도입해 보시길 정말 강력하게 추천해 드리고 싶습니다.