FE개발자의 성장 스토리 12 : Angular E2E 테스팅 경험기

안녕하세요, 비즈인프라FE파트의 nina입니다.

지난 해 동료들과 함께 지식관리자센터 서비스의 프론트엔드 프로젝트에 E2E 테스트를 적용했습니다. 그 과정에서 테스팅 라이브러리 선정이나 테스트 단위 구성, Mocking 여부 등 다양한 문제들에 대해 파트 동료들과 함께 고민하면서 과제를 진행했습니다. 여기에서는 테스트 적용 중 경험한 문제와 해결 방법, 그리고 이를 통해 느낀 점에 대해 정리하겠습니다.



목표 정하기: 어떤 테스트를 만들 것인가

파트에서 유지 보수 중인 다른 Angular 프로젝트에도 이미 E2E 테스트가 적용되어 있었습니다. 하지만 테스트 실행 시 제대로 렌더(Render)가 되지 않는 상태에서 검증을 진행하는 등의 이슈가 있어 테스트가 간헐적으로 실패하는 문제가 있었습니다. 처음에는 작성된 테스트에 CI/CD를 적용하여 테스트가 성공할 때에만 Merge를 진행하도록 설정했지만, 테스트의 신뢰도가 떨어지다 보니 나중에는 테스트가 실패해도 Merge를 진행하고 있었습니다.

새로운 프로젝트에도 테스트를 적용하게 되면서, 렌더링 등 기존 테스트가 가지고 있던 이슈 해결뿐 아니라, 테스트 자체를 개선해 신뢰도 있고 의미 있는 피드백을 제공하고, 실행 시간이 빠르며, 작성 및 유지 보수에 리소스가 많이 들지 않는 테스트를 작성하는 것을 목표로 했습니다.


테스팅 라이브러리로 Cypress 선정

앞서 논의한 목표들을 실현하기 위해 적절한 테스팅 라이브러리를 선정했습니다.

기존에는 Angular에서 공식적으로 지원하는 테스팅 라이브러리인 Protractor를 사용했었는데, 2022년부터는 개발을 중단하고 Angular V15부터는 Protractor 지원을 중단한다는 소식이 있었습니다. Javascript 표준이 변경되면서 Protractor에서 제공하던 WebDriver wrapping이나 비동기 처리의 간소화 등의 기능이 더 이상 필요하지 않게 되어, 현재 Javascript 표준에 맞는 다른 테스팅 툴의 사용을 권장한다는 것이 주요 내용이었습니다. 

그래서 저희는 Angular 공식 문서에서 마이그레이션을 제공하고 있는 Cypress가 저희가 사용하기에 적합한지를 리서치했습니다.


렌더 이슈 해결 가능

Cypress는 Automatic waiting을 지원하기 때문에 코드 작성에 비동기 처리가 거의 필요 없었습니다. 즉, 자동으로 같은 동작을 성공할 때까지 여러 번 반복해 결과를 얻는 방식으로 처리를 하고 있는데, 이렇게 되면 기존에 간헐적으로 렌더가 되지 않았던 문제를 해결할 수 있어 테스트 결과가 간헐적으로 실패하는 일이 줄어들고, 테스트 결과를 더 신뢰할 수 있게 됩니다.



유지 보수가 쉬운 테스트 구성 가능

기존 프로젝트의 테스트 코드는 아래처럼 모든 코드에 await가 계속 반복되고 있었습니다.

				
					it('테스트', async () => {
  await click(openFormButton);
  await fillInput(input, 'asdf');
  await waitLoading();  await click(confirmButton);
  ...
});
				
			

Cypress의 Automatic waiting 기능을 활용하면 위의 코드를 아래와 같이 개선할 수 있습니다. 

				
					it('테스트', () => {
  cy.get(openFormButton).click();
  cy.get(input).type('asdf');
  cy.get(confirmButton).click();
  ...
});
				
			

기존의 동작에서는 click과 같은 동작에서도 Element를 DOM에서 찾을 때까지 대기하기 위해 비동기 코드를 사용해야 했고, waitLoading 함수처럼 대기만을 위한 코드도 임의로 추가해야 했습니다. 반면에 Automatic waiting 기능을 활용하면, 비동기 처리에 대한 고려를 하지 않아도 되기 때문에 작성과 유지 보수에 리소스도 덜 들고, 가독성도 더 개선될 수 있을 것으로 보였습니다.


빠른 실행 시간

