티스토리 뷰

Cypress가 동작하는 코드를 이해하기 위해서 몇 가지(Queue, Chains of Commands, Assertion, timeout, retry 등)

개념에 대해 이해할 필요가 있다. 

공식문서의 Core Concepts Part를 읽으며 정리한 내용이다.

공식문서: (https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#What-you-ll-learn)

 

Cypress Can Be Simple (Sometimes)

Cypress의 장점은 하는일에 비해 코드의 양이 적다는 것이다. 그리고 쉽다.

각 Commands를 공식문서에서 찾아보지 않아도 어떤 내용인지 알 수 있다.

1. /posts/new 페이지에 접근하여

2. Input 중에 post-title클래스명을 가진 input을 찾아서 My First Post를 입력한다.

3. input 중에 post-body클래스명을 가진 input을 찾아서 Hello, world! 를 입력한다.

4. Submit이라는 텍스트를 가진 요소를 찾아서 클릭한다.

5. h1태그를 가져와서 My First Post라는 텍스트를 포함하고 있는지 테스트한다.

 

단순하고 짧은 코드이지만 이 기능이 얼마나 많은 기능을 테스트하고 있는지 생각해보자.

프론트엔드의 기능뿐만 아니라 데이터베이스에 데이터가 저장되고 네트워크의 성공 여부까지

테스트하고 있는 것과 마찬가지다.

 

공식문서에서 Cypress가 어떻게 테스트를 이렇게 단순한 코드로 가능하게 하는지 그 핵심 개념에 대해 설명한다.

 

Querying Elements

제이쿼리를 써봤다면 Selector를 사용하는데 익숙할 것이다.

Cypress는 jQuery의 선택자를 이용해서 웹 개발자에게 익숙한 방법을 제공한다.

하지만 아주 중요한 차이점이 있다!

// This is fine, jQuery returns the element synchronously.
const $jqElement = $('.element')

// This will not work! Cypress does not return the element synchronously.
const $cyElement = cy.get('.element')

제이쿼리와 리턴하는 모양이 다르다.

제이쿼리는 DOM Element를 리턴해서 사용할 수 있지만

Cypress는 Element를 리턴하지 않는다. 변수에 담아서 사용할 수 없다. 

왜 이런 차이가 중요할까?

// $() returns immediately with an empty collection.
const $myElement = $('.element').first()

// Leads to ugly conditional checks
// and worse - flaky tests!
if ($myElement.length) {
  doSomething($myElement)
}

만약 제이쿼리에서 원하는 엘리먼트를 찾지 못한 경우에는 비어있는 jQuery collection을 반환한다.

그래서 값이 제대로 있는지 조건 확인을 해야 한다. 

하지만 Cypress는 어떤가?

cy
  // cy.get() looks for '#element', repeating the query until...
  .get('#element')

  // ...it finds the element!
  // You can now work with it by using .then
  .then(($myElement) => {
    doSomething($myElement)
  })

DOM에서 원하는 element를 찾을 때까지 계속 시도한다. 사용자가 원하는 Element가 화면에 나타날 때까지 계속 시도한다.

두 가지 경우가 있다. Element가 나타나는 경우, 끝까지 안 나타나는 경우

1) 나타나는 경우:. then() 블록을 타고 실행 순서를 보장하며 코드를 실행한다.

2) 안 나타나는 경우: 기본 timeout시간 동안 계속해서 재시도하다가 timeout을 넘어가는 경우(4초) 테스트가 실패한다.

그리고. then() 블록은 실행되지 않는다.

 

이러한 특성은 Cypress를 견고하게 만들고 다른 테스트 도구들에서 발생하는 다양한 문제를 예방한다.

재시도 (Retrying)와 제한시간 (timeout)을 통해 Cypress가 아주 강력하고 쉬워진다.

이와 같이 Cypress는 웹앱이 비동기적인 특성을 이해하고 즉시 실패시키지 않고 작업을 완료할 수 있는 시간을 제공한다.

대부분의 명령들은 커스텀할 수 있는 timeout 기간을 가진다. 디폴트는 4초이다. 아래와 같이 설정이 가능하다.

// Give this element 10 seconds to appear
cy.get('.my-slow-selector', { timeout: 10000 })

 

이 Timeout은 퍼포먼스와 trade off 관계를 가진다.

왜냐하면 테스트가 실패하는 경우 실패라는 결과를 받기까지 4초가 걸린다는 뜻이기 때문이다(case default)

그러므로 default timeout값을 조정하는데 신중해야 한다.

 

Chains of Commands

Cypress는 코드 작성에 Commend Chain을 사용하는데, 이 메커니즘을 이해하는 것이 매우 중요하다.

