E2E Testing / Cypress 핵심 개념 - Variables and Aliases
사실 테스트 코드에 대한 관심은 예전부터 있었는데
프론트엔드의 TDD 실용성에 대한 의문도 있었다.
이번에 공부를 하면서 프론트엔드에서 작성할 수 있는 테스트가 여러 분류가 있고,
그중에 내가 실제로 수동으로 매번 하는 테스트가 바로 integration test와 E2E test의 중간 정도 된다는 것을 알았다.
내가 생각했던 TDD의 실용성에 대한 부분이 '테스트 코드' 하나로 퉁칠 수 있는 부분이 아니라
'어떤' 테스트를 '언제' 할것인지에 따라 효율적으로 사용할 수 있을 것 같다는 생각이 들었다.
결론부터 말하자면 나는 '코드를 수정,개선하거나 내부 로직을 바꿀 때' 'E2E테스트'가 필요했다.
시험 삼아 E2E test 프레임워크로 유명한 Cypress를 이용해서
매번 손으로 직접 브라우저에서 테스트하던 일을 테스트 코드로 대체하여 개발기간을 줄일 수 있을지 판단해봤다.
1. 프론트엔드의 테스트 종류
2. 나에게 언제, 어떤 test가 필요한지
3. Cypress를 선택한 이유
4. 처음 작성해본 테스트 코드와 후기
5. Cypress 핵심 개념 - Variables and Aliases
1. 프론트엔드 테스트 종류
1) Unit Testing(단위 테스트)
실행 가능한 가장 작은 단위의 소프트웨어를 테스트한다.
프론트엔드의 입장에서 생각해본다면 함수 단위, 혹은 하나의 컴포넌트 단위로 생각해볼 수 있다.
보통 프론트엔드 개발자라도 TDD라고 하면 단위 테스트를 많이 생각하고, 쉬우며 효과가 좋다고 배운다.
내 생각은 조금 다르다.
2) Integration Testing(통합 테스트)
하나의 단위(Unit)을 넘어서 두 개 이상 통합하여 기능을 테스트한다.
예를 들면 외부 라이브러리를 사용한 것이 제대로 동작하는지,
내가 보낸 데이터가 DB에 제대로 저장되는지를 확인한다.
당연히 단위 테스트보다 조금 복잡하다.
3) E2E Testing(종단간 테스트)
사용자가 실제 프로그램을 사용하는 상황을 테스트하는 것.
개발자가 직접 브라우저에서 수동으로 구현을 확인하듯 브라우저를 띄워 의도한 기능을 테스트하는 것이다.
이 테스트는 자동화할 수 있지만 실행시간이 상대적으로 길고 코드를 작성하기 까다롭고 실행도 까다롭다.
2. 나에게 언제, 어떤 test가 필요한지
나는 '코드를 수정,개선하거나 내부 로직을 바꿀 때' 'E2E 테스트'가 필요하다.
필요성을 느끼게 된 계기는 이렇다.
유저 리스트를 sorting 하는 버튼이 있었는데 이 버튼의 내부 로직을 개선하면서
처음의 요구사항을 일부 놓치고 merge 하는 실수가 있었다.
그래서 이런 생각을 하게 되었다.
'버튼이 쿼리스트링을 통해 데이터를 fetch를 하든, Local State를 통해 fetch를 하든 그건 개발자가 알아서 할 거고!
(유저가 그걸 알고 싶을 리는 없잖아..?) 유저에게 보이는 화면이 여전히 정상적으로 작동하는지 테스트하고 싶다.'
그전에는 그저 다시 한번 요구사항을 확인하면서 모든 게 제대로 동작하는지 하나하나 이벤트를 발생시켜가며
눈으로 확인하는 수밖에 없었다. 당연히 시간이 오래 걸렸고 귀찮았다.
이를 해결하기 위해 E2E Testing의 필요성이 느껴졌고
로직을 변경함에 따라 다시 수정해야 하는 단위 테스트보다는
"철저히 유저 입장에서 중요한 것" 만 테스트하고 싶다는 욕구가 생겼다.
E2E Test를 통해 해결할 수 있다면
코드의 변경이 필요하거나 개선할 때 유저의 입장에서 원하는 기능들에 대한 테스트를 먼저 작성하고
로직을 수정한 뒤에 간단하게 테스트 코드만 실행함으로써 실수를 줄이면서도 시간을 꽤 아낄 수 있을 것 같다.
3. Cypress를 선택한 이유
E2E Testing 프레임워크로 유명한 Cypress와 TestCafe를 후보에 두었다.
기능적으로는 거의 동일하지만 Cypress를 선택한 첫 번째 이유는 async await구문을 이용하지 않아도
동기적으로 작동하는데 그 이유는 내부적으로 queue를 쓴다고 한다.
그리고 api가 매우 직관적이고 단어만 읽어도 어떤 테스트인지 알 수 있을 정도로 심플하다.
위의 이유로 처음 보는 코드도 쉽게 읽고 배울 수 있을 것 같아서 선택했다.
두 번째는 훨씬 활발하게 사용되고 업데이트되는 프로젝트였다.
참고할 수 있는 자료도 Cypress가 더 많다. 아니 testcafe가 너무 없다..
4. 처음 작성해본 테스트 코드와 후기
때마침 간단한 수정 작업이 있었다.
홈, 소식, 검색 메뉴에서 각 버튼이나 탭을 클릭했을 때 크롬 탭 이름을 변경하는 작업이다.
먼저 실패하는 테스트 코드를 작성했고
코드를 수정하면서 브라우저에서 직접 테스트하지 않고 Cypress 브라우저를 띄워놓고 테스트 코드를 돌려서 확인했다.
만약 Cypress 테스트가 없다면 코드를 수정한 다음 계속 버튼을 클릭해가면서,
가끔 새로고침도 해가면서... 가끔 뒤로 갔다가 다시 클릭해보면서... 긴 여정을 거치며 테스트를 했을 것이다.
그리고 E2E test의 단점에서 테스트가 느리다고 했는데
그 속도를 unit test나 integration test와 비교할 대상이 아니라는 생각이 든다.
오히려 테스트 코드로 실행하는 게 직접 하는 것보다야 훨~~씬 빠르다.
결과적으로 모든 기능(기존에 잘 작동하던 기능)에 E2E 테스트를 작성하는 것은 비효율적이고 할 필요도 없어 보인다.
다만 내부 로직 개선이나 코드 변경이 필요할 때, 변경 직전에 작성하면 테스트 시간을 줄이고 실수도 방지할 수 있을 것 같다.
------아래는 공식문서에서 볼 수 있는 내용이다-------
5. Cypress Core Concepts - Variables and Aliases
Cypress의 핵심 개념중에 처음으로 접하게 되는 Variables와 Aliases에 대한 설명이다.
번역하면 변수와 별칭인데, 이 개념에 대해 알아야 하는 이유는
Cypress 테스트 코드를 작성하면 변수를 사용할 일이 거의 없고
별칭을 사용하기 때문에 이 차이를 알고 사용법을 알아야 한다.
가장 처음에 등장하는 핵심개념은
반환 값에 대한 설명이다.
Cypress Commends를 실행해서 나오는 결괏값을 사용하거나 할당할 수 없다.
// this won't work the way you think it does
const button = cy.get('button')
const form = cy.get('form')
button.click()
제대로 된 요소에 접근한다고 해도 그것을 어떤 변수에 할당해서 사용할 수 없다는 뜻이다.
Closures
그럼 어떻게 Cypress로 가져온 객체에 접근할 수 있을까?
.then()을 이용한다.
.then()의 인자로 get의 명령으로 액세스 한 객체에 접근할 수 있다.
cy.get('button').then(($btn) => {
// $btn is the object that the previous
// command yielded us
})
위 코드에서는 $btn이라는 별칭으로 button요소에 접근한 것이다.
비동기 코드를 작성하는 Promise와 같은 방식으로 동작한다. then 내부에서 Cypress코드를 중첩해서 작성할 수 있다.
각각의 중첩된 명령은 이전에 완료된 명령에 접근할 수 있다.
then() 밖에 작성된 코드는 중첩된 명령이 끝날 때까지 실행되지 않는다.
즉, 우리가 평소 하듯이 then을 통해 비동기 코드를 작성하면 된다. 코드의 실행 순서를 보장할 수 있다.
cy.get('button').then(($btn) => {
// store the button's text
const txt = $btn.text()
// submit a form
cy.get('form').submit()
// compare the two buttons' text
// and make sure they are different
cy.get('button').should(($btn2) => {
expect($btn2.text()).not.to.eq(txt)
})
})
// these commands run after all of the
// other previous commands have finished
cy.get(...).find(...).should(...)
Variables
Cypress를 사용하면 const, let, var를 쓸 일이 거의 없다.
위 예제처럼 클로저를 사용하면 변수에 할당하지 않고 양도된 객체에 접근할 수 있기 때문이다.
변수를 사용하게 되는 한 가지 예외가 있는데, 변경 가능한 값을 다루는 경우다.
위 예제처럼 수정 전, 수정 후 값을 비교할 필요가 있을 때, 전의 값을 변수에 저장하는 용도로 사용할 수 있다.
이것이 변수를 사용하는 거의 유일한 case이다.
Aliases
위에서 설명한 .then()으로 Cypress명령으로 액세스 한 값에 접근하는 것은 쉽고 옳은 방법이다.
그러나 Cypress를 사용하다 보면 다른 훅에서 액세스 한 값에 접근을 필요로 하는 경우가 매우 많을 것이다.
예를 들면 before, beforeEach 같은 것이다.
beforeEach(() => {
cy.get('button').then(($btn) => {
const text = $btn.text()
})
})
it('does not have access to text', () => {
// how do we get access to text ?!?!
})
위 예제처럼 변수 text에 text commends의 반환 값을 담을까?
다른 스코프에서 접근이 불가능하다.
당연히 전역 스코프에 let을 사용해서 변수만 할당하는 방법이 떠오른다.
describe('a suite', () => {
// this creates a closure around
// 'text' so we can access it
let text
beforeEach(() => {
cy.get('button').then(($btn) => {
// redefine text reference
text = $btn.text()
})
})
it('does have access to text', () => {
// now text is available to us
// but this is not a great solution :(
text
})
})
Do not do this!
가능은 한데.. 이렇게 하지 마라! 별칭(alias)을 사용하면 이렇게 할 필요 없이 쉽게 가능하다.
첫 예제는 훅과 테스트 간에 객체를 공유하는 방법이다.
beforeEach(() => {
// alias the $btn.text() as 'text'
cy.get('button').invoke('text').as('text')
})
it('has access to text', function () {
this.text // is now available
})
공식문서에 따르면 Mocha의 공유 context객체를 활용하기 때문에 this.으로 해당 별칭에 접근이 가능하다고 설명한다.
Mocha의 공유 context가 무엇인지는 잘 모르지만 리액트의 contextAPI처럼 전역에서 접근이 가능하도록
설정해주는 방법과 비슷한 맥락인 것 같다.
describe('parent', () => {
beforeEach(() => {
cy.wrap('one').as('a')
})
context('child', () => {
beforeEach(() => {
cy.wrap('two').as('b')
})
describe('grandchild', () => {
beforeEach(() => {
cy.wrap('three').as('c')
})
it('can access all aliases as properties', function () {
expect(this.a).to.eq('one') // true
expect(this.b).to.eq('two') // true
expect(this.c).to.eq('three') // true
})
})
})
})
이렇게 별칭을 사용하면 모든 훅과 테스트에서 접근이 가능하다.
Avoiding the use of this
화살표 함수를 사용하면 this를 사용할 수 없다. 이것은 Cypress의 특징이 아니라 자바스크립트의 특징이다.
그래서 this. 대신 사용하는 방법이 있다. 화살표함수를 사용하고 싶으니까 아래의 방법을 사용하자.
@ 과 cy.get() 명령으로 별칭에 접근하자.
beforeEach(() => {
// alias the users fixtures
cy.fixture('users.json').as('users')
})
it('utilize users in some way', function () {
// use the special '@' syntax to access aliases
// which avoids the use of 'this'
cy.get('@users').then((users) => {
// access the users argument
const user = users[0]
// make sure the header contains the first
// user's name
cy.get('header').should('contain', user.name)
})
})
Elements
별칭은 DOM요소도 지정할 수 있다.
// alias all of the tr's found in the table as 'rows'
cy.get('table').find('tr').as('rows')
rows라는 별칭으로 <tr>을 참조했다.
그리고 나중에 언제든 cy.get()으로 rows를 참조할 수 있다.
// Cypress returns the reference to the <tr>'s
// which allows us to continue to chain commands
// finding the 1st row.
cy.get('@rows').first().click()
Stale Elements:
리액트 같은 대부분의 SPA 앱들은 DOM을 계속 리랜더링 한다.
그러므로 별칭을 지정한 DOM요소가 분명히 DOM에서 사라질 것이고 이것을 내가 cy.get(@)으로 가져온다면
Cypress는 자동으로 이 요소를 다시 찾아준다.
<ul id="todos">
<li>
Walk the dog
<button class="edit">edit</button>
</li>
<li>
Feed the cat
<button class="edit">edit</button>
</li>
</ul>
edit버튼을 누르면 <li> 텍스트가 사라지고 <input> 이 나온다. 이 input에 사용자가 수정할 값을 입력하면
새로운 값으로 수정되고 새로운 <li> 텍스트가 랜더링 된다.
cy.get('[data-testid="todos"] li').first().as('firstTodo')
cy.get('@firstTodo').find('.edit').click()
cy.get('@firstTodo')
.should('have.class', 'editing')
.find('input')
.type('Clean the kitchen')
@firstTodo로 참조할 때, Cypress는 모든 요소가 아직 DOM에 그대로 있는지 확인한다.
만약 그대로 있다면, 그대로 반환한다.
그렇지 않다면, 별칭을 정의하는 명령을 다시 실행한다.
쉽게 생각해보자면 위에 첫 번째 줄에서 firstTodo로 별칭을 만들고
두 번째 @firstTodo 참조 결과와
세 번째 @firstTodo 참조 결과는 다를 테니 Cypress가 알아서 잘 만들어준다 는 뜻으로 이해하면 될 것 같다.
Intercepts
별칭은 Intercepts와도 사용이 가능하다.
Cypress의 Intercepts 명령은 axios에서 쓰던 Intercepts와 같은 의미의 intercepts이다.
가로챈다는 뜻이다.
공식문서에서는 이렇게 설명한다.
경로를 가로채서 별칭을 부여하면 다음과 같은 것들이 가능하다.
1. request(네트워크 요청)이 의도한 대로 작동하는지 보장한다.
2. 서버가 response를 보낼 때까지 기다린다.
3. assertions에 대한 실제 request객체에 접근
다음 예제는 경로를 가로채서 별칭을 부여하고 완료되기를 기다리는 예제이다.
cy.intercept('POST', '/users', { id: 123 }).as('postUser')
cy.get('form').submit()
cy.wait('@postUser').then(({ request }) => {
expect(request.body).to.have.property('name', 'Brian')
})
cy.contains('Successfully created user: Brian')
번역이 좀 어려운데 해당 api로 보내는 post요청에 별칭을 부여하면
then(({requset})=>{})의 request처럼 네트워크 요청을 중간에 가로채서
request의 Body에 어떤 속성이 있는지 체크하는 것도 가능하다는 예제를 보여주는 것 같다.
Request
네트워크도 테스트할 수 있다는 게 참 신기하다.
다음은 Intercepts처럼 Request 명령과도 사용할 수 있다.
위에서 나온 then(({request})) 가 아니라 cy.request()라는 Cypress의 명령이다. 헷갈리지 않길.
cy.request('https://jsonplaceholder.cypress.io/comments').as('comments')
// other test code here
cy.get('@comments').should((response) => {
if (response.status === 200) {
expect(response).to.have.property('duration')
} else {
// whatever you want to check here
}
})
})
Intercept는 요청을 가로채는 명령이고 request는 그 네트워크 요청 자체로 이해했다.
그래서 콜백 함수의 인자로 요청에 대한 응답을 가져올 수 있다. 응답의 속성을 테스트할 수 있다.
https://docs.cypress.io/guides/core-concepts/variables-and-aliases#What-you-ll-learn
Variables and Aliases | Cypress Documentation
What you'll learn How to deal with async commands What Aliases are and how they simplify your code Why you rarely need to use variables with Cypress How
docs.cypress.io