티스토리 뷰

Cypress

Cypress 핵심 개념 - Retry-ability

변기원 2022. 10. 24. 18:11

Commands vs assertions

최신의 모든 웹은 동기적으로만 작동하기 않기 때문에 Cypress의 핵심기능 중 하나인 Retry에 대해 이해할 필요가 있다.

개발자가 DOM 렌더링이나 response 시간에 대해 신경 쓰지 않고 Cypress코드를 작성할 수 있게 해주는 기능이 바로

Retry기능이기 때문이다. 굳이 신경쓰지 않아도 Cypress는 잘 작동하겠지만 

더 적은 코드로 효율적인 테스트를 하기 위해서는 Retry기능을 이해할 필요가 있다.

아래는 6개의 Command과 2개의 Assertion이 있다.

위 테스트 코드는 잘 통과한다.

처음에 말했듯이 최신의 웹은 동기적으로만 작동하지 않기 때문에,

위 테스트코드에서 DOM요소를 쿼리하고 오직 2개만 있는지 확신할 수 없다.

세 가지 경우가 있을 수 있는데

1. 이 테스트 코드가 실행됐을 때 DOMM이 아직 업데이트되지 않았을 수 있다.

2. DOM요소를 만들기 전에 백엔드 서버로부터 response를 기다리고 있을 수도 있다.

3. DOM에 결과를 보여주기 전에 과한 연산 작업으로 인해 과부하가 걸리게 될 수도 있다.

 

그래서 cy.get()명령은 더 스마트해야 하고 우리의 앱이 잠재적으로 더 업데이트될 수 있다고 예상해야 한다.

위의 코드에서 cy.get() 명령은 DOM요소를 쿼리하고 셀렉터와 일치하는 요소를 찾는다. 그리고 뒤따라오는

assertion들이 찾아낸 요소와 일치하는지 확인하는 작업을 거친다.

 

만약 cy.get() 명령에 따라오는 assertion이 통과하면 명령은 성공적으로 끝난다.

만약 cy.get()명령에 따라오는 assertion이 실패하면 cy.get()은 다시 한번 DOM요소를 쿼리 한다.

그리고 cy.get()이 yield 한 요소에 대해 assertion을 확인한다. 또 실패하면?

다시 cy.get()이 DOM요소를 쿼리 한다. 그리고 timeout에 도달할 때까지 반복한다.

 

retry기능은 wait기능을 하드코딩하지 않고 assertion들이 pass 하는 즉시 테스트가 완료되도록 한다. 

만약 DOM요소가 렌더링 되는데 수 밀리세컨드 아니 혹은 몇 초가 걸리더라도 괜찮다.

테스트 코드는 아무것도 변경하지 않아도 여전히 통과시킬 것이다.

예를 들어 UI를 일부러 3초 딜레이를 주고 3초 후에 수정하도록 해보고 테스트해보자.

3초 후에 렌더링 되지만 여전히  should('have.length',2) assertion이 통과된다.

즉, Cypress를 사용하면 DOM요소가 즉시 렌더링되지 않아도, 언제 렌더링 될지 몰라도 retry기능 덕분에

이것에 대해 신경 쓰지 않고 직관적인 테스트 코드를 작성할 수 있다는 장점에 대해 설명하는 부분이다.

Multiple assertions

하나의 command에 따르는 여러 개의 assertion들은 순서대로 각각 하나씩 retry 된다.

첫 번째, 두번째 assertion이 통과하고나면 해당 명령이 첫번째, 두 번째, 세 번째 assertion과 함께 retry 된다.

 

아래 코드는. should()와. and() assertion이 있다. and()는. should() 명령의 별칭을 사용한다.

그래서. and() assertion은 콜백 함수를 사용한다. 그리고 콜백함수 안에 2개의 expect assetion이 있다.

cy.get('[data-testid="todo-list"] li') // command
  .should('have.length', 2) // assertion
  .and(($li) => {
    // 2 more assertions
    expect($li.get(0).textContent, 'first item').to.equal('todo a')
    expect($li.get(1).textContent, 'second item').to.equal('todo B')
  })

