12 / 19 테스트코드

김하은·2022년 12월 19일
0

이번주는 여태껏 만들었던 자유게시판 코드들을 배포하는 주이다.
오늘은 배포전 테스트 코드를 작성하는것에 대해 배웠다.

내가만든 코드가 실제로 잘 작동하는 지를 보는 코드이다.

테스트?

마우스로 클릭하는 등의 행위를 컴퓨터가 대신하게하는 코드를 만든다.

기능을 검사해주는코드?

업데이트 배포라고 생각하기

배포란, 24시간 컴퓨터에 yarn dev해놓는 것을 의미한다. 외부컴퓨터를 빌려 yarn dev해놓는것.

버그를 다 잡아놓고 배포를 했다(게시판, 상품, 마이페이지) --> 버그를 미리 잡아놨기에 문제되는것 없다.
==> 버전1

  • 결제기능 추가 =>결제테스트, 버그수저으 취소등 되는지 확인 후 배포 ==> 버전2

배포를 했더니 결제는 잘 된다. 얼마후 상품에서 에러가 발생!

==> 결제하면서 만든코드중, 상품관련에 영향을 준것!
====> 여기서부터 꼬이기 시작한다. 과연이것이 상품에만 영향을 주었을까? 다른 코드에도 영향을 주어 안되는게 아닐까?!
일일이 사람손으로 실행해봐야한다.

===> 지뢰밭.

이것을 박기위해 기존에 첫 배포때까지 다 테스트가 필요하다.
.
.
.
사이즈가 커질수록 테스트를 사람이 하기가 어려워진다.

거기다 실제로 사람이 볼 수 있게 코드를 만들어 놔야 지뢰밭을 빠르게 점검이 가능하다.

보통 버전 1 까지는 테스트 코드를 안만드는 것이 일반적이다.
결제부터는 테스트코드가 필요하다. (자동화 필요)

테스트 코드 만드는 시점?

스타트업을 기준으로 배포가 끝나면 테스트코드를 만들기 시작한다.
즉, 버전 2를 만들기위한, 버전 1에대한 시장성검증, 홍보 등을 하는 중에 테스트코드를 만든다.

테스트코드는 어떤 도구를 사용하나?
테스트 종류는?

  1. 버튼클릭등의 개별기능 단위테스트
  2. 여러기능을 한꺼번에 통합테스트
  3. 접속하고 로그인 --> 구매까지의 과정.. 이렇게 시나리오가 있는 E2E테스트(end to end) =유저시나리오 테스트 라고도 한다.

이때 시나리오란 구체적인것을 말한다.
환불을 예로 들면 , 버튼을 클릭하면 환불이 되는것이 아니라, 로그인 --> 포인트가 있어야하니 구매 --> 환불
이런식으로 구체적인 시나리오를 의미한다.

단위테스트와 통합테스트를 위해 Jest라는 라이브러리를 사용한다.

E2E테스트를 위해서는 (유저시나리오 테스트) 브라우저에서 실행되며, Cypress라는 도그를 사용해 컴퓨터가 알아서 실행한다.

리엑트테스트에서는 자바스크립트 테스트도구인 Jest와 리엑트 등 JSX용인 testing library를 사용한다.

Jest and React Testinglibrary 를 사용해 단위 테스트를 진행해보았다.

yarn add --dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom

해당부분은 nextjs의 Docs부분이다. 네가지를 다 설치하는 명령어이고 devdependency에 설치하기에 --dev로 적어준다.

그리고 jest.config.js라는 파일을 만들어

// jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: './',
})

// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const customJestConfig = {
  // Add more setup options before each test is run
  // setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
  moduleDirectories: ['node_modules', '<rootDir>/'],
  testEnvironment: 'jest-environment-jsdom',
}

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

Docs의 다운로드 명령어 아랫부분에있는 것을 붙여넣는다.

우리는 eslint를 사용중이기에 뭔가를 더 추가해줘야한다.
eslitrc.js의 plugins에 ["react","jest/globals"]라고 "jest/globals"이부분을 추가해주고, package.json에 실행명령어를 추가해준다.

