React & Library

[Cypress] 프론트엔드 E2E 테스트 회고

yoonjong Park 2024. 12. 19.

E2E 테스트를 하게 된 이유

사실, 급박하게 프로젝트가 진행된 경우, 기존 개발 구조를 조금도 건드리지 않고 진행할 수 있는 유일한 테스트가 아닐까 싶다.

기존 퇴사자들의 코드가 축적된 부분도 많았기 때문에, E2E 테스팅을 진행하게 되었다.

E2E 테스트를 하기위해 Cypress를 고르게 된 이유는, 대규모 프로젝트가 아닌이상 굳이 Playwright 까지 사용할 필요는 없다고 여겨졌기 때문이다.

그리고, 다른 팀원과 동일한 테스팅 라이브러리를 사용하기 위함이다. 같이 도입하면서 여러 자료조사를 해보았고, 대부분 cypress 애용하는 것을 있었다.

테스트 원칙을 세우다

가볍게 자료조사를 하고, 코드를 작성하면서 여러 의구심이 들었었다. 자연스럽게 원칙을 세워놓을 필요가 있다고 생각했다. 이를 위해 여러 레퍼런스들을 찾아 학습했다. 그리고 이를 적용했다. cypress에서 권장하는 원칙은 배제한 경우도 있다. 

cypress에서는 아래와 같은 원칙 순서를 요청했었다. 

좀 우스운 건, React Testing Library는 정 반대의 우선순위라는 것이다ㅋㅋㅋㅋ (장난치남…?)

cypress에서 권장하는 테스팅 selector 우선순위
React Testing Library에서 권장하는 테스팅 selector 우선순위

 

 

결국 내맴대로 여러 자료조사를 해서 관점을 구축할 밖에 없어서 아래와 같이 정리했다.

E2E 테스팅 원칙

  • 테스트 코드는 상호 독립적으로 작성한다. (참고1)
  • 제어할 수 없는 값에 의존하지 않는다. (참고2)
  • 페이지 URL 별로 테스트를 진행한다.
  • component 원본 코드에 되도록 별도의 attributes를 작성을 권장하지 않는다.
    • cypress 에서는 data-cy=”submit” 과 같이 별도의 attribute 작성하는 것을 권장하고 있으나 (참고3), Testing Library 는 이와 반대의 입장 (참고4)
    • attributes 를 추가로 작성하면, 추가적인 유지보수가 필요하지 않을 것으로 생각하지만, 다음과 같은 이유로 하지 않도록 한다. (참고5)
      • HTML 코드 오염 - 컴포넌트 코드가 불필요하게 복잡해질 수 있음.
      • 속성 유지 관리 - 역할이 변경되면, data-cy 과 같은 test id 값도 변경하게 되면서 추가적인 관리가 필요하게 됨.
      • 보안 이슈 - 관리자라서 크게 관계는 없겠지만, data-cy를 이용해서 조작이 용이하게 만드는 보안상 단점 발생
      • UI 조작 flow의 연동 - 실제 사용자는 attributes 를 보지 않음. 이에 의존하면 UI Flow를 놓칠 수 있음.
      • 지속적 의존 - test Id가 없어도 테스트가 가능함에도, 구조적 관계를 이용해서 대부분 테스트가 가능함.

 

작업 내용

  • 자주 사용하는 함수들은 test-util로 공통 함수화해서 공통적으로 동작하도록 하게 했다. 작업하다보니 총 8개의 공통함수를 추출해서 사용해었다.
  • beforeEach 메소드를 사용해서, 테스트 (it) 마다 공통적으로 셋업해야 하는 path permission 확보되도록 했다.

cypress.config.mjs

작업하다보니 설정을 잡아주는 게 꽤 중요했다. 중요했던 부분만 설명하겠다.

defaultCommandTimeout을 설정해두어야, api호출이나 rendering이 잘 되었는지 각 명령어마다 조금씩 delay가 되어도 대기하면서 확인한다.  이 걸 안하면, 각 테스트 명령어마다 wait 같은 delay 구문과 확인 구문을 또 넣어야 되는 경우가 발생한다.