두 번째 assertion의 expect($li.get(0).textContent, 'first item').to.equal('todo a') 이 실패하기 때문에

세 번째 assertion까지 절대 도달할 수 없다. timeout이 끝나고 나면 커맨드 로그는 

.should('have.length', 2) 까지는 통과했지만 두 번째 에서 실패했다고 잘 보여준다.

Not every command is retried

모든 명령들이 전부 retry 되지는 않는다. 

Cypress는 DOM을 쿼리 하는 명령들만 재시도한다. 예를 들면 cy.get(), .find(), .contains(), .first() 등등

1. .fisrt()는 요소가 DOM에 존재할 때까지 자동으로 retry 한다.

2. .fisrt()는 chained 된 assertion들이 모두 통과할 때까지 자동으로 retry 한다.

 

Why are some commands NOT retried?

명령이 잠재적으로 앱의 상태를 변경시킬 수 있는 명령들은 retry 되지 않는다. 예를 들면. click() 명령.

 

Built-in assertions

명령들은 대부분 기본 assertion을 가지고 있어서 명령들이 retry 된다. 

예를들면 .eq() 명령의 경우, 별도의 assertion을 작성하지 않아도

yielded 되어 전달받은 요소에서 필요한 index를 가진 요소를 찾을 때까지 retry 한다.

cy.get('[data-testid="todo-list"] li') // command
  .should('have.length', 2) // assertion
  .eq(3) // command

retry 되지 않는 일부 명령도 여전히 기본 제공되는 waiting기능이 있다. 예를들면 .click() 명령은 

요소가 클릭 가능해질 때까지 기다릴 수 있다.

Cypress는 실제 사람 유저가 브라우저를 사용하듯이 작동한다.

1. 유저가 요소를 클릭할 수 있나?

2. 요소가 눈에 보이지 않는 상태는 아닌가?

3. 요소가 다른 요소에 가려져있는 상태는 아닌가?

4. 요소가 disabled 속성을 가지고 있지는 않은가?

 

.click() 명령은 이런 기본 assertion들이 모두 통과될 때까지 자동으로 기다린다. 그리고 딱 한번 실행된다. Retry 없이.

 

Timeouts

각각 명령은 기본적으로 최대 4초간 retry 한다. 디폴트 timeout은 편집 가능하다. 공식문서 참고

그러나 공식문서에서는 전역의(default) timeout을 수정하지 않기를 추천한다.

대신 각각의 명령에 {timeout: ms} 옵션을 부여함으로써 각각의 명령마다 timeout 값을 다르게 줄 수 있다.

// we've modified the timeout which affects default + added assertions
cy.get('[data-testid="mobile-nav"]', { timeout: 10000 })
  .should('be.visible')
  .and('contain', 'Home')

{timeout:0}을 주면 retry하지 않는다.

// check synchronously that the element does not exist (no retry)
// for example just after a server-side render
cy.get('[data-testid="ssr-error"]', { timeout: 0 }).should('not.exist')

Only the last command is retried

오직 마지막 명령만이 retry 된다. 이상한 테스트의 코드를 예로 들면

it('adds two items', () => {
  cy.visit('/')

  cy.get('[data-testid="new-todo"]').type('todo A{enter}')
  cy.get('[data-testid="todo-list"] li')
    .find('label')
    .should('contain', 'todo A')

  cy.get('[data-testid="new-todo"]').type('todo B{enter}')
  cy.get('[data-testid="todo-list"] li')
    .find('label')
    .should('contain', 'todo B')
})

이 기능을 오직 프론트엔드의 기능으로 구현한다면 코드는 문제없이 통과한다.

그러나 백엔드 서버와 통합한 후 테스트를 하게 되면 실패한다.

그리고 실패의 이유가 로그에 명확히 찍히지 않는다.

이상하다. 눈으로 보기에도 todo B라는 텍스트가 보이는데 왜 Cypress 테스트가 실패하는 걸까?

  cy.get('[data-testid="todo-list"] li')
    .find('label')
    .should('contain', 'todo A')