Cypress를 사용하면서 실행 순서 때문에 고민할 필요가 없는데,

그 이유는 각 명령이 다음 명령을 위해 subject를 생성한다. 끝나거나 실패할 때까지 계속 subject를 만들어 전달한다.

덕분에 우리는 Promise를 직접 사용하지 않아도 되지만 메커니즘은 이해해야 한다.

cy.get('textarea.post-body').type('This is an excellent post.')

위와 같은 Cypress코드가 있다.

. type()이라는 명령을. get()에 체이닝 했다. 

. get()에서 생성한 subject를. type()에 전달한 것이다. 아마 DOM element일 것이다.

  * 처음에 말했듯이 리턴은 아니기 때문에 변수에 할당은 안된다. 그저 뒤에 있는 체인에 전달하는 것이다. 

  문서에서는 subject yielded라고 한다.

 

여러 가지 action Commands가 있다.

뒤에서 다시 자세히 설명하지만,

이런 명령들은 액션이 발생하기 위한 element의 몇 가지 조건을 보장한다.(assertions에서 추가 설명)

예를 들면. click() 명령을 사용하면  Cypress는 이 버튼이 actionable 한 상태인지 보장한다.

1. hidden상태이지 않아야 하고

2. 다른 요소에 가려져있지 않아야 하고

3. disabled 한 상태가 아니여야 하고

4. 애니메이션이 작동하는 중이면 안된다.

 

cy.get(':checkbox').should('be.disabled')

cy.get('form').should('have.class', 'form-horizontal')

cy.get('input').should('not.have.value', 'US')

위의 코드가 바로 assertion에 대한 예이다.

정리하자면 요소, 크게 보면 앱의 상태를 설명할 수 있는 명령이다.

Cypress는 요소가 이 상태에 도달할 때까지 기다리다가 패스하지 못하면 실패한다.

이 덕분에 우리는 요소가 정확히 언제 해당 assetion을 만족하게 되는지 신경 쓰지 않아도 된다.

이 모든 게 timeout 과 Retry 덕분이다. Cypress가 쉬운 이유.

 

Subject Management

command chaining을 이해하기 위해 subject management를 필수적으로 이해해야 한다.

공식문서에서도 계속 같은 내용을 반복적으로 설명하며 조금씩 깊어지는데,

제이쿼리와 달리 cy.get()으로 접근한 Element를 변수에 할당할 수 없는 원리와 이어진다.

cy.clearCookies() // Done: 'null' was yielded, no chaining possible

cy.get('.main-container') // Yields an array of matching DOM elements
  .contains('Headlines') // Yields the first DOM element containing content
  .click() // Yields same DOM element from previous command

cy.clearCookies() 명령은 null을 yielded 하여(return 아님!!) 체이닝이 불가능하다.

뒤의 명령에 보내줄 subject가 없다고 보면 된다.

반면 cy.get(). contains(). click()은 특정한 element를 yield 한다. 

그러므로 뒤의 명령에 보내줄 subject가 있는 것이다. 

뒤의 명령은 앞의 명령이 넘겨준 subject에 명령을 실행하는 것이다.

 

 

