a를 넣으면 b가 나옴을 테스트한다. 테스트를 작성하는 방법으로 일반적으로 AAA패턴 또는 GWT패턴을 따름.
AAA(Arrange-Act-Assert) 패턴
Arrange: 테스트 시작전 준비해야할것
Act: 대상동작을 실행. e.g., RESt API 호출, 웹페이지 상호작용
Assert: 예상결과를 검증. 테스트가 성공인지 실패인지결정
https://martinfowler.com/bliki/GivenWhenThen.html
GWT(Given-When-Then) 패턴
Given: 동작을 시작하기전 환경상태, 테스트의 사전조건
When: 지정하려는 행위
Then: 행위로 인해 기대된 변화
https://automationpanda.com/2020/07/07/arrange-act-assert-a-pattern-for-writing-good-tests/
크게 아래의 패턴을 따름
- 테스트를 위한 환경 만들기
- 테스트 동작 재현
- 올바른 동작이 실행되었는지 또는 변경사항을 검증하기
TextField 컴포넌트의 className prop에 설정된 css class가 텍스트 필드에 적용되었는가를 검증하는 테스트작성시
it('className prop으로 설정한 css class가 적용된다.', () => {
// 테스트를 위한 환경 만들기
// -> className을 지닌 컴포넌트 렌더링
await render(<TextField className="my-class" />);
// Act - 테스트할 동작 발생
// -> 렌더링에 대한 검증이기 때문에 이 단계는 생략
// -> 클릭이나 메서드 호출, prop 변경 등등에 대한 작업이 여기에 해당
// Assert - 올바른 동작이 실행되었는지 검증
// -> 렌더링 후 DOM에 해당 class가 존재하는지 검증
expect(screen.getByPlaceholderText('텍스트를 입력해 주세요.')).toHaveClass('my-class');
});
기능테스트는 앱 요구사항에서 누락된 부분, 오류가 있는 부분, 생략된 부분을 찾는것.
앱의 기능이 요구사항대로 잘 동작하는지 검증하는 테스트
함수나 메서드등을 독립적으로 검증하는 과정.
프론트엔드에서는 단일 컴포넌트(클래스)의 상태관리에 대한 함수등이 단위테스트 대상이 되기도함.
다른 컴포넌트나 함수와의 상호작용검증이 아닌 각 행위를 독립적으로 검증.
다른 모듈에 대한 의존성이 거의 없고, 해당 모듈 자체만으로 작지만 독립적인 역할을 수행하는 공통 컴포넌트, 공통 유틸, 헬퍼함수, 리액트 훅등이 테스트의 대상이 됨.
export default function TextField({
placeholder,
...
}) {
...
return (
<input
type="text"
placeholder={placeholder || '텍스트를 입력해 주세요.'}
...
/>
);
}
// placeholder에 대한 테스트 코드 작성
describe('placeholder', () => {
it('기본 placeholder "텍스트를 입력해 주세요."가 노출된다.', async () => {
await render(<TextField />);
const textInput = screen.getByPlaceholderText('텍스트를 입력해 주세요.');
expect(textInput).toBeInTheDocument();
});
it('placeholder prop에 따라 placeholder가 변경된다.', async () => {
await render(<TextField placeholder="상품명을 입력해 주세요." />);
const textInput = screen.getByPlaceholderText('상품명을 입력해 주세요.');
expect(textInput).toBeInTheDocument();
});
});
개별적인 모듈을 결합하여 그들사이의 상호작용과 인터페이스 오류의 부재를 검증하는 과정.
특정상태를 기준으로 동작하는 컴포넌트 조합 및 API와 상호작용하는 컴포넌트 조합이 대상.
하위 컴포넌트가 제대로 렌더링(혹은 동작)되는지 검증
가능한 모킹을 하지 않고 실제와 유사하게하는게 좋음. 하지만 모킹이 필요하다면 msw같은 도구를 활용.
const NavigationBar = () => {
const { isLogin, setIsLogin, setUserData } = useUserStore(state =>
pick(state, 'isLogin', 'setIsLogin', 'setUserData'),
);
...
const handleClickModalAgree = () => {
remove();
setIsLogin(false);
Cookies.remove('access_token');
toggleIsModalOpened();
};
const { data, remove } = useProfile({
config: {
onSuccess: profile => {
setUserData(profile);
\
initCart(profile.id);
},
enabled: !!isLogin,
},
});
return (
<>
<Toolbar>
<Box>
{isLogin ? (
<ApiErrorBoundary>
<CartButton cart={cart} />
<LogoutButton onClick={toggleIsModalOpened} />
</ApiErrorBoundary>
) : (
<LoginButton />
)}
</Box>
</Toolbar>
...
</>
);
};
// 로그인, 비로그인이 되었을때의 컴포넌트 렌더링 테스트
describe('로그인이 된 경우', () => {
it('장바구니(담긴 상품 수와 버튼)와 로그아웃 버튼(사용자 이름: "Maria")이 노출된다.', async () => {
await render(<NavigationBar />);
expect(screen.getByTestId('cart-icon')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(
await screen.findByRole('button', { name: 'Maria' }),
).toBeInTheDocument();
});
});
describe('로그인이 안된 경우', () => {
it('로그인 버튼이 노출되며, 클릭 시 "/login" 경로와 현재 pathname인 "pathname"과 함께 navigate를 호출한다.', async () => {
const navigateFn = vi.fn();
const { user } = await render(<NavigationBar />);
expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(navigateFn).toHaveBeenNthCalledWith(1, '/login', {
state: { prevPath: 'pathname' },
});
});
});
-> 통합테스트는 비지니스 로직을 나누어 컴포넌트의 상호작용을 검증하는데 매우 효율적이다. 하지만 모킹에 의존하다보니 전체 워크 플로우를 검증하기에는 무리가 있다.
실제 시나리오에 최대한 가깝게 앱의 전체 워크플로우를 검증하기위해서는 E2E테스트를 사용하는게 좋다.
End-to-End 테스트는 사용자 관점에서 소프트웨어의 전체 시스템을 테스트하는 과정. 완성된 앱을 실행해 전체 소프트웨어 시스템의 흐름을 검증.
사용자의 인터페이스, 데이터베이스, 네트워크, 외부 시스템 및 서비스 등 소프트웨어의 모든 계층을 포함.API를 포함한 모든 워크플로우를 검증.
사용자가 앱을 사용하는 다양한 시나리오가 실제 환경에서 정상적으로 작동 하는지 검증.
권한체크후 페이지를 리다이렉트하는 테스트 예시
beforeEach(() => {
cy.visit('/');
});
describe('로그인이 필요한 페이지에 비로그인 상태로 접속하면 로그인 페이지로 리다이렉트 된다', () => {
it('장바구니 페이지에 접속할 경우 로그인 페이지로 리다이렉트 된다', () => {
cy.visit('/cart');
cy.assertUrl('/login');
});
it('구매 페이지로 접속할 경우 로그인 페이지로 리다이렉트 된다', () => {
cy.visit('/purchase');
cy.assertUrl('/login');
});
});
E2E테스트의 한계
프론트엔드에서 기능외의 제대로 렌더링이 되었는지에 대한 시각적인 부분 검증을 위한 테스트
일반적으로 이야기하는 스냅샷 테스트는 jsDOM을 이용한 스냅샷 테스트이며, 리액트에서는 렌더링 된 가상의 DOM 객체가 스냅샷 테스트의 대상이 되기도 함.
💡 스냅샷 테스트의 과정
1. 대상 컴포넌트를 렌더링 합니다.
2. 컴포넌트의 DOM을 직렬화해 스냅샷으로 기록합니다.
3. 기존의 스냅샷과 새로운 스냅샷을 비교합니다. (비교할 스냅샷이 없다면 현재 스냅샷을 기록합니다.)
4. 기존 스냅샷과 현재 스냅샷이 동일한 경우 테스트는 통과합니다.
5. 기존 스냅샷과 현재 스냅샷이 다르다면 변경 사항을 확인합니다.
1. 5-1. 의도한 변경일 경우 스냅샷을 업데이트 합니다.
2. 5-2. 의도한 변경이 아닐 경우 잘못된 부분을 찾아 수정합니다.
const PageTitle = () => (
<Typography variant="h4" component="h1" padding="20px 0">
상품 리스트
</Typography>
);
// PageTitle에 대한 스냅샷 테스트시
it('pageTitle 스냅샷 테스트(toMatchInlineSnapshot)', async () => {
const { container } = await render(<PageTitle />);
expect(container).toMatchInlineSnapshot(`
<div>
<h1
class="MuiTypography-root MuiTypography-h4 css-1lnl64-MuiTypography-root"
>
상품 리스트
</h1>
<div
style="position: fixed; z-index: 9999; top: 16px; left: 16px; right: 16px; bottom: 16px; pointer-events: none;"
/>
</div>
`);
});
it('pageTitle 스냅샷 테스트(toMatchSnapshot)', async () => {
const { container } = await render(<PageTitle />);
expect(container).toMatchSnapshot();
});
스냅샷 테스트는 DOM구조가 복잡한 컴포넌트를 쓰면 스냅샷 결과만 30-40줄이 넘을수도 있기에 가독성이 좋지않으며, 결과만보고 컴포넌트가 실제로 어떻게 렌더링 될지 알기어려움.
실제로 렌더링 되는 결과를 눈으로 확인하고 관리하는 방법으로 시각적 회귀 테스트를 쓸 수 있다.
소프트웨어 영역에서 '회귀'란
회귀(Regression)는 기본적으로 '돌아가다', '되돌아가다'라는 의미를 가지고 있으며, 소프트웨어 테스트 분야에서는 새로운 변경사항이 기존의 시스템이나 기능에 영향을 주어 발생할 수 있는 문제를 찾아내는 과정을 의미.
회귀테스트란 소프트웨어의 시각적 요소가 변경 전후로 일관되게 유지되는지 확인하는 방법.
시각적 회귀테스트를 위해 Storybook과 연계된 시각적 회귀 테스트 도구를 활용하는 경우가 많음.
Storybook외에 Chromatic, Applitools, Percy, BackstopJS 등이 있음.