테스트 개념정리

지리·2025년 9월 7일

테스트란?

  • 작성된 코드가 의도한 대로 정확하게 작동하는지 확인하는 과정
  • 테스트를 통해 사용자에게 안정적인 제품 제공가능

테스트시 장점

사려깊은 설계가능

  • 테스트 작성시 구현보다 인터페이스에 집중하게 한다.
  • 테스트가 완료되는 시점이 코딩이 완료되는 시점으로, 코딩이 완료되는 시점을 정확히 알 수 있다.
  • 테스트 코드를 작성하면서 원하는 기능을 추가하기위해 무엇이 필요한지 고민을 하게 만든다.

리팩토링시 이점

  • 코드를 안정적이게 수정이 가능하게한다.
  • 신뢰할 수 있는 코드가 되면, 서브밋시에 결함이 적다.

좋은 문서자료가 됨

  • 테스트를 위해 기능을 작은 단위로 짜게되면 잘짜여진 테스트는 문서를 읽는 것처럼 동작을 명확하게 이해가능하게 한다.
  • 리뷰단계에서 검증시간이 줄어들게 된다.

올바른 테스트를 위한 작성 규칙

인터페이스 기준으로 테스트 작성

  • 인터페이스 기준으로 테스트를 작성한다.
    e.g., 함수A의 내부구현에 대해서 테스트를 하는게 아니라, 함수A에 a를 넣으면 b가 나옴을 테스트한다.
  • 테스트는 외부에 노출되는 public 메서드 기준으로 작성한다.
  • 컴포넌트에 대한 단위/통합 테스트의 경우, 구현세부명세가 아닌 UI구성요소를 사용자가 사용하는 방식으로 테스트한다.
    e.g., 버튼을 눌렀을때 알럿이 뜬다.

커버리지보단 의미있는 테스트기준으로 작성

  • 아주간단한 함수는 과감하게 테스트를 생략하는게 좋음. 이런함수는 다른 모듈이나 컴포넌트의 로직에 포함되었을때 한번에 검증하는 것이 더 효율적.

테스트코드도 가독성을 높여서 작성

  • 명확한 테스트 디스크립션이 있다면 테스트 파일만 보고 앱동작이 파악가능해진다.

하나의 테스트에선 하나의 동작만 검증

  • 하나의 테스트에서 한번에 검증하는 것이 아니라 여러 개로 나누어 검증하는 것이 가독성과 유지 보수에 좋다.

테스트 작성하기

테스트를 작성하는 방법으로 일반적으로 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/

크게 아래의 패턴을 따름

  1. 테스트를 위한 환경 만들기
  2. 테스트 동작 재현
  3. 올바른 동작이 실행되었는지 또는 변경사항을 검증하기

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테스트를 사용하는게 좋다.

E2E테스트

End-to-End 테스트는 사용자 관점에서 소프트웨어의 전체 시스템을 테스트하는 과정. 완성된 앱을 실행해 전체 소프트웨어 시스템의 흐름을 검증.
사용자의 인터페이스, 데이터베이스, 네트워크, 외부 시스템 및 서비스 등 소프트웨어의 모든 계층을 포함.API를 포함한 모든 워크플로우를 검증.
사용자가 앱을 사용하는 다양한 시나리오가 실제 환경에서 정상적으로 작동 하는지 검증.

권한체크후 페이지를 리다이렉트하는 테스트 예시

beforeEach(() => {
  cy.visit('/');
});

describe('로그인이 필요한 페이지에 비로그인 상태로 접속하면 로그인 페이지로 리다이렉트 된다', () => {
  it('장바구니 페이지에 접속할 경우 로그인 페이지로 리다이렉트 된다', () => {
    cy.visit('/cart');

    cy.assertUrl('/login');
  });

  it('구매 페이지로 접속할 경우 로그인 페이지로 리다이렉트 된다', () => {
    cy.visit('/purchase');

    cy.assertUrl('/login');
  });
});

E2E테스트의 한계

  • 단위, 통함 테스트보다 느린속도 -> 피트백을받아 수정까지의 시간이 길어지며, 이는 생산성의 저하로 이어질수 있음
  • 외부환경에 의해 테스트가 쉽게 실패할 수 있어 테스트가 항상 일관적으로 동작해야 한다는 결정성을 보장하기 어려우며, 관리비용이 큰 테스트

UI테스트와 회귀 테스트

프론트엔드에서 기능외의 제대로 렌더링이 되었는지에 대한 시각적인 부분 검증을 위한 테스트

스냅샷 테스트

일반적으로 이야기하는 스냅샷 테스트는 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 등이 있음.

profile
공부한것들, 경험한 것들을 기록하려 노력합니다✨

0개의 댓글