cy
  // Find the el with id 'some-link'
  .get('#some-link')

  .then(($myElement) => {
    // ...massage the subject with some arbitrary code

    // grab its href property
    const href = $myElement.prop('href')

    // strip out the 'hash' character and everything after it
    return href.replace(/(#.*)/, '')
  })
  .then((href) => {
    // href is now the new subject
    // which we can work with now
  })

위 코드는 명령 흐름에 직접 참여하여 subject를 직접 다루는 예제이다.

. then() 명령을 사용해서 직전 명령이 전달한 subject에 직접 접근하는 것이다.

콜백 함수의 첫 번째 인자로 직전 명령의 subject를 받을 수 있다.

위의 경우 get() 명령이 전달한 subject, 그러니까 예제에서는 링크가 달린 a태그를 $myElement로 받은 것이다.

직접 chain의 중간에 끼어들어 코드를 작성할 수 있다.

 

Commands Are Asynchronous

명령이 비동기적이라는 것의 의미

Cypress 명령이 입력되는 순간에는 아무것도 하지 않는다는 것을 이해하는 것이 중요하다.

나중에 실행하기 위해 queue에 전부 넣는다. 이것이 Cypress개발자들이 Cypress의 명령이 비동기적이라고 하는 의미이다.

it('hides the thing when it is clicked', () => {
  cy.visit('/my/resource/path') // Nothing happens yet

  cy.get('.hides-when-clicked') // Still nothing happening
    .should('be.visible') // Still absolutely nothing
    .click() // Nope, nothing
    .should('not.be.visible') // Definitely nothing happening yet
})

// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!

함수가 실행되면 모든 코드가 대기열에 들어가고 그제야 순서대로 실행한다.

 

Mixing Async and Sync code

동기 코드를 비동기적인 Cypress코드와 같이 사용하는 방법

동기 코드는 Cypress의 실행을 기다리지 않고 즉시 실행된다. 아래 코드는 비정상적인 코드이다.

it('does not work as we expect', () => {
  cy.visit('/my/resource/path') // Nothing happens yet

  cy.get('.awesome-selector') // Still nothing happening
    .click() // Nope, nothing

  // Cypress.$ is synchronous, so evaluates immediately
  // there is no element to find yet because
  // the cy.visit() was only queued to visit
  // and did not actually visit the application
  let el = Cypress.$('.new-el') // evaluates immediately as []

  if (el.length) {
    // evaluates immediately as 0
    cy.get('.another-selector')
  } else {
    // this will always run
    // because the 'el.length' is 0
    // when the code executes
    cy.get('.optional-selector')
  }
})

// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!

위 코드에서 중간쯤 보이는 Cypress.$('. new-el')이 바로 동기 코드인데

위에 비동기 코드는 먼저 작성이 되어있다고 하더라도 실제로는 아무것도 실행되지 않은 상태이다.

함수가 다 읽히면 대기열에 하나씩 쌓여있기만 한 상태일 것이다.

반면에 동기 코드는 바로 실행되어 버린다.

즉 우리는 http://localghost:3000/my/resource/path에 들어간 적도 없지만

new-el이라는 클래스를 가진 요소에 접근하려고 하는 것이다. el 변수는 빈 배열을 반환받을 것이고

If() 문은 항상 false가 입력되어 언제나 else문을 타게 되는 비정상적인 코드가 된 것이다.

 

이 동기적인 코드를 사용하려면. then() 명령을 사용한다.

it('does not work as we expect', () => {
  cy.visit('/my/resource/path') // Nothing happens yet

  cy.get('.awesome-selector') // Still nothing happening
    .click() // Nope, nothing
    .then(() => {
      // placing this code inside the .then() ensures
      // it runs after the cypress commands 'execute'
      let el = Cypress.$('.new-el') // evaluates after .then()

      if (el.length) {
        cy.get('.another-selector')
      } else {
        cy.get('.optional-selector')
      }
    })
})

// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!

. then() 안에 들어있는 코드 전부 대기열에 그대로 들어가고 내부의 코드 자체가 비동기가 되어 문제없이 작동하게 된다.

'비동기'라는 배를 타고 떠난 대형어선이 새끼 어선들을 풀어

동기적으로 일하든 비동기적으로 일하든 어쨌든 항구 입장에선 떠난 배는 비동기다.

 

비동기 개념을 헷갈리면 이해가 안 될 테니 예제가 하나 더 있다.

친절하다.

it('test', () => {
  let username = undefined // evaluates immediately as undefined

  cy.visit('https://app.com') // Nothing happens yet
  cy.get('.user-name') // Still, nothing happens yet
    .then(($el) => {
      // Nothing happens yet
      // this line evaluates after the .then executes
      username = $el.text()
    })

  // this evaluates before the .then() above
  // so the username is still undefined
  if (username) {
    // evaluates immediately as undefined
    cy.contains(username).click()
  } else {
    // this will always run
    // because username will always
    // evaluate to undefined
    cy.contains('My Profile').click()
  }
})

// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!

If(username) 이 문제다. 이 부분이 동기적이라서 username은 항상 undefined이다.

언제나 else문만 타게 되는 이상한 코드

아래는 개선한 코드

it('test', () => {
  let username = undefined // evaluates immediately as undefined

  cy.visit('https://app.com') // Nothing happens yet
  cy.get('.user-name') // Still, nothing happens yet
    .then(($el) => {
      // Nothing happens yet
      // this line evaluates after the .then() executes
      username = $el.text()

      // evaluates after the .then() executes
      // it's the correct value gotten from the $el.text()
      if (username) {
        cy.contains(username).click()
      } else {
        cy.get('My Profile').click()
      }
    })
})

// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!

비동기라는 대형어선에 동기라는 소형어선들이 탔기 때문에

어쨌든 이건 항구 입장에선 비동기다. 

Cypress 명령은 나중에 실행할 대기열(Queue)에만 추가되고 즉시 반환된다.

그렇기 때문에 리턴 값을 반환받아 뭔가를 할 수 없다. 명령들은 큐에 들어갈 뿐이고 완전히 뒤에서 관리된다.

 

Commands Run Serially

it('hides the thing when it is clicked', () => {
  cy.visit('/my/resource/path') // 1.

  cy.get('.hides-when-clicked') // 2.
    .should('be.visible') // 3.
    .click() // 4.
    .should('not.be.visible') // 5.
})

함수의 실행이 완료되고 나면 그제야 대기열에 들어있던 코드를 순서대로 실행한다.

1. http://localhost:3000/my/resource/path에 접근한다.

2. hides-when-clicked라는 클래스명을 가진 요소를 찾는다.

3. 요소가 보이는지 확인한다.

4. 요소를 클릭한다.

5. 요소가 보이지 않는지 확인한다.

이 모든 것은 직렬적으로 발생한다. 왜일까?

 

Cypress가 우리가 보이지 않는 곳에서 열일하기 때문이다. Cypress가 뒤에서 실행하는 마법을 보자

1. http://localhost:3000/my/resource/path에 접근한다. 모든 리소스가 로드되고 페이지 로드 이벤트가 발생되기를 기다린다. 모든 컴포넌트 마운트가 완료되길 기다린다.

2. hides-when-clicked라는 클래스명을 가진 요소를 찾는다. DOM에서 찾을 때까지 계속 재시도한다.

3. 요소가 보이는지 확인한다. 그리고 어설션을 통과할 때까지 재시도한다.

4. 요소가 클릭 가능한 상태가 될 때까지 기다린 후에 요소를 클릭한다. 

5. 요소가 보이지 않는지 확인한다. 그리고 어설션을 통과할 때까지 재시도한다.

 

우리가 예상했던 상태와 일치되게 만들기 위해 Cypress가 뒤에서 엄청 많은 일을 한다.

그렇기 때문에 모든 게 직렬적으로 실행되고, 실행되어야만 한다.

더 똑똑한 것은 대부분 pending상태를 볼 수 없을 만큼 빠르게 완료되지만 visit과 같은 시간이 조금 더 걸릴만한 요청은 

Cypress가 알아서 더 오래 기다려준다. 이런 특정 명령들은 특정한 timeout이 있고 설정도 따로 가능하다.

 

 

Assertions

Assertions는 요소, 객체, 앱의 상태를 설명합니다.

Cypress가 다른 테스팅 툴과 다른 점은 명령이 자동으로 assetion을 다시 시도한다는 것입니다.

그러므로 우리는 assertion을 일종의 guards라고 생각할 수 있습니다.

이 guards를 사용하여 우리의 앱이 어떻게 생겼는지 설명하면

Cypress가 자동으로 해당 상태에 도달할 때까지 기다리거나, 막거나, 재시도합니다.

 

1) 버튼을 클릭하고 나면 버튼의 className이 active가 될 거라고 예상합니다.

이런 경우 assertion을 이렇게 입력합니다.

cy.get('button').click().should('have.class', 'active')

2) http요청을 서버에 보낼 때 request의 body가 {name:'Jane'} 일 것이라고 예상합니다.

