logo
menu

프론트엔드 테스트 코드 경험 1

2024. 07. 20.

  • #리액트

  • #프로젝트

프론트엔드 개발하면서 테스트 코드 작성 경험을 공유하고자 합니다. 해당 내용에 주관적인 경험이 있습니다.
 
프론트엔드 개발자로서 테스트 코드를 작성하면서 필요하다 필요하지 않다 등 여러 논쟁이 있지만, 작성하면서 좋은점이 많다고 생각하여 제 경험을 공유하고자 합니다.
총 2편으로 제공하고자 합니다.
1편은 프론트엔드에서 테스트 코드의 기본 개념과 React-Testing-Library 개발 경험에 대해서 알아보겠습니다.
2편에서는 현재 제가 테스트하고 있는 방식과 활용 방안에 대해서 알아보겠습니다.
 

테스트 툴

  • jest, mocha, vitest (테스트 프레임워크)
  • react-testing-library (리액트 테스트 툴)
  • Cypress, Playwright (E2E 테스트 툴)
등..
 

테스트 종류

notion image
테스트 단위를 어떤 관점에서 하는지에 따라 유닛 테스트, 통합 테스트, E2E 테스트로 구분할 수 있습니다.
  • 유닛테스트
    • 개별 함수나 컴포넌트를 테스트하는 것으로, 빠르고 간단하게 작성할 수 있습니다
  • 통합 테스트
    • 통합 테스트는 여러 컴포넌트나 모듈이 함께 작동하는지를 확인합니다.
      통합테스트의 관점이 헷갈릴 수 있는데 저의 경우
      하나의 컴포넌트 안의 여러 컴포넌트로 구성되어 있는 것도 여러 개의 컴포넌트가 있다고 생각하여 통합으로 테스트하고 하나의 페이지 단위를 테스트할 때에도 여러 모듈이 묶여 있는 단위이기 때문에 통합 테스트를 수행합니다.
      ⇒ 즉, 통합테스트의 바운더리를 크게 보고 있습니다!
  • E2E 테스트
    • E2E 테스트는 애플리케이션의 전체 흐름을 테스트합니다. 실제 동작 환경을 기반으로 테스트를 수행합니다!
 
❓ 그럼 프론트엔드 에서는 어떤 테스트를 해야할까??
이 부분에 대해서 정답은 없습니다! 현재 작성한 코드에서 필요한 부분이 무엇인지? 리소스 및 유지보수를 하기 위한 것들까지 고려해서 테스트를 도입하면 될 거 같습니다.
 
제 경험을 공유하면 어떤 관점에서 테스트를 하느냐에 따라서
유저 행동을 테스트하는 기능 테스트코드 내부를 테스트를 할 수 있습니다.
개인적으로 프론트엔드 테스트는 유저 행동이 이렇다면 이렇게 동작한다를 테스트 하는 기능 테스트를 하는 것이 맞다고 생각하지만, 테스트 종류 및 테스트 하고 싶은 거에 따라서 둘 다 필요하다고 생각합니다.
 
예를 들어서 로그인 페이지를 테스트할때 있을때 통합 테스트를 작성한다면 아래와 같이 작성할 수 있습니다.
  1. 올바른 이메일, 비밀번호를 입력하면 로그인 버튼이 활성화된다
  1. 로그인을 실패하면 사용자에게 알림을 띄워 알려준다
 
