커맨드 재시도(Retry-ability) 이해하기

커맨드 재시도(Retry-ability) 이해하기

동적인 웹 앱을 테스트할 때 DOM이 아직 갱신되지 않았는데 커맨드가 실행되면 문제가 생기기 마련이에요. Cypress가 이런 상황에서 쓰는 핵심 기능이 바로 **재시도(Retry-ability)**입니다. 평소에는 마치 차의 좋은 변속기처럼 거의 의식되지 않지만, 동작 원리를 알면 더 빠르고 예상 밖의 실패가 적은 테스트를 작성할 수 있어요. 이 글에서는 커맨드의 종류와 재시도가 일어나는 규칙을 살펴볼게요.

출처: Cypress 공식 문서 — Retry-ability in Cypress 원본 URL: https://docs.cypress.io/app/core-concepts/retry-ability

커맨드의 세 종류

Cypress 테스트에서 cy에 체이닝하는 모든 메서드는 커맨드지만, 그 동작 규칙이 서로 달라요.

  • Query(쿼리) — 체인 전체를 묶어서 함께 재시도해요.
  • Assertion(단언) — 일종의 쿼리인데, Command Log에 특별히 표시돼요.
  • Non-query(비쿼리) — 딱 한 번만 실행돼요.

아래 테스트를 보면 쿼리 5개, 액션 1개, 단언 2개가 있어요. 쿼리와 단언은 서로 엮여서 목표 조건이 될 때까지 계속 다시 실행되고, 액션(.type())은 한 번만 실행됩니다.

it('creates an item', () => {
  cy.visit('/')                                   // non-query: 한 번만 실행
  cy.focused().should('have.class', 'new-todo')   // query + assertion
  cy.get('.header').find('.new-todo').type('todo A{enter}')
  cy.get('.todoapp').find('.todo-list li').should('have.length', 1)
})

재시도는 '위에서부터' 다시 실행돼요

현대 웹 앱에서는 아무것도 동기적이지 않아요. cy.get().find()가 DOM에서 일치하는 요소를 찾아 .should()에 넘기고, 단언이 통과하면 성공하고, 실패하면 Cypress는 연결된 쿼리 체인의 맨 위부터 다시 DOM을 조회해서 단언을 재실행해요. 단언이 통과하거나 타임아웃에 도달할 때까지 이 과정을 반복합니다.

가장 중요한 건, 재시도 덕분에 테스트에 하드코딩한 대기를 넣지 않아도 된다는 점이에요. 앱이 DOM 요소를 렌더링하는 데 몇 밀리초가 걸리든 몇 초가 걸리든 테스트 코드는 전혀 바꿀 필요가 없습니다. 예를 들어 UI 갱신을 3초 뒤로 지연시켜도, Cypress는 그 동안 단언이 통과할 때까지 쿼리 .get().find()를 재실행하다가 통과하면 테스트가 초록색으로 끝나요.

단언이 여러 개면 순서대로

쿼리와 단언은 항상 순서대로 실행되고, 항상 '위에서부터' 재시도돼요. 단언이 여럿이라면 각각이 통과해야 다음 단언으로 넘어갑니다. .should().and()가 나란히 붙었을 때, 앞의 단언이 통과했더라도 뒤의 단언이 실패하면 그 단언이 타임아웃까지 재시도하다가 실패로 끝나요.

중간에 낀 단언은 재시도 경계가 돼요

단언이 쿼리 체인의 중간에 나타나면 그 단언은 일종의 재시도 경계(retry boundary)로 동작해요. 단언이 통과하면 그 시점의 subject가 고정되고, 이후 쿼리는 체인 처음이 아니라 고정된 subject부터 재시도됩니다.

이게 문제가 되는 경우가 있어요. 첫 단언 통과 후 앱이 DOM을 다시 렌더링해서, 다음 쿼리로 넘길 요소가 이미 DOM에서 분리(detach)돼 버리는 상황이죠. 이때 Cypress는 체인 상단부터 다시 조회할 수 없어서 실패하게 됩니다.

이럴 땐 체인을 여러 문장으로 쪼개서 각 재조회가 처음부터 새롭게 시작하게 하거나, .should(callbackFn)을 써서 모든 단언이 새로 조회한 요소에 대해 함께 재시도되도록 만들 수 있어요.

// 체인 분리 — 각 쿼리가 처음부터 다시
cy.get('.list').find('li').eq(2).should('contain', 'Header')
cy.get('.list').find('li').eq(2).children('.child').eq(3).should('contain', 'child')

// 또는 .should(callbackFn)으로 함께 재시도
cy.get('.list').find('li').eq(2).should(($li) => {
  expect($li).to.contain('Header')
  expect($li.children('.child').eq(3)).to.contain('child')
})

타임아웃과 내장 단언

많은 커맨드에는 단언을 붙이지 않아도 재시도되게 하는 내장 단언이 있어요. 예를 들어 .eq()는 단언 없이도 해당 인덱스 요소를 찾을 때까지 재시도됩니다. 그리고 액션 커맨드(예: .click())는 요소가 actionable해질 때까지 기다렸다가 딱 한 번 동작을 시도하는데, 요소가 보이는지, 뒤에 다른 요소에 가려져 있지 않은지, disabled 속성이 없는지 같은 한 사람의 사용자 관점 검사를 자동으로 수행해요.

기본적으로 재시도하는 커맨드는 기본값 4초까지 기다리는데, 이 값이 defaultCommandTimeout 설정이에요. 커맨드마다 다른 시간을 원하면 전역 설정 대신 커맨드 자체의 { timeout: ms } 옵션을 넘기는 걸 권장합니다.

cy.get('[data-testid="mobile-nav"]', { timeout: 10000 })
  .should('be.visible')
  .and('contain', 'Home')

타임아웃을 0으로 덮어쓰면 사실상 재시도를 끈 것과 같아요. 서버사이드 렌더링 직후에 요소가 존재하지 않는 것을 동기적으로 확인할 때 유용합니다.

cy.get('[data-testid="ssr-error"]', { timeout: 0 }).should('not.exist')

체인을 끊는 커맨드 주의 — .then()과 스텁 단언

.then()은 쿼리 체인을 끊어요. 체인 이전의 조회(요소의 텍스트 읽기 등)가 다시 실행되지 않으므로, 값이 나중에 설정되는 번호 예제처럼 쓰면 실패합니다. 이럴 때는 .should(callbackFn)으로 조회와 텍스트 호출, 단언을 모두 재시도되게 만들면 돼요.

스텁(stub)을 단언할 때도 주의가 필요해요. expect(onClick).to.be.calledTwice처럼 스텁에 직접 단언하면 재시도되지 않아서, 지연된 콜백이 아직 실행되기 전에 테스트가 끝나버릴 수 있어요. 대신 스텁을 .as()로 별칭을 붙이고 cy.get('@alias')로 참조해서 단언하는 것을 권장합니다.

it('calls the click prop', () => {
  const onClick = cy.stub().as('clicker')
  cy.mount(<Clicker click={onClick} />)
  cy.get('button').click()
  cy.get('button').click()
  cy.get('@clicker').should('have.been.calledTwice')
})

더 알아보기