프론트앤드에서의 테스트 (나에겐 너무나 멀고 힘든 테스트코드 짜기..)

2022-05-22

가장 먼저!
프론트앤드 개발자가 개발하는 방식 생각해보기

  1. 디자이너가 전달해준 디자인을 보고 마크업을 시작한다.
  2. 브라우저에서 렌더링된 결과를 확인하는 과정을 거친다.
  1. 필요한 기능의 요구사항을 확인한 후 기능을 추가한다.
  2. 제대로 동작하는지 브라우저에서 확인한다.
  1. 기존의 코드를 리팩토링한다.
  2. 리팩토링 전과 동일하게 동작하는지 확인한다.

-> 사실 우리는 테스트와 개발을 동시에 하고, 어쩌면 테스트를 하는데 더 많은 시간을 사용한다.

테스트란?

" 프로그램을 실행하여 오류와 결함을 검출하고 애플리케이션이 요구사항에 맞게 동작하는지 검증하는 절차 "

우리는 테스트를 왜 해야하는가

  1. 발생 가능한 결함을 예방하고 요구사항을 충족시키는지 확인
  2. 개발 과정에서 생기는 변경 사항들로 인해 새로운 결함이 유입되지 않았는지 확인

테스트를 통해서 불필요한 버그가 없는 코드를 작성할 수 있고,
좋은 코드를 자신있게 작성할 수 있게 된다.

그러나 가장 큰 문제가 있다.

"테스트에는 너무 많은 시간과 노력이 필요하다."

Write test

새벽 2시에 전화받고 버그를 수정하는 것보다 테스트에서 버그를 잡는 것이 훨씬 낫다.

테스트를 작성하는게 구현하는것보다 시간이 더 걸릴 수도 있지만 확실하게 유지보수하는 시간을 절약할 수 있다.

테스트를 작성할 때 생각해야 하는 것은 테스트로 인해 이 프로젝트에 버그가 없다는 확신을 얼마나 가져오는가에 대한 것이다.

TypeScript와 ESLint와 같은 언어와 도구를 사용하면 좋지만, 이것들은 비즈니스 로직에 버그가 없다고 보장해주지 않기 때문에 테스트를 작성해야 한다.

Not too many

테스트 커버리지가 70%이상일 경우, 오히려 테스트에서 감소하는 수익을 얻는다.

테스트 환경에서 재현하기 어려운 한 줄의 코드를 테스트 통과 시키기 위해 구현되는 세부적인 것들을 테스트하고 있을 수도 있다.

Mostly integration

테스팅 트로피: 소프트웨어 테스트를 위한 4계층의 검증된 방법

테스팅 트로피로 알아보는 테스트의 유형

  • Static Test (정적 테스트) - 코드를 실행시키지 않고 하는 테스트
    오타, 참조에러, 타입에러 등 개발자의 실수로 발생할 수 있는 에러를 잡아준다.
    ESLint, Typescript
  • Unit Test (단위 테스트)
    작은 단위를 떼어내서 테스트
    함수/클래스같은 개별 유닛에 값을 넣고 예상한 값이 나오게 하도록 테스트한다.
  • Integration Test (통합 테스트)
    여러 부분들이 통합되어 어떤 side effect가 있는지 등을 포함해 원하는 결과가 나오도록 테스트한다.
  • E2E Test
    사용자 시나리오대로 테스트했을 때 해당 제품이 잘 돌아가는지 테스트한다.

마틴 파울러가 설명한 테스트의 단계

E2E 테스트는 가장 느리며, 비용이 많이 드는 반면 Unit 테스트는 가장 저렴하며 빠르다.

그러나 Unit Test만 하면 UI테스트의 이점을 가져갈 수 없다.

UI테스트와 Unit테스트의 중간 특성을 지닌 통합테스트를 위주로 작성하자.
왜 통합테스트가 중요할까?
https://twitter.com/erinfranmc/status/1148986961207730176
→ 유닛이 개별적으로 작동하는지 확인하는 유닛 테스트가 중요하지 않다는 것은 아니지만 제대로 함께 작동하는지 확인하지 않으면 아무 소용이 없다.