그리고 Cypress에서도 Protractor와 같이 Headless 모드를 지원합니다. Headless 모드는 직접 브라우저를 띄우지 않고 Node 엔진 위에서만 실행하여 실행 시간 단축이 가능합니다. 테스트 작성 시에는 브라우저 모드를, 빠른 실행 시간을 원할 때에는 Headless 모드를 사용할 수 있었습니다.

이렇게 툴에서 저희가 필요로 하는 부분들을 채워줄 수 있다는 것을 확인하고, Cypress로 테스팅을 진행하게 되었습니다.

 

Cypress로 테스트하기


작성하면서 알게 된 Cypress의 편리한 기능들도 있었는데요, 그중 몇 가지를 함께 소개해 보겠습니다. 

Commands 기능

첫 번째는 Commands 기능으로, 마치 메소드처럼 테스트 코드를 묶어서 재사용할 수 있게 해 주는 기능입니다. Commands로 선언하면 다른 Cypress에서 제공하는 메소드들과 같이 따로 Import 하지 않아도 cy 객체로부터 호출하여 사용할 수 있습니다. 

아래 코드와 같이 자주 사용하는 실행 로직을 Commands로 만들어서 중복을 피할 수 있고, 테스트 코드와 분리하여 관리할 수 있었습니다.

				
					// 선언
Cypress.Commands.add('logoutAndLogin', (account) => {
  cy.logout();
  cy.visit('/');
  cy.login(account);
});

// 사용
cy.logoutAndLogin(account);
				
			

Intercept 기능

두 번째는 Intercept 기능입니다. Intercept 기능은 서비스에서 호출된 API를 중간에서 조작할 수 있는 기능입니다. 저희는 특정 API 호출 시 응답이 올 때까지 대기했다가 테스트를 재개하거나, 응답을 Mocking 하는 데 주로 사용했습니다. Intercept API 사용으로 임의의 시간 동안 대기하는 코드를 거의 없앨 수 있었고, Mocking도 간단하게 할 수 있었습니다.

				
					// Intercept API를 이용한 API 응답 대기
it('API 응답을 받을 때까지 기다렸다가 진행하기', () => {
  cy.intercept('GET', `${apiUrl}/hello`).as('hello');
  cy.get(button).click();
  cy.wait('@hello');
});
				
			
				
					// Intercept API를 이용한 API 응답 대기
it('API 응답을 받을 때까지 기다렸다가 진행하기', () => {
  cy.intercept('GET', `${apiUrl}/hello`).as('hello');
  cy.get(button).click();
  cy.wait('@hello');
});
				
			

 

무엇을 테스트하는지를 명확히 하기

 

프론트엔드 E2E 테스트에는 기능적 테스트와 시각적 테스트 두 종류가 있습니다. 

  • 기능적 테스트는 이미지 비교를 통해 테스트 성공 여부를 결정하는 Visual Regression 방식 사용
  • 시각적 테스트는 DOM 트리를 탐색을 통해 화면을 조작하고 검증

아래 그림은 Visual Regression 방식의 테스팅(기능적 테스트) 예시입니다. 기대했던 이미지와 실행 결과를 캡처한 이미지를 비교하여 픽셀의 어떤 부분이 달라졌는지를 검증합니다. 아래 그림의 왼쪽 이미지에서 변경 사항만 색깔이 있는 부분으로 표시되고 있는 것을 확인할 수 있습니다.

이들 중 저희는 기능적 테스트를 지향하기로 했습니다. 시각적 테스트를 하게 되면 마크업 변경이 있을 때마다 테스트 코드가 변경되어야 하는데, 테스트 코드가 너무 자주 수정되면 유지 보수 비용이 증가할 것 같다고 생각했기 때문입니다.

같은 맥락에서 DOM 탐색에서도 Data Attribute를 추가하여 해당 Attribute로 DOM 탐색을 하는 방식을 채택했습니다.

기존의 테스트에서는 코드가 중복되는 것을 방지하고, HTML 구조 변경과 테스트 코드 변경을 분리하기 위해 Selector들을 하나의 객체로 모아 관리하는 Page Object를 사용하고 있었습니다. Page Object를 사용하면 Element를 가져오는 코드가 중복되는 것을 방지하고, Selector를 테스트 코드와 분리할 수 있다는 장점이 있습니다.

				
					class PageObject {
  getConfirmButton = elementByCss('.submit_form > div.confirm_button > button');
  getCancelButton = elementByCss('.submit_form > div.cancel_button > button');
  getTitle = elementByCss('.title');
}
				
			

