이번 주에는 점심 뭐먹지 2단계 미션을 진행하면서 cypress로 e2e 테스트 코드를 작성해야했다.
미션의 package.json에 등록되어있는 cypress 테스트 실행 명령은 아래와 같았다.
npx cypress open
(우테코 미션에서는 npm run test
명령어였다)
나는 이번에 e2e 테스트 코드를 한 파일에 적지 않고 아래와 같이 컴포넌트 단위로 짜게되었는데, e2e 테스트 코드를 작성한 후 코드를 리팩토링할 때 마다 아래 테스트들을 하나 하나씩 눌러 돌려보는데 너무 귀찮았다.
jest 테스트 코드를 돌릴 때 처럼 분명히 한번에 모든 e2e테스트를 돌려보는 명령어가 있을거라 생각했고 그리해서 발견한 것이 아래의 cypress 테스트 실행 명령어였다.
npx cypress run --headed
npx cypress run
⬇️ npx cypress run
으로 cypress를 실행한 결과 스샷
내가 딱 원하는대로 모든 e2e케이스를 자동으로 돌려주고 결과를 터미널창에서 깔끔하게 보여준다.
이렇게 편안함에 딱 만족할려던 차에!
npx cypress open
로 돌릴 때는 잘 통과되던 테스트가
npx cypress run
으로 실행하니 통과하질 못하는 상황이 벌어졌다.npx cypress run --headed
로 실행해봐도 마찬가지였다.
참고로 이 테스트가 돌아갈 때 사용되는 비즈니스 로직 변경은 아무것도 없었다.
이유가 도대체뭘까?
일단 에러메시지를 살펴보자.
CypressError: Timed out retrying after 4000ms:
cy.find()
failed because the page updated as a result of this command, but you tried to continue the command chain. The subject is no longer attached to the DOM, and Cypress cannot requery the page after commands such ascy.find()
.
Common situations why this happens:
- Your JS framework re-rendered asynchronously
- Your app code reacted to an event firing and removed the element
You can typically solve this by breaking up a chain. For example, rewrite:
cy.get('button').click().should('have.class', 'active')
to
cy.get('button').as('btn').click()
cy.get('@btn').should('have.class', 'active')
https://on.cypress.io/element-has-detached-from-dom
번역본 ⬇️
CypressError: 4000ms 후에 재시도하는 동안 시간이 초과되었습니다: cy.find()가 이 명령으로 인해 페이지가 업데이트 되었기 때문에 실패했고, 명령 체인을 계속 시도했습니다. 대상이 더 이상 DOM에 연결되어 있지 않고, cy.find()와 같은 명령 후에 Cypress가 페이지를 다시 쿼리할 수 없습니다.
이런 상황이 발생하는 일반적인 경우는:
여러분의 자바스크립트 프레임워크가 비동기적으로 다시 렌더링되었습니다
앱 코드가 이벤트 발생에 반응하여 요소를 제거했습니다
이 문제를 해결하는 일반적인 방법은 체인을 분리하는 것입니다. 예를 들어, 다음과 같이 작성하세요:
cy.get('button').click().should('have.class', 'active')
를
cy.get('button').as('btn').click()
cy.get('@btn').should('have.class', 'active')
로 바꾸세요.
https://on.cypress.io/element-has-detached-from-dom
찾아보니 해당 에러 메시지는 페이지가 업데이트되어 대상 요소가 DOM에서 분리되었기 때문에 cy.find()
명령어가 실패한 것이라한다. 이는 비동기적인 렌더링이나 이벤트 처리로 인해 요소가 제거되었을 가능성이 있다라고한다.
select에서 sort값을 고르면('이름순','거리순') 음식점 데이터를 비동기적으로 받아온 후 정렬해서 렌더링하는 방식으로 구현했기때문에 위에서 언급된 비동기적인 렌더링이 이 에러의 원인인것같다.
특정 cypress 실행 환경에서만 테스트가 실패하고있기 때문에 각 명령어의 실행환경 차이점을 알아보자.
npx cypress open
에서는 cy.find()
실행에서 런타임 타임아웃 에러가 발생하지 않는 경향이 있다.npx cypress open
을 사용하여 테스트를 작성하고 디버깅하는 것이 효과적이다.npx cypress run --headed
에서는 cy.find()
실행에서 런타임 타임아웃 에러가 발생할 수 있다.npx cypress run
을 사용하여 완전히 자동화된 테스트 실행을 수행하는 것이 일반적이다.npx cypress run
에서도 cy.find()
실행에서 런타임 타임아웃 에러가 발생할 수 있다.npx cypress run --headed
를 사용할 수 있다.정리해보자면 npx cypress open
으로 실행할 때는 브라우저에서 직접 테스트를 확인하면서 진행하기 때문에 비동기 작업의 완료를 기다릴 수 있다고한다.
하지만 npx cypress run
으로 실행할 때는 자동화된 환경에서 테스트가 빠르게 진행되므로, 비동기 작업이 완료되기 전에 다음 테스트 단계로 넘어갈 수 도 있다는 것이다.
그래서 이 경우 cy.wait()같은 명령어로 명시적인 시간을 추가해주는 작업 등을 해줘야 될수도있다.
그럼 여기서 다시 드는 의문 !
'이름순','거리순'은 둘다 똑같은 sortKey
라는 함수를 통해 데이터를 처리하고, 렌더링 로직도 똑같은데,
왜 '이름순'은 통과하고 '거리순'은 통과하지 못하는가?라는 의문이 들었다.
⬇️ 아래는 sort하는 함수이다.
sortByKey(data: RestaurantInfo[], sorting: SortingValues): RestaurantInfo[] {
const result = data.slice().sort((a, b) => {
if (sorting === '이름순') {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
}
if (sorting === '거리순') {
return a.distance - b.distance;
}
return 0;
});
return result;
}
'이름순'정렬과 '거리순'정렬의 차이점은 sortByKey 함수안의 각각의 정렬 로직 차이 밖에 없다.
따라서 여기서 내린 결론은 '거리순'정렬로직 보다 '이름순'정렬 로직 처리 속도가 더 빨라서, '거리순'정렬 테스트에서만 저런 에러가 나는게 아닌가? 였다.
if (sorting === '거리순') {
return a.distance - b.distance;
}
if (sorting === '이름순') {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
}
하지만 '거리순'정렬 로직은 두 숫자 차이 계산이기 때문에 매우 빠르고, '이름순'정렬은 두 문자열 비교이기때문에 비교적으로 숫자열 비교보다 느리다고한다.하지만 이 경우 정렬 로직 둘 다 매우 간단한 연산이기때문에 이런 차이가 테스트 결과에 큰 영향을 미칠 가능성은 낮아보인다.
그럼 도대체 이유가 뭘까?
곰곰히 생각하던 중 두 정렬의 큰 차이점 하나가 떠올랐다.
바로 각 정렬이 정렬 필터의 기본값인지 아닌지이다.
정렬 필터의 기본값은 "이름순"이었다.
그리고 이 때 '이름순'정렬 테스트는 통과했지만 '거리순'정렬 테스트는 통과하지 못했다.
애초에 첫 페이지가 이름순으로 이미 정렬되어있었기 때문에 '이름순'정렬 테스트에서는 cy.find()
명령어 실행 중 타임아웃 에러가 발생하지 않았고,
'거리순'으로 바꿨을 때 비로소 e2e 테스트에서 sorting로직이 처음 발생한것이고 이에 따라 cy.find()
타임아웃 에러가 발생이 한게 아닐까?
⬇️바로 정렬 카테고리 기본값을 아래와 같이 '거리순'으로 바꿔보았다.
바로 테스트를 돌려보았다.. 과연 내 생각이 맞을까??
맞았다 😭 단순했던 원인을 참 멀리멀리 돌아서 찾은 느낌이다..
비동기적인 렌더링이 원인이었기때문에 렌더링이 다 될 때 까지 기다리게 하면 해결된다.
it('거리순을 선택하면 거리순으로 정렬되어야한다.', function () {
cy.get('#sort-filter').select('거리순');
cy.wait(2000); // 명시적인 대기 시간 추가
// ....
});
});
it('거리순을 선택하면 거리순으로 정렬되어야한다.', function () {
cy.get('#sort-filter').select('거리순');
const sortedRestaurants = [...this.restaurantsData].sort((a, b) => a.distance - b.distance);
cy.get('.restaurant-list-container .restaurant', { timeout: 10000 }) // 재시도 옵션 추가
.each(($restaurant, index) => {
cy.wrap($restaurant)
.find('.restaurant__name')
.should('have.text', sortedRestaurants[index].name);
cy.wrap($restaurant)
.find('.restaurant__distance')
.should('have.text', `캠퍼스부터 ${sortedRestaurants[index].distance}분 내`);
});
});
it('거리순을 선택하면 거리순으로 정렬되어야한다.', function () {
cy.get('#sort-filter').select('거리순');
const sortedRestaurants = [...this.restaurantsData].sort((a, b) => a.distance - b.distance);
cy.get('.restaurant-list-container .restaurant')
.should('have.length', sortedRestaurants.length) // 렌더링 완료 확인
.each(($restaurant, index) => {
cy.wrap($restaurant)
.find('.restaurant__name')
.should('have.text', sortedRestaurants[index].name);
cy.wrap($restaurant)
.find('.restaurant__distance')
.should('have.text', `캠퍼스부터 ${sortedRestaurants[index].distance}분 내`);
});
});
Cypress.Commands.add('waitForRender', () => {
cy.get('.restaurant-list-container').should('be.visible');
});``` // 이렇게 command에 등록후 사용할 수도있음. cy.waitForRender();
sort 작업에 비동기적인 작업이 들어간다는 사실을 초장에 잘 짚고 넘어갔으면
비동기적인 렌더링이 원인이다
-> 근데 e2e 테스트 에러는 '이름순','거리순'중에 한 케이스에서만 에러가 난다
-> 그럼 e2e 테스트에서 sort 작업이 한 번만 이루어지는게 아닌가?
-> 카테고리 필터의 초기값 차이?
이렇게 빠르게 에러 원인을 파악할 수 있었을거같은데, 내가 짠 코드를 내가 잘 파악하지 못해 트러블 슈팅을 오랫동안 하게된것같다.