TypeScript Jest 를 통한 테스트 실행 속도 개선기
더 빠르게 테스트를 실행할 방법을 찾아서
  • Github

시작하기에 앞서

개발중인 프로젝트에서 jest 를 통해 테스트코드를 실행하고있는데, 실행하는 속도가 오래걸리는것을 확인했다.
테스트코드는 아주 자주 많이 실행되어야하는데, 이 시간이 오래걸린다면 개발 생산성에 아주 큰 걸림돌이 될 수 있다.

그래서 이를 더 빠르게 개선해볼 수 없을까 고민하며 실험해본 내용을 기록한다.

Why

jest 에서 --coveage 옵션 적용 시 JavaScript heap out of memory 가 발생하고 있다.
어디선가 메모리 누수가 발생하고 있다고 생각했다.

토스에서는 커버리지 80%로 약 1400개의 테스트가 실행되고있고, 이 테스트가 1분이 넘어가서 리팩토링을 했다고 한다.
(개선 후 2334개의 테스트 케이스가 6s 255ms 만에 끝남 - 맥북프로 i9 기준)

우리는?

개발중인 프로젝트에서 테스트를 실행해보면
956개 테스트가 164초 약 2분 44초가 넘게 걸리고 있었다. (로컬 mac 기준)

image

결론적으로 잘못 실행하고 있으며 오래 실행되고 있다고 판단했다.

원인 분석

각 코드에서 초기화하던것을 글로벌 설정으로 변경

"clearMocks": true,
"resetMocks": true,

(실행 속도 차이는 없다.)

이 옵션은 각 spec.ts 에서 mock clear하는작업을 자동으로 처리해준다.

beforeEach(() => {
  jest.clearAllMocks();
}); // 위 설정으로 인해 필요하지 않음

--logHeapUsage 을 통해 메모리 누수 확인해봤지만, 특별한 이상 없었다.

--runInBand 옵션을 사용하고있다.

runInBand 는 JEST_WORKER_ID 를 1개만 설정한다. 그래서 모든 spec.ts 를 순차적으로 실행한다.
시간을 오래걸리게 하는 가장 큰 원인이었다.

Jest 공식문서에서는 이 옵션은 디버깅을 위해 사용할것을 제안하고있었다.

해결

--runInBand 옵션 제거

무수히 많은 에러 발생했지만 실행시간은 38초로 매우 빨라지는걸 볼 수 있었다.
원인으로는 모든 TC가 순차적으로 실행하지 않고 병렬로 실행하면서 속도는 엄청 올라갔고, tc가 실행되다가 fail로 인해 중간에 멈췄기 때문이다.

image

다만 동시에 실행하다보니 DB 데이터를 동시에 접근하면서 TC 실패 발생한다.
(서로의 TC에 영향을 주고있다. 원인은 db를 공유하기 때문)

병렬로 실행하되, 각 TC가 본인만의 디비를 가지고있으면 되지않을까?

jest가 실행될 때 node.env.JEST_WORKER_ID 를 통해 본인의 id 를 알 수 있다.
(1번 worker가 끝나면 다음 spec.ts 가 1번 worker에 할당되는 방식)

최대 개수는 PC에서 사용가능한 코어수 - 1 (메인스레드) 입니다.

const databaseName = `shop_test_${node.env.JEST_WORKER_ID}`;

각 TC 마다 디비를 별도로 주면 디비 공유 문제를 해결할 수 있지 않을까?

jest_worker_id 별로 디비를 생성했다.

예를들면

foo_test_1
foo_test_2
...
bar_test_1
bar_test_2
...

TC의 설정 부족으로 여전히 failed 하는 tc는 존재하지만 63초로 시간은 많이 줄어들었다.

image

매 테스트케이스마다 db schema 를 생성하려니 시간이 오래걸리는걸 개선해보자

생성할 DB는 5개이다.
예를들면 foo, bar, qaz, qwe, asd

