NHN Cloud Console 코드 안정화를 위한 통합 테스트 적용기


NHN Cloud Console의 프런트엔드 통합 테스트를 어떻게 작성하였고 작성 이후 어떻게 달라졌는지 이야기하려고 한다.

목차

NHN Cloud Console의 특징

NHN Cloud ConsoleNHN Cloud에서 제공하는 Cloud Computing 서비스를 관리 할 수 있는 도구와 여러 작업 창으로 구성이 되어있는 웹 어플리케이션이다.

기술블로그4

NHN Cloud Console(이하 Console)은 120여 개의 Cloud 서비스를 품는 골격 서비스이면서 조직, 프로젝트, 멤버, 예산 등 Cloud를 관리할 수 있는 서비스를 제공하고 있다. 이 밖에도 민간, 공공, private 등 다양한 환경에도 그에 맞춘 Console을 제공하고 있다.

그만큼 다양한 이해관계가 얽혀 있으며 수많은 규칙이 존재하는 Console은 구현 코드 또한 복잡하다.

처음으로 마주한 Cloud Console 프런트엔드 코드

필자는 2021년 9월, Console 프로젝트에 프런트엔드 개발자로 합류했다. 프로젝트의 용어, 개발 프로세스, 배포 프로세스, 구성 방식 등은 쉽게 파악했으나, 서비스 특성상 스펙이 굉장히 복잡했다. 이 복잡한 스펙이 코드에 그대로 녹아있다 보니 신규 기능 개발을 하거나 버그를 수정할 때마다 어려움을 겪었다.

초기 NHN Cloud Console 프런트엔드 코드의 문제점은 다음과 같았다.

  • 타입의 부재
  • 절차적인 코드 스타일
  • 안티패턴의 사용(예 동등 연산자)

우리는 코드를 수정할 때마다 명탐정이 되어야 했다. 기존 기능이 정상적으로 동작하지 않으면 그 이유를 찾기 위해 모듈과 모듈을 넘나들며 코드 한줄 한줄을 해석해야 했고, 겨우 히스토리를 찾아서 코드를 수정하더라도 수정한 코드가 다른 기능에 영향을 주지 않을까 조마조마해했다.

그러다 보니 기능 추가 요청이 들어오거나 했을 때 방어적으로 코드를 수정했다. 내가 맞닥뜨린 문제에 관해서만 처리하도록 조건문으로 분기 처리한 것이다.

function 기능() {
  // QA에서 나오는 기대 조건을 그대로 조건문에 넣는다.
  if(공공기능 && !어드민) {
    다른기능()
  }

  원래기능()
}

이 외에도 많은 위험 인자들이 코드 곳곳에 도사리고 있었다.

우리는 배포 날이면 대략 8시간 이상 QA가 무사히 통과하기를 빌 수밖에 없었다.

via GIPHY

왜 우리는 통합 테스트를 선택했을까?

우리는 이러한 상황에서 수정한 코드가 사이드 이펙트를 일으키지 않을 것이라는 확신이 필요했고, 복잡한 스펙들을 따로 기록해둘 필요성이 있었다. 이 모든 요구사항은 테스트 작성을 통해 해소될 수 있었다.

프런트엔드 테스트는 크게 단위 테스트, 통합 테스트, E2E 테스트가 있다.

  • 단위 테스트: 독립적인 하나의 요소를 대상으로 검증을 수행한다. 여기서 요소는 단일 함수가 될 수도 있고 특정한 컴포넌트가 될 수도 있다. 하나의 요소만 대상으로 테스트를 실행하기 때문에 여러 요소와의 상호 작용은 검증할 수 없다. 그렇기 때문에 중요한 단일 로직 또는 계산 로직, 저수준의 UI 컴포넌트에 적합한 테스트이다.
  • 통합 테스트: 일부 요소들이 조합되었을 때 올바르게 동작하는지 검증한다. 컴포넌트 간의 상호 작용으로 변경 사항이 제대로 반영되는지 검증한다. 통합 테스트의 범주는 특정 레이아웃으로 나눌 수도 있고, 특정 도메인별로 나눌 수도 있다. 단, 너무 큰 범위로 나누지 않는 것이 좋으며, 상이한 컨텍스트를 포함하지 않는 것이 좋다.
  • E2E 테스트: 애플리케이션 자체를 구동하여 실행하는 테스트이다. 그렇기 때문에 설정 과정이나 테스트 실행 시간이 오래 소요된다. 하지만 실제 프로덕션 앱 환경과 거의 유사한 환경에서 검증할 수 있기 때문에 전체적인 워크 플로우와 라우팅 등 통합 테스트에서 검증할 수 없는 것들을 검증할 수 있다.