retries는 사실 굳이 안넣어도 되지만, dev 시스템 상 수시로 배포가 이루어져서 백엔드 서버가 뻗는다. 이럴 때를 대비해서 3번 정도는 retry 하길 바래서 넣어뒀다. 해두길 잘한 거 같다. 그 외에도 headless(cypress run)  방식으로 테스팅 하다보면, hydration이 마저 안되는 경우도 발생한다. 이런 상황을 방지하고자 넣어두었다. (100% 좋은 방식은 아닐 것이라 판단한다.)

video는 테스트 실패시에만 녹화기록이 남도록 setupNodeEvents 부분에 각종 custom 구문을 작성해두었다.

나머지는 설정에 관한 설명은 생략한다.

import { defineConfig } from 'cypress';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';

export default defineConfig({
  e2e: {
    baseUrl: '커스텀 baseURL',
    defaultCommandTimeout: 10000,
    defaultBrowser: 'chrome',
    viewportWidth: 1920,
    viewportHeight: 1080,
    video: true,
    retries: { runMode: 3 },
    numTestsKeptInMemory: 1,
    setupNodeEvents(on) {
      on('before:run', async () => {
        // 스크린샷 폴더 내용 삭제
        const __filename = fileURLToPath(import.meta.url);
        const __dirname = path.dirname(__filename);
        const screenshotsFolder = path.join(
          __dirname,
          'cypress',
          'screenshots',
        );

        try {
          const screenshotsFiles = await fs.readdir(screenshotsFolder);
          for (const file of screenshotsFiles) {
            await fs.rm(path.join(screenshotsFolder, file), {
              recursive: true,
              force: true,
            });
          }
          console.log(
            `스크린샷 폴더 내 하부폴더/파일 삭제 완료: ${screenshotsFolder}`,
          );
        } catch (err) {
          console.error(
            `스크린샷 폴더 내 하부폴더/파일 삭제 실패: ${screenshotsFolder}`,
            err,
          );
        }

        try {
          const videosFolder = path.join(__dirname, 'cypress', 'videos');
          const videosFiles = await fs.readdir(videosFolder);
          for (const file of videosFiles) {
            await fs.rm(path.join(videosFolder, file), {
              recursive: true,
              force: true,
            });
          }
          console.log(
            `비디오 폴더 내 하부폴더/파일 삭제 완료: ${videosFolder}`,
          );
        } catch (err) {
          console.error(
            `비디오 폴더 내 하부폴더/파일 삭제 실패: ${screenshotsFolder}`,
            err,
          );
        }
      });

      on('after:spec', (_, results) => {
        if (results && results.video) {
          const videoPath = results.video;

          if (results.stats.failures === 0) {
            // 테스트 성공 시 비디오 삭제
            fs.unlink(videoPath, (err) => {
              if (err) {
                console.error(
                  `FAILED DELETE VIDEO FILE - Failed to delete video: ${videoPath}`,
                  err,
                );
              } else {
                console.log(`PASSED TEST - Deleted video: ${videoPath}`);
              }
            });
          } else {
            // 테스트 실패 시 비디오 보존
            console.error(`FAILED TEST - Check Video: ${videoPath}`);
          }
        }
      });
    },
  },
});

.gitignore

/cypress/downloads // 추가 - cypress 이슈로 downloads.html이 생성되고 삭제되지 않는 경우가 있다고 함. 같이 commit 되는 경우를 방지하기 위함.

/cypress/videos // 추가 - 테스트 실패시 생성되는 MP4 video 파일 commit 방지

/cypress/screenshots // 추가 - 테스트 실패시 생성되는 screenshots 파일 commit 방지

tsconfig.json

"types": ["cypress", "node"], // 추가
"include": [
    "**/*.ts",
    "**/*.tsx",
    "jest.config.ts_",
    "src/redux/sagas/commonts",
    "cypress/**/*.ts" // 추가
],

.eslintrc.json 

 "extends": [..., "plugin:cypress/recommended"], // 추가
 "plugins": [..., "cypress"] // 추가

package.json

"devDependencies": {
  ...
    "@types/cypress": "^1.1.6",  // cypress와 매칭되는 type, eslint-plugin 추가
    "cypress": "^13.16.0", 
    "eslint-plugin-cypress": "^4.1.0",
},
"scripts": {
  ...
    "cypress:open": "cypress open", // 추가하지 않아도 무방
     "cypress:run": "find cypress/e2e -name '*.cy.ts' | xargs -n 1 -P 12 yarn cypress run --config-file=cypress.config.mjs --spec" // 병렬로 테스트 파일을 12개씩 실행하여 테스팅 시간 단축. 코어 수 맞춰서 12개로 설정

}