"test":"jest",
"test:watch":"jest --watch"

둘은 비슷하지만 조금 다른데, yarn test로는 한번만 즉, 뭔가를 바꾸고 test를 하면 그후 또 수정을 한다면 한번더 yarn test를 해주어야 테스트가 되지만, yarn test:watch를 한다면 실시간으로 반영해 저장되면 테스트가된다. 따라서 실행명령을 두개다 추가해준다.

테스트 코드 작성하기위한 파일준비.

보통

__test__라는 이름의 테스트 폴더를 만든다. 테스트할 코드의 파일이 tsx이면 여기들어갈 파일도 tsx이고 ts이면 ts로 맞춰준다.

폴더를 __test__ 로 하는게 일반적인데, 넥스트 에서는 pages에 폴더를 만들면 그게 하나의 페이지가 자동으로 되니 폴더 위치를 외부로 옮긴다. (페이지가 될 폴더는 아니기에)

it("테스트제목")이런식으로 작성하면 나중에 실패시 어느부분에서 실패했는지 보여줄때 이 제목이 나오게된다.

먼저 단순히 두 수를 보내 더하는것을 해보았다.

본문

export const add = (a: number, b: number) => {
return a + b;
};

테스트코드

import { add } from "../../pages/34-01-jest";

it("더하기 잘 되는지 테스트 해보기", () => {
const result = add(3, 5);
expect(result).toBe(8);
});
// describe("나만의 테스트 그룹만들기", () => {
// it("내가 하고싶은 테스트 1", () => {});
// it("내가 하고싶은 테스트 2", () => {});
// it("내가 하고싶은 테스트 3", () => {});
// });

함수에 인자를보내 각각의 자리로 넣는다. it("테스트 제목",()=>{
테스트 내용
});
const result = add(3, 5);
expect(result).toBe(8);

add라는 함수에 인자로 a에는 3이 b에는 5가 들어가는데,그것을 변수에 담고, result라는 변수가 toBe(8) 8일것을 기대한다는 의미.
yarn test로 테스트를 실행해보면, 8이 나온다면 passed를 서버에(터미널) 찍어준다.
만약, 숫자 하나를 바꾸게되면 8이 기대한값이 나오지 않아 passed하지 못하고 에러로 어디가 잘못되었는지 터미널에찍히니 확인.

yarn test:watch 는 계속 입력하고 저장하는것을 기다리고 있는것.

describe라는것으로 여러개 테스트를 할 수 있다.

==> 단순히 jest만 사용하는법


컴포넌트에 적용하는 방법
가짜로 가상돔을 그리기 위해 @testing-library/react 에서 import하는 render라는것을 사용한다. 해당 컴포넌트를 render로 묶고, 그 랜더링 결과는 screen이라는 것에 들어간다.
이 screen안에 뭐가있는지 물을 수 있다.

jest-dom에 그리기

import '@testing-library/jest-dom'

dom:

Document Object Model 문서를 객체 형태로 만든것. document.head 이런식으로 html을 객체처럼뽑을 수 있다.(그안의 글자들이 있는경우뽑을 수 있음.)

따라서 그려진 결과를 screen으로 뽑기가 가능하다.

이번에는 태그의 글자들을 검증해보자

export default function JestUnitTestPage() {
  return (
    <>
      <div>철수는 13살 입니다.</div>
      철수의 취미 입력하기: <input type="text" />
      <button>철수랑 놀러가기</button>
    </>
  );
}
import { render, screen } from "@testing-library/react";
import JestUnitTestPage from "../../pages/34-02-jest-unit-test";
import "@testing-library/jest-dom";

it("내가 원하는대로 그려지는지 테스트하기", () => {
  render(<JestUnitTestPage />);

  const myText1 = screen.getByText("철수는 13살 입니다.");
  expect(myText1).toBeInTheDocument();

  const myText2 = screen.getByText("철수의 취미 입력하기:");
  expect(myText2).toBeInTheDocument();

  const myText3 = screen.getByText("철수랑 놀러가기");
  expect(myText3).toBeInTheDocument();
});