위의 설명에 나와 있듯이 통합 테스트는 일부 요소들이 조합되었을 때 올바르게 동작하는지 검증하는 것이다. 컴포넌트들의 조합으로 이루어지는 프런트엔드 개발 특성상 통합 테스트를 적용하는 게 맞는다고 판단하였고, 단위 테스트는 유틸 함수에 국한해서만 작성하기로 했다. 왜 이런 선택을 하였을까?

우리는 아래와 같은 이유로 통합 테스트를 도입하기로 하였다.

  • Console에서는 단위 테스트로 검증할 만한 요소가 없으며, 이 테스트만으로는 Console의 워크 플로우를 검증할 수 없다.
  • E2E 테스트는 환경 구성 및 구동 시간 이슈도 있지만, 이보다는 앱에서 호출하는 API나 데이터의 구조를 전혀 알 수 없는 상황이었기 때문에 이슈가 많을 거라 판단했다. 이런 측면에서 상대적으로 범위를 줄이면서 컴포넌트의 조합을 검증할 수 있는 통합 테스트를 먼저 고려했다.
  • QA 시트에 있는 내용을 대상으로 검증하기에 통합 테스트가 가장 적합했다.
  • Console의 스펙들을 명시적으로 나타내기에 적합했다.

통합 테스트 작성

테스트는 라우터를 기준으로 페이지마다 작성하였다. 페이지마다 작성한 이유는 작업 담당자를 나누기도 편할 뿐 아니라 다음에 스펙 파악이 용이하기 때문이다.

우리가 통합 테스트 작성을 위해 사용했던 도구들은 다음과 같다.

  • MSW(Mock Service Worker) : 표준 API인 서비스 워커를 사용하기 때문에 다양한 플랫폼에서의 지원 여부에 대해 고민할 필요가 없으며, 아주 적은 비용으로 모든 API를 모킹하는 것이 가능하기 때문에 선택했다.
  • jest : jest는 노드 환경에서의 단위 테스트, 통합 테스트를 지원하는 훌륭한 도구이기 때문에 선택했다.
  • testing-library : 프런트엔드 테스트는 특히 BDD 기반으로 검증하는 것이 중요하기 때문에 이 철학을 잘 계승하고 있는 testing-library를 선택했다.

통합 테스트를 작성하면서 직면한 문제들

여러 사람이 코드를 작성하면 서로 다른 코딩 스타일이 코드를 이해하는 데 방해가 되듯이 테스트 코드도 마찬가지이다.

테스트 코드는 작성도 중요하지만 다른 개발자가 봐도 금방 이해하고 빠르게 이슈에 대응할 수 있어야 한다. 즉, 테스트 코드를 해석하는 시간이 실제 프로덕션 코드를 해석하는 시간과 맞먹어서는 안 된다.

테스트 코드를 읽는 데 대표적으로 방해가 되는 요소들은 다음과 같다.

  • 여러 개의 단언
  • 과도한 중첩과 테스트 훅(beforeEach와 같은)사용으로 코드를 한 화면에 보기 어려움
  • 일관성 없는 개행
  • 스펙을 파악할 수 없는 테스트 데이터

이 외에도 각기 다른 변수명이나 함수명, 테스트 단언과 직접적으로 연관되지 않는 코드 같은 자잘한 요소들도 테스트 코드를 읽는 데 방해가 되는 요소들이다.

이런 이유로 초반에 우리 팀은 통합 테스트 코드를 작성하고 작성한 테스트 코드를 리뷰 받는 데 어마어마한 시간을 썼다.

<많은 테스트 코드 리뷰 개수>

image

개수도 개수지만 리뷰 하나하나 장문의 글들이 달렸고 리뷰 말고도 논의하는 자리도 많았다. 실제 프로덕션 코드를 작성하는 시간보다 테스트 코드를 보고 파악하는 데 시간을 더 보냈다.

일관성 있는 통합 테스트를 위한 컨벤션

이러한 이유로 우리는 일관성 있는 Console의 FE 통합 테스트를 작성하고자 다음과 같은 원칙을 세웠다.

"테스트를 빠르게 읽고 프로덕션 코드의 디버깅을 할 수 있어야 한다."

그리고 원칙을 실현하기 위해 다음과 같은 4가지 컨벤션을 정했다.

  • 테스트 데이터 준비 (Arrange)
  • 컴포넌트 렌더 (Render)
  • 액션 (Action)
  • 단언 (Assertion)