cy.request('/users/1').its('body').should('deep.eq', { name: 'Jane' })

그렇다면 언제 assertion을 쓰는가?

가장 좋은 테스트 코드는 assertion을 전혀 하나도 하지 않는 것이다. 무슨 말이지?

이런 테스트 코드를 쓴다면 여러 assertion이 필요할 수 있다. 예를 들면

1) visit() 명령이 성공하지 못할 수도 있다.

2) 클릭하려는 요소가 다른 요소에 의해 가려져있을 수도 있다.

3) 타이핑하려는 input이 disabled상태일 수도 있다.

4) 컴포넌트 구성요소에서 에러가 발생할 수도 있다.

이 외에도 수십 가지가 더 있을 수 있다. 

그럼에도 불구하고 위의 코드는 유효하게 잘 동작한다. 그 이유는 명령들에게 Default Assertion이 있기 때문이다.

Default Assertions

모든 돔 관련 명령들은 자동으로 요소가 존재하기를 기다리기 때문에

should('exist')와 같은 assertion을 작성하지 않아도 되는 것이다.

 

assertions를 작성하는 데는 두 가지 방법이 있다.

Implicit Subjects

. should()와. and()를 이용하는 것이 assertion을 입력하는 기본적인 방법이다.

이것들은 현재 명령 체인에 의해 yielded 된 subject에 적용된다.

// the implicit subject here is the first <tr>
// this asserts that the <tr> has an .active class
cy.get('tbody tr:first').should('have.class', 'active')

cy.get('#header a')
  .should('have.class', 'active')
  .and('have.attr', 'href', '/users')