각 해당하는 텍스트가 해당문서에 있는지 즉, import하는 컴포넌트에 글자들을 뽑아오는것.

toBeInTheDocument() => 지금 그려지는 문서에 있는지.라는 의미

그런데 이때 약간의 문제가생겼다. 멘토님께서 먼저 test를 해보셨는데 방금 작성한 곳에서 테스트가 통과하지 못했다. 방금 import한것에 문제가 있었던건데, 알고보니 버전 문제였다.

현제 설치한것은 13버전인데 리엑트버전에따라 이게 조금 다르다고한다.
18버전에서는 13을 사용하지만 우리는 17버전을 사용하고있으니 12버전 으로 package.json에서 버전을 고치고, ("^12.1.2"버전)

rm -rf node_modules 로 노드모듈 삭제

rm -rf yarn.lock 로 yarn.lock 파일삭제 → 이전에 설치했던 버전들이 기억되어있기때문에 삭제

이 두가지를 진행 후,

yarn install로 다시설치를 한다.

그냥 노드모듈즈만 지웠다 까는 것보다 오래 걸린다.


일일이 사람손으로 안적어도 되는 방법이 있다!

일일이 들어있는지 없는지를 검증하는데 , 번거롭다. 이때 사용할 수 있는것이 스냅샷이라는것이다.!!

말그대로 사진으로찍어서 찍은것을 저장하고, 만약 변경된 사항이 있으면 기존에 스냅샷과 비교해 수정사항이 있으면 passed가 뜨지 않는다. 일부러 수정한것이면 다시찍고, 알지도 못한것이었다면 참고하여 고친다.

스냅샷테스트

export default function JestUnitTestPage() {
  return (
    <>
      <div>철수는 14살 입니다.</div>
      철수의 취미 입력하기: <input type="text" />
      <button>철수랑 놀러가기</button>
    </>
  );
}

기존과 방법은 비슷하다. render()로 해당 컴포넌트를 감싸고,
그 랜더링한 결과는 변수 result에 담아보면 result.container이라는곳에 랜더링한 결과가 들어있고, toMatchSnapshot() 이라는것을 사용해 기존 스냅샷과 일치하는지 비교한다. 스냅샷이 없을경우, 사진을 찍는다.

import { render } from "@testing-library/react";
import JestUnitTestPage from "../../pages/34-03-jest-unit-test-snapshot";

it("기존 사진과 바뀐게 없는지 비교해보자!! - 스냅샷 테스트", () => {
  const result = render(<JestUnitTestPage />);
  expect(result.container).toMatchSnapshot();
});

이렇게하면 지금은 비교할 스냅샷이 없으니 yarn test시

1 snapshot written from 1 test suite

라고 나오며 현 폴더에 __snapshots__ 이라는 폴더가 생기며 그 안에 현재의 스냅샷이 들어있는것을 볼 수 있다.

만약 수정사항이 있고, 해당내용으로 이용하고싶을경우에는 업데이트를 해야한다.
이때 기존것을 지우고 다시 스냅샷을 찍거나, u 를 치고 엔터를 누르면 업테이트가 된다고 하셨는데 안되어 살펴보니

yarn test -u

라고 쳐야 된다고 쓰여있었다.

뭔가를 수정을하고 해당 명령어로 수정한것으로 스냅샷을 업데이트해보았다.
업데이트가 아닌 이전것을 쓰고 싶다면 스냅샷을 보고 바뀐부분을 수정해주면 된다.

지금껏 한것이 UI테스트(presenter부분)

snapshot을 이용한 코드로 테스트하기,
스토리북을 통한 눈으로 보면서 하는 두가지종류가있다.

스토리북. : 디자이너와 협업위한 UI북으로 실제 기능까지 작동하는 버튼 등이 들어있다.


기능테스트 - 컨테이너부분테스트하기.

버튼클릭등, 이벤트가 발생.

render(<컴포넌트명/>) 이부분은 기존과 비슷,
FireEvent.click이면 버튼 클릭시
=> 이벤트를 클릭하는것을 대신해줌
FireEvent.click(screen.해당버튼을 가져온다)
이때 해당버튼의 글자를 가져와도 되지만, 주로 버튼등에 role이라는 역할넣어 역할이름을 넣어준다.