test('테스트 단언문', () => {
  // 테스트 데이터 준비
  const mockedData = {}
  const expectedData = {}
  //...

  // 렌더
  render(Component)

  // 액션 (액션별로는 개행하지 않는다.)
  clickButton()
  checkInput()

  // 단언
  expect().toBeVisible()
})

테스트 데이터 준비 (Arrange)

테스트에 필요한 데이터는 테스트 함수 상위에 정의한다. 이러면 개발자가 테스트에 필요한 데이터가 무엇인지 그리고 기대하는 데이터가 무엇인지 미리 인지하고 테스트를 작성할 수 있다. 또한 이 테스트를 읽는 개발자는 테스트 함수 상단만 보고도 이 테스트가 어떤 데이터 때문에 실패하는지, 더 필요한 데이터는 무엇인지에 관한 정보를 얻을 수 있다.

import defaultData from 'defaultData.json'

test('테스트 단언문', () => 
  // 테스트 데이터 준비
  const mockedData = {
    ...defaultData,
    name: '이 테스트에서는 이 이름 데이터를 테스트에 활용합니다.'
  }

  // 렌더
  render({data: mockedData})

  // 단언
  expect(screen.getByText(mockedData.name)).toBeVisible()
})

특히 기본 모킹 데이터인 json 파일을 그대로 가져와서 사용하면 다른 개발자가 json을 수정할 때 깨지기 쉬운 테스트가 되기 때문에 반드시 데이터를 오버라이드해서 사용할 수 있도록 한다. 위와 같이 작성하면 데이터를 찾기 위해 화면을 위아래로 스크롤 하지 않아도 되고 다른 파일을 열어보지 않아도 된다. 그뿐만 아니라 데이터를 테스트마다 독립적으로 정의하기 때문에 다른 파일 및 테스트의 데이터 수정으로부터 안전하게 테스트를 지킬 수 있다.

컴포넌트 렌더 (Render)

테스트할 컴포넌트를 렌더링하는 데 필요한 코드는 사전 모킹 데이터(API 모킹 포함)와 타깃 컴포넌트이다. 그리고 UI로 확인하면서 테스트를 진행하는 것이 아니기 때문에 렌더링 함수 호출 이후 화면에 컴포넌트가 렌더링 되었음을 확인하는 단언도 필요하다.

test('테스트 단언문', () => {
  // 테스트 데이터 준비
  const mockedData = {
    ...defaultData,
    list: [],
    name: '이 테스트에서는 이 이름 데이터를 테스트에 활용합니다.'
  }

  // 컴포넌트 렌더에 필요한 코드 시작
  overrideApi1()
  overrideApi2()
  render(Component)

  // 데이터가 화면에 완전히 보인다는 단언문
  if (mockedData.list.length) {
    await waitFor(() =>
      expect(screen.getByRole('cell', { name: mockedData.list[0].name })).toBeVisible()
    )
  }
  // 컴포넌트 렌더에 필요한 코드 끝
})

통합 테스트는 대게 컴포넌트를 렌더링한 뒤 특정 기능들을 테스트하는 게 주목적이기 때문에 테스트 함수 내에 렌더링을 위한 코드를 모두 노출할 필요가 없다. 따라서 추상화 함수 하나로 뽑아서 테스트 함수 안에서는 렌더 함수 하나만 사용할 수 있도록 한다.

test('테스트 단언문', () => {
  // 테스트 데이터 준비
  const mockedData = {
    ...defaultData,
    name: '이 테스트에서는 이 이름 데이터를 테스트에 활용합니다.'
  }

  // 추상화된 컴포넌트 렌더 함수
  renderComponent({data: mockedData})
})

즉, 테스트 단언에 작성한 내용에 집중할 수 있는 코드들을 보여주고 그 외적인 코드들은 숨겨둔다.

액션 (Action)

액션은 사용자의 행위를 추상화한 함수를 말한다. 사용자 액션을 모두 테스트 본문에 적으면 테스트 코드가 길어질 수밖에 없다. 테스트를 읽었을 때 중요한 것은 어떻게 행위를 했는가가 아니라 어떤 행위를 했는가이다.

액션 함수는 함수 이름만 보고도 사용자가 화면에서 무슨 작업을 수행할지 상상할 수 있도록 이름을 짓는 것이 좋다.

이때, 사용자의 여러 행위를 한 함수에 담아 너무 큰 추상화를 이루지 않도록 해야 한다. 다시 그 함수를 해석하려면 테스트 코드에서 시선이 멀어지고, 테스트 코드에서 시선이 멀어진다는 것은 결국 코드의 가독성이 나쁘다는 뜻이기 때문이다.