tc마다 timeout을 5000 으로 주고있는데, 디비 세팅하다가 이 시간이 초과되기도 한다.

mysqldump 를 사용해 한번 만들어 둔 db를 복사하면 더 빠르지 않을까?

ex) foo_test → foo_test_1 복사

지금은 knex에서 제공하는 migrations 기능을 사용하고 있다.
이걸 최초 1번만 사용해 sql 덤프파일을 만들고 그 파일을 재사용하면 빨라지지않을까 생각해봤다.

처음에 foo_test 에 대한 dump 파일을 생성하고, foo_test_1, foo_test_2.. 는 이 덤프파일을 이용하는 것이다.

병렬로 동시에 db에 접속하니 Too many connection 발생

원인으로는 knex 에 연결할 때 connection pool 을 10개씩 주고있는데, jest worker 마다 새로운 연결을 만들다보니 worker * 10 개씩 연결하고 있다.

image

mysql connection pool 조절하여 해결해줬다.
test 환경에서 1로 설정

failed 가 많이 줄어들었어요.

현재 failed 되는 코드는 잘못 작성된 코드로 판단하여 TC 수정했다.
그러면서 위에서 나왔던 메모리 누수도 사라졌다..

아마도 메모리 누수는 외부 서버 호출에 대해 mocking 하면서 사라졌을거라 추측한다.

spec.ts 가 한번 실행될 때 database 생성을 여러번 하고있다.

fooTestModule.setFixtures(fooFixtures);
barTestModule.setFixtures(barFixtures);

테스트가 실행되기 전 (beforeEach) fixture를 세팅하는데, 특정 디비를 세팅할 때마다 모든 디비를 세팅하고있기 때문이었다.
setFixtures 함수 안에 보면 전체 디비를 구성하는 로직이 들어있다.

이를 해결하기위해 setFitures에서 디비 세팅하는 로직을 제거하였다.

spec.ts 마다 최초 1회만 생성할 필요가 있어요.

혹은 테스트를 실행할 때 JEST_WORKER_ID 개수만큼 생성할 수 없을까?

jest 의 setupFiles을 사용하면 jest 를 실행할 때 최초 1회 실행하고싶은 코드를 구성할 수 있다.

// setupTests.js

// 최초 1회 실행할 코드를 작성
console.log("최초 1회 실행되는 코드");

// jest.config.js

module.exports = {
// 다른 Jest 설정...
setupFiles: ['./setupTests.js'],
};

문제는 여기서는 jest worker 의 개수를 알 수 없어서 다른 방법이 필요했다.

디비를 연결할 때 이미 세팅했던 디비인지 확인하는 과정을 넣어보자.

각 worker는 개별로 실행되기때문에 어떠한 값을 공유할 공간이 없다.
(공유할 데이터 공간을 만들기위해 별도의 인프라를 넣을 순 없다..)

mysql 쿼리를 사용해서 디비의 존재 여부를 확인하면 해결할 수 있었다.

MYSQL_PWD="root" mysql --user=root --host=127.0.0.1 --port=3306 -e "use ${dbname}"`

최종 41초로 줄어든 실행속도

image

결론

  • 재미있는 실험이었었다.
  • 개발중인 프로젝트에서 jest 전체 실행 시간을 많이 줄어들었다.
164s → 41s 약 1/4 로 감소 (로컬 mac 기준)
  • spec.ts 마다 디비를 생성하는게 맞는걸까 고민하던 차 관련 블로그 글도 있어서 용기를 얻었다.
  • 로컬에서는 전체 실행 41초라는 기분좋은 결과를 볼 수 있었지만, github action 에서는 그렇게 빠르지 않았다. (아주 빨라졌어!! 라고 할만큼 차이가 었었다.) 로컬과 GHA의 worker의 개수 차이때문에. (CPU차이)
  • ts-node 를 사용해 빌드하고있는데, @swc/jest 를 사용하면 20배 더 빠른 빌드가 가능하다. (이건 다음 글을 통해 소개해본다)