
개요
이전 글에서 소프트웨어 공학의 테스트 원칙과 프론트엔드 테스트 방법론을 살펴보았다. 이제 실제로 시나리오를 구성하고 테스트 코드를 작성하는 방법을 알아보자.
프론트엔드 단위 테스트 이해하기
단위 테스트란단위 테스트는 소프트웨어의 기본 구성 단위인 모듈을 테스트하는 과정으로, 모듈 테스트라고도 불린다. 개발자는 구현 단계에서 각 모듈의 개발을 완료한 후, 해당 모듈이 요구
laurent.tistory.com
테스트를 작성하기에 앞서, 컴포넌트가 어떤 역할을 하는지 알기 위해 시나리오를 먼저 구성하는 것이 좋다. 이는 마치 사용자의 관점에서 애플리케이션을 바라보는 것과 같다. 예를 들어, 로그인 컴포넌트를 테스트한다고 가정해보자. 사용자가 유효한 이메일과 비밀번호를 입력했을 때는 로그인이 성공해야 하고, 이메일 형식이 잘못되었을 때는 에러 메시지가 표시되어야 한다.
Vitest + React Testing Library
프로젝트 내 다양한 시나리오를 바탕으로 Vitest와 React Testing Library를 활용해 테스트 코드를 작성했다. Vitest는 Vite의 번들러를 직접 활용해 ES 모듈을 네이티브로 지원하고, 테스트 파일을 병렬로 실행하기 때문에 Jest보다 빠른 실행 속도를 보여준다. 그리고 Vite의 설정을 그대로 재사용할 수 있어 별도의 복잡한 설정 없이도 TypeScript, JSX 등을 바로 지원한다.
React Testing Library를 사용하면서 DOM 쿼리나 이벤트를 통해 실제 사용자처럼 컴포넌트와 상호작용할 수 있었다. 사용자의 인터랙션을 시뮬레이션하는 방법으로는 fireEvent와 userEvent가 있다. fireEvent는 하나의 DOM 이벤트만 발생시키는 반면, userEvent는 브라우저에서 일어나는 것처럼 여러 이벤트를 순차적으로 발생시킨다.
예를 들어 사용자가 입력 필드에 텍스트를 입력할 때는 단순히 입력 이벤트만 발생하는 것이 아니라 키보드를 누르고, 문자가 입력되고, 입력 필드의 값이 변경되는 등 여러 이벤트가 연속적으로 발생한다. userEvent는 이러한 브라우저 동작을 더 정확하게 시뮬레이션한다. 이러한 이벤트들과 함께 getByText나 getByRole 같은 쿼리를 사용해 화면에 렌더링된 요소를 찾아 검증했다. getByRole은 실제 사용자가 웹 페이지를 인식하는 것과 비슷해 React Testing Library에서 권장하는 쿼리 방식이다.
명세 기반 테스트
우리는 명세 기반 테스트 기법을 활용해 애자일의 유저 스토리나 UML의 유스케이스와 같은 프로그램 요구사항을 테스트 입력으로 사용할 것이다. 요구사항에서 얻을 수 있는 모든 정보를 사용해 해당 요구사항을 광범위하게 수행하는 테스트를 체계적으로 도출해 볼 것이다.
하지만 우리는 테스트를 기반으로 코드를 작성하지 않기 때문에, 이미 작성되어 있는 코드를 토대로 테스트를 구현할 것이다. 코드 관점에서 JavaScript가 특정 조건에 따라 UI가 노출되거나 로직이 실행된다면, 해당 내용들은 모두 테스트 대상이라고 볼 수 있다.
우리는 새로운 요구사항을 토대로 구현할 어떤 컴포넌트를 뽑아냈다.
이 컴포넌트는 form 내부에서 input 요소로 사용자의 입력값을 받아 문자열을 처리한다.
잠시 생각을 하고 FormInput 컴포넌트에 대해 다음과 같은 요구사항을 도출했다.
- 텍스트 입력을 위한 input 필드를 제공해야 한다.
- placeholder 텍스트를 지원해야 한다.
- 입력값 변경을 감지하고 상위 컴포넌트에 알려야 한다.
- 포커스 시 하단에 초록색 밑줄 애니메이션을 표시한다.
- 다양한 input type(text, number 등)을 지원해야 한다.
이 요구사항을 염두에 두고 아래와 같은 컴포넌트를 작성했다. 이 기능을 개발하면서 TDD를 썼을 수도 있고 그렇지 않을 수도 있지만, 프로그램이 제대로 동작한다는 것은 어느 정도 확신하고 있다. 하지만 완전히 확신하지는 못 한다.
export default function FormInput({ id, label, value, placeholder, type = "text", onChange }: FormInputProps) {
return (
<div className="flex items-center gap-4">
<Label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</Label>
<div className="relative flex-grow">
<Input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="w-full peer border-0 border-b border-input bg-transparent focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 rounded-none placeholder:text-muted-foreground placeholder:text-sm"
type={type}
/>
<div className="absolute bottom-0 left-0 h-[2px] w-full origin-left scale-x-0 bg-primary transition-transform duration-300 ease-in-out peer-focus:scale-x-100" />
</div>
</div>
);
}
[1] 폼 제출 시 잘못된 데이터가 전송될 수 있다.
[2] 사용자가 입력한 값이 제대로 반영되지 않을 수 있다.
[3] 시각적 피드백이 없어 사용자가 혼란을 겪을 수 있다.
이제 첫 번째 구현을 완료했으므로 테스트를 할 준비를 한다. 명세 테스트와 경계값 테스트를 통해 컴포넌트의 동작을 검증할 것이다. 이를 위해 먼저 가능한 시나리오를 생각해보자. 이 컴포넌트가 제대로 동작하는지 확인하는 가장 좋은 방법은 가능한 모든 입출력을 테스트해보는 것이다.
요구사항과 입출력에 대해 이해하기
모든 요구사항을 어떻게 작성했든 결국 세 가지 요소로 구성된다.
- 프로그램이 수행해야 할 동작
- 프로그램이 받아들이는 입력 값
- 입력값에 따른 기대 출력 값
이런 관점으로 우리의 FormInput 컴포넌트를 살펴보자. 이 컴포넌트는 사용자의 입력을 받아 처리하는 역할을 하고 입력된 값을 상위 컴포넌트에 전달해 적절한 시각적 피드백을 제공해야 한다.
예를 들어 사용자가 이메일을 입력하는 상황을 생각해보자. 컴포넌트는 입력값으로 문자열을 받고(입력), 이를 검증해 상위 컴포넌트에 전달하고(동작), 입력 상태에 따른 시각적 피드백을 보여준다(출력). 입출력에 대해 이해하는 것은 컴포넌트의 동작을 정의하고 검증하는 데 도움이 된다.
만약 이러한 입출력 관계가 제대로 동작하지 않는다면 폼 제출 시 잘못된 데이터가 전송되거나, 사용자의 입력이 제대로 반영되지 않을 수 있다. 따라서 우리는 이러한 시나리오들을 테스트를 통해 검증할 필요가 있다.
여러 입력값에 대해 프로그램이 수행하는 바를 탐색하기
컴포넌트가 다양한 상황에서 어떻게 동작하는지 탐색하면서 그 역할을 더 깊이 이해할 수 있다. 다른 개발자가 작성한 코드를 테스트할 때 더욱 중요하다. 지금은 우리가 직접 작성한 코드지만, 명세 기반 테스트의 관점에서 컴포넌트를 새롭게 살펴보자.
FormInput 컴포넌트의 기본적인 동작을 살펴보면,
- 사용자가 input 필드에 텍스트를 입력하면 onChange 이벤트가 발생하고, 이 값이 상위 컴포넌트에 전달된다.
- 입력 필드에 포커스되면 하단에 밑줄 애니메이션이 표시된다.
- placeholder가 설정되어 있다면, 입력값이 없을 때 이 텍스트가 보인다.
- label이 input 필드와 연결되어 있어 클릭하면 해당 입력 필드가 포커스된다.
먼저 컴포넌트의 기본적인 동작을 이해하는 데 집중하고, 예외 상황에 대한 테스트는 이후에 진행하도록 하자.
테스트 가능한 입출력과 구획 탐색하기
우리는 프로그램의 정확성을 충분히 확신할 수 있도록 입출력에 대해 우선순위를 매기고 일부를 선택하는 방법을 찾아야 한다. 프로그램에 대해 가능한 입력과 출력의 수는 거의 무한대지만 프로그램은 일부 입력 집합에 대해 특정 입력 값에 관계 없이 동일한 방식으로 동작한다.
자원은 한정적이므로 이 입력 중 하나만 테스트할 것이므로, 그 하나의 케이스가 그런 부류의 입력 전체를 대표하는 것이라 믿을 것이다. 테스트 용어로 말하자면 이 두 입력은 동등$_{equivalence}$하다.
일단 이런 부류 혹은 구획을 식별했으면 이 프로세스를 반복적으로 적용해서 프로그램이 다르게 동작하는, 아직 테스트되지 않은 다른 부류를 찾을 것이다. 도메인을 계속 분할해나가면 입력에 대한 모든 부류를 찾아낼 수 있다.
이런 탐색을 체계적으로 하는 방법은 아래와 같이 생각해나가면 된다.
- 각 입력에 대해 ‘내가 전달하는 입력이 어떤 부류에 해당하는가?’
- 각 입력과 다른 입력의 조합에 대해 ‘placeholder가 있는 input은 어떻게 동작하는가?’
- 이 프로그램에서 기대하는 여러 부류의 출력에 대해 ‘배열을 리턴하는가?’
이제 개별 입력값들을 살펴보면서 FormInput 컴포넌트의 props를 하나씩 분석해보자.
- `id` input 요소를 식별하는 문자열이다. HTML의 label과 input을 연결하는 데 사용되는데, label의 htmlFor 속성과 input의 id 속성이 같은 값을 가지면서 둘이 연결된다. 이를 통해 label 클릭 시 해당 input에 포커스가 되고, 스크린 리더가 label과 input의 관계를 파악할 수 있다.
- `label` input 필드의 용도를 설명하는 텍스트다. 해당 입력 필드가 어떤 정보를 요구하는지 알 수 있고, 스크린 리더 사용자를 위한 접근성도 제공할 수 있다.
- `value` input 필드의 현재 값이다. FormInput에서는 상위 컴포넌트에서 관리되는 상태값이고 사용자의 입력에 따라 업데이트된다.
- `placeholder` 입력 필드가 비어있을 때 표시되는 설명 텍스트다.
- `type` input의 종류(기본값 ‘text’)다. 브라우저에게 입력 인터페이스를 제공하도록 지시한다.
- `onChange` input의 값이 변경될 때 호출되는 함수다. 사용자의 입력값을 상위 컴포넌트로 전달하고 상태를 업데이트하는 역할을 한다.
일단 입력변수를 자세히 분석하고 나면 변수들의 가능한 조합을 살펴보자. 프로그램에서 입력변수는 서로 관련이 있을 수 있다.
FormInput 컴포넌트의 props 조합을 살펴보면,
- id, label, value, placeholder, onChange는 필수값이다.
- type은 선택값이다. 지정하지 않으면 'text'로 동작한다.
- id와 label은 쌍으로 존재하고 접근성을 위해 htmlFor로 연결된다.
- Input 컴포넌트의 props 중 id와 label은 조금 더 설명할 필요가 있다. 우리는 shadcn/ui의 Label 컴포넌트를 사용하고 있는데 이 컴포넌트는 HTML의 label 태그를 래핑하고 있다. Label 컴포넌트에 전달된 htmlFor prop은 HTML label 태그의 for 속성으로 변환되고 이 값이 input의 id와 일치하면 두 요소가 연결된다. 웹 표준에서 정의한 방식대로 label을 클릭하면 연결된 input에 포커스가 되고 스크린 리더가 이 관계를 인식할 수 있게 된다.
- value와 onChange는 함께 동작해 제어 컴포넌트를 구성한다.
- 제어 컴포넌트란 React가 input의 상태를 완전히 제어하는 방식을 말하는데, value prop으로 현재 입력값을 지정하고 onChange prop으로 값이 변경될 때마다 실행될 함수를 지정한다. 이렇게 하면 모든 입력값의 변화를 React가 추적하고 관리할 수 있다.
경계 분석하기
소프트웨어 시스템에서 버그는 입력 도메인의 경계에서 흔하게 발생한다. 개발자들은 종종 '크거나 같다(>=)'와 '크다(>)' 같은 비교 연산자를 혼동하는 실수를 저지른다. 이런 버그가 있어도 프로그램은 대부분의 입력값에서는 정상적으로 동작한다. 하지만 입력값이 경계에 가까워지면 프로그램이 실패하기 시작한다.
예를 들어 입력값이 10을 기준으로 다른 동작을 하는 프로그램을 생각해보자. 테스터는 이 입력 도메인을 '10 미만'과 '10 이상'이라는 두 구획으로 나눌 수 있다. 이때 10이라는 값이 바로 경계가 된다.
개발자가 경계 근처의 로직을 잘못 구현할 가능성은 다른 부분에 비해 더 높다. 이것이 바로 경계 테스트가 필요한 이유다. 경계 테스트는 경계 값 주변에서 프로그램이 올바르게 동작하는지 검증한다. 우리는 경계를 발견할 때마다 경계값과 그 주변값(앞의 예에서는 9와 10)으로 테스트를 수행해 프로그램이 의도한 대로 동작하는지 확인해야 한다.
테스트 고안하기
입력, 출력, 경계를 분석했으니 구체적인 테스트 케이스를 만들어 보자. 이상적으로는 모든 가능한 입력값 조합을 테스트하는 것이 좋겠지만 노력에 비해 성과가 크지 않을 것이다.
FormInput 컴포넌트의 경우를 보자. value prop이 문자열일 때, 빈 문자열일 때, label이 있을 때와 없을 때, type이 'text'일 때와 'email'일 때 등 다양한 경우의 수가 있다. 이 모든 경우를 조합하면 테스트 케이스가 기하급수적으로 늘어난다.
대신 우리는 의미 있는 조합에 집중할 수 있다. 예를 들어 빈 문자열이 입력됐을 때는 placeholder가 보여야 하므로 value가 빈 문자열인 경우와 placeholder prop을 함께 테스트한다. 또는 type이 'email'일 때는 이메일 형식의 문자열을 value로 사용하는 식이다. 이렇게 서로 관련 있는 props를 중심으로 테스트 케이스를 구성하면 테스트의 수를 효과적으로 줄이면서도 컴포넌트의 기능을 검증할 수 있다.
모든 경우의 수를 기계적으로 조합하는 것이 아니라 컴포넌트의 실제 사용 패턴과 props 간의 관계를 고려해 테스트 케이스를 설계해야 한다.
테스트 시나리오 문서화
테스트 케이스를 구현하기 전에, 우리는 먼저 테스트 시나리오를 체계적으로 문서화했다. 각 컴포넌트별로 테스트할 기능과 예상되는 동작을 정리하고 실행 조건과 기대 결과를 작성했다. 각 테스트의 우선순위를 설정해 중요도에 따라 테스트를 진행할 수 있도록 했다.
FormInput 컴포넌트의 경우, 가장 높은 우선순위로 입력값 변경 시의 이벤트 처리를 테스트하기로 했다. 컴포넌트를 렌더링하고 입력창에 "테스트"라는 값을 입력했을 때, onChange 함수가 호출되고 입력값이 변경되는지 확인하도록 했다.
그 다음으로는 라벨 텍스트 렌더링과 placeholder 표시 기능을 테스트하기로 했다. 이 두 기능은 사용자 인터페이스의 중요한 부분이지만, 입력값 처리만큼 핵심적이지는 않아 우선순위를 P2로 설정했다. label prop으로 "이메일"을 전달했을 때 해당 텍스트가 화면에 표시되는지, placeholder로 "이메일을 입력하세요"를 전달했을 때 입력창에 올바르게 표시되는지 확인하는 테스트를 계획했다.
시나리오 문서는 테스트 코드를 작성하는 과정에서 좋은 자료가 되었다. 우리는 이 문서를 바탕으로 각 시나리오를 코드로 구현하면서 누락된 테스트 케이스는 없는지, 우선순위에 맞게 진행되고 있는지 지속적으로 확인할 수 있었다. 테스트 개발을 하고 있지 않은 구성원도 쉽게 진행 상황을 파악할 수 있었다.
테스트 케이스 자동화하기
이제 테스트 케이스를 자동화할 차례다. 테스트를 자동화하는 일은 거의 기계적인 작업이지만, 각 테스트가 어떤 동작을 검증해야 하는지 이해하는 것이 중요하다. 우리는 앞서 분석한 컴포넌트의 동작과 경계값을 바탕으로 테스트를 작성할 것이다.
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import FormInput from '@/components/RssRegistration/FormInput';
describe('FormInput', () => {
describe('접근성', () => {
it('label과 input이 htmlFor로 올바르게 연결되어야 한다', () => {
render(
<FormInput id="test" type="text" label="테스트" value="" placeholder="test" onChange={() => {}} />
);
const label = screen.getByText('테스트');
const input = screen.getByRole('textbox');
expect(label).toHaveAttribute('for', 'test');
expect(input).toHaveAttribute('id', 'test');
});
});
describe('입력 처리', () => {
it('value prop이 입력 필드에 정확히 반영되어야 한다', () => {
render(
<FormInput id="test" type="text" label="테스트" value="초기값" placeholder="test" onChange={() => {}} />
);
expect(screen.getByRole('textbox')).toHaveValue('초기값');
});
it('onChange 핸들러가 입력값 변경을 정확히 전달해야 한다', () => {
const handleChange = vi.fn();
render(
<FormInput id="test" type="text" label="테스트" value="" placeholder="test" onChange={handleChange} />
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '테스트' } });
expect(handleChange).toHaveBeenCalledWith('테스트');
});
});
describe('타입별 동작', () => {
it('email 타입은 이메일 입력 필드로 렌더링되어야 한다', () => {
render(
<FormInput id="email" type="email" label="이메일" value="" placeholder="test@example.com" onChange={() => {}} />
);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('type', 'email');
});
it('일반 텍스트 입력은 text 타입으로 렌더링되어야 한다', () => {
render(
<FormInput id="name" type="text" label="이름" value="" placeholder="이름 입력" onChange={() => {}} />
);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('type', 'text');
});
});
describe('시각적 피드백', () => {
it('빈 값일 때 placeholder가 보여야 한다', () => {
render(
<FormInput id="test" type="text" label="테스트" value="" placeholder="안내문구" onChange={() => {}} />
);
expect(screen.getByPlaceholderText('안내문구')).toBeInTheDocument();
});
});
});
FormInput 컴포넌트의 테스트는 크게 네 가지 영역으로 나누어 작성했다. 먼저 접근성 측면에서 label과 input의 연결을 테스트해 htmlFor 속성을 통한 두 요소의 올바른 연결을 확인한다. 다음으로 입력 처리 테스트에서는 React의 제어 컴포넌트로서 value prop이 입력 필드에 정확히 반영되는지, onChange 핸들러가 입력값의 변경을 올바르게 전달하는지를 검증한다.
타입별 동작 테스트에서는 각 input type이 의도한 대로 렌더링되는지 확인한다. text type은 일반 텍스트 입력을, email type은 이메일 형식 입력을 받는 필드로 올바르게 렌더링되어야 한다. 마지막으로 시각적 피드백 테스트에서는 빈 값일 때 placeholder가 제대로 표시되는지를 검증한다.
우리는 각 테스트 케이스를 독립된 테스트 함수로 작성했다. Vitest에서 제공하는 describe와 it을 사용해 테스트를 구조화해 테스트의 목적과 검증하는 동작을 드러내는 데 도움이 된다.
이펙트티브 소프트웨어 테스팅 책 저자 마우리시오 아니시는 아래의 방식대로 명세 기반 테스트 접근법을 도식화했다. 전체 과정에 대한 숲을 보고 싶을 때면 아래 사진을 참고해보자.
결론
명세 기반 테스트를 통해 소프트웨어의 동작을 분석하고 테스트를 작성하는 과정을 살펴보았다. FormInput 컴포넌트를 예시로 사용했지만 접근 방식 자체는 다양한 컴포넌트나 함수에 적용할 수 있을 것이다. 입력값과 출력값을 분석해 각각의 역할과 관계를 이해하고, 이를 바탕으로 의미 있는 테스트 케이스를 도출했다.
입력값 간의 관계, 데이터의 흐름, 그리고 타입에 따른 동작 차이 등 프로그램의 핵심 기능을 중심으로 테스트를 구조화했다. 모든 경우의 수를 테스트하는 대신 실제로 사용하는 패턴들이나 중요한 경계값을 고려해 효율적으로 테스트 세트를 구성할 수 있다.
앞으로도 새로운 기능이 추가되거나 요구사항이 변경될 때 위와 같은 테스트 방식을 적용한다면 더 안정적인 소프트웨어를 유지할 수 있을 것이다.
references
'Web, Front-end > 테스트' 카테고리의 다른 글
Test Double과 객체 - Dummy, Fake, Stub, Mock, Spy (0) | 2025.01.24 |
---|---|
프론트엔드 단위 테스트 이해하기 (0) | 2025.01.08 |
컴퓨터 전공 관련, 프론트엔드 개발 지식들을 공유합니다. React, Javascript를 다룰 줄 알며 요즘에는 Typescript에도 관심이 생겨 공부하고 있습니다. 서로 소통하면서 프로젝트 하는 것을 즐기며 많은 대외활동으로 개발 능력과 소프트 스킬을 다듬어나가고 있습니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!