function checkMemberRowInputByName(memberName) {
  // 실제로 체크박스 하나 체크 하려고 해도 테스트 코드 여러줄이 필요하다.
  const rowOnScreen = within(screen.getByRole('row', { name: new RegExp(memberName, 'i') }))
  const checkbox = rowOnScreen.getByRole('checkbox', {
    name: /check/i,
  })

  userEvent.click(checkbox)
}


test('테스트 단언문', () => {
  // 테스트 데이터 준비
  const mockedData = {
    ...defaultData,
    name: '이 테스트에서는 이 이름 데이터를 테스트에 활용합니다.'
  }

  // 컴포넌트 렌더
  renderComponent({data: mockedData})

  // 액션
  // 액션을 위한 여러 절차적 로직은 다 함수에 감쳐둔다.
  // 중요한 것은 "'테스트유저'의 체크박스를 클릭한다."라는 사용자의 행위다.
  checkRowInputByName('테스트유저')
})

액션 함수의 prefix는 일관된 단어를 사용한다. 우리는 user-event에 나와 있는 함수의 이름과 동일하게 prefix를 짓는다.

단언 (Assertion)

단언에는 2가지 중요한 컨벤션을 정했다.

첫째, 단언은 최소 1개 이상이어야 한다.

불필요한 단언은 위의 렌더나 액션 함수들 안에 감춰둔다. 만약 테스트의 단언과 연관된 단언이라면 n개도 가능하다. 예를 들어 화면에 존재하다가 사라지는 텍스트를 테스트하는 경우, 텍스트의 존재를 확인하는 단언과 사라진 상태를 확인하는 단언으로 총 2개가 될 것이다.

test('테스트 단언문', () => {
  // 테스트 데이터 준비
  const mockedData = [
    {name: 'A'},
    {name: 'B'}
  ]

  // 컴포넌트 렌더
  renderComponent({data: mockedData})

  // 필터 B를 선택해서 'A'가 사라졌다는 것을 증명하기 위한 준비 단언
  expect(screen.getByText(mockedData[0].name)).toBeVisible()

  // 액션
  clickFilter('B')

  // 필터 B를 선택해서 'A' 가 사라지고 'B'가 나타났음을 확인하는 단언
  expect(screen.getByText(mockedData[0].name)).not.toBeInTheDocument()
  expect(screen.getByText(mockedData[1].name)).toBeVisible()
})

둘째, jest-dom matchers를 사용한다.

통합 테스트는 시각 테스트가 아닌 사용자에게 기능을 정확히 제공하는지 검증하는 테스트다. 따라서 통합 테스트에서는 "리스트 형태가 2개 있다", "div 태그가 노출되었다"라는 정보는 중요하지 않다. 실제 사용자의 행위 이후에 원하는 문제를 적절하게 해결해 주고, 모달이나 그에 상응하는 문구 또는 데이터가 노출되는지 검증하는 게 주목적이다.

그렇기 때문에 jest의 matchers의 사용을 지양하고 jest-dom의 matchers를 사용하도록 한다.

test('테스트 단언문', () => {
  // 테스트 데이터 준비
  const mockedData = [
    {name: 'A'},
    {name: 'B'}
  ]

  // 컴포넌트 렌더
  renderComponent({data: mockedData})


  // ❌ 단순 row 갯수만 파악
  const [, ...rows] = screen.getAllByRole('row')

  expect(rows).toHaveLength(2)

  // ✅ 화면의 UI가 아닌 실제 사용자에게 원하는 데이터가 잘 노출되는지 파악
  expect(screen.getByText(mockedData[0].name)).toBeVisible()
  expect(screen.getByText(mockedData[1].name)).toBeVisible()
})

팁으로 사용자에게 데이터가 잘 노출되는지 확인하는 matcher는 toBeInTheDocument 보다 toBeVisible이 더 정확하다.

통합 테스트 작성 그 이후

안정적인 서비스 코드를 만들기 위한 발판으로 통합 테스트를 채택하고 여러 시행착오를 거치면서 몇 개월에 걸쳐 작성한 Console의 통합 테스트는 대략 1,800개에 이른다. 이 테스트 덕분에 우리는 좀 더 공격적으로 코드를 수정할 수 있게 됐다. 이제 이렇게 작성한 테스트가 어떤 힘을 발휘했는지 이야기해보려고 한다.

좋았던 점

우선 타이트한 컨벤션을 정해서 통합 테스트를 작성하면서 얻을 수 있었던 가장 큰 장점은, 테스트 코드 리뷰 시간과 코드 스펙을 파악하는 시간이 비약적으로 줄었다는 점이다.

<적어진 테스트 코드 리뷰 개수>

image

