jest, testing-library를 이용한 TDD 찍먹 해보기

    반응형

    저는 4~5년동안 개발하면서 단 한번도 테스트케이스를 작성한적이 없습니다. 실제로 TDD를 실무에 적용하기란 현실에선 벅찰 수도 있다는 말들이 꽤 많은것 같습니다. 실제로 저도 TDD가 되어있는 회사에 속해본적도 없었고, 상황도 많이 달랐습니다. SI는 정말 오로지 납품, 마감기한이 1순위니까 그 분위기에 테스트케이스를 한다고하면 현실을 살라고... 한 소리 들을수도 있었달까(?) 그럼에도 불구하고 시도할 수 있는 환경이었다면 한번쯤 해보는것도 나쁘지 않았을텐데 하는 아쉬움이 크더라고요

    물론 혼자서도 할 수 있지만, 대규모 프로젝트 또는 5인 이상 같은 프론트엔드 개발자가 있는 팀에서 시도해봤을때 더 크게 빛을 발하는 부분이지 않나라는 안일한 생각을 가지며... 단 한번도 시도해봐야지^^란 생각조차 안했습니다...

    아무튼, 이번에 어떤건지 궁금해서 찍먹정도 해보려고 합니다. 라이브러리는 testing-library와 jest를 사용하려고 합니다. jest로 기본 테스트 케이스 골격을 만들고 testing-library로 UI와 상호작용하는 유닛 단위 테스트 케이스를 작성해보겠습니다

     

    설치 및 세팅


    $ yarn add jest @testing-library/react @testing-library/jest-dom @testing-library/user-event ts-jest jest-environment-jsdom

    설치 해줘야 하는 패키지가 생각보다 많습니다

    $ yarn create jest

    저는 Next.js api Router 환경이라 위와 같이 선택해줬습니다. 참고로 패키지 버전들이 Node.js 최신 버전(v24)를 쓰고 있어서 Node.js 버전도 업그레이드 해줬습니다

    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const nextJest = require("next/jest");
    
    const createJestConfig = nextJest({ dir: "./" });
    
    const config = {
      clearMocks: true,
      collectCoverage: true,
      coverageDirectory: "coverage",
      coverageProvider: "v8",
      testEnvironment: "jsdom",
      setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
    };
    
    module.exports = createJestConfig(config);

    이제 프로젝트 Root에 jest.config.ts파일이 생성 되었을텐데 위와 같이 추가 작성해줍니다
    jest는 기본적으로 node.js 기반의 CommonJS만를 지원하길래 ESM 충돌이 일어나서 CommonJS 방식으로 수정해줬습니다

    import "@testing-library/jest-dom/jest-globals";

    또 Root에서 jest.setup.ts 파일을 생성해서 위와 같이 작성해줍니다

    {
      "compilerOptions": {
    	/* ... */
        "types": ["jest", "@testing-library/jest-dom"]
      },
      "include": [
        "next-env.d.ts",
        "**/*.ts",
        "**/*.tsx",
        ".next/types/**/*.ts",
        "jest.config.ts"
      ],
    }

    tsconfig.json 파일도 수정해줍니다

    이제 테스트케이스를 작성해주면 됩니다. 저는 src/__tests__ 폴더를 생성해줬습니다

     

     

    Login Form 테스트케이스


    TDD라고 하기엔 뒤늦게 도입한 케이스라.. 컴포넌트는 이미 있고 테스트케이스를 뒤늦게 작성하게된 케이스인데요^^...ㅋㅋ
    암튼 해보겠습니다

    export default function SignIn() {
      const [formData, setFormData] = useState<IFormData>(DEFAULT_VALUES);
      const [error, setError] = useState<{ [key: string]: boolean }>();
    
      const onSubmit = useCallback((formData: IFormData) => {
        console.log("로그인 시도");
      }, []);
    
      const handleSubmit = useCallback(
        (e: React.FormEvent) => {
          e.preventDefault();
    
          if (!formData.id && !formData.password)
            return setError({ id: true, password: true });
          else if (!formData.id) return setError({ id: true });
          else if (!formData.password) return setError({ password: true });
    
          onSubmit(formData);
        },
        [formData, onSubmit],
      );
      
      return (
        <>
          <h1 className="mt-10 mb-2 text-2xl font-semibold">Login</h1>
          <form onSubmit={onSubmit}>
            <label>
              <p className="text-base text-gray-600">ID</p>
              <TextField.Root
                placeholder="Email or ID"
                size="3"
                className={`mt-1 w-80 ${error?.id ? "border border-red-500 !shadow-none" : ""}`}
              >
                <TextField.Slot side="right">
                  <XCircleIcon width={20} />
                </TextField.Slot>
              </TextField.Root>
            </label>
            <label className="mt-3.5 block">
              <p className="text-base text-gray-600">Password</p>
              <TextField.Root
                placeholder="Enter password"
                type="password"
                size="3"
                className={`mt-1 w-80 ${error?.password ? "border border-red-500 !shadow-none" : ""}`}
              >
                <TextField.Slot side="right">
                  <EyeIcon width={20} />
                </TextField.Slot>
              </TextField.Root>
            </label>
            
            {(error?.id || error?.password || error?.all) && (
              <p className="mt-1 text-center text-sm font-semibold text-red-500">
                아이디 또는 비밀번호를 확인해주세요
              </p>
            )}
    
              <Button
                variant="solid"
                type="submit"
                className="block !w-full"
                size="3"
              >
                Login
              </Button>
          </form>
        </>
      );
    }

    위와 같이 로그인 컴포넌트를 작성해줬습니다. 아직 API 연동하기 전입니다. 이 상태에서 실패케이스부터 먼저 작성하려고 했습니다

    describe("로그인", () => {
      const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
      afterEach(() => {
        consoleLogSpy.mockClear();
      });
    
      test("ID와 Password를 입력하지 않고 Login 버튼 클릭시 로그인 요청이 발생하지 않고, 에러 메세지를 보여주고 빨간색 테두리를 적용한다", async () => {
        render(<SignIn />);
    
        await userEvent.click(screen.getByRole("button", { name: "Login" }));
    
        expect(
          await screen.findByText("아이디 또는 비밀번호를 확인해주세요"),
        ).toBeInTheDocument();
        expect(
          screen.getByPlaceholderText("Email or ID").parentElement,
        ).toHaveClass("border border-red-500 !shadow-none");
        expect(
          screen.getByPlaceholderText("Enter password").parentElement,
        ).toHaveClass("border border-red-500 !shadow-none");
    
        expect(consoleLogSpy).not.toHaveBeenCalled();
      });
    });

    처음에는 일단 이렇게 ID와 Password 둘 다 작성하지 않은 케이스만 작성해봤습니다. 그리고 yarn test로 테스트를 실행시켜봤더니

    ReferenceError: ResizeObserver is not defined

    라는 에러가 뜨더라고요

    chatGPT에 검색해보니 이번 프로젝트에서 radix-ui를 쓰고 있는데 jsdom 환경에서는 ResizeObserver가 없다고 하더라고요
    https://github.com/jsdom/jsdom/issues/3368

     

    Implement `ResizeObserver` · Issue #3368 · jsdom/jsdom

    Basic info: Node.js version: v16.14.0 jsdom version: 19.0.0 Minimal reproduction case const { JSDOM } = require("jsdom"); const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`); console.log("RE...

    github.com

    global.ResizeObserver = class ResizeObserver {
      observe() {}
      unobserve() {}
      disconnect() {}
    };

    jest.setup.ts 파일에 위 코드를 추가하니 해결이 되었습니다
    그리고 다시 실행해보니

    class 할당이 제대로 트리거 되지 못하는 에러가 발생하고 있습니다

    이와 같이 UI 라이브러리를 쓰게되면 의도치않게 커스텀 스타일을 강제로 주입하거나 그래야하는데 TextField 컴포넌트가 그런 케이스였습니다. input인데 error 상태 값 주입하는 옵션이 따로 없더라고요.. 그래서 직접 스타일을 주입한건데 테스트 코드는 input을 직접 바라보고 있고, 나도 input에 스타일을 준거지만 사실 렌더링 되었을땐 input의 wrapper 태그에 할당된 케이스이고... 

    GPT는 아래와 같이 수정하라고 하더라고요

    // 전
    expect(screen.getByLabelText("ID")).toHaveClass(
      "border border-red-500 !shadow-none",
    );
    
    // 후
    expect(
      screen.getByPlaceholderText("Email or ID").parentElement,
    ).toHaveClass("border border-red-500 !shadow-none");

    근데 이게 맞나? 이게 맞아요????
    사실 input을 찾는것도 label을 기준으로 찾는거라 모호하다 싶었는데, input의 placeholder를 기준으로 잡는건 더 모호하지 않나??? 싶은??? 물론 어차피 이 테스트케이스를 배포전 또는 PR시 실행시키니까 휴먼 에러든 뭐든 잡아내서 고치게끔 하겠지만야.... 뭔가 딱 너야!!!를 콕 집는 느낌이 아니어서... 원래 테스트케이스란게 이런건가요?? 현업에서 써봤다거나 이미 크게 구축 되어있다거나 다른 사람 코드를 긴밀하게 볼 수 있는 기회가 없었어서 잘 모르겠네요...

    아무튼 이렇게 사용자가 로그인 시도 시 input을 입력하지 않았을 경우에 대한 테스트케이스를 작성을 완료 했고

    yarn test로 실행시키니 모두 통과되었습니다!

    그리고 이걸 github actions에 태울 생각인데요(?) PR 생성 시 테스트코드를 실행해서 뭘 통과했고, 못했는지를 보려고 합니다

    name: Run Tests on PR
    
    on:
      pull_request:
        branches: [main, develop]
    
    jobs:
      test:
        runs-on: ubuntu-latest
    
        steps:
          - name: Checkout code
            uses: actions/checkout@v3
    
          - name: Set up Node.js
            uses: actions/setup-node@v3
            with:
              node-version: "24.2.0"
    
          - name: Install dependencies
            run: yarn install
    
          - name: Run tests
            run: yarn test

    github action 실행 파일을 위와 같이 작성해줬습니다

    PR을 작성하니 뺑글뺑글 실행되면서 대략 50초정도 걸리더니 모두 통과되었습니다
    만약 통과되지 않앗을경우 어떻게 뜨는지 보고싶어서 다시 push 해봤는데요

    이렇게 뜨더라고요 
    이거 말고 막 여러개 나열되면서(?) 항목별로 x표시되고 그런게 있었던것 같은데.. 왜 난 그렇게 안보이는지 몰겠숨


    아무튼 여기까지 찍먹완! 이걸 본격적으로 써먹으면 좋을 회사에 가보고 싶군요...

    반응형

    댓글