배경
기존에 서비스중이던 프로젝트 구조를 변경에 대응하기 쉽도록 리팩토링하는 작업을 맡게 되었다.
그 중 가장 시급했던 부분은 로그인/인증 파트였는데, 기존에도 이상하게 에러가 많이 발생하는 파트였지만... 에러를 잡기 위해서 고치면 고칠수록 다른 부분에서 또 다른 이슈가 발생하는 상황이 있었다.
규모가 규모인지라, 별도의 QA 팀도 없어서 이슈를 고쳤을 때 또 다른 이슈는 없는지, 또 어느 범위까지 영향을 미치는 것인지 충분히 파악이 되지 않은 상태에서 배포를 하다보니 미흡한 부분이 그대로 유저에게 노출이 되었고... 비슷한 이슈에 대한 반복적인 대응에 나를 포함한 다른 여러 유관부서 팀원들의 리소스를 낭비하게 되는 현상이 있었다. 😢
분명 나는 개선하려고 리팩토링을 시작했던 건데... 가독성도 좋아지고 변경에 대응하기 쉽도록 구조는 개선되었지만, 완벽히 파악되지 않는 코드를 계속해서 고친다는 것은 불확실성도 높고 또 얼마만큼의 리소스를 쏟게 될지 알 수 없는 일이었다..
이에 주요 로직에 대한 테스트 코드를 도입하기로 했는데, 유닛/통합 테스트보다는 전반적인 서비스 로직이 의도대로 동작하는가를 테스트하는 것이 우선이었기에 e2e 테스트를 도입하게 되었다.
도입 과정
cypress를 선택한 이유
cypress 를 선택한 이유는 가장 범용적인 프론트엔드 e2e 테스트 라이브러이기도 하고, 또 기존에 몇몇 서비스 로직은 이미 cypress 를 활용해 작성이 된 바가 있어서이다.
cypress의 장점은 위와 같이 실제 브라우저를 기반으로 동작하기 때문에 일단 친숙하다. 브라우저 기반의 다양한 로직(ex 웹스토리지) 에 대한 테스트도 진행할 수 있다. 그리고 문법도 jest, react testing library 와 유사하기 때문에 이러한 테스팅 라이브러리를 활용해 본 경험이 있다면 러닝 커브도 높지 않다고 본다.
cypress 의 context 와 it 을 잘 활용해 케이스를 나누면, 사람이 읽기에 더 친숙한 테스트 케이스를 만들 수 있다. 작성한 테스트 케이스가 테스트 브라우저 인터페이스 좌측에 인덴트되어 구분되기 때문에, 테스트 코드를 까보지 않아도 무엇을 테스트하는지 한 눈에 알기가 쉽다. e2e 의 도입 목적이 QA 보조 수단이었던 점을 생각했을 때, 보는 사람이 이해하기 쉽다는 것이 큰 장점이었다.
어려움?
우선 기존에 존재하던 e2e 테스트 로직의 문제점은... 이해하기가 어렵다는 것이었다.(이는 곧 유지보수의 어려움으로 이어진다..)
이해가 어려웠던 점은 우선 각잡고 작성된 코드가 아니어서였던 이유도 있지만, cypress 문법의 특성으로 인한 부분도 있다고 본다. 특히 문제라고 생각했던 지점은 cypress 의 commands 관리 방식이었다.
일단, cypress의 command 기능을 사용하면, 마치 cy.get, cy.contains 를 활용하는 것처럼 커스텀 액션을 전역적으로 활용할 수 있다는 장점이 있다. 중복된 로직을 줄일 수 있어 잘 활용하면 굉장히 유용한 기능이다.
아래 로직은 기존 테스트 코드 commands 파일의 일부이다.
// commands.ts 일부
Cypress.Commands.add('login', (email, password) => {
cy.get($ELEMENT.ID_INPUT).type(email);
cy.get($ELEMENT.PW_INPUT).type(password);
cy.get($ELEMENT.SUBMIT_BUTTON).click();
});
Cypress.Commands.add('assertLogout', (apiForWait: string) => {
cy.wait(apiForWait).then(() => {
cy.visit('/login');
cy.contains('로그아웃').click();
cy.contains('네').click();
});
});
// 테스트 로직에서의 활용
cy.login(ACCOUNT.VALID.ID, ACCOUNT.VALID.PW);
내가 생각한 문제점은, 커맨드가 어떤 액션에 대한 것인지 한눈에 파악하기가 어렵다는 점이다.
첫 머리부터 Cypress.Commands.add 로 시작하기 때문에 물론 login, assertLogout 이라는 커맨드명을 전달해 의미를 파악할 수 있지만... 이런 commands 가 아주 많이 존재한다면 그 또한 여간 복잡한게 아니라서 처음에 코드를 열었을 때 당혹스러움도 느꼈던 것 같다.
(하지만 그래도 작성해주셔서 감사합니다...😉)
그리고.. 도대체 어디까지 commands에 등록을 해야하는가에 대한 문제도 있었다. 몇 번의 중복 횟수를 기준으로 할 것인지?
일단 몇 번을 센다는 것 자체가 전체 로직을 알아도 파악하기 어려운 부분이고, 그렇다고 기준 없이 모든 중복 액션을 커맨드로 등록하는 것 또한 관리가 어려운 부분이었다. 만약 모든 중복 액션을 커맨드로 관리한다면 크기가 매우 커질 것이기 때문이다..😇 cypress 를 타입스크립트와 함께 사용하려면 d.ts 파일에 타입을 선언해주어야 하는데 이때 Chainable interface 에서 커스텀 커맨드에 대한 타입 선언이 필요하기 때문에 여간 귀찮은 일이 아닐 수 없다...
아래가 그 예시이다. (공식문서)
// cypress/support/index.ts
Cypress.Commands.add('dataCy', (value) => {
return cy.get(`[data-cy=${value}]`)
})
// cypress/support/index.ts
declare global {
namespace Cypress {
interface Chainable {
/**
* Custom command to select DOM element by data-cy attribute.
* @example cy.dataCy('greeting')
*/
dataCy(value: string): Chainable<JQuery<HTMLElement>>
}
}
}
그리고.. 어디까지 테스트 할 것인지..?
또한 테스트의 범위를 결정하는 것도 문제였다. e2e 테스트의 도입 이유는 전반적인 서비스 로직이 의도대로 동작하는가를 테스트하기 위함인데, 이 범위가 한 페이지 안에서의 동작인지 만약 여러 페이지를 거치게 되는 기능이라면..?
한 페이지에서 UI 동작을 테스트하고자 한다면 버튼이 정상 렌더 되는지, 눌리는지, input에 유효성검사가 포함된다면 이 모든 부분을 다 테스트 해주는 것이 맞는데, 기획서 상 하나의 큰 기능으로 분류되는 페이지들을 테스트하기 위해 각 페이지의 모든 부분을 다 테스트 해주는 것이 과연 효율적인가에 대한 문제이다.
내가 생각한 e2e 테스트의 목적과, 다른 팀원이 생각한 e2e 테스트의 목적이 약간 상이했기에 이 부분에 대한 조율이 필요했다. 내가 생각한 목적은 보다 기획서상 기능에 충실한 테스트였고, 또 다른 의견으로는 프론트엔드의 기능에 충실한 테스트였다고 정리할 수 있겠다.
기획서상 기능에 충실하다는 것은, 굵직한 기능 예를들면 로그인/회원가입/주요한 서비스 로직에 대한 통합적인 테스트를 의미하는 것이고, 프론트엔드의 기능이라고 함은, 우리의 구현이 정상 동작 하는가에 대한 테스트를 의미한다.
보다 통합적인 테스트에 무게를 실었던 이유는 테스트 코드의 유지보수성에 있었다. 구현의 동작은 (그럴 가능성은 적다 하더라도) 세부적인 내용이 언제든 바뀔 수 있고 그러면 그에 따라 깨지는 테스트를 유지보수해야하는 추가적인 리소스가 필요하다. 하지만 보다 굵직한 기능 단위에 대한 테스트는 세부적인 내용이 변화하더라도 쉽게 바뀌지 않으면서 유저 시나리오에 따른 동작의 무결성을 보장할 수 있다. (그런 점에서 카카오엔터의 E2E 테스트 도입기 에 나온 기획서 기반 테스트코드에도 깊은 공감을 할 수 있었다.)
그리고 리팩토링하면서 유저에게 직접 QA 를 받은 아찔했던 경험이... 이런 전반적인 기능에 대한 테스트 필요성을 더욱더 크게 만들었던 것 같다... 🥲
위와 같은 여러 문제점과 의사결정 과정에서... 결국 테스트 코드에도 책임 즉 관심사 분리가 필요하다는 결론으로 이어졌다.
테스트 코드를 구조화 해보자..!
결론부터 말하자면, 우리는 하나의 테스트 단위 (기획서 상 하나의 플로우 혹은 한 페이지) 를 기반으로 관련된 테스트 코드를 캡슐화하여 관리하는 전략으로 코드를 구조화하였다. 이 내용을 도식화하면 아래와 같다.
위와 같은 구조는... 나와 같이 테스트 코드 구조화의 필요성을 느꼈던 Christian Dangl의 cypress 디자인 패턴 제안 영상을 참고해 도입할 수 있었다. 이 영상에서 제안하는 여러가지 방식이 있는데, 기획서상 플로우와 페이지에 대한 테스트 로직을 적절히 분리하면서도 재사용성을 높이기엔 위 구조가 가장 적합하다고 판단했다. (좋은 내용이니 e2e 테스트 구조화에 관심이 있으시다면 보시는 것을 추천드립니다.)
위와 같은 구조의 이점은 아래와 같다.
1. 관심사 분리
- 한 클래스에 하나의 기능에 대한 액션이 응집되기 때문에 관리가 편하다. 예시를 들면...
- 위와 같이 로그인이라는 하나의 기능을 테스트하기 위해 필요한 여러 액션을 메서드로 관리할 수 있다. 메서드의 이점은 메서드 명을 통해 이 액션이 무엇에 대한 것인지 한 눈에 파악하기 쉽다는 것이다.
- 만약 여기서 코드 수정으로 인해 테스트 로직도 수정이 필요하다면, 해당 메서드를 찾아 연관된 내용을 수정하면 된다.
2. 테스트 로직의 가독성 향상
- 테스트에 필요한 액션이 클래스의 메서드로 관리되기 때문에, 실제로 테스트를 하는 spec.cy.ts 파일에서는 이 클래스로 인스턴스를 만들고 필요한 액션을 호출해 테스트를 진행할 수 있다.
- 무엇을 가져오고, 존재하는지 확인하고, 클릭하는 기본적인 유저 액션이 메서드에 의해 감춰지기 때문에, 테스트 케이스가 많아져도 이런 세부적인 구현 내용보다 적절한 테스트 로직이 짜여져있는가에 집중할 수 있다.
- 예시를 들면 아래와 같다...
- 메서드 이름을 조금 더 컴팩트하게 짓는다면 더 좋았을지도 모르겠지만... 어쨌든 저런 메서드를 일일히 테스트 로직 안에서 구현하려면 꽤나 수고롭기도 하고 은근히 복잡해 이해도가 떨어진다. 저 메서드의 세부 구현에 대한 예시를 들어보자.
- 위와 같은 로직이 계속 반복해서 등장한다면... 물론 테스트 spec 파일 내에 별도 함수로 선언해 재사용도 가능하겠지만, 만약 서비스 전체에 걸쳐 반복적으로 사용된다면...? 어디에서 관리를 해야할지 고민하다가 commands 로 빠질 수도 있을 것이다. 그런 commands 가 과연 저 케이스 하나 뿐일까...?
- 이런 지점이 고민의 시작이었고 결국 하나의 기능 단위로 캡슐화해서 재사용성도 높일 수 있었다.
3. repository 와 page 의 분리
- support 파일의 Page 클래스는 주로 한 페이지에 존재하는 엘리먼트를 직접 가져오는 역할을 담당한다. 이러한 엘리먼트를 Page 클래스의 엘리먼트를 멤버 변수로 선언해놓고, 이를 가져오는 반복적인 액션을 메서드로 관리한다.
- 이렇게 되면, 해당 페이지의 구현적인 부분을 테스트 할 때는 Page 클래스를 인스턴스로 하여 별도로 테스트 할 수 있고, 다른 페이지와의 상호작용 및 전반적인 플로우를 테스트할 때는 Repository 클래스를 만들어 그 안에서 Page 클래스를 인스턴스로 생성해 재사용 할 수 있다.
- 물론 한계도 있다. 위 코드에서 보다시피 로그인 인스턴스에서 한 번 더 엘리먼트를 가져와 이를 활용하는 어떻게보면 반복적인 작업이 등장할 수도 있는데, 해당 엘리먼트에 대한 세부적인 내용을 관리하는 책임은 Page 클래스에 존재하기 때문에 변경에 대한 대응이 어렵지는 않을 것이다. 물론 이보다 더 좋은 방법도 있을 것이다...
결론
위 테스트 구조를 도입하며 여러 의견이 있었지만, 결론적으론 테스트 로직을 짜는데 있어 어느정도의 기준과 구조는 있으면 좋다는 결론에 도달하게 되었다.
테스트 코드를 작성하는 것은 생각보다 꽤 큰 비용이 드는 일인 것 같다. 기획서를 따르면 좋은데 기획서가 세부적으로 작성되어 있지 않다거나, 잦은 변경으로 인해 업데이트가 되지 않은 경우엔 참 어렵다. 그리고 더 어려운 것은... 그 코드를 내가 작성한게 아니라면 기능에 대한 이해 뿐만 아니라 코드 구현에 대한 이해가 필요하다. 특히 이 부분으로 인해서 시간이 굉장히 오래걸렸는데... 거기에 적절한 구조가 없었다면 더 복잡하고 유지보수하기 어려운 그야말로 처치곤란 테스트 로직이 되지 않았을까 하는 생각도 든다.
처음엔 내가 도입했지만 팀원들과 의견을 조율하며 편하고 관리하기 쉬운 방향으로 변화하고 있고, 현재도 이 구조를 기반으로 많은 e2e 테스트가 작성되었다. 리팩토링에 대한 부담도 줄어들었지만, 테스트 로직을 작성하며 우리 서비스에 대한 이해도도 높일 수 있었던 중요한 경험이었다. 서비스가 어떻게 돌아가는지 알아야 무엇이 문제인지, 어떻게 고쳐야 할지 결론적으로는 일을 잘 하는 방향으로 나아갈 수 있기 때문이다.
처음 이 서비스 리팩토링을 맡고 가장 난감했던 것은 이 서비스가 전반적으로 어떻게 돌아가는 것인지 이해하기가 쉽지 않았다는 것이다. 그전까진 다른 사람이 작성한 코드를 볼 일도 많이 없었고... 그래서 해당 서비스에 대한 사내 자료를 찾으며 이에 대한 플로우 차트를 발견했을 땐 감사하고 기쁜 마음도 들었다. 내가 작성한 테스트 케이스가 이 다음에 서비스를 맡을 누군가에게 도움이 되었으면... 하는 그런 마음도 든다.
참고자료
E2E 테스트 도입 경험기 | 카카오엔터테인먼트 FE 기술블로그
Design Patterns for sustainable automatic E2E Tests with cypress - Christian Dangl
GitHub - boxblinkracer/cypress-designpatterns: Demo Project for the Cypress Design Patterns talks
'프로젝트 > work' 카테고리의 다른 글
file, blob 객체의 차이점 (0) | 2024.01.08 |
---|---|
CRA to Vite 마이그레이션 및 테스트환경 세팅하기 (0) | 2023.09.21 |