위의 코드와 같은 동작을 HTML Tag에 Data Attribute를 추가하는 방식으로 변경한 것이 아래의 코드입니다. 위의 코드에서는 상위 DOM 구조가 변경되면 해당 Element 탐색까지의 경로가 변경되어 결국 코드 변경이 필요한데, 아래 코드에서는 대상 Element의 속성만 유지되면 코드를 변경하지 않아도 되어 변동성이 낮아집니다.

				
					{
  confirmButton: '[data-cy="confirm-button"]',
  cancelButton: '[data-cy="cancel-button"]',
  title: '[data-cy="title"]'
}
				
			

대신 Production용으로 빌드 된 코드에 테스트 관련 코드가 들어가게 되면 개발에만 사용하는 속성을 외부 사용자도 Inspector나 빌드 된 파일에서 볼 수 있고, 불필요한 코드가 포함되면 빌드 파일의 용량도 늘어나기 때문에 Webpack 플러그인을 추가하여 빌드 단계에서 Attribute를 제거했습니다.

처음에는 Angular Directive를 이용해 DOM 초기화 후 해당 속성을 모두 없애는 방식으로 처리했지만, 빌드 파일을 내려받았을 때 여전히 추가된 테스트 속성을 볼 수 있고, 빌드 파일의 용량이 기존보다 늘어나는 문제를 해결하지 못하고 있어 좀 더 근본적으로 빌드 단계에서부터 해당 속성을 제거하는 방식을 사용했습니다.

그리고 Mocking을 언제 해야 하는지에 대해서도 논의가 있었습니다. 기존 테스트는 Mocking을 사용하지 않고 있었는데, 이번에 작성하는 테스트에서는 원하는 형태의 데이터를 API 호출로만 만들기가 어려운 케이스가 있었습니다. 지식관리자센터에서는 생성된 센터를 챗봇 서비스와 연동할 수 있는데, 연동 데이터는 API 호출로 만들기가 어려웠습니다. 이런 경우처럼 직접 데이터를 만들 수 없는 경우 Mocking을 사용하기로 하였고, 테스트 환경 세팅을 위해 API 호출을 여러 번 해야 하는 경우에도 복잡도가 높아질 수 있기 때문에 Mocking을 하기로 하였습니다. 이처럼 때에 따라 더 적합한 방식을 사용하기로 하였습니다.


테스트 가이드 문서 제작

테스트 작성에 관한 리서치를 진행하다 보니 실제 테스트 동작과 코드를 보는 것이 추후 어떻게 테스트를 작성하게 될지에 대해 훨씬 빠르게 감을 잡을 수 있겠다는 생각이 들었습니다. 그래서 로그인과 간단한 모달을 테스트하는 샘플 코드를 작성하여 동료들에게 공유하였습니다.

그러나 샘플 테스트 코드만으로는 바로 테스트를 작성하기에 모호한 부분이 있다는 의견이 있어, 가이드 문서를 제작하게 되었습니다. 테스트 작성은 일반적으로 지향하는 바가 비교적 명확하지만, 명시하지 않으면 누락하거나 동료들마다 다른 관점을 가질 수 있는 부분도 있기 때문에 가이드 제작이 의미가 있었습니다.

가이드는 참고하여 바로 테스트를 작성할 수 있도록 실현 중심으로 작성했습니다. 테스트 이름 짓는 법, 테스트 잘 작성하는 법, 테스트 잘 실패하는 법, 테스트 구조나 파일명 규칙, 테스트 실패 시 매뉴얼 등 여러 상황별 가이드를 작성하였고, 동료들이 준 의견을 반영하여 최종적으로 가이드를 수정하였습니다.

아래는 가이드 중 “테스트 잘 작성하기” 항목입니다. 참고한 자료는 글 하단에 표기하였습니다.

테스트를 작성할 때 PR에 가이드 링크를 첨부하거나, 가이드 내용을 기반으로 주석을 다는 규칙을 정하는 등 동료들이 함께 참여하여 더 세부적인 규칙을 세우고 논의를 전개해 나갔습니다.


테스트 단위 선정

테스트 작성을 어떤 단위로 진행해야 할지도 고민되는 부분이었습니다. 기존의 테스트는 가장 기본적인 기능만을 실행하고 있었는데, 이 때문에 테스트를 성공했다는 것은 최소한의 동작만 보장하였습니다. 

새로 작성하는 테스트는 더 많은 범위의 비즈니스 로직이 잘 동작한다는 것을 보장해 테스트 성공이 더 큰 의미를 가지도록 개선하고자 했습니다. 