.husky/pre-push

git push 를 실행하면, e2e 테스팅을 진행한다. commit 때마다 테스팅을 진행하는 것은 불필요해보여서 PR 올리기 전에만 한번씩 테스팅 하는 것으로 동료와 합의했다. 처음 쓰는 사용자를 위해 안내문구도 추가해주었다.

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "\nDocker를 키고 mallmanager를 구동시켜주세요.\n"
echo "로컬환경에서 yarn dev 를 구동시켜주세요.\n"
echo "cypress 테스팅 파일은 병렬로 실행됩니다.\n"
echo "Running pre-push hook to run cypress:run\n"

yarn cypress:run

.yarnrc.yml

...
packageExtensions: 
  debug@*:
    dependencies:
      supports-color: '*' // 삭제 가능 - cypress 실행 시 무시해도되는 cypress warning 방지하기 위함

 

성능 개선 사항 1: 테스트 시간을 7분에서 2분대로 개선하다

cypress는 테스트를 직렬로 순차적으로 진행하게 되어 있다. cypress 유료버전을 쓰라고 안내하는데, 뭔가 방법이 있을 것 같았다.

그래서 gpt에 터미널을 이용해서 병렬로 cypress 테스트를 실행할 수 있는 명령어를 짜달라고 했다. 그렇게 나온 것이 아래의 명령어다.

"find cypress/e2e -name '*.cy.ts' | xargs -n 1 -P 12 yarn cypress run --config-file=cypress.config.mjs --spec" 

// 병렬로 테스트 파일을 12개씩 실행하여 테스팅 시간 단축. 코어 수 맞춰서 12개로 설정

명령어의 기능들은 구글링과 gpt로 검색해서 알아보길 바란다.  12 라고 쓰여있는 부분은 내가 custom 한 부분인데, 현재 우리팀이 쓰고 있는 cpu 코어수가 12코어라서 멀티 프로세스하는데 12개까지는 큰 무리가 없을 것으로보고, 12개 단위로 cypress테스팅을 진행하도록 작업했다. 이게 정확히 맞지는 않겠지만, 그래도 0(무한대)로 놓거나 일괄적으로 100으로 놓기에는 무리가 있다고 보였다. 어떤 근거 설정이 필요해서 위와 같이 조치했다.(나 스스로 발상해서 해결했기에, 너무나도 깊은 감동이었다.)

성능 개선 사항 2: Memory dump 증상을 없애다.

cypress를 cypress open으로 가상 브라우저를 직접 띄워서 테스팅 하는 경우, 테스팅 개수가 많아지면서 다운되는 경우가 종종발생되기 시작했다. 이를 방지하고자, 테스팅 파일을 페이지별로 하던 것을 페이지의 각 목적별로 나누었다. 예를 들어 /edit 페이지 일경우, edit.cy.ts 하나로 테스트를 진행하던 것을.. edit-pc-*.cy.ts, edit-mobile-*.cy.ts 형태로 나누었다. 각 파일들이 갖는 테스팅 구문 개수가 어느정도 최소로 관리되도록 했다. 이후에는 메모리 이슈로 테스팅이 멈추는 증상이 사라졌다. (휴...)

소감

생각보다 즐거웠다. 계속 파악하고 알아내가며 개선해내는 작업이 즐거웠다.

관련해서 작업한 것들은 가이드문서화해서 지라 컨플에 게시하고 동료들에게 공유해서 👍 따봉을 받았다ㅎㅎ 다음 동료가 고생하지 않길 빈다.

앞으로도 테스팅 요소가 생길 때마다 추가적인 작업을 꾸준히 진행할 예정이다. E2E를 도입하고나니, 배포에 혹시나 하는 걱정으로 인한 두근거림이 사라졌다ㅎㅎ 주변에도 추천해줘야겠다.

참고

  1. 스프링캠프 2019 [Track 2 Session 3] : 무엇을 테스트할 것인가? 어떻게 테스트할 것인가? (@우아한형제들. 권용근)
  2. 테스트하기 좋은 코드 - 테스트하기 어려운 코드 (@인프런 CTO 이동욱)
  3. Cypress Doc > App > Core Concepts > Best Practices > How It Works
  4. 테스팅 라이브러리 > About Queries 단락
  5. MDN > custom data attributes

 

 

 

댓글