하지만 해당 투두의 비즈니스 로직을 처리하고 싶다면? 아니면 API 가 제대로 요청되는지 테스트 하고 싶다면 내부적으로 어떻게 호출되는지 테스트 할 수 있습니다.
API 요청시 제대로 호출되는지 테스트
import axios from 'axios'; import { signin, signup } from './sign'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked<typeof axios>; describe('sign api', () => { const EMAIL = 'test@test'; const PASSWORD = 'testtest'; const accessToken = 'accessToken'; it('/signup API - 회원가입 수행한다', async () => { mockedAxios.post.mockResolvedValue({ data: null }); const result = await signup(EMAIL, PASSWORD); expect(axios.post).toHaveBeenCalledWith('/auth/signup', { email: EMAIL, password: PASSWORD }); expect(result).toEqual({ data: null }); }); it('/signin API - 로그인을 수행한다', async () => { mockedAxios.post.mockResolvedValue({ data: { access_token: accessToken } }); const result = await signin(EMAIL, PASSWORD); expect(axios.post).toHaveBeenCalledWith('/auth/signin', { email: EMAIL, password: PASSWORD }); expect(result).toEqual({ access_token: accessToken }); }); });
커스텀 훅(비즈니스 로직) 테스트
import React from 'react'; import { render, act } from '@testing-library/react'; import { TodoContext } from './'; import { TodoProvider } from './TodoProvider'; import { getTodos, createTodo, deleteTodo, updateTodo, Todo } from '../../apis'; jest.mock('../../apis'); const mockTodos: Todo[] = [ { id: 1, todo: 'Todo 1', isCompleted: false, userId: 1 }, { id: 2, todo: 'Todo 2', isCompleted: true, userId: 1 }, ]; const mockNewTodo: Todo = { id: 3, todo: 'new Todo 3', isCompleted: false, userId: 1, }; describe('TodoProvider', () => { beforeEach(() => { jest.resetAllMocks(); }); // GYU-TODO: TodoProvider 를 테스트 하는 방법이 렌더링 하는 거거 밖에 없을까? // 인자로 전달된 contextValue 를 가지고 테스트 하는 방법이 없을까? // 강제로 호출시켜서 contextValue 가 달라지는지.. 테스트 하는 방법이 없을까? it('렌더링되면 todos 정보를 가져와서 상태를 업데이트한다', async () => { (getTodos as jest.Mock).mockResolvedValue(mockTodos); let value: any; await act(async () => { return render( <TodoProvider> <TodoContext.Consumer> {(contextValue) => { value = contextValue; return null; }} </TodoContext.Consumer> </TodoProvider>, ); }); expect(getTodos).toHaveBeenCalledTimes(1); expect(value.todos).toEqual(mockTodos); }); it('onTodoAdder 가 호출되면 todos가 증가한다', async () => { (getTodos as jest.Mock).mockResolvedValue(mockTodos); (createTodo as jest.Mock).mockReturnValue(mockNewTodo); // GYU-TODO: 따로 함수를 추출하면 상태 변경이 일어나지 않음.. ? 왜?? let value: any; await act(async () => { return render( <TodoProvider> <TodoContext.Consumer> {(contextValue) => { value = contextValue; return null; }} </TodoContext.Consumer> </TodoProvider>, ); }); expect(getTodos).toHaveBeenCalledTimes(1); expect(value.todos).toEqual([...mockTodos]); await act(async () => value.onTodoAdder(mockNewTodo['todo'])); expect(value.todos).toEqual([...mockTodos, mockNewTodo]); }); it('onTodoDelete 가 호출되면 해당 todo 아이템이 사라진다', async () => { (getTodos as jest.Mock).mockResolvedValue(mockTodos); (deleteTodo as jest.Mock).mockReturnValue(null); let value: any; await act(async () => { return render( <TodoProvider> <TodoContext.Consumer> {(contextValue) => { value = contextValue; return null; }} </TodoContext.Consumer> </TodoProvider>, ); }); expect(value.todos).toEqual([...mockTodos]); await act(async () => value.onTodoDelete(mockTodos[0]['id'])); expect(value.todos).not.toEqual([...mockTodos]); expect(value.todos).toEqual([mockTodos[1]]); }); it('onTodoUpdate 가 호출되면 해당 todo 아이템이 변경된다', async () => { const mockUpdatedTodo = { ...mockTodos[0], isCompleted: true, todo: 'Updated TodoItem', }; (getTodos as jest.Mock).mockResolvedValue(mockTodos); (updateTodo as jest.Mock).mockReturnValue(mockUpdatedTodo); let value: any; await act(async () => { return render( <TodoProvider> <TodoContext.Consumer> {(contextValue) => { value = contextValue; return null; }} </TodoContext.Consumer> </TodoProvider>, ); }); // GYU-TODO: 데이터 검증하는 부분이 가독성이 안좋아보임 expect(value.todos).toEqual([...mockTodos]); await act(async () => value.onTodoUpdate(mockUpdatedTodo)); expect(value.todos).not.toEqual([...mockTodos]); expect(value.todos).toEqual([mockUpdatedTodo, mockTodos[1]]); }); });
 
이렇게 둘 다 필요하기 때문에 상황에 맞게 테스트 할 수 있습니다.
❓ 그렇다면 저는 어떻게 테스트 코드를 작성하고 있을까요??
결론부터 말하면 E2E 기반(단, API 모킹함)(Playwright 테스트 툴) 으로 사용자 액션(기능테스트, BDD 기반 테스트) 을 기반으로 테스트 코드를 작성하고 있습니다.
제가 해당 방식으로 테스트 하는 이유는
  1. 내가 구현한 서비스 한해 안정성을 확보하고 싶다
  1. RTL 을 이용해서 테스트 코드를 유닛 테스트, 컴포넌트 테스트, 통합 테스트 모두 테스트 하기에는 리소스가 없어서 (커버리지 100% 는 부담) E2E 로 최대한 논리적인 테스트 커버리지를 높여서 최대한 적은 리소스로 많은 부분을 커버하고 싶었다.
  1. RTL 로 테스트를 작성하다보면 모킹해야하는 경우가 많아 테스트를 위한 코드를 처리해야해서 유지보수가 힘든다고 판단했습니다. (왜냐하면 실제 브라우저 환경이 아닌 테스트 환경인 Node 이기 때문에 여러 기능들을 모킹해야 하는 경우가 많습니다)
  1. 속도로 보면 RTL 기반으로 작성하는 것이 좋지만, 다른 점이 훨씬 좋아서 Playwright 기반 E2E 로 작성하고 있습니다.
 

테스트 방법

테스트 코드를 작성할 때 일반적으로 Given - When - Then 패턴을 맞춰 테스트 코드를 작성합니다.
  • Given: 테스트를 하기 위해 세팅하는 주어진 환경
  • When: 테스트를 하기 위한 조건으로 프론트엔드에선 사용자와의 상호작용인 경우도 많음
  • Then: 예상 결과를 나타내며 의도대로 동작하는지 검증 및 확인할 수 있음
it('올바른 이메일, 비밀번호를 입력하면 로그인 버튼이 활성화된다', async () => { // given const { email, password, signinButton } = customRender(); expect(signinButton).toBeDisabled(); // when await userEvent.type(email, 'test@test'); await userEvent.type(password, 'testtest'); // then expect(signinButton).toBeEnabled(); });
 

TDD

TDD 와 테스트 코드 작성은 다른 개념입니다. TDD 는 테스트를 작성하기 위한 패턴 중 하나로 TDD 가 와 닿지 않는다, 이상하다? 와 같은 이유로 테스트 코드 작성에 대해 회의적인 것은 한번 더 고민해볼 필요가 있다고 생각합니다!
TDD 는 테스트 주도 개발로 선 개발 후 테스트 방식이 아닌 선 테스트 후 개발 방식의 프로그래밍 방법을 의미합니다.
RED -> GREEN -> REFACTOR 패턴으로 프로그래밍을 하는 방식입니다.
  • RED : 항상 실패하는 테스트를 먼저 작성
  • GREEN : 테스트에 통과하는 프로덕션 코드 작성
  • REFACTOR : 테스트가 통과하면 프로덕션 코드를 리팩토링 (반복되는 코드, 긴 메소드, 큰 클래스, 긴 매개변수 목록 등등 코드를 좀 더 효율적으로 바꾸기)
 
저는 TDD 방식을 사용하고 있지 않습니다. 이전에 몇번 작성하기는 했지만 크게 와닿지 않아서 TDD 를 하지 않고 BDD 방식(유저 액션(기능) 테스트)으로 테스트 를 작성하고 있습니다.
 
그래서 저는 아래 방식으로 테스트 코드를 작성합니다
  1. 제가 작성한 코드의 한정 테스트 코드를 항상 작성합니다.
  1. 코드를 구현하기 전에 디자인과 기획서를 참고해서 작업에 따른 시나리오를 작성합니다.
    1. (보통 페이지 단위로 테스트 시나리오를 작성)
  1. 구현
    1. (구현 하면서 놓친 테스트 시나리오가 있다면 시나리오 추가)
즉, 작업량 (페이지 단위)에 따라서 시나리오를 다 작성했다면 기능을 구현하고 테스트 코드를 작성합니다!
해당 방식을 선택한 이유는 기능을 다 만들고 테스트 코드를 작성하면 제가 구현한 것만 테스트 코드를 한다고 생각합니다(폭이 좁아짐) 그래서 기획서와 디자인을 참고해서 발생할 수 있는 케이스를 모두 시나리오를 작성해서 미리 어떤 작업을 구현할 것인지 고민할 수 있고 개발하면서 예상하지 못했던 케이스도 확인할 수 있어서 좀 더 넓게 테스트 코드를 작성할 수 있어서 해당 방식을 사용하고 있습니다.
 
이제 테스트에 필요한 라이브러리와 사용법에 대해 간략하게 알아봅시다!

Jest

  • Jest 는 테스트 프레임워크로 JS 에서 테스트를 찾고 실행하고 검증해서 테스트 성공 여부 결정함.
  • Jest 단언테스트의 통과 여부를 결정함.
    • expect 로 시작해서 주어진 인수가 예측이 맞는지 확인

jest-dom

  • Jest 의 기본 matcher 는 노드 환경 요소만 제공함. DOM 요소에서 사용할 수 있는 matcher 존재하지 않음

Jest 동작 원리 (방식)

test("description", callbakc());
  • 테스트 통과나 실패가 되는 원리
  • 첫번째 인자는 테스트의 문자열로 설명으로 테스트가 실패시 어떤 테스트가 실패했는지 알려주는 역할
  • 두번째 인자는 콜백함수로 테스트의 성공 실패를 판단.
    • 에러가 발생하거나 단언문이 틀렸을 때 실패로 판단
    • 그 외에는 성공 케이스로 판단.
      • 그래서 빈 콜백함수를 주면 성공으로 판단!

React Testing Library (RTL)

RTL 의 역할 및 사용 이유

  • 테스트를 위한 가상 DOM 을 제공함.
    • 컴포넌트를 가상 DOM 으로 렌더링하는데 도움이 됨
  • 가상 DOM 을 검색하는데 도움이 됨 (getByText 등)
  • 가상 DOM과 상호작용에 도움이 됨 (click 등)
  • 브라우저 없이 테스트 가능하게 함.
⇒ RTL 은 "테스트를 위한 가상 DOM 을 제공"하고 "상호 작용을 위한 유틸리티를 제공" (DOM 요소 찾기 및 클릭 등)

RTL DOM 조회 (query)

notion image
  • 크게보면 get query find 기능이 있고 All 여부에 따라 단수/복수로 나눌 수 있다.
  • 본인의 경우
      1. get 은 해당 DOM 이 있는 경우 사용함.
        1. 예를 들어 페이지 렌더링하면 추가 버튼 이 있어야하는경우
          it('로드하면 "추가 버튼" 이 노출된다', () => { expect(screen.getByRole("button", { name: "추가 버튼" })).toBeInTheDocument(); });
      1. query 는 존재하지 않을때 query 를 사용하고
        1. 예를 들어 “사과 텍스트가 버튼을 클릭하면 사라지는 경우”
          it('사과 텍스트가 "버튼"을 클릭하면 사라진다.', () => { expect(screen.getByText("사과")).toBeInTheDocument(); user.click(screen.getByRole("button", { name: "버튼" })); expect(screen.queryByText("사과")).not.toBeInTheDocument(); });
        2. find 는 동적으로 조회가 필요할 때 사용한다.
          1. 예를 들어 버튼을 클릭하면 모달이 나오는 경우
            it('사과 텍스트가 "버튼"을 클릭하면 사라진다.', () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); user.click(screen.getByRole("button", { name: "버튼" })); // 바로 생기는게 아닌 텀이 있는 경우 find expect(screen.findByRole("dialog")).toBeInTheDocument(); });
또한, RTL 에서 query 를 조회할 때 또 하나 신경써야하는게 있는데 쿼리를 조회할 때 RTL 에서 권장하는 순서가 존재한다.
  1. 누구나 액세스 가능한 쿼리 (Queries Accessible to Everyone)
      • getByRole
      • 마우스 사용 및 화면 시각적 요소 및 보조 기술을 사용하는 액세스 가능한 쿼리
        • 요소는 문서에서 역할을 가지는데 (button, heading 등) 페이지에서 요소 역할을 식별하는 것
      • getByLabelText
        • form 관해서 사용 (스크린 리더에서 액세스 할 수 있음)
      • getByPlaceholderText
        • 입력 요소
      • getByText
        • 대화형이 아닌 입력 요소
      • getByDisplayValue
        • form 관한 요소
  1. Semantic Queries
      • 어느 것도 사용할 수 없으면 시맨틱 쿼리 사용
      • getByAltText
        • 이미지에 사용
      • getByTitle
        • 타이틀과 관련된 요소에 사용
  1. Test IDs
      • 쿼리에 관한 최후에 수단
      • getByTestId
        • 사용자가 볼수도 없고 스크린 리더도 액세스할 수 없는 경우
따라서 RTL 은 a11y 접근하는 방식을 권장!

RTL 상호작용

  • 가상 DOM과 상호작용
FireEvent 와 UserEvent 차이
FireEven
// FireEvent 예제 import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import MyComponent from './MyComponent'; test('버튼 클릭 시 텍스트 변경', () => { const { getByText } = render(<MyComponent />); const button = getByText('Click me'); fireEvent.click(button); expect(getByText('You clicked!')).toBeInTheDocument(); });
  • DOM 이벤트를 트리거하는 방법입니다.
  • 사용 목적: 주로 특정 DOM 이벤트를 신속하게 트리거해야 할 때 사용됩니다. 예를 들어, 클릭 이벤트, 변경 이벤트 등을 직접 호출하여 테스트할 수 있습니다.
  • 장점: 이벤트를 직접적으로 트리거하기 때문에 속도가 빠릅니다.
  • 단점: 실제 사용자 상호작용을 완벽히 모사하지는 않기 때문에, 종종 더 복잡한 이벤트 흐름이나 브라우저 기본 동작을 제대로 반영하지 못할 수 있습니다.
 
User Event
// User Event 예제 import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import MyComponent from './MyComponent'; test('버튼 클릭 시 텍스트 변경', async () => { const user = userEvent.setup(); const { getByText } = render(<MyComponent />); const button = getByText('Click me'); await user.click(button); expect(getByText('You clicked!')).toBeInTheDocument(); });
  • 사용자가 브라우저에서 상호작용하는 방식을 보다 현실적으로 시뮬레이션합니다.
  • 사용 목적: 사용자의 실제 상호작용을 보다 정확하게 모사하고자 할 때 사용됩니다. 예를 들어, 키보드 입력, 드래그 앤 드롭, 마우스 클릭 등을 실제 사용자가 하는 것처럼 시뮬레이션합니다.
  • 장점: 사용자 상호작용을 보다 정밀하게 테스트할 수 있으며, 브라우저의 기본 동작도 함께 테스트할 수 있습니다.
  • 단점: fireEvent에 비해 상대적으로 속도가 느릴 수 있습니다. 그러나 테스트의 현실성을 높이는 데 큰 도움이 됩니다.
 

RTL 방식으로 테스트 코드 경험?

  • 무한스크롤 테스트
    • Intersection-Observer 기능을 react-intersection-observer(RIO) 라이브러리를 활용하여 구현했다. RIO (공식문서) 에서 테스트 관련 내용을 잘 정리해줘서 해당 부분을 참고하여 테스트 코드를 작성했다.
      it('다음 요청 데이터가 있고, 스크롤이 맨 아래로 넘어가면 다음 데이터를 요청후 렌더링한다.', async () => { customRender({ mocks: [successGetTeacherHistories, successGetTeacherHistoriesNext], // 1 }); await screen.findByRole('table'); // 2 const rows = screen.getAllByRole('row'); expect(rows).toHaveLength(SETTLEMENT_HISTORY.length + 1); // 3 // 스크롤 맨 아래 이동과 같은 기능 대체 - IntersectionObserver inView 발생 mockAllIsIntersecting(true); // 4 const nextHistoryItem = SETTLEMENT_HISTORY_NEXT[0]; await waitFor(() => expect( screen.queryByLabelText('loading-spinner'), ).not.toBeInTheDocument(), ); // 5. 로딩이 끝났으면 expect(screen.getAllByRole('row')).toHaveLength( SETTLEMENT_HISTORY.length + SETTLEMENT_HISTORY_NEXT.length + 1, ); // 6 // 7 expect(screen.getByText(nextHistoryItem.content)).toBeInTheDocument(); expect( screen.getByText( format(new Date(nextHistoryItem.createdAt), 'yyyy-MM-dd hh:mm:ss'), ), ); expect( screen.getByText( `${Number(nextHistoryItem.changePoint).toLocaleString()} 딱지`, ), ).toBeInTheDocument(); });
      1. [진입시 초기 응답값, 다음 페이지 요청시 나오는 응답값] 으로 모킹했다.
      1. 데이터가 오면 table 이 나와서 데이터 요청 성공이 되기까지 대기한다.
        1. 즉, 응답 데이터가 오고 데이터가 렌더링됐음을 확인
      1. 초기 응답 데이터에서 개수(15) 와 헤더에 나오는 row 값으로 정상적으로 응답했는지 확인
      1. IntersectionObserver 에서 가시성이 일치하면 다음 데이터를 요청하므로 Intersection 이 됐음을 가정
      1. waitFor(loadingSpinner not.toBeInTheDocument() ) 로 로딩이 사라졌는지 확인
        1. (추가 데이터가 올 때까지 대기)
      1. 추가 데이터가 왔으면 기존 데이터 + 새로 온 데이터 의 개수와 동일한지 확인
        1. (추가 데이터가 와서 데이터가 추가 됐음을 확인)
      1. 새로운 데이터가 잘 왔는지 확인 테스트