처음에는 모든 비즈니스 로직에 대한 테스트를 작성하기 위해 테스트 항목 리스트를 뽑았는데, 막상 작성하려고 보니 모든 항목들이 서로 독립으로 진행되어야 하는지에 대한 의문점이 생겼습니다. 그래서 파트의 다른 동료들에게 자문을 구했습니다. E2E는 테스트 단위가 유저 시나리오이고, 그렇기 때문에 시나리오 안에서는 독립적인 수행이 어렵고 대신 각 시나리오는 독립적으로 구성해야 한다는 이야기가 있었습니다.

그래서 저희는 모든 비즈니스 로직에 대한 테스트 항목 리스트를 작성한 후, 페이지 단위로 테스트 항목을 묶은 8개의 시나리오를 구성하고 각 시나리오 내에서는 의존성을 가질 수 있도록 허용했습니다. 시나리오가 끝날 때에는 처음 테스트를 실행할 때 상태로 되돌려야 한다는 규칙을 정했습니다.

이렇게 각 시나리오를 파일로 구성하니 파일 단위로 테스트를 개발하고 실행할 수 있어 개발 시에 이점이 있었고, 테스트 실행 시간도 대폭 줄었습니다. 테스트 작성에 관한 논의가 끝나고 시나리오 단위로 지라 티켓을 생성하고, 팀원들이 나누어 테스트를 작성하는 것으로 개발을 진행하였습니다.


마치며

테스트 작성 과제를 마무리하며 동료들과 KPT 방식의 간단한 회고를 진행하였습니다.

테스트를 작성하면서 크고 작은 문제들이 발생했을 때 당장의 문제를 해결하기보다는 테스트 작성에서 추구해야 할 목적이 무엇인지 다시 고민해 보고 가장 적합한 해결책을 찾았다는 점이 좋았습니다. 그 과정에서 테스트에 대한 이해도를 많이 높일 수 있었습니다. 그리고 Cypress라는 툴을 사용하면서 테스트 작성에서 발생할 수 있는 문제들을 Cypress에서는 어떤 방식으로 해결했는지를 살펴볼 수 있었습니다. 또한, 습관적으로 테스트를 작성하는 것이 아니라 이전 테스트 개선이라는 목적의식을 확실히 하며 과제를 진행한 부분이 좋았습니다.

아쉬웠던 점도 있었습니다. 샘플 코드를 보고 코드 작성을 바로 할 수 있겠다고 생각하여 바로 작성을 했는데, Cypress에 대한 이해가 부족해 서로 작성하는 방식도 다르고, 라이브러리에 대한 이해가 높아지면서는 기존에 작성한 코드들을 갈아엎는 케이스가 많았습니다. 가령 앞에서 언급한 Intercept API를 모를 때는 특정 시간만큼 wait를 하고 있었는데, API에 대해 이해한 후에는 해당 코드를 Intercept로 개선하는 작업이 있었습니다. 이러한 시행착오를 줄이기 위해 개념적인 논의 이후에 Cypress에 대한 이해를 높일 수 있는 학습 시간이 있었으면 좋았겠다는 의견이 공통적이었습니다. 좀 더 엄격한 가이드가 있으면 추후 테스트를 작성할 때 비용을 줄일 수 있을 것이라는 의견도 있었습니다.

이렇게 회고로 개선해야 할 점들을 확실하게 알게 되었고, 추후 동료들과 논의하여 개선 계획을 세울 예정입니다.

여기까지가 동료들이 함께 고민하며 테스트를 작성한 과정입니다. 저희가 고민하고 결론 내린 것들은 정답이 아니지만, 프로젝트에 맞고 잘 동작하는 테스트를 만들 수 있었습니다. 앞으로 테스트를 작성하면서 이 글을 읽으시는 분들도 저희가 고민했던 같은 문제들에 직면할 수 있을 것 같은데요, 저희가 먼저 고민했던 것들이 조금이나마 도움이 될 수 있었으면 합니다.

감사합니다.



참고

Latest Posts

[get Server!] [커머스CIC] 채널개발파트 소개 드려요!

평소 커머스 도메인에 관심이 많았다면? 톡딜을 통해 핫템을 득해본적이 있다면? 한번이라도 라이브커머스를 넋놓고 쳐다본적이 있다면? 라이언이랑 춘식이랑 함께하는 카카오 커머스CIC에서 개발자의 꿈을