이번에 리액트 컴포넌트 테스팅에 대해 공부하면서 알게된 내용들을 정리해보려 한다.
코드 테스트가 필요한 경우
- 코드 작성 후 원하는대로 동작하는지 확인할 때
- 버그 발생 시, 어떤 상황에서 버그가 발생하는지 알기위해 (ex array가 empty일때만 버그일경우)
- 코드 리팩토링 후 제대로 동작하는지 확인할 때
테스팅 장점
- 미연에 에러 방지 가능
- TDD(Test Driven Development) 등의 방법론을 적용해서 생산성을 향상시킬수있음
무조건 실패하는 케이스를 미리 작성하고, 이걸 하나씩 success시켜가면서 완성시키는 코드 작성 방법
테스트가 늘어나면서 테스트 코드 자체가 구현 코드에 대한 문서가 됨 - 테스트가 용이하게 코드를 작성하므로써 코드 품질과 코드의 신뢰성을 높힌다.(단위별로 나눠 작성)
-> 하나의 함수가 너무 많은 일을 하지 않게 하며, 기능들을 작게 분리 한다.
테스팅 용어
- 화이트박스 테스팅 : 컴포넌트 내부구조 다 안다고 가정
- 블랙박스 테스팅 : 컴포넌트 내부구조 모름
- Unit Testing : 독립적으로 동작하는 함수, 메소드, 클래스 등 작은 부분의 기능, 로직을 테스트
- Integration Testing : 앱의 특정 부분이 동작하는지 테스트. Unit 테스트보다 큰 개념으로, 여러 컴포넌트가 한꺼번에 동작하거나, 페이지의 어떤 부분이 잘 동작하는지 테스트하는 경우 (ex react-router, redux 등이 특정 컴포넌트와 함께 잘 동작하는지 테스트)
- e2e Testing(end-to-end) : 유저가 특정 시나리오를 가지고, 그 시나리오의 end-to-end로 잘 동작하는지 테스트
(ex 유저 회원가입(end) -> Header의 유저 정보 클릭 -> 유저 정보의 id가 잘 나오는지 테스트(end)) - Mocking - 특정 동작을 흉내내는 것 (실제API를 호출하는게 아니라 가짜 payload를 반환하는 mocking function을 만든다)
- Stubbing - 더미를 채워 넣는 것 (child 컴포넌트를 렌더링하지않고 대신 그자리에 <div> 등을 채워 넣음)
테스팅의 구성
- setup : 테스트하기 위한 환경을 만듬 (mock data, mock function등을 준비함)
- expectation : 원하는 테스트 결과를 만들기 위한 코드 작성
- assertion : 정말 원하는 결과가 나왔는지 검증하는 단계
이제 테스팅 코드 예시를 살펴보자
const transformUser = (user) => {
const { name, age, address } = user;
return [ name, age, address ]
}
//code
test('Test transformUser', () => {
//setup
const mockUser = { name, 'testName', age: 20, address: 'testAddress' }
//expectation
const result = transformUser(mockUser)
//assertion
expect(result).toBe(['testName', 20, 'testAddress'])
})
expect()는 Jest에서 제공하는 assertion 함수이다.
Jest란 무엇인가요?
- facebook에서 오픈소소화한 테스팅 프레임워크
- assertion 함수들, test runner mock라이브러리 등 모두 제공
- create-react-app에서 기본적으로 사용됨
- 사용성이 좋고, 가장 인기 있는 프로젝트
Jest의 핵심 기능
- Assertion matchers ( .toBe() , .toEqual() 등)
expect('hi').toMatch('hi') 검증에 정규표현식도 가능. Jest는 풍부한 matcher를 제공해서, 여러 상황에서 match를 체크할 수 있음. expect()는 expectation object를 리턴한다. 이 obejct의 메서드를 이용해 여러 매칭상황을 assert 한다.
.not prefix를이용해서 반대되는 상황을 testing 가능 - Async assertion : 비동기 상황의 테스트를 할수있는 여러방법 제공. callback, promise, async/await을 모두 활용가능
- Mock funtions : mockfunctions을 만들어서 모듈 전체를 mocking할수도 있다. 라이브러리 전체를 mocking할 수도 있음
ex) jest.mock('../mocks/api.js') - Testing lifcycle funtions : 테스트가 끝난 후 작업 실행 설정 가능
- Grouping : test a,b,c를 describe()를 사용해 묶을 수 있음
- Snapshot testing : react component를 테스팅할 때 유용함
Jest 활용
Assertion Matchers
다음은 Jest에서 굉장히 자주 사용되는 Assertion Matchers이다.
- toBe()
- toEqual()
- toContain()
- toMatch()
- toThrow()
function isPythagorean(a,b,c) {
return a * a + b * b === c * c
}
function creatTodo(id, title, content) {
return { id, title, content }
}
function transformUser(user) {
return { name, age, address } = user
return [name, age, address]
}
3개의 테스팅 함수가 있고, 각각 검증하는 예시이다.
pythagorean 검증
test('Should 3, 4, 5 pythagorean', () => {
expect(isPythagorea(3, 4, 5)).toBe(true)
})
test('Should 3, 4, 6 not pythagorean', () => {
expect(isPythagorea(3, 4, 6)).toBe(false)
})
.toBe() 는 JavaScript의 Object.is 메서드를 이용해서 테스트를 한다고 한다.
createTodo 검증
test('Should create user', ()=> {
const id = 1, title = 'Test todo', content= 'Test content';
expect(createUser(id, title, content).toEqual({ id, title, content })
})
test('Should create user', ()=> {
const id = 1, title = 'Test todo', content= 'Test content';
expect(createUser(id, title, content).title).toMatch('Test todo')
})
.toEqual()은 deep equlity이다. strict 한 비교라서 객체의 nested된 객체까지도 같은지 검사한다.
객체의 내용을 비교하는 것은 tobe(), 객체 그 자체를 비교할 때는 toEqual()을 사용한다.
두 번째는 createUser(id, title,content)로 만들어진 객체에서 title의 값을 assertion 하는 것이다.
정규표현식도 가능 하다.
transformUser testing
test('Should contain name after transformUser', () => {
const user = { name: 'test name', age: 20, address: 'test address' }
expect(transformUser(user)).toContain('test name') // true
})
test('Should contain name after transformUser', () => {
const user = { name: 'test name', age: 20, address: 'test address' }
expect(transformUser(user)).not.toContain(30) // true
})
.toContain()은 반환받은 array에서 값을 검사한다.
.not.toContain()은 반환받은 array에서 값이 없는지 검사한다.
Async assertion
3가지 비동기 테스팅 경우를 모두 제공하는데,
- Callback pattern : test 함수가 콜백이 끝나면 done()을 호출해서 활용. 에러시 에러바로 발생시키거나, done(err)로 넘김
- Promise
- async/await : 둘 다 test 함수가 기다리게하거나 promise를 리턴해서 test()함수에게 promise를 인지시킴
function isPythagoreanAsync(a,b,c) {
return new Promise(resolve => {
setTimeout( ()=> {
const result = isPythagorean(a, b, c)
if(result) return resolve(result)
reject(new Error('Not pythagorean')
},500)
})
}
위 코드에서 isPythagorean(a,b,c)라는 함수를 받아서 실행하는 비동기 isPythagoreanAsync()함수가 있다.
이 함수를 테스팅하는 3가지 방법은 다음과 같다.
test('Should 3, 4, 5 be pythagorean async' , (done) => {
isPythagoreanAsync(3, 4, 5).then(done).catch(done)
})
test('Should 3, 4, 5 be pythagorean async' , () => {
return expect(isPythagoreanAsync(3, 4, 5)).resolves.toBe(true)
})
test('Should 3, 4, 5 be pythagorean async' , (done) => {
return expect(isPythagoreanAsync(3, 4, 5)).rejects.toBe('Not pythagorean')
})
- callback pattern == done을 활용 성공시 then(done)
실패시 catch(done). 실패시에는 done에 error 인자가 넘어가기 때문. - isPythagoreanAsync()함수가 resolves라는 함수를 실행하게되면은 .toBe()로 검증
- 3,4,6이여야하기 때문에, 실패한다.
Mock functions
- jest.fn() : mock function 객체를 만듬
- jest.mock() : 특정 모듈을 mocking
- mockReturnValueOnce() : 리턴하는 값을 임의로 조작. 여러번 호출하면 순서대로 세팅된 값을 반환
- mockResolvedValue() : resolve하는 값을 조작
- toHaveBeenCalled() : 이 함수가 호출되었는지 검증
- toHaveBeenCalledWith(arg1, arg2...) : 이 함수가 특정 인자와 함께 호출되었는지 검증
- toHaveBeenLastCalledWith(arg1, arg2...) : 마지막으로 특정 인자와 함께 호출되었는지 검증
Lifecycle functions
각 테스트의 시작과 끝, 전체 테스트의 시작과 끝에 원하는 작업 할 수 있음
beforeEach, afterEach, beforeAll, afterAll 함수 활용
describe 블록 안에서 사용하면 별도의 scope를 가진다.
ex) describe('설명', 콜백()=> {}
실행순서
- beforAll, beforeEach, afterEach, afterAll 의 순서로 Lifecycle 함수들이 실행됨
- 다만, describe 블록 안에 있는 before- , after- 함수는 해당 블록 scope 안에서 실행됨
- describe 함수는 모든 test()함수 이전에 실행되고 그 외 test()함수들은 순차적으로 실행됨
beforeEach(()=> {
setupMockData()
})
afterEach(()=> {
clearMockData()
})
만약
beforeAll
afterAll
beforeEach
afterEach
describe(
test1()
test2()
test3()
)
이렇게 있으면 실행 순서는
beforeAll
before
test1
after
before
test2
after
...
afterAll
Grouping
describe 함수로 여러 test()함수를 논리적으로 묶을 수 있음
describe함수안에 describe 함수가 중첩될 수 있음 (독립적인 scope를 가짐)
describe('This is group 1', ()=> {
describe('This is inner group 1', ()=> {
test('Test 1', () => {})
})
describe('This is inner group 2', ()=> {
test('Test 2', () => {})
})
})
Snapshot Testing
특정 함수, 모듈 , 컴포넌트 등의 결과를 serializable한 형태의 snapshot으로 저장하고,
추후 변경 발생시 이전의 snapshot과 새로운 snapshot을 비교해서 변경이 발생했는지를 추측함
Jest의 주요 기능으로 코드변경이 컴포넌트 렌더링 결과에 영향을 미치는지에 대해 파악하기 적합.
하지만 TDD 개발방식과는 적합하지않음. TDD는 기능을 먼저 정의하고 오류 수정하는 테스팅개발인데,
Snapshot은 결과를 가지고 테스팅하기 때문에 내부 로직과는 상관이없기때문이다.
// Snapshot Testing
test('User component', () => {
const mockProps = {
name: 'test-username', //setup
age: 20
}
const { container } = render(<User {...mockProps} />) //expectation
expect(container.firstChild).toMatchSmapshot()// assertion
})
- toMatchSnapshot() : 호출하면 기존에 스냅샷이 없을 경우 .snap파일을만듬.
기존 스냅샷이 있으면 새로운 스냅샷과 비교하고 변경사항이 있으면 테스트는 실패한다. - toMatchInlineSnapshot() : 호출하면 별도의 스냅샷 파일을 만들지 않음.
파일안에 스냅샷 코드를 그대로 박아주기때문에 어떻게 스냅샷이 쓰엿는지를 하나의 파일안에서 알 수 있게 됨.
하지만 코드가 길어질 수 있음.
Jest-dom
react-testing library는 Jest를 확장해서 좀 더 쓰기편한 DOM Assertion을 제공하는데 다음과 같다.
- toBeInTheDocument()
- toHaveValue()
- toBeDisalbled()
- toBeVisible() 등
DOM 테스팅에 유용하다.
expect(getByText('abc')).toBeInTheDocument())
react-testing-library란 무엇인가요?
- React앱을 테스팅하기 위한 테스팅 라이브러리
- 사용자의 관점에서 컴포넌트를 테스트하는 것을 강조
- 기본 철학 : 테스트가 소프트웨어가 사용되는 모습을 닮을 수록 테스트를 더욱 신뢰할 수 있게 됨.
The more your test resemble the way your software is used, the more confidence they can give you. - React 컴포넌트가 렌더링한 결과에 대한 접근만 가능하다. ( 결과 중심 )
=== 쿼리는 내부 상태나 내부 메서드에 접근할 수 없다.
React 컴포넌트의 특정 메서드나 상태를 테스트하는게 아니라,
실제 유저가 사용하는 방식대로 테스트하는 접근이다.
유저가 페이지에서 어떤 DOM요소에 접근하는 방법을 흉내낸다.
이 방식으로 테스트 코드를 작성하면 내부 구현이 바뀌어도 테스트가 깨지지 않는다. === 신뢰할 수 있다.
react-testing-library의 쿼리
- getBy : 관련 쿼리는 원하는 요소를 찾지 못할 경우 or 여러개의 요소를 찾을 경우 에러
- getAllBy : 여러 요소를 찾아 배열로 반환. 원하는 요소를 못 찾으면 에러
get관련 쿼리들은 원소가 반드시 페이지에 존재해야만 하는 경우에 쓴다. - findBy : 관련 쿼리는 원하는 원소가 없더라도 비동기적으로 기다린다.
여러 원소를 찾거나 , 정해진 timeout동안 찾지 못하면 에러 - findAllBy : 관련 쿼리는 여러 원소를 검색해 배열로 반환.
정해진 timeout동안 찾지 못하면 에러. 둘다 반환값은 Promise다. resolve, reject 반환 똑같음.
유저의 어떤 동작 후에 등장하는 원소 등을 테스팅할때 사용한다. - queryBy : 관련 쿼리는 getBy와 비슷하게 동작하지만 원소를 못 찾아도 에러가 아님, 단 여러개를 찾으면 에러
- queryAllBy : 관련 쿼리는 getAllby와 비슷하지만 하나도 못찾으면 에러 대신에 빈 배열을 반환함.
특정 요소를 못 찾는게 true일때 (ex) delete버튼으로 삭제 후에 요소가 없어야할때 ) 활용
쿼리의 사용 우선순위
유저가 페이지를 이동하는 방식(사용하는 방식)에 가까운 쿼리일수록 먼저 사용하는게 좋다.
접근성이 높은 HTML 코드를 작성할수록 테스트가 용이한 코드가 된다.
ByRole
- 기존 쿼리에 ByRole을 붙이면 가장 높은 우선순위 쿼리가 됨.
ex) getByRole, findByRole, queryByRole - accessibility tree에 있는 요소들을 기준으로 원소를 찾는다. 유저가 웹페이지를 사용하는 방식을 가장 닮은 쿼리 왜냐하면 가장 큰 목적이자 기능이기 때문
- 동일한 role을 가진 경우에 , accessible name(원소의 특징을 나타내는 이름)을 이용해 원소를 구별함.
ex) button에 경우 value 값(텍스트) - 임의로 role 혹은 aria- 를 부여하는 것을 지양한다. 이유는 html에서 지정한 default role을 중요시하기때문.
- 자주 사용되는 Role : button, checkbox, listitem, heading, img, form, textbox, link
ByText
- 유저가 볼 수 있는 Text값을 기준으로 쿼리를 찾는다.
ex) getByText, getByLabelText , findByText 등 - ByLabelText : label과 연관된 원소를 찾는다.
- ByPlaceholderText : placeholder 와 연관된 원소를 찾는다.
- ByText : 주어진 Text와 연관된 원소를 찾는다.
- ByDisplayValue : input, textarea, select등의 value를 기준으로 원소를 찾는다.
Semantic queries
- 유저에게 안보이지만 접근성 스펙에 적합한 alt, title을 이용해 원소를 검색함
- ByAltText : img, area, input 등의 alt 속성으로 원소 검색
- ByTitle : title속성으로 검색
Test ID
- data-testid속성을 원하는 원소에 임의 지정하고 쿼리를 이용해 찾음 ex) ul의 3번째 li만 테스트하고싶을 때.
ex) getByTestId, findByTestId
ex) <input data-testid='username-input' /> - 유저가 해당 속성을 기반으로 화면의 요소를 찾는게 아니므로 우선순위가 낮다.
- 보통 다른 쿼리로 테스트를 작성할 수 없을 때 이 쿼리를 백도어로 활용한다.
활용 예시
function TestForm() {
const formRef = useRef();
cosnt handleSubmit = (e) => {
e.preventDefault();
formRef.current.reset();
}
return (
<form onSubmit={handleSubmit} ref={formRef}>
<label htmlFor='username'>Username</label>
<input id='username' type='text' name='username' />
<input type='submit' value='Submit' />
</form>
)
}
//test
test('제출 버튼을 찾아 클릭하면, Username 인풋이 비워진다. ', () => {
const { getByRole } = render(<TestForm />)
const usernameInput = getByRole('textbox', { name : 'Username' })
const submitButton = getByRole('button', { name: 'Submit' })
userEvent.type(usernameInput, 'test username')
userEvent.click(submitButton)
expect(usernameInput).toHaveValue('')
})
기본적으로 input의 submit 버튼을 누르면 reset()되는 폼태그인데, 진행되는 순서는 다음과 같다.
- test()에서 <TestForm /> 렌더시 getByRole 쿼리를 가져온다.
- getByRole로 'textbox' role을 찾는것과 동시에, 추가로 name이 'Username'인 요소를 찾는다.
- userEvent 중에 type 이벤트로 usernameInput에 'test username'이라는 타이핑을 하고 버튼 클릭.
- toHaveValue()라는 jest-dom 메서드로 검증
react-testing-library에서 제공하는 user-event 라이브러리
- 내장 이벤트 함수인 fireEvent, reateEvent를 좀 더 직관적이고 범용적으로 사용할 수 있도록 만든 라이브러리.
- click, type, keyboard, upload, hover, tab등 유저가 실제로 웹페이지를 사용하며 만드는 이벤트를 메서드로 제공한다.
ex) userEvent.click(buttonElement)
click
userEvent.click(submitButton)
userEvent.click(submitButton, { shiftKey: true })
userEvent.click(submitButton, { shiftKey: true }, { clickCount: 5})
testing 코드만 보고 어떤 로직의 컴포넌트인지 유추가 가능하다.
test('숨은 텍스트를 보여준다. ', () => {
const text = 'Hidden text!';
const { getByRole, queryByText } = render(<Expansion text={text} /> )
expect(queryByText('Hidden text!')).toBe(null); // 버튼 클릭전엔 안보여야 한다
const showButton = getByRole('button', { name: 'Expand' });
expect(showButton).toBeInTheDocument();
userEvent.click(showbutton);
expect(queryByText(text)).toBeInTheDocument();
})
- 컴포넌트 렌더링시 queryByText()로 찾는 요소가 있는지 테스트
- getByRole로 찾은 버튼이 문서에 보여지고 있는지 테스트
- 버튼 클릭시 text로 찾은 요소가 문서에 보여지고 있는지 테스트
위 testing 코드의 컴포넌트
function Expansion({ text }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(b => !b)}>Expand</button>
{expanded && <div>{text}</div>
</div>
)
}
type
userEvent.type(inputElement, 'react advanced')
userEvent.type(inputElement, 'react{space}advanced{enter}')
await userEvent.type(inputElement, 'react advanced', { delay: 300})
2번째에서 만약 enter를 입력했을 때 submit하는 기능이있다 하면 넣어줄 수 있다.
비동기 처리도 가능하다.
test('Typeahead에서 쿼리에 따른 검색 결과를 보인다.', () => {
const mockSearchData = ['kim', 'song', 'shim', 'park', 'yoon'];
const { getByPlaceholderText, getAllByRole } = render(
<Typeahead searchData={mockSearchData} />
)
const inputElement = getByPlaceholderText('Type name...');
userEvent.type(inputElement, 's');
expect(getAllByRole('listitem').length).toBe(2);
userEvent.clear(inputElement);
expect(getAllByRole('listitem').length).toBe(mockSearchData).length);
})
- type 메서드로 's' 입력 후, 요소 개수 테스트
- clear 메서드로 입력값 초기화 후 테스트
Jest와 react-testing-library에 대해 공부한 내용을 정리해 보았다.
익숙한 것보다 생소한 내용이 많아서 계속해서 사용해보면서 감을 익혀야 할 것 같다.
'FRONTEND > 기타' 카테고리의 다른 글
[yarn-package 배포] 나만의 boilerplate 만들기 (with inquirer) (2) | 2024.02.06 |
---|---|
[CI/CD] Docker-compose + github-actions + EC2 적용기 (0) | 2024.01.31 |
Linux 폴더 권한 확인 + 권한 설정 (0) | 2024.01.30 |