리뷰 시간이 준 것은 위에서 언급한 것처럼, 누구나 테스트를 읽기 쉽도록 테스트 컨벤션을 정했기 때문이다.

<실제 작성된 테스트 코드>

// 테스트의 개행의 위치만 보아도 어떤 의도로 코드가 작성되었는지 파악할 수 있다.

test('리스트는 역할 개수 기준으로 내림차순 정렬 할 수 있어야 한다.', async () => {
  const targetRoleGroupList = [
    { ...orgRoleGroups.roleGroups[0], roles: getMockedRoleList({ size: 4 }) },
    { ...orgRoleGroups.roleGroups[1], roles: getMockedRoleList({ size: 3 }) },
    { ...orgRoleGroups.roleGroups[2], roles: getMockedRoleList({ size: 5 }) },
  ]
  const sortedRoleGroupRoleCountList = ['5 개', '4 개', '3 개']

  await renderRoleGroupSetting({ orgRoleGroupList: targetRoleGroupList })

  await clickSortableColumn({ columnName: '역할 수', order: sortOrder.DESC })

  sortedRoleGroupRoleCountList.forEach((roleCountText, rowIndex) => {
    const rowScreen = getTableRowScreenByIndex({ rowIndex })

    expect(rowScreen.getByText(roleCountText)).toBeVisible()
  })
})

이렇게 리뷰 시간이 줄면서 프로덕션 코드를 더 많이 신경 쓸 수 있게 됐다.

두 번째는 우리가 하고 싶은 리팩토링을 마음 놓고 할 수 있다는 점이다. 우리는 통합 테스트 작성 외에도 DDD를 통한 리팩토링을 상당 부분 진행했다. 그런데도 많은 이슈가 나오지 않은 이유는 CI에서 우리가 작성한 테스트로 바뀐 코드의 무결성을 사전에 검증하기 때문이다.

세 번째는 Console의 도메인 지식과 스펙을 빠르게 습득하고 기존 버그, 잘못된 설계를 손쉽게 찾을 수 있었다는 점이다. 테스트 작성을 위해선 API 문서와 화면 기능, 기존에 작성된 코드들을 세심히 살펴봐야 한다. 그 때문에 화면에서 무심코 지나쳤던 잘못된 표기나 두 번 렌더링 되는 등의 성능적인 이슈도 발견할 수 있었다.

마지막으로 좋았던 점은 통합 테스트에 정리된 스펙을 항상 최신화할 수 있다는 점이다. 기존에 문서로만 정리했던 도메인 지식과 스펙은 현행화가 힘들다. 반면 통합 테스트는 CI를 통해 코드가 바뀔 때마다 매번 실행할 수 있다. 테스트가 실패하면 그때마다 현행화가 진행되는 것이다.

image

보강해야 할 점

이렇게 통합 테스트를 작성하면서 많은 것을 얻기도 했지만, 통합 테스트가 100% 커버리지를 가지는 것은 아니다. 통합 테스트의 한계는 노드 환경에서 동작한다는 점이다. 실제로 UI가 틀어지거나 라우터 이동, 파일 다운로드, 업로드 같은 브라우저와 함께 수행되는 기능 테스트는 통합 테스트에서 이뤄지기 어렵다.

따라서 Console의 UI 테스트는 비주얼 테스트로, 브라우저와 함께 수행되는 기능 테스트는 E2E 테스트로 진행할 계획이다.

맺음말

처음 프로젝트에 합류했을 때는 Console의 프런트엔드 코드를 보고 어떻게, 어디서부터 건드려야 할지 막막했다. 하지만 지금은 통합 테스트 도움을 받아 Console 프런트엔드 코드를 안정화할 수 있었고, 코드 수정 시에 느끼던 두려움도 줄었다. 이 글을 읽는 여러분도 필자와 같은 상황이었다면 "지금 당장 테스트를 작성해야겠다"라고 결심했을 것이다. 하지만 이때 주의할 점은 일관성 없는 테스트는 또 다른 유지보수라는 괴로움을 낳을 수 있다는 것이다. 테스트의 본질을 먼저 파악하고 테스트를 어떻게 활용할 수 있을지 먼저 생각한다면, 테스트는 더 이상 골칫거리가 아닌 서비스의 안정성을 크게 높일 수 있는 발판이 될 것이다. 이 통합 테스트 적용기를 참고해 통합 테스트와 함께 개발하고 있는 서비스를 좀 더 안정적으로 만들어보길 바란다. 💪

우리는 앞으로도 부족했던 테스트를 보강해 Console의 안정성을 높이고 더불어 성능도 높일 계획이다.

최호철2023.02.07
Back to list