버튼의 role="submit-button" 이라고 하면
FireEvent.click(screen.getByRole("submit-button"))
이렇게 작성하면 해당역할 이름의 버튼을 대신 클릭해준다.

이번에는 카운트를 올리고 해당 카운트를 화면에 보이게 하는 부분으로 작성해보자.

기존에 진행했었던
카운트 올리기를 이용.

import { useState } from "react";

export default function CounterStatePage() {
  const [count, setCount] = useState(0); //let count =0
  function onClickCountUp() {
    setCount((prev) => prev + 1); //count = count + 1
  }

  return (
    <>
      <div role="count">{count}</div>
      <button role="count-button" onClick={onClickCountUp}>
        카운트 올리기!!!
      </button>
    </>
  );
}

카운트 버튼을 누르면 div에 1이 올라가야하므로 div에도 role을 준다.

실행후, 0으로 시작하던 부분이 1로 증가하였으니 1을 가질것을 기대한다고 코드를 적어준다.

FireEvent.click(screen.getByRole("submit-button"))
expect(screen.getByRole("count")).toHaveTextContent("1"); 
// 텍스트가 1을 가질것을 기대함.

전체 코드

import { fireEvent, render, screen } from "@testing-library/react";
import CounterStatePage from "../../pages/34-04-jest-unit-test-event";
import "@testing-library/jest-dom";

it("버튼을 눌렀을때, 제대로 작동하는지 테스트하자", () => {
  render(<CounterStatePage />);

  fireEvent.click(screen.getByRole("count-button"));
 expect(screen.getByRole("count")).toHaveTextContent("1");
});

컨테이너중에 API요청 부분

실제요청은 보내지않고 프론트에서만 테스트 하는 방법으로 mutation을 가짜로 만드는 방법을 이용한다.

여기서 잠깐!!
벡엔드에서의 테스트도 비슷하다. 실제 DB에 보내지 않고, 가짜로 만드는 방법을 하용한다.

이렇게 가짜로 만드는 것을 '모킹한다' 라고 표현한다.
API를 가짜로 만들어야하기에 각각의 라이브러리들에서 제공되는것이 있다.

axios-mock-adapter
아폴로의 MockedProvider

리덕스 리엑트 쿼리에도 mock 제공하는것이 있다.

==> 과정.
등록하기 클릭 ==> 해당함수 실행되며 뮤테이션 날라감 ==> 결과를 result에 담고 router.push하여 페이지를 이동한다.

각 input과 button에 role을 주고, 테스트 코드를 작성한다.

import { gql, useMutation } from "@apollo/client";
import { useRouter } from "next/router";
import { ChangeEvent, useState } from "react";

// prettier-ignore
export const CREATE_BOARD = gql`
  mutation createBoard($createBoardInput:CreateBoardInput!) { # 그래프큐엘 주석. 변수의 타입적는곳
   createBoard(createBoardInput:$createBoardInput) {# 실제 우리가 전달할 변수 적는곳.
      _id
      writer
      title
      contents
    }
  }
`;
export default function GraphqlMutationPage() {
  const router = useRouter();
  const [writer, setWriter] = useState("");
  const [title, setTitle] = useState("");
  const [contents, setContents] = useState("");

  const [나의함수] = useMutation(CREATE_BOARD);
  const onClickSubmit = async () => {
    // const writer = "qqq"// // 이함수에 있으면 현제스코프 적용
    const result = await 나의함수({
      variables: {
        createBoardInput: {
          // variables가 $역할을 해주니 여기에는 $쓰지 않음.원래는 각각 $가 들어감.($writer...등)
          writer: writer, // 이름같아도 다른것이기에 문재되지 않음. 이 함수에 없으면 스코프체인으로 위에서 찾음
          title: title,
          contents: contents,
          password: "1234",
        },
      },
    });
    console.log(result);
  router.push(`/boards/${result.data.createBoard._id}`); // 생성한 게시글로 이동
  };

  const onChangeWriter = (event: ChangeEvent<HTMLInputElement>) => {
    setWriter(event.target.value);
  };
  const onChangeTitle = (event: ChangeEvent<HTMLInputElement>) => {
    setTitle(event.target.value);
  };
  const onChangeContents = (event: ChangeEvent<HTMLInputElement>) => {
    setContents(event.target.value);
  };
  return (
    <>
      작성자:
      <input role="input-writer" type="text" onChange={onChangeWriter} />
      <br />
      제목: <input role="input-title" type="text" onChange={onChangeTitle} />
      <br />
      내용:
      <input role="input-contents" type="text" onChange={onChangeContents} />
      <br />
      <button role="submit-button" onClick={onClickSubmit}>
        GRAPHQL-API(동기) 요청하기
      </button>
    </>
  );
}