통합 테스트는 자신감과 속도/비용 간의 균형을 훌륭하게 유지하는 테스트이다.

프론트앤드 관점에서의 테스트

프론트앤드 개발자가 뭘 하는 개발자인지 생각해보면 그 답은 명확해진다.

  1. 시각적 요소
    입력값 (사용자의 액션), 출력값 (사용자의 액션에 따른 화면의 변화)를 검증

    • 스냅샷 테스트
      HTML구조가 의도한 대로 나타나는지를 테스트

    • 시각적 회귀 테스트
      HTML에 CSS를 더해서 컴포넌트가 실제로 브라우저에서 렌더링되는 모습이 의도한 대로 나타나는지를 테스트
      storybook, chromatic

  2. 사용자 이벤트 처리

import userEvent from "@testing-library/user-event";
 
userEvent.type(input, "인풋에 입력");
userEvent.click(button);
  1. API 통신
    보통 mocking하여 사용
  • mocking : 해당 코드가 의존하는 부분을 가짜(mock)로 대체하는 기법
test("returns undefined by default", () => {
  // 함수 인스턴스를 만듦.
  const mock = jest.fn();
 
  // mock 함수에 foo라는 인자를 전달해줌.
  const result = mock("foo");
 
  expect(result).toBeUndefined();
  expect(mock).toHaveBeenCalled();
  expect(mock).toHaveBeenCalledTimes(1);
  expect(mock).toHaveBeenCalledWith("foo");
});
const doAdd = (a, b, callback) => {
  callback(a + b);
};
 
test("calls callback with arguments added", () => {
  const mockCallback = jest.fn();
  doAdd(1, 2, mockCallback);
  expect(mockCallback).toHaveBeenCalledWith(3);
});

테스트 개발론

TDD (Test-Driven-Development)

  • 테스트를 먼저 작성하는 테스트 주도 개발
  • 궁극적 목표 : Clean Code that works (작동하는 깔끔한 코드)
  • 어떻게 Testable Code를 작성할 수 있을까?
    관심사의 분리

[A5] 프론트엔드에서 TDD가 가능하다는 것을 보여드립니다.

BDD (Behavior-Driven-Development)

  • TDD에서 확장
  • 애플리케이션의 동작에 초점을 맞추는 행위 주도 개발
  • 시나리오를 기반으로 테스트 케이스를 작성

given-when-then

  • 테스트 시나리오 작성을 세 섹션(given, when, then)으로 나누는 것

given: 동작 작동 전에 필요한 것들을 설명
when: 동작
then: 동작으로 인해 예상되는 변경 사항에 대한 설명

describe("<SearchInput />", () => {
  it("검색 인풋이 렌더링 된다.", () => {
    // given
    const placeholderText = "test searchInput"
 
    // when
    render(<SearchInput placeholder={placeholderText} />)
 
    const $input = screen.getByDisplayValue("")
    const $icon = screen.getByText("search")
 
    // then
    expect($input).toBeInTheDocument()
    expect(screen.getByPlaceholderText(placeholderText)).toBeInTheDocument()
    expect($icon).toBeInTheDocument()
  })
  it("검색 인풋에 오토포커싱 한다.", () => {
    // given
    const autoFocus = true
 
    // when
    render(<SearchInput autoFocus={autoFocus} />)
 
    const $input = screen.getByDisplayValue("")
 
    // then
    expect($input).toHaveFocus()
  })
 
  it("유저 입력 후 엔터를 눌렀을 때 검색기능이 동작한다.", () => {
    // given
    const handleSearch = jest.fn()
    const inputValue = "test"
 
    // when
    render(<SearchInput onSearch={handleSearch} />)
 
    const $input = screen.getByDisplayValue("")
 
    userEvent.type($input, `${inputValue}{enter}`)
 
    // then
    expect(handleSearch).toBeCalledWith(expect.any(Object), inputValue)
  })
})