and를 사용하면 여러 개의 assertion을 작성할 수 있다. 왜냐하면. should()가 자신이 받은 subject를 변경하지 않고

다음으로 전달하기 때문이다.

만약 이런 경우 explicit subjects를 사용하면 비효율적인 것처럼 보인다.

cy.get('tbody tr:first').should(($tr) => {
  expect($tr).to.have.class('active')
  expect($tr).to.have.attr('href', '/users')
})

implicit 형식이 더 짧다. 그럼 언제 explicit 형식을 사용할까?

1. 같은 subject에 대해 여러 개의 assertion을 작성할 때

2. assertion을 작성하는 동안 subject를 개발자의 입맛에 맞게 커스텀하기 위해

 

. should() 명령을 사용하면 yield 된 subject를 콜백 함수의 첫 번째 인자로 넘겨줄 수 있다. 마치 then()처럼.

Cypress는 이 콜백 함수로 인자가 전달되기까지 자동으로 재시도하고 기다린다.

cy.get('p').should(($p) => {
  // massage our subject from a DOM element
  // into an array of texts from all of the p's
  let texts = $p.map((i, el) => {
    return Cypress.$(el).text()
  })

  // jQuery map returns jQuery object
  // and .get() converts this to an array
  texts = texts.get()

  // array should have length of 3
  expect(texts).to.have.length(3)

  // with this specific content
  expect(texts).to.deep.eq([
    'Some text from first p',
    'More text from second p',
    'And even more text from third p',
  ])
})

 

Timeouts

거의 모든 명령이 어떤 방식으로든 timeout을 활용한다.

모든 assertions는 디폴트든, 사용자가 직접 커스텀한 것이든 같은 timeout value를 가진다.

// because .get() has a default assertion
// that this element exists, it can time out and fail
cy.get('.mobile-nav')

위 코드에서 mobile-nav라는 클래스명을 가진 요소를 선택하려 한다.

생략된 타임아웃과 assertion은 아래와 같다.

1. 해당 요소가 DOM에 존재하기를 4초간(디폴트) 기다린다.

// we've added 2 assertions to our test
cy.get('.mobile-nav').should('be.visible').and('contain', 'Home')

위에는 두 개의 assertion이 적용되어 있다.

생략된 타임아웃과 assertion은 아래와 같다.

1. 해당 요소가 DOM에 존재하기를 4초간(디폴트) 기다린다.

2. 해당 요소가 눈에 보이기까지 4초간 기다린다.

3. 해당 요소가 Home이라는 텍스트를 가질 때까지 4초간 기다린다.

 

타임아웃은 각 명령마다 커스텀 될 수 있으며, 이것은 해당 명령 이후에 chain 된 다음 명령에 영향을 끼치게 된다.

// we've modified the timeout which affects default
// plus all added assertions
cy.get('.mobile-nav', { timeout: 10000 })
  .should('be.visible')
  .and('contain', 'Home')

1. 해당 요소가 DOM에 존재하기를 10초간(디폴트) 기다린다.

2. 해당 요소가 눈에 보이기까지 10초간 기다린다.

3. 해당 요소가 Home이라는 텍스트를 가질 때까지 10초간 기다린다.

 

위 예제와 같이 해당 assertion마다 timeout을 주는 게 아니라 상단의 명령에 전달해주면 chain 된 아래의

assertion에 영향을 미치게 되는 것이다.

// 🚨 DOES NOT WORK
cy.get('.selector').should('be.visible', { timeout: 1000 })
// ✅ THE CORRECT WAY
cy.get('.selector', { timeout: 1000 }).should('be.visible')

주의, 타임아웃 커스텀은 assertion에 직접 하는 게 아니다.

 

몇 가지 명령에는 default timeout이 다른 대부분의 명령보다 길게 설정되어 있다.

cy.visit() 명령은 원격에서 리소스를 가져와야 하고 로딩해서 보여줘야 하므로 시간이 조금 더 걸릴 수 있으므로 60초로 설정되어 있다.

cy.exec() 명령은 데이터베이스 시딩? 과 같은 시스템을 실행해야 하므로 좀 더 시간이 걸릴수 있으므로 60초로 설정.

cy.wait() 명령은 2가지의 다른 타임아웃이 있다. 라우팅 별칭을 지정하는 경우 request와 매칭 하는데 5초.

서버의 response와 매칭 하는 데는 30초를 준다.

request와의 매칭은 더 빠르고 response는 백엔드 서버와의 상호작용이므로 좀 더 시간이 걸릴 수 있다고 생각하기 때문이다.

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/05   »
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 31
글 보관함