테스트 코드

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import GraphqlMutationPage, {
  CREATE_BOARD,
} from "../../pages/34-05-jest-unit-test-mocking";
import { MockedProvider } from "@apollo/client/testing";
import "@testing-library/jest-dom";
import { useRouter } from "next/router";

// 가짜 useRouter만들고, 가짜 push 넣어놓기
jest.mock("next/router", () => ({
  useRouter: jest.fn(),
}));
const push = jest.fn();
(useRouter as jest.Mock).mockImplementation(() => ({
  push,
}));

// 가짜 mutation만들기(요청과 받아오는 응답 모두)
const mocks = [
  {
    request: {
      query: CREATE_BOARD,
      variables: {
        createBoardInput: {
          // 실제 나가는 것
          writer: "철수",
          title: "안녕하세요",
          contents: "철수입니다",
          password: "1234",
        },
      },
    },
    result: {
      // 실제 받아온 결과가 result에 담아지고 result.
      data: {
        //data.
        createBoard: {
          //createBoard.
          _id: "벡엔드에서-받은-게시글ID", // _id하면 이 id를 받아올 수 있음
          writer: "철수",
          title: "안녕하세요",
          contents: "철수입니다",
        },
      },
    },
  },
];

it("API를 모킹하여 테스트 하자!!", async () => {
  render(
    <MockedProvider mocks={mocks}>
      <GraphqlMutationPage />
      {/* 여기 원본에 들어가 실행되나 가짜로 만든것을 이용해 실행됨 */}
    </MockedProvider>
  );
  fireEvent.change(screen.getByRole("input-writer"), {
    target: { value: "철수" }, // 입력해 내보내는것
  });
  fireEvent.change(screen.getByRole("input-title"), {
    target: { value: "안녕하세요" },
  });
  fireEvent.change(screen.getByRole("input-contents"), {
    target: { value: "철수입니다" },
  });


  fireEvent.click(screen.getByRole("submit-button"));

  await waitFor(() => {
    // 원본의push부분이 일어나는 부분
    expect(push).toHaveBeenCalledWith("/boards/벡엔드에서-받은-게시글ID");
    //push하는데 원하는곳("/boards/벡엔드에서-받은-게시글ID") 으로 이동됐는지
  });
});

일단 render로 해당 컴포넌트를 감싸준다. input태그에 값을 임의로 컴퓨터가 대신 넣어주어야하니
fireEvent.change를 사용하고, 기존에 event.target.value해서 값을 받아왔으니 target의 value를 사용해값을 넣어준다.

그리고 아까와 같은 방식으로 버튼클릭해주는 것도 적는다.

그런데 이번에는 클릭시에 input에 적어준 부분이 뮤테이션으로 날라가야한다. 그러나 테스트이기에 실제로 보내지는 않고 mocking즉, 가짜로 보내기를 한다.

가짜 뮤테이션

다른 API들도 많기에 배열로 만든다.

const mock = [ ]
안에는 request와 result가 객체로 들어단다. request는 요청시에 보내는것. 쿼리는 실제 쿼리를 import해오고, variables에 input에 적어준 value와 같게 적어준다.
result로는 원본에서의 응답값을 가짜로 받아오는것이니 그레프 큐엘을 참고하면 좋다.
result.data.createBoard._id이런식으로 받아왔으니
result부분만 적어보면
const mock = [
result:{
data:{

    }
  }  
  

]