의 로그가 찍힌 명령 부분에 마우스를 올려보면 요소를 제대로 찾고 있는 것을 확인할 수 있다.

그럼 에러 로그가 찍힌 명령 부분에 마우스를 올려보면? todo B를 찾지 못하고 여전히 todo A 가 찍힌 li요소를 가리키고 있다.

 

테스트를 하는 동안 cy.get('[data-testid="todo-list"] li') 명령은 렌더링 된 li요소를 빠르게 찾아낸다. 그리고 

백엔드 서버에서 response를 받는 시간이 걸리기 때문에 코드가 실행됐을 때는 오직 todo A 만 있는 상태이다.

그래서 저 명령은 항상 첫 번째 li만 가져오게 된다.

 

지금까지 위에서 설명한 retry기능이 하나도 도움이 되지 않는다?

위에서는 DOM요소를 쿼리 하는 명령들은 DOM이 잠재적으로 update 될 거라고 가정하여 스마트하게 행동한다고 하지 않았나?

다양한 이유들로 인해 Cypress는 assertion 전에 마지막 명령만 retry 한다.

cy.get('[data-testid="new-todo"]').type('todo B{enter}')
cy.get('[data-testid="todo-list"] li') // queries immediately, finds 1 <li>
  .find('label') // retried, retried, retried with 1 <li>
  .should('contain', 'todo B') // never succeeds with only 1st <li>

위 코드에서는 실패한 assertion인 should 전의 마지막 명령인 find만 계속 retry 하게 된다.

cy.get('[data-testid="todo-list"] li') 라인에서 이미 첫 번째 li만 가져왔기 때문에 계속 Retry 해봤자

영원히 find('label')은 첫 번째 라벨(todo A)만 찾게 된다. 그래서 실패할 수밖에 없다.

assertion전에 마지막 명령만 retry 한다는 특징을 이해한다면 위의 잘못된 테스트 코드를 쉽게 수정할 수 있다.

it('adds two items', () => {
  cy.visit('/')

  cy.get('[data-testid="new-todo"]').type('todo A{enter}')
  cy.get('[data-testid="todo-list"] li label') // 1 query command
    .should('contain', 'todo A') // assertion

  cy.get('[data-testid="new-todo"]').type('todo B{enter}')
  cy.get('[data-testid="todo-list"] li label') // 1 query command
    .should('contain', 'todo B') // assertion
})

DOM요소를 쿼리 하는 명령을 하나로 합친다.

위의 수정된 코드에서는 처음에   .should('contain', 'todo B') 어설션이 실패하고 나면

 cy.get('[data-testid="todo-list"] li label')을 재시도한다. 

아까와 달리 li 안에 label까지 쿼리 하는 명령인데 (변경 전에는 label만 계속해서 쿼리함)

li가 1개에서 2개로 업데이트되므로 위의 retry는 성공적으로 DOM의 변화를 가져올 수 있다.

 

* 팁: contains() 명령을 이용하면 긴 코드를 하나로 합칠 수 있다. Cypress는 이런 방식의 코드를 추천한다.

 

위와 비슷한 예제가 있다.

// 🛑 not recommended
// only the last "its" will be retried
cy.window()
  .its('app') // runs once
  .its('model') // runs once
  .its('todos') // retried
  .should('have.length', 2)

// ✅ recommended
cy.window()
  .its('app.model.todos') // retried
  .should('have.length', 2)

깊게 중첩된 자바스크립트 객체의 속성을 사용할 때 나눠진 .its()명령을 사용하지 말자.

위의 잘못된 코드를 보면 should()어설션이 실패했을 때 바로 위의 명령만 계속해서 재시도함을 알 수 있다.

.(dot) 분리 기호를 이용해 속성 이름을 조합해서 사용하자. 

 

Alternate commands and assertion

다른 방법은 명령과 어설션을 번갈아가며 사용하는 방법이 있다.

