2022. 9. 4. 08:52ㆍFrontend
Overview
2년 조금 넘는 시간 동안 프론트엔드 개발을 해 오면서 애플리케이션 생태계에서의 웹 서비스의 비중이 시간이 지날수록 점점 커지는 것을 실감하고 있습니다. 빠른 주기의 업데이트와 유연한 UI를 제공하기 위해서 많은 앱 애플리케이션들이 웹뷰를 사용해서 서비스를 구현하고 있으며 이에 따라 데스크톱 생태계에서 뿐 아니라 스마트폰 / 태블릿 생태계에서도 웹 서비스는 여전히 서비스의 가장 앞단에서 수많은 사용자에게 접점을 제공하는 중요한 역할을 맡고 있습니다.
이렇게 애플리케이션 내에서 웹, 즉 웹뷰의 사용이 점점 확대됨에 따라 웹뷰에서 제공하는 기능 또한 다양해졌고, 이로 인해 웹뷰 프로젝트의 규모가 커지고, 복잡도가 증가하게 되는 경우가 많아지는 것 같습니다. 여느 큰 규모의 프로젝트들이 그렇듯, 코드 베이스의 규모가 커지게 됨에 따라 새로운 코드를 추가하거나 기존 코드를 수정하는 작업은 추가적인 비용이 들게 됩니다. 코드 한쪽을 수정했는데 전혀 예상하지 못한 다른 페이지의 다른 컴포넌트에서 CSS가 깨져있거나, 로직에 조건 하나를 추가했는데 기존에 잘 되던 다른 부분에서 로직이 망가지거나 하는 것이죠. 프로젝트의 규모가 크면 클수록 이러한 부수효과를 한 번에 알아차리기도 쉽지 않고, 이러한 부수효과는 애플리케이션을 사용하는 사용자의 입장에서 서비스의 신뢰도를 떨어뜨리게 됩니다.
물론 적절한 프론트엔드 아키텍처를 도입하여 변경에 유연한 시스템을 설계할 수도 있지만, 프로젝트의 처음부터 이 프로젝트가 "어느 정도 규모까지 커질 것이고, 어떤 기능이 추가될 것이다"를 예측하기가 힘든 경우가 많습니다. 즉 기능이 추가되고, 요구사항이 변경되면서 아키텍처도 그에 따라 유연하게 바뀌어 가야 한다는 것이죠. 그렇기 때문에 안정적인 서비스를 제공하기 위해서는 무엇보다도 "테스트코드"를 잘 작성하는 것이 선행되어야 합니다. 소프트웨어를 변경하든, 기능을 추가하든 간에 기존에 작동하던 코드가 여전히 잘 작동하는 것을 보장하는 것이 시스템의 안정성에 가장 중요한 부분이라는 의미입니다. 규모가 크면 클수록 이는 앞서 이야기한 이유들로 인해 더 중요해집니다.
하지만 현실적인 여러 이유들로 인해 프론트엔드에서 적은 비용으로 효율적인 테스트 방식을 도입하는 것에는 생각보다 많은 장벽들이 존재합니다. 이를테면 모킹(Mocking) 해야 할 것들이 너무 많다던가, 리팩터링 할 때마다 테스트 코드를 다시 짜야한다던가, 테스트 결과에 일관성이 없다던가 하는 것들이죠. 실제로 테스트 코드를 다룬 많은 저서들과 글들이 백엔드 아키텍처에서 테스트하는 방식에 대해 주로 다루고 있다 보니 안내하는 방법을 그대로 프론트엔드에 적용하기 어려운 경우가 많습니다. 프론트엔드와 백엔드는 그 이름에서도 알 수 있듯 관심을 가지는 주체 자체가 다르고 서비스를 제공하는 방식 자체도 많이 다르기 때문에 동일한 테스트 방식을 적용하기 어렵습니다.
몇 개의 시리즈로 포스팅할 예정인 "프론트엔드 테스트 전략"은 이러한 고민들에서 출발한 "프론트엔드에서의 현실적인 테스트 전략"을 다루려고 합니다. 여기서의 "현실적"이란 현업에서 프로젝트를 진행하면서 완벽하진 않지만, 적은 비용으로 많은 팀원들이(혹은 팀 전체가) 최대한 안정적인 서비스를 제공할 수 있는 방법을 의미합니다. 즉 완벽한 테스트 방식이더라도 컴포넌트 작성에 한 시간, 테스트 코드 작성에 10시간이 걸린다면 이는 "현실적"인 테스트 방식은 아니라는 의미로 사용했습니다. 시리즈는 다음의 순서로 포스팅될 예정입니다.
1부 - Overview. 프론트엔드에서의 테스트 방식에 대한 고찰. 현실적인 테스팅 방안 제시
2부 - Integration Test. Cypress를 사용한 Integration Test 방안 제시 (feat. Nextjs)
3부 - Unit Test. Jest를 사용한 Unit Test 방안 제시
4부 - CI / CD Pipeline 및 Multi-Container Parallel Testing
5부 - 더 시도해볼 수 있는 테스트 전략 - 1 Visual Regression Test
6부 - 더 시도해볼 수 있는 테스트 전략 - 2 Behavior Driven Development & Test Driven Development
Realistic Problems...
테스트 코드를 작성하는 것이 중요하다는 것은 아마 대부분의 개발자들이 동의하는 지점일 것이라 생각합니다. 하지만 실제로 많은 프론트엔드 프로젝트들이 테스트 코드 없이 개발됩니다. 물론 "시간이 없어서" 작성하지 못하는 경우들도 있겠지만 나중에 시간이 없어서 급하게 만든 프로젝트에 기능을 추가해야 할 때 더 많은 시간을 쓰고, 더 많은 버그와 씨름하는 경우가 적지 않다는 것을 감안할 때, 보다 근본적인 이유가 있지 않을까? 하는 생각이 드는 것 같습니다.
가장 큰 문제. 어디부터 시작해야 할지 모르겠다
개인적으로 생각하기에 프론트엔드 프로젝트에 테스트를 작성하는데 어려움을 주는 가장 큰 이유는 "모호함"인 것 같습니다. 즉 "어디부터, 어디까지, 어떻게, 무엇을, 언제" 테스트해야 할지가 명확하지 않다는 점입니다. 실제로 Jest, Cypress, Playwright, Test-Renderer, Mocha 등 프론트엔드에서 테스트를 지원하는 수많은 툴들이 있지만 대부분의 경우 이러한 툴들의 Tutorial이나 Examples를 보면서 몇 번 따라 하는 데 그치는 경우가 많습니다.
문제는 "무엇을, 어떻게, 왜, 언제, 어디부터" 테스트해야 할지를 명확하게 정의하지 않은 상태에서 작성하는 테스트 코드는 방향을 잃기 쉬워진다는 점입니다. 시중에 나와있는 유용한 여러 테스트 툴들은 테스트하려는 관심사가 각기 다르고, 여러 관심사를 동시에 만족시킬 수 있도록 지원하는 툴들도 있기 때문에 "내가 무엇을 테스트 해야 하는지", 그리고 "이를 테스트하기 위해서는 현실적으로 어디까지 테스트해야 하는지"를 스스로 명확하게 정의하지 않으면 방향을 잃고 테스트 코드를 말 그대로 "작성"하고 실제로 서비스 안정성과는 큰 연관관계를 보여주지 못하는 부분. 이를테면 "커버리지"를 높이거나 아주 단순한 로직 "컴포넌트가 마운트 되었다 안되었다"들 만 테스트하는데 그치게 됩니다.
테스트 코드를 작성하는데 너무 많은 비용이 든다
어려움을 주는 두 번째 요인은 대부분의 경우 "테스트 코드를 작성하는데 너무 많은 비용이 든다"는 것입니다. 아주 간단한 예제인 Todo List 애플리케이션에 테스트 코드를 작성하는 것을 예로 들어보겠습니다. 리스트의 목록은 전역 스토어(여기서는 Redux라고 하겠습니다)를 사용한다고 가정하고, 컴포넌트 구조는 다음과 같이 List 컴포넌트 안에 Item 컴포넌트 여러 개가 위치하도록 잡는다고 하겠습니다.
가장 많이 사용되는 테스트 툴 중의 하나인 Jest + Testing Library를 사용해서 List 컴포넌트의 테스트 코드를 작성한다고 했을 때, 개별 아이템에 대한 테스트는 Item 컴포넌트에서 하도록 하고 List 컴포넌트에 대한 테스트만을 진행하게 될 것입니다.(Shallow Mount) 전체 리스트를 받아오는 경우를 테스트 하기 위해 store 및 selector에 대한 모킹을 해야 하며, 로그인 기능이 있고, 각 계정에 따른 리스트를 서버에서 받아온다면 로그인 기능 및 네트워크 요청에 대한 모킹도 추가적으로 진행해주어야 합니다. Item 컴포넌트도 위와 같은 방법으로 테스트 코드를 작성할 수 있을 것입니다.
하지만 만약에 요구 사항의 변경으로 인해 List 컴포넌트와 Item 컴포넌트를 합치고, 데이터를 저장하는 방식도 서버 사이드가 아닌 localStorage에서 진행하도록 변경된다면, 위의 테스트코드를 말 그대로 "뜯어고쳐야" 합니다. 각각의 모킹 방법들을 찾느라 장시간 고생을 했는데, 이걸 다 지우고 localStorage를 모킹 하는 새로운 방법을 찾아야 합니다. 또 item 컴포넌트에서 사용했던 테스트 코드를 지우고 List 컴포넌트 안으로 다 옮겨야 합니다. (복붙이 가능하다면 불행 중 다행입니다.)
작은 컴포넌트를 예시로 들었지만 실제 서비스에 사용되는 컴포넌트는 이보다 훨씬 복잡한 경우가 많기 때문에 실제로 테스트에 드는 비용이 훨씬 더 커지게 됩니다. 간단한 기능 테스트를 위해 수십개의 기능들을 모킹 해야 하고, 이렇게 작성된 테스트 코드들은 요구사항의 변경에 유연하지 못합니다. 즉 요구사항이 변경되거나, 리팩터링을 진행하게 되면 테스트 코드를 "처음부터 다시 작성"해야 하는 상황이 발생하는 것이죠. 아무리 TDD가 좋다, 테스트 코드가 좋다고 하지만 컴포넌트 작성에 1시간, 테스트코드 작성에 10시간이 드는 상황이라면 테스트 코드를 작성하는 선택을 하는 것이 쉽지 않을 것입니다.
위의 내용을 정리해보면 다음과 같습니다.
- 테스트 코드를 작성하기 위해 모킹 해야 하는 것이 너무 많다. localStorage, network, global store....
- 모킹을 위한 learning curve가 존재한다. 즉, 모킹 하기 위해 들이는 시간이 너무 길다.
- 무엇을 어디까지 테스트해야 할지 모르겠다. 이 컴포넌트는 사실상 테스트할 필요 없이 무조건 렌더링 될 텐데 커버리지를 위해 이게 렌더링 되는지 안 되는 지도 테스트해야 하나?
- 코드의 구조가 변경되거나 요구사항이 변경되면 테스트 코드도 같이 변경되어야 한다. 이는 전체적인 개발 사이클을 느리게 만들어서 서비스가 기민하게 변경되는 것을 막는다.
하지만 이러한 이유들에도 테스트 코드를 작성하는 것은 위에 언급한 이유들로 인해 여전히 중요합니다. 따라서 위에서 이야기한 현실적인 어려움을 극복하기 위해 "무엇을, 어떻게, 언제, 어디서" 테스트할지를 기준으로 현실적인 테스트 전략을 마련해 보려 합니다.
무엇을 테스트해야 하는가
결론부터 이야기하면 프론트엔드 서비스는 "유저가 원하는 것"을 테스트해야 합니다. 즉 "철저히 유저 중심"이어야 한다는 것입니다. 앞서 예시로 든 Todo List 애플리케이션을 생각해보겠습니다. 결국 프론트엔드에서 서비스 안정성이라는 것을 "내부 구조가 바뀌고, 새로운 기능이 추가되었더라도 기존에 유저가 사용하던 기능을 여전히 사용할 수 있도록 하는 것"이라고 정의한다면 유저에게 중요한 것은 "추가"버튼을 눌렀을 때 내 할 일이 할일 목록에 추가되고 체크하면 없어지는 기능 그 자체이지, 버튼이 div로 만들어졌는지 button으로 만들어졌는지, 할일 목록을 localStorage에 저장하든지 서버에 저장 하든지는 유저의 관심사가 아니라는 것입니다.
따라서 테스트 코드를 작성할 때에도 철저히 유저의 입장에서 작성되어야 합니다. 이 컴포넌트가 메모리에 렌더링 되었나 렌더링 되지 않았나, 이전과 동일한 prop을 가지는가 등을 테스트하는 것도 중요할 수 있겠지만, 정작 이 모든 것들이 다 테스트되었는데 실제로 유저가 할 일을 추가할 수 없다면 이 모든 테스트는 서비스 안정성에 아무런 기여를 하지 못하는 것이 됩니다.
서비스의 안정성을 평가하는 것은 "유저"이고 테스트 코드는 유저가 원하는 기능을 내부 구현이나 로직의 변경 혹은 리팩터링에 상관없이 항상 제공할 수 있다는 것을 보증해야 합니다. 나머지 테스트 방식은 그 이후가 되어야 합니다. 그리고 이후 포스팅에서 살펴보겠지만 "유저의 관점에서 테스트하는 방식"은 테스트 코드의 변경도 최소한으로 줄여줍니다. 내부 로직이나 구현이 바뀌어도 테스트 코드는 그대로 유지할 수 있는 방식으로 테스트가 가능하다는 의미이며, 이는 테스트 비용을 급격하게 줄여줍니다.
프론트엔드 애플리케이션을 테스트할 때는 유저의 입장에서
원하는 기능이 제대로 동작하는지를 테스트하는 것이 무엇보다도 중요합니다.
어떻게 테스트해야 하는가
Integration Test (feat. E2E Test)
"유저의 관점에서 테스트"하기 위해서는 핵심 테스트 방식으로 Integration 테스트를 사용하는 것이 좋습니다. 개별 컴포넌트의 동작을 테스트하는 방식이 아닌 각 컴포넌트들이 유기적으로 맞물리면서 "실제로 유저에게 제공하는 기능"을 기준으로 테스트해야 한다는 의미입니다. 물론 E2E테스트가 가능한 상황이라면 가장 좋겠지만 E2E 테스트의 경우 그 정의상(End to End) 프론트엔드 개발자 이외에도 백엔드, 인프라, 클라이언트에 이르기까지 다양한 관심 주체들과의 Consensus도 맞추어야 하고, 대규모의 준비가 필요하기 때문에 현실적으로 수행하기 어려운 경우들도 많습니다. 따라서 Integration 테스트를 수행하되, 추후 E2E 테스트를 진행할 준비가 되었을 때, 테스트 코드의 큰 변경 없이 E2E 테스트도 수행할 수 있도록 유연하게 테스트 코드를 작성하는 것이 좋습니다. (2부 포스팅에서 Integration Test를 유연하게 구성하고 테스트 케이스를 작성하는 방법에 대해 다룰 예정입니다.)
Integration Test(이하 통합 테스트)에 대한 Wikipedia의 정의에 따르면 통합 테스트란 단일 컴포넌트를 테스트하는 것이 아닌 여러 컴포넌트들이 묶여 서로 상호작용하는 모듈을 단위로 테스트하는 것을 의미합니다. 프론트엔드에서의 의미 있는 모듈은 사용자에게 보이는 "페이지", 즉 "화면" 이므로 화면 단위로 통합 테스트를 구성하는 것이 좋습니다. 이를테면 하나의 화면 안에서 유저가 할 수 있는 행동들의 케이스를 나누고, 이를 브라우저 환경에서 테스트하는 것입니다. 이렇게 "하나의 화면"안에서 "유저의 행위"를 기준으로 통합 테스트를 작성하면 실제로 내부 구현이 바뀌고, 데이터를 다루는 방식이 바뀌더라도 테스트코드의 변경 없이 유저에게 일관된 기능을 제공함을 보장할 수 있습니다.
Integration testing (sometimes called integration and testing, abbreviated I&T) is the phase in software testing
in which individual software modules are combined and tested as a group.
Unit Test
프론트엔드에서의 단위 테스트는 "유틸성 로직"을 위주로 작성하는 것이 좋습니다. 위에서 이야기했던 것처럼 단일 컴포넌트를 하나의 "Unit"으로 간주해서 테스트를 작성하게 되면 수많은 모킹을 해야 하며, 이렇게 어렵사리 작성한 테스트 케이스는 리팩터링에 취약합니다. 컴포넌트가 합쳐지거나 나누어질 때, 테스트 코드도 합쳐지거나 나누어지는 등 같이 변경되어야 한다는 의미이죠. 이는 테스트 비용을 높여 테스트 코드를 작성하는 장벽을 높이므로 가급적이면 "화면"에 대한 테스트는 최대한 통합 테스트를 통해 수행하고 Unit Test는 화면 렌더링과 관련이 없는 비즈니스 로직들. 즉 유틸성 로직 위주로 작성해야 합니다.
여기서의 유틸성 로직이란 배열을 필터링하거나 mapping 하는 로직, 스트링을 변환하는 로직, 객체를 합치거나 나누어 새로운 객체를 만드는 로직 등 "동일한 입력"에 대해서는 항상 "동일한 출력"을 내는 순수 함수로서의 로직들을 의미합니다. 컴포넌트 내에 사이드 이펙트. 즉 부수효과를 동반하는 로직들이 있다면 이 로직들을 순수 함수와 그렇지 않은 함수들로 분리하고 순수 함수들에 대해서 Unit Test를 수행하는 것으로 테스트 코드 작성을 시작하는 것을 권장합니다.
Jest 테스트 러너를 사용하면 이러한 유틸성 로직들을 간편하게 테스트할 수 있으며 TDD 방법을 도입하는 것도 쉬워집니다. 사전에 많은 것들을 모킹 할 필요 없이 내가 구현하려 하는 유틸성 로직에 대한 정의를 테스트 코드를 옮길 수 있기 때문입니다. Jest를 사용해서 유틸성 로직들을 Unit Testing 하는 방법에 대해서는 3부 포스팅에서 다룰 예정입니다.
Visual Regression Test
Visual Regression Test(이하 시각적 회귀 테스트)란 통합 테스트, 유닛 테스트에서 해결되지 않는 UI 변경사항을 테스트 해줍니다. 통합 테스트는 애플리케이션이 사용자가 의도한 대로 동작하는지를 보장하는 테스트 방식이고, 유닛 테스트는 비즈니스 로직이 동일한 인풋에 대해서 항상 동일한 아웃풋을 리턴하도록 보장하는 테스트 방식입니다. 시각적 회귀 테스트는 실제로 사용자가 의도하지 않은 UI상의 변경 (CSS가 깨진다든지, 적용되지 말아야 할 곳에 CSS가 같이 적용된다든지)을 테스트하는 방식입니다. 실제로 유저에게 보이는 UI의 일관성을 보장한다는 관점에서 시각적 회귀 테스트는 중요한 테스트라고 할 수 있으며 Storybook, Cypress 등 많은 툴들이 이 시각적 회귀 테스트를 지원하고 있습니다. 시각적 회귀 테스트에 대한 내용은 5부 포스팅에서 다룰 예정입니다.
언제, 어디서 테스트해야 하는가
"무엇을 테스트할지", "어떻게 테스트할지"를 정했다면, 이제 "언제, 어디서" 테스트할지를 정해야 합니다. "언제" 라는 것은 테스트 코드를 작성하는 시점, 테스트 코드가 동작하는 시점 모두를 의미하며 "어디서"라는 것은 테스트 코드를 작성하는 장소와 테스트 코드가 동작하는 장소 모두를 의미합니다.
실제로 테스트코드가 작성되는 환경은 개발 환경이지만, 그렇다고 해서 개발한 컴포넌트를 배포하기 전에 개발환경, 즉 로컬 머신에서 한번 테스트해보고 배포하는 것 만으로는 충분하지 않습니다. 실제로 프로덕션에 배포되는 프로젝트는 격리된 컨테이너에서 동작하며, 로컬 머신과는 사양이 다르기 때문에 실제로 프로덕션 환경의 격리된 컨테이너에서 테스트를 수행하고, 성공 시에만 프로덕션에 코드를 배포하도록 하는 CI / CD 파이프라인을 구축하는 것이 중요합니다.
또한 매번 기능을 개발할때마다 수동으로 테스트하는 것은 테스트 비용을 높이는 원인으로 작용하므로 CI / CD 파이프라인을 구축해서 자동으로 PR생성 시에, 혹은 Deploy시에 테스트하도록 구성하는 것은 테스트 비용을 낮추고 지속적으로 안전한 서비스를 제공하는데 도움이 됩니다.
실제로 Github과 Google Cloud Build, Jenkins, Docker등을 사용하면 이러한 파이프라인을 어렵지 않게 구축할 수 있으며 4부 포스팅에서 이를 다룰 예정입니다.
Conclusion
이번 포스팅에서는 프론트엔드에서의 현실적인 테스트 방안이라는 다소 큰 주제를 다루기 위한 대략적인 개요를 설명하였습니다. 프론트엔드에서 무엇을 테스트해야 하는지, 어떻게 테스트해야 하는지, 언제 어디서 테스트해야 하는지에 대한 큰 그림을 소개하였으며, 현실적으로 프론트엔드에서 테스트하는 것이 왜 어렵게 느껴질 수밖에 없는지에 대해서도 간략하게 설명하였습니다. 이후 이어지는 포스팅에서는 이번 포스팅에 소개된 여러 주제들을 하나씩 보다 구체적으로 살펴볼 예정입니다.
'Frontend' 카테고리의 다른 글
Conceptual Model of React Suspense (0) | 2022.09.12 |
---|---|
프론트엔드 테스트 전략 - (2) Integration Test (1) | 2022.09.04 |
Declarative React, and Inversion of Control (0) | 2022.09.03 |
모노레포의 기술적 요구사항 (5) - Sparse Checkout (0) | 2022.08.07 |
모노레포의 기술적 요구사항 (4) - Plugin (0) | 2022.08.07 |