이런식으로 적어주는것이다. 물론 벡엔드가 정상이라는 가정하에작성된다. _id에는 벡엔드에서 받아온 해당 게시물 _id가 된다. 가상 테스트이니 받아왔다치고 작성하는것. 따라서 _id부분은 "벡엔드에서-받은-게시글ID" 라고 적었고, 나머지는 보낸 그대로 받으면 될테니 동일하게 적어준다. 이때 그래프 큐엘에서도 비밀번호는 받아올 수 없기에 받는 부분에서는 제외한다.

기존에 아폴로 세팅에서 하위자식(props.children)을 Apolloprovider로 client즉, 세팅값을 넘겨주어야 props.children 에서 useMutation이나 useQuery가 가능했다. 테스트 파일도 비슷하다.

다만 Apolloprovider은 실제로 요청을 날리는 것이니, 가짜로 날리기 위해 MockProvider을 사용한다. 기존 render부분에 적어준 컴포넌트를 MockProvider로 감싼다.

여기서 router.push 부분도 컴포넌트에 있었다. 실제로 페이지가 넘어갈수는 없기에 가짜로 만들어야한다.

jest.mock("어떤 라이브러리를 가짜로 만들것인지",()=>({
useRouter:jest.fn()})
useRouter라는 애를 jest.fn() =>가짜로 만든다.라는 의미.
이 안의 push도 가짜로 만들어야 하니
const push = jest.fn()
이렇게 push를 가짜로 만들고,
useRouter와 push를 합쳐야한다.

useRouter안에 push를 넣기

import {useRouter} from 'next/router'
을 하고....

(useRouter as jest.Mock)라고 useRouter의 타입은 가짜 타입이다 라고 해주고,

.mockImplementation => 가짜를 구현해달라는 의미

(useRouter as jest.Mock).mockImplementation(() => ({
push,
}));

useRouter는 가짜 타입이고(가짜고,) 그안에 push라는 가짜를 구현해달라는 의미

===> 이렇게 쓰면 useRouter안에 push가 포함되는 구조가 만들어지고 router.push가 가능해진다.

await WaitFor(()=>{
expect(push).toHaveCalledWith('/boards/벡엔드에서-받은-게시글ID')
//push를 하는데, '/boards/벡엔드에서-받은-게시글ID'로 요청되었는지 기대한다.
})라는것.

await를 붙여야 체이지 이동하는것을 기다린다!


useQuery테스트시:
result 부분에 작성해 받는 data가 적어둔것들로 나오고,
그 데이터가 div등에 찍히고,
그 div를 getByText하여 가져오면됨.

TDD

: 테스트를 먼저 만드는 것을 의미한다.
// TDD => 테스트를 먼저 만들자
// fireEvent.change(screen.getByRole("input-password"), {
// target: { value: "1234" },
// });

이렇게 input의 실제 태그에는 password부분이 없지만, 일단 미리 테스트 코드를 미리 만들어 놓고 나중에 테스트하면서 채워나가는 ? 형식이다.

이것을 긍정적으로 보는경우도있고,뭐하러 하냐는 식으로 보는 경우도 있다.
필요하다라는 쪽은 기능만들고 후에 테스트를 만들면 시간이 없을 수도 있고, 안만들게 되게에 따라서 테스트를 강제화 하기위해 미리 만들고 기능을 만드는식.

그 회사 문화에 따라 다르다고 할 수 있다.

해당 수업내용은 포트폴리오에 적용하려고 했던것을 봐가며 적용햐려했지만, 생각보다간단하지가 않았다. 모든 컴포넌트를 컨테이너, 프레젠터로 나누고 있었고, props로 넘기는게 있어 빨간줄이 생겼다. 망상 응용하려하니 막혔고... 꼭 적용하지 않아도 된다셔서 넘어가기로 했다.

오늘은 공부에 좀 게을러진 날이었다.
.
.
.
.
크게 얻는 날이 왔으면....

0개의 댓글