it('adds two items', () => {
  cy.visit('/')

  cy.get('[data-testid="new-todo"]').type('todo A{enter}')
  cy.get('[data-testid="todo-list"] li') // command
    .should('have.length', 1) // assertion
    .find('label') // command
    .should('contain', 'todo A') // assertion

  cy.get('[data-testid="new-todo"]').type('todo B{enter}')
  cy.get('[data-testid="todo-list"] li') // command
    .should('have.length', 2) // assertion
    .find('label') // command
    .should('contain', 'todo B') // assertion
})

이렇게 해주면 두 번째 find('label')도 정상적으로 통과하게 된다. 그 원리는 위의 설명과 같다.

    .should('have.length', 2) 라인에서 통과하지 않으면 그 위의  cy.get('[data-testid="todo-list"] li') 명령을 재시도하고

    .should('have.length', 2) 어설션이 통과를 하고 나서야 다음 명령으로 넘어가기 때문에

의도한 yield를 가져올 수 있기 때문이다.

 

Use .should() with a callback

만약 retry 할 수 없는 명령을 사용해야 하는데, 모든 명령 체인을 Retry 해야 할 필요가 있는 경우 

.should(CallbackFn) 안에 명령을 작성하는 것을 고려해보자. 

<div class="random-number-example">
  Random number: <span id="random-number">🎁</span>
</div>
<script>
  const el = document.getElementById('random-number')
  setTimeout(() => {
    el.innerText = Math.floor(Math.random() * 10 + 1)
  }, 1500)
</script>
// WRONG: this test will not work as intended
cy.get('[data-testid="random-number"]') // <div>🎁</div>
  .invoke('text') // "🎁"
  .then(parseFloat) // NaN
  .should('be.gte', 1) // fails
  .and('be.lte', 10) // never evaluates

위 코드는 의도한 데로 작동되지 않는다.

불행히도 .then()명령은 재시도되지 않기 때문에 이 테스트는 실패하기 전에 딱 한 번만 실행된다.

.should(CallbackFn) 을 사용해서 해결해보자

cy.get('[data-testid="random-number"]').should(($div) => {
  // all the code inside here will retry
  // until it passes or times out
  const n = parseFloat($div.text())

  expect(n).to.be.gte(1).and.be.lte(10)
})

should내부의 코드가 실패하면 get() 명령부터 retry 한다. 그래서 1.5초 후 숫자가 렌더링 될 때 retry 할 수 있고

테스트는 성공하게 된다.

 

Use aliases

별칭 사용법

테스트하기 위해 cy.stub(), cy.spy() 같은 명령을 사용할 경우 재시도를 위해 별칭을 이용해서 cy.get('@alias').should('...') 를 사용하자

const Clicker = ({ click }) => (
  <div>
    <button onClick={click}>Click me</button>
  </div>
)

it('calls the click prop twice', () => {
  const onClick = cy.stub()
  cy.mount(<Clicker click={onClick} />)
  cy.get('button')
    .click()
    .click()
    .then(() => {
      // works in this case, but not recommended
      // because .then() does not retry
      expect(onClick).to.be.calledTwice
    })
})

만약에 클릭이벤트에 딜레이가 있다면 위 코드는 실패하게 된다.

.then()은 retry를 하지 않기 때문에.

const Clicker = ({ click }) => (
  <div>
    <button onClick={() => setTimeout(click, 500)}>Click me</button>
  </div>
)

이렇게 딜레이를 주면 테스트가 실패한다.

왜냐하면 expect(onClick).to.be.calledTwice 코드가 실행되었을 때는 아직 0.5초가 지나지 않아서

한 번도 실행이 안된 상태이기 때문이다.

 cy.get('@alias').should('...') 를 사용해서 코드를 개선해보자

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

  // good practice 💡
  // auto-retry the stub until it was called twice
  cy.get('@clicker').should('have.been.calledTwice')
})

cy.stub() 명령을 clicker라는 별칭을 지정해서 should 어설션이 통과할 때까지 계속해서 retry 하는 코드를 작성했다.

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함