2024. 5. 9. 01:36ㆍ카테고리 없음
Unit Test
단위 테스트(Unit Test)는 함수나 메서드와 같이 하나의 작은 코드 단위를 테스트한다. 즉 특정 모듈이 목표한 기능을 올바르게 수행하고 있는지 검증하는 절차다. 중요한 점은 다른 코드들은 모두 잘 동작한다는 가정하에 해당 모듈이 잘 동작하는 지를 테스트 한다는 점이다.
가장 간단한 Jest 예제를 확인해보자.
function sum(a, b){
return a + b;
}
it("a+b", () => {
expect(sum(1, 3)).toBe(4);
}
toBe는 Jest의 matcher로, expect가 반환한 "expectation" 객체를 Object.is를 사용하여 테스트한다. Jest가 실행되면 실패한 모든 matcher를 추적하여 오류 메세지를 출력한다.
위의 예제는 굉장히 단순하다. 하지만 다음과 같은 함수에 대해 생각해보자.
const 테스트하고자하는함수 = (파라미터) => {
const 중간결과 = 관심없는함수(파라미터);
const 최종결과 = // 테스트하고자하는함수의 구체적인 로직
return 최종결과
}
위 예제에서 테스트하고자하는함수를 테스트 하려고 할 때, 테스트의 결과가 관심없는함수의 영향을 받을 수 있다는 문제점이 있다. 유닛 테스트의 의의는 하나의 모듈이 잘 동작하고 있는가를 검증하는 것에 있다. 하지만 이렇게 테스트를 수행하게 되면 이 유닛 테스트가 실패했을 때 실패 원인이 내가 테스트하고자 있는 함수 외에 외부의 영향을 받을 수 있다.
이런 문제점을 해결하기 위해 Jest는 mocking 기능을 제공한다.
// 기존 함수 모킹
jest.mock('../어딘가', () => ({
관심없는함수: jest.fn()
}));
it('테스트하고자하는함수', () => {
const 인자 = ..
// 모킹한 함수가 항상 특정 값을 반환하도록 설정
관심없는함수.mockReturnValue(...);
const 예상결과 = ..
const 결과 = 테스트하고자하는함수();
expect(결과).toBe(예상결과);
});
위 테스트코드에서는 관심없는함수의 구체적인 구현과 무관하게 항상 특정 값을 반환하도록 모킹을 해주었다. 이제 더이상 테스트하고자하는함수의 테스트 결과는 관심없는함수에 영향을 받지 않는다. 테스트가 실패했을 때 더이상 테스트하고자하는 범위 외의 로직을 신경쓸 필요가 없어진다.
Jest vs Vitest
잠깐 Jest와 Vitest를 비교해보자. Vitest 공식문서의 설명에 잘 나와있는데, 기본적으로 Vitest는 Vite 환경에서 통합된 파이프라인을 제공한다. 예를 들어 개발, 빌드 및 테스트 환경 구성을 vite.config를 재사용하여 단일 파이프라인으로 통합할 수 있다. (물론 Vite 환경 외에서도 Vitest를 사용할 수 있다.)
조사를 하면서 개인적으론 Jest보단 Vitest를 사용해야겠다는 마음을 많이 먹었는데, 그 이유는 다음과 같다.
1. Jest의 경우 바벨과 타입스크립트 등에 대한 설정을 직접 해주어야 한다. 설령 개발 환경(번들러 설정)에 바벨, 타입스크립트에 대한 설정이 있다 할지라도 Jest만을 위한 중복된 설정이 추가로 필요하다. 반면 Vitest는 별다른 설정 없이 최신 문법을 사용 가능하며, 타입스크립트도 기본적으로 지원한다. 심지어는 JSX도 잘 지원한다.
2. Jest는 ESM에 대한 지원이 아직 experimental인 반면 Vitest의 경우 ESM first다. 이 부분은 꽤나 치명적이라고 생각한다.
물론 Jest의 생태계가 더욱 풍부하고 커뮤니티도 많이 활성화 돼있지만, Vitest의 경우 Jest와의 호환성이 꽤나 높은 편이라 Vitest를 사용하더라도 이 부분은 큰 단점은 아니라고 생각한다.
E2E 테스트
E2E 테스트는 애플리케이션이 예상대로 작동하는지 모든 종류의 사용 시나리오를 확인하는 테스트다. 다시 말해 실제 유저가 앱을 사용하는 실제 시나리오를 시뮬레이션 한다.
잘만 된다면 매우 편리할 것 같다는 생각이 든다. 하지만 어떻게 이게 가능한지 감이 잘 오지 않기에 가장 많이 들어본 cypress의 공식문서를 확인해보자.
다음의 과정을 cypress의 command와 매핑을 해보자.
1. 페이지를 방문하고
2. 엘리먼트를 획득한 다음
3. 엘리먼트와 상호 작용하고
4. 페이지의 콘텐츠에 대해 assert 한다.
describe('My First Test', () => {
it('clicking "type" navigates to a new url', () => {
// 1) 페이지에 방문하고,
cy.visit('https://example.cypress.io')
// 2) 엘리먼트를 찾고, 3) 엘리먼트와 상호작용
cy.contains('type').click()
// 4) 페이지에 대한 assert
cy.url().should('include', '/commands/actions')
})
})
cypress 공식 문서의 영상을 보면 실제 브라우저 환경에서 테스트가 수행되는 것을 알 수 있다. 이처럼 실제 유저의 사용 시나리오를 테스트 코드로 작성하여 브라우저 환경에서 자동으로 QA를 진행해주는 역할을 하는 것이 E2E 테스트다.
다만 API 요청 등과 같은 실제 시나리오를 모두 테스트하기에, 테스트가 특정 환경에서는 통과하고 다른 환경에서는 실패하는 경우가 존재할 수 있다. 인터넷 속도, 컴퓨터 성능 등 외부 요인에 영향을 받기 때문이다. 실패 케이스를 완벽히 재현하기 어렵기 때문에 이런 경우엔 E2E 테스트에 대한 신뢰가 감소할 수 있다.
Cypress vs Puppeteer
Puppeteer에 대해 잠깐 알아보자면, Puppeteer의 경우 좀 더 특화된 사용 예시를 위해 설계되었던 도구다. 좀 더 raw한 dev tool protocol을 사용하기 때문에, 헤드리스 브라우저가 주된 요구 사항이라면 Puppeteer를 사용하는게 적절해 보인다. 물론 Cypress와 Puppeteer 둘다 헤드리스와 헤드풀(headful) 모드를 둘다 지원하긴 한다.
하지만 실제 유저 시나리오를 검증하는 E2E 테스트에는 Cypress가 잘 어울린다. 애초에 Cypress는 E2E 테스트를 위해 설계됐기 때문에 API가 실제 유저의 인터랙션과 유사하다. 물론 Puppeteer도 이런 인터랙션을 테스트 할 수 있지만 더 많은 양의 코드를 작성해야 한다.
또한 Cypress는 자체적인 assertion을 가지고 있다. Puppeteer는 자체적인 assertion이 없다. Jest 또는 Mocha와 같은 테스트 프레임워크와 결합하여 사용해야 한다.
둘의 철학은 매우 다르게 느껴진다. 실제 유저 사용 시나리오를 최대한 비슷하게 테스트하는게 중요하다면 Cypress가 적절해 보인다. 하지만 그 외에 헤드리스 브라우저만으로도 충분한 경우라면 Cypress의 철학과는 조금 멀어진다고 느끼기에 Puppeteer와 테스트 프레임워크를 결합하여 사용하는게 적절해 보인다.
Snapshot Test
스냅샷 테스트는 무엇일까? 짧게 요약하자면 개발자가 검증해야 하는 값을 직접 적는 것이 아닌 어플리케이션의 UI나 데이터 구조를 저장된 기준 스냅샷과 비교하여 변경 사항을 감지하는 테스트 방법론이다. unit 테스트나, e2e처럼 특정 범위에 국한되는 것이 아니라, 어떤 범위의 테스트에도 적용할 수 있다.
위에서 살펴본 Unit Test 예제의 경우 간단한 함수에 대해 테스트하고 있다. 하지만 실제 어플리케이션을 만들때 사용되는 함수는 위 예제처럼 간단하지 않을 수 있다.
아래 api 함수를 테스트하려는 경우, 반환값이 매우 복잡하다.
const api = require('./api')
api.topSeller()
.then(console.log)
/*
{
"id": "1234-5678...",
"name": "Nerf Gun Zombie Blaster Dominator",
"displayName": "Zombie Nerf Gun",
"alias": "zombie-nerf-gun",
"SKU": "...",
"price": ...,
"currency": "...",
"availability": "...",
"promotion". "..."
and many other fields
}
*/
위와 같이 외부 api 객체 형태의 값을 assertion 해줘야 하면, 손으로 직접 matcher에 복잡한 값을 적는 것은 수많은 함수들에 대한 테스트를 작성해야 하는 개발자에게 번거로운 일이다. 직접 작성하지 않고 콘솔에 출력된 값을 복사/붙여넣기를 통해 테스트를 작성할수도 있다.
it('returns top seller item', () => {
const expected = {
id: '1234-5678-...',
name: 'Nerf Gun Zombie Blaster Dominator',
displayName: '...'
// and the rest of the properties
}
return api.get()
.then(item => expect(item).toDeepEqual(expected))
})
하지만 SnapShot 테스트는 복사 붙여넣기를 하는 불편함을 해소해줄 수 있다. 다음의 코드를 보자.
it('returns top seller item', () =>
api.get().then(item =>
expect(item).toMatchSnapshot()
)
)
위 코드에서는 어떤 값도 개발자가 직접 입력하지 않았다. 단순히 toMatchSnapshot 메서드를 사용 했다. 그럼 도대체 이 toMatchSnapShot은 어떤 역할을 할까?
맨 처음 이 테스트 코드가 Jest에 의해 실행되면 결과 값을 다음과 같은 모습으로 snapshot file에 저장한다.
exports[`returns top seller item 1`] = `
{
id: '1234-5678-...',
name: 'Nerf Gun Zombie Blaster Dominator',
displayName: '...'
// and the rest of the properties
}
`;
snapshot file이 저장된 이후 (최초로 테스트 코드가 실행된 이후), 다시 테스트 코드가 실행되면 snapshot file에 저장된 값과 현재 테스트에서 반환한 값이 일치하는지 확인한다.
개발자는 더 이상 복잡한 값을 테스트하기 위해 불필요한 복사/붙여넣기를 하지 않아도 된다. 만약 코드가 수정되어 snapshot file 값 자체를 수정해야 한다면? 한줄의 명령어로 snapshot file을 업데이트 할 수도 있다.
또한 프론트엔드 개발자에게 UI, 즉 시각적인 측면도 매우 중요하다. 하지만 Unit Test와 E2E 테스트는 기능적인 측면에 집중하고 있다. Snapshot 테스트는 이런 시각적인 테스트를 가능하게 해준다.
먼저 Jest에서 UI 컴포넌트의 Snapshot테스트를 알아보자.
1. UI 컴포넌트를 렌더링하고
2. 스냅샷을 생성한 다음
3. 테스트와 함께 저장된 참조 스냅샷 파일과 비교한다.
4. 이 때 두 스냅샷이 일치하지 않으면 테스트는 실패한다.
Jest는 그래픽 UI를 렌더링하는 대신 테스트 렌더러를 사용하여 직렬화 가능한 값을 생성한다.
실제 코드로 한번 알아보자.
import {useState} from 'react';
const STATUS = {
HOVERED: 'hovered',
NORMAL: 'normal',
};
export default function Link({page, children}) {
const [status, setStatus] = useState(STATUS.NORMAL);
const onMouseEnter = () => {
setStatus(STATUS.HOVERED);
};
const onMouseLeave = () => {
setStatus(STATUS.NORMAL);
};
return (
<a
className={status}
href={page || '#'}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</a>
);
}
위와 같은 Link 컴포넌트가 있을 때 다음과 같이 테스트를 작성할 수 있다.
import renderer from 'react-test-renderer';
import Link from '../Link';
it('renders correctly', () => {
const tree = renderer
.create(<Link page="http://www.facebook.com">Facebook</Link>)
.toJSON();
expect(tree).toMatchSnapshot();
});
테스트가 처음 실행이 되면 다음과 같은 스냅샷 파일이 jest에 의해 생성된다.
exports[`renders correctly 1`] = `
<a
className="normal"
href="http://www.facebook.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Facebook
</a>
`;
이후 테스트를 실행할 때 Jest는 렌더링된 결과물을 이전 스냅샷과 비교한다. 일치하면 테스트가 통과하고, 일치하지 않으면 코드에서 버그를 발견했거나 구현이 변경되어 스냅샷을 업데이트 해야 한다. 또한 이 스냅샷 파일도 작성한 코드와 함께 커밋하고 코드 리뷰를 해야한다.
이렇게 스냅샷 테스트를 통해 의도하지 않은 UI 변경을 방지할 수 있고, 스냅샷을 자동으로 저장하고 업데이트도 간편하기에 유지 관리 하기에 쉽다는 장점이 있다.
그럼 Cypress를 이용한 E2E 테스트에 Snapshot 테스트를 어떻게 적용 해야할까? cypress에서는 cypress-plugin-snapshots와 같은 플러그인(지금은 사실상 유지가 되지 않는다.)을 사용하여 스냅샷을 생성할 수 있다.
snapshot을 사용하지 않고 아래와 같은 시나리오를 테스트 하기 위한 assertion을 작성하려면 어떻게 해야할까?
it('marks completed items', () => {
// several actions
enterTodo('first item')
enterTodo('second item')
enterTodo('item 3')
enterTodo('item 4')
toggle('item 3')
toggle('item 4')
})
it('marks completed items', () => {
// several actions
// ...
// assertions
getTodoItems().should('have.length', 4)
getCompleted().should('have.length', 2)
getTodo('first item').find('[type="checkbox"]').should('not.be.checked')
getTodo('second item').find('[type="checkbox"]').should('not.be.checked')
getTodo('item 3').find('[type="checkbox"]').should('be.checked')
getTodo('item 4').find('[type="checkbox"]').should('be.checked')
})
모든 엘리먼트에 접근해서 각 엘리먼트에 대한 assertion을 작성해야 이 시나리오에 대한 테스트를 작성할 수 있다. 하지만 snapshot을 통해 이 과정을 좀 더 쉽게 할 수 있다.
it('marks completed items', () => {
// actions
enterTodo('first item')
enterTodo('second item')
enterTodo('item 3')
enterTodo('item 4')
toggle('item 3')
toggle('item 4')
// make sure app has rendered the check marks
getCompleted().should('have.length', 2)
// single snapshot of entire <ul class="todo-list"> element
cy.get('ul.todo-list')
.snapshot({ name: 'todo-list with 2 completed items' })
})
이 시나리오에 대한 스냅샷을 저장해놓으면 수 많은 assertion을 작성할 필요가 없어진다.
Visual Regression Test
하지만 위와 같은 스냅샷 테스트의 경우 DOM을 직렬화한 텍스트를 저장한다. 이는 꽤 큰 단점이 있는데, 똑같은 화면을 보여도 HTML과 CSS가 바뀌면 테스트는 실패한다.
이에 반해 시각적 회귀 테스트 (Visual Regression Test)는 DOM이 아닌 화면의 스크린샷을 새로운 스크린 샷과 비교하여 이미지의 차이점을 비교하기 기 때문에 만약 화면은 유지해야 하지만 내부적인 리팩토링이 필요한 경우 좀 더 적절할 수 있다.
이런 식으로 기준이 되는 이미지와 테스트시 캡쳐한 이미지의 차이를 보여준다. 하지만 실제 이미지를 캡쳐하고 비교하기 때문에 테스트 시간이 오래걸리고, 당연한 말이겠지만 화면의 디자인 자체가 달라지면 테스트가 실패하게 된다.
마치며
다양한 테스트의 종류가 있고, 또 그 안에 조금씩 다른 철학을 가진 테스트 라이브러리가 있다. 프론트엔드에서의 테스트는 DOM이 결합되는 경우가 대부분이기에 내가 최소한 어떤 부분은 안전하다는 확신을 얻고 싶은지 고민하고 적절한 테스트 종류와 라이브러리를 택해야 한다.
프론트엔드에서 어떤 테스트를 적용할지 고민한 레몬베이스의 글이 꽤나 유익해서 공유하고자 한다.