1. 들어가며
올해 테스트 코드를 도입하면서 유틸뿐만 아니라 화면 단위의 파일까지도 테스트를 작성하기 시작했다. 처음에는 테스트 작성에 많은 시간이 소요되었지만, 기준이 잡히고 나니 점차 작성 속도가 빨라지는 것을 느꼈다.
그러나 테스트 코드를 작성한 지 1~2달이 지났을 때, 전반적인 테스트 코드를 구동시킨 결과 몇 가지 문제점이 드러났다:
- 리팩토링 및 버그 수정으로 인해 이미 삭제된 함수나 변수를 검증하려다 발생하는 오류.
- 새로운 기능이 추가되었지만 테스트 코드 상 오류가 없어 검증되지 못한 케이스의 증가.
물론, 테스트 코드를 잘 작성하고 목표한 테스트 커버리지에 도달하는 건 중요하다. 하지만, 장기적인 관점에서 “테스트 코드의 관리”도 중요하다는 걸 깨달았다.
그렇다고 매번 테스트 코드를 수동으로 확인한다고 해도, 모든 작업을 팔로우할 수 없기에 놓칠 가능성이 컸다.
그래서 두 가지 방법으로 테스트 코드 관리를 개선하자는 아이디어가 나왔다:
- Pre-Push 단계에서 커밋한 파일의 테스트 코드를 자동으로 실행.
- 원격과 로컬의 커버리지 파일(coverage-final.json)을 비교해 새로운 기능이나 함수 추가로 인해 커버리지가 감소했는지 체크.
우선 이번 포스팅에는 첫 번째 방법인, “Pre-Push 단계에서 커밋한 파일의 테스트 코드 파일을 실행시키는 방법”에 대해 알아보겠다.
이번 포스팅을 읽고 나면, 아래 gif처럼 Pre-push단계에서 테스트 코드를 검증할 수 있게 된다.
<hello.js의 sum함수를 수정하고 커밋시, sum함수의 테스트코드가 실패하여 push가 중단된 모습>
*목차
1. 들어가며
2. 테스트 코드를 관리하는 법 1, Pre-Push 단계에서 테스트 자동화하기
1) Git Hooks와 Pre-Push에 대해 알아보자
2) 커밋한 파일의 테스트 구동시키는 법: 설계
3) 커밋한 파일의 테스트 구동시키는 법: 구현
3. 마치며
2. 테스트 코드를 관리하는 법 1, Pre-Push 단계에서 테스트 자동화하기
1) Git Hooks와 Pre-Push에 대해 알아보자
- Git은 이벤트가 발생했을 때 특정 스크립트를 실행할 수 있는 기능을 제공하는데, 이를 Git Hook이라고 한다.
- Git Hook은 Git의 저장소 디렉토리 내부에 위치한
hooks
디렉토리 안에 정의되며 크게 두 가지로 나눌 수 있다:
종류 | 설명 |
---|---|
클라이언트 사이드(Client-side) Hooks | 개발자의 로컬 저장소에서 발생하는 이벤트에 반응하여 실행된다. |
서버 사이드(Server-side) Hooks | 원격 저장소에서 발생하는 이벤트에 반응하여 실행된다. |
- 우선 클라이언트 사이드 훅은 여러 가지가 있는데, 자주 쓰는 훅은 pre-commit, commit-msg, post-commit, pre-push이 있다.
훅 종류 | 설명 |
---|---|
pre-commit | 커밋이 시작되기 전에 실행된다. 코드 린트, 테스트 등을 수행할 수 있다. |
commit-msg | 커밋 메시지가 작성된 후 실행된다. 메시지 형식을 검사할 때 사용된다. |
post-commit | 커밋이 완료된 후 실행된다. 알림 전송이나 추가 작업을 수행할 수 있다. |
pre-push | git push 명령어가 실행되기 전에 실행된다. 푸시 전에 추가적인 검사를 수행할 수 있다. |
- 서버 사이드 훅은 pre-receive, update, post-update 등이 있다. 만약 모든 훅 종류를 알고 싶다면 git 공식문서를 참고하자.
훅 종류 | 설명 |
---|---|
pre-receive | 푸시된 데이터가 서버에 도착하기 전에 실행된다. |
update | 각 브랜치에 대한 업데이트가 도착할 때마다 실행된다. |
post-update | 모든 참조가 업데이트된 후 실행된다. |
- 이처럼 git Hook에는 커밋, 푸시, 업데이트 등 다양한 Git 작업에 따라 이벤트가 발생한다.
- 이 중
pre-push
훅은git push
명령이 실행될 때 작동한다. 만약 pre-push 단계가 실패하면, 리모트에 데이터가 전송되지 않게 된다. - 그래서
pre-push
훅을 사용하면 커밋한 파일의 테스트를 자동으로 실행하거나, 테스트 실패 시 푸시를 중단하도록 설정할 수 있다.
결국, 테스트 코드의 지속 가능성을 위해 pre-push 단계에서 테스트코드를 검증해야한다!
그럼 구체적으로 어떤 방향으로 작업하는 게 좋을까?
2) 커밋한 파일의 테스트 구동시키는 법: 설계
(1) 핵심 아이디어
테스트 자동화를 통해 푸시 단계에서 코드 품질을 검증해보자! 핵심 아이디어는 다음과 같다.
A. 변경된 파일에 대한 테스트 자동 실행:
- 커밋한
.js
,.ts
,.vue
,.jsx
,.tsx
파일에 대응하는 테스트 파일(.test.js
,.spec.js
,.test.ts
,.spec.ts
)이 있는지 확인한다. - 테스트 파일이 있다면 pre-push 훅에서 테스트를 실행하여 테스트 코드가 동작하는지 검증한다.
B. 테스트 자동화 스크립트:
- Git의 pre-push 훅을 활용하여, 푸시하려는 커밋들에 포함된 파일의 테스트를 수행한다.
.husky/pre-push-script.ts
에서 스크립트를 실행해 테스트를 수행한다.
C. 테스트 파일 매핑 규칙:
- 소스 파일과 동일한 디렉토리 구조를 기반으로 테스트 파일 경로를 매핑한다.
- 예를 들어,
project/folder1/folder2/A.vue
는tests/folder1/folder2/A.spec.js
또는A.test.ts
와 매핑된다.
/* 프로젝트 구조 예시 */
your-project/
├── project/
│ ├── folder1/
│ │ ├── folder2/
│ │ │ ├── A.vue
│ │ │ └── B.vue
│ └── ...
├── tests/
│ ├── folder1/
│ │ ├── folder2/
│ │ │ ├── A.spec.js
│ │ │ └── B.spec.ts
│ └── ...
├── .husky/
│ ├── pre-push
│ └── pre-push-script.ts
├── package.json
├── tsconfig.json
└── vitest.config.ts
(2) Pre-Push 스크립트의 동작 흐름
- 위 아이디어를 구체화하면 총 4단계를 거쳐 테스트가 구동되게 된다.
단계 | 설명 |
---|---|
1. 변경된 파일 감지 | 푸시하려는 커밋의 파일 목록에서 변경된 파일을 확인한다. |
2. 테스트 파일 매핑 | 변경된 각 파일에 대해 대응하는 테스트 파일을 찾는다. |
3. 테스트 실행 | 테스트 파일이 존재하면 vitest 를 통해 실행한다. |
4. 결과 처리 | 모든 테스트가 성공하면 푸시를 진행하고, 실패 시 푸시를 중단한다. |
3) 커밋한 파일의 테스트 구동시키는 법: 구현
이 포스팅의 구현 코드는 typescript, vitest, husky, pnpm, nodeLTS 환경을 기준으로 한다.
만약 만약 내부 코드가 궁금하다면 test-pre-push-husky를 참고해보자!
(1) 패키지 설치
- 먼저 프로젝트에
husky
와vitest
등을pnpm
을 사용하여 설치한다. tsconfig.json
설정파일은 이 링크의 파일, vitest 설정 파일은 이 링크의 파일처럼 작성하면 된다.
pnpm add -D husky vitest ts-node typescript jsdom
(2) husky 초기화
husky
를 초기화하여 Git hooks를 설정한다.
pnpm dlx husky
package.json
에prepare
스크립트를 추가한다.- 만약 훅이 제대로 작동하지 않는다면 이 스크립트를 구동시키면 된다.
{
"scripts": {
"prepare": "husky"
}
- 위 명령어를 거치면,
.husky
라는 폴더가 생성된다. - 우리는 pre-push 훅을 커스텀해야하기에,
.husky/pre-push
파일을 아래 명령어로 생성한다.
touch .husky/pre-push
chmod +x .husky/pre-push
- 그리고
.husky/pre-push
을 다음과 같이 작성한다.
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm run pre-push
(3) pre-push 스크립트 작성
package.json
파일을 열고,scripts
섹션에pre-push
명령어를 추가한다.- .husky/pre-push-script.ts은 pre-push 단계에서 실행한 스크립트 파일이다.
{
"scripts": {
"prepare": "husky",
"pre-push": "node .husky/pre-push-script.ts",
"test": "vitest",
}
}
(4) pre-push-script.ts 구현하기
pre-push-script.ts
은 pre-push 단계에서, 커밋한 파일의 테스트 코드를 실행하는 스크립트이다.- 코드의 전반적인 흐름은 다음과 같은데, 실제 구현된 코드를 살펴보면서 동작을 확인해보겠다.
A. 상수 정의
- 소스 파일과 테스트 파일의 경로 그리고 테스트 스크립트를 지정하는 상수이다.
const SRC_PREFIX = 'project/';
const TEST_PREFIX = `${SRC_PREFIX}tests/`;
const TEST_RUN_SCRIPT = 'pnpm vitest run';
상수 | 설명 |
---|---|
SRC_PREFIX | 소스 파일들이 위치한 디렉토리의 경로이다. 예시에서는 project/ 로 설정했다. |
TEST_PREFIX | 테스트 파일들이 위치한 디렉토리의 경로이다. 소스 파일 디렉토리 경로에 tests/ 를 추가하여 설정된다. |
TEST_RUN_SCRIPT | 테스트를 실행하기 위한 스크립트 명령어이다. 여기서는 vitest 를 사용하여 테스트를 실행하도록 설정했다. |
B. 메인 함수(즉시 실행 함수: IIFE)
- 메인으로 실행되는 코드로, 커밋된 파일 목록에 해당하는 테스트 파일을 찾는다.
- 만약 테스트 파일이 실패할 경우, 푸시를 중단(
process.exit(1)
)시킨다.
(function () {
/* (a) */
const files = getCommittedFiles();
/* (b) */
if (files.length === 0) {
console.log('수정된 파일이 없습니다. 푸시를 계속합니다.');
process.exit(0);
}
/* (c) */
const hasFailure = runTests(files);
/* (d) */
if (hasFailure) {
console.error('푸시를 중단합니다. 위의 오류를 확인하세요.');
process.exit(1);
}
console.log('모든 테스트가 성공적으로 통과했습니다. 푸시를 계속합니다.');
process.exit(0);
})();
번호 | 설명 |
---|---|
(a) | getCommittedFiles() 함수를 호출하여 커밋된 파일 목록을 가져온다. |
(b) | 변경된 파일이 없으면 푸시를 계속하고 종료한다. |
(c) | 변경된 파일이 있다면 runTests(files) 함수를 호출하여 해당 파일들의 테스트를 실행한다. |
(d) | 테스트 결과에 따라 푸시를 계속하거나 중단한다. |
C. getCommittedFiles 함수
- 커밋에서 변경된 파일 목록을 추출한다.
function getCommittedFiles(): string[] {
try {
/* (a) */
const input = fs.readFileSync(0, 'utf-8');
const lines = input.trim().split('\n');
let committedFiles: string[] = [];
lines.forEach(line => {
/* (b) */
const [_, localSha, __, remoteSha] = line.split(' ');
/* (c) */
const isLocalBranch = remoteSha === '0000000000000000000000000000000000000000';
/* (d) */
if (isLocalBranch) {
const listCommand = `git ls-tree -r --name-only ${localSha}`;
const output = execSync(listCommand, { encoding: 'utf-8' });
committedFiles = committedFiles.concat(output.split('\n').filter(file => file.trim() !== ''));
}
else {
/* (e) */
const diffCommand = `git diff --name-only ${remoteSha} ${localSha}`;
const output = execSync(diffCommand, { encoding: 'utf-8' });
committedFiles = committedFiles.concat(output.split('\n').filter(file => file.trim() !== ''));
}
});
/* (f) */
const uniqueFiles = Array.from(new Set(committedFiles));
return uniqueFiles;
}
catch (error) {
/* (g) */
console.error('커밋한 파일을 가져오는 중 오류가 발생했습니다.');
process.exit(1);
}
}
번호 | 설명 |
---|---|
(a) | 표준 입력(stdin )으로부터 커밋한 파일 정보를 읽어온다. |
(b) | 각 라인별로 로컬 SHA와 원격 SHA를 분리한다. |
(c) | 로컬 브랜치인지 여부를 확인한다. (remoteSha 가 모두 0인 경우 로컬 브랜치임). |
(d) | 로컬 브랜치라면 git ls-tree 명령어로 전체 파일 목록을 가져온다. |
(e) | 원격 브랜치라면 git diff 명령어로 로컬과 원격을 비교하여 변경된 파일 목록을 가져온다. |
(f) | 중복을 제거한 고유한 파일 목록을 반환한다. |
(g) | 파일 목록을 가져오는 중 오류가 발생하면 에러 메시지를 출력하고 스크립트를 종료한다. |
D. runTests 함수
- 변경된 파일들에 대응하는 테스트 파일을 찾아 실행한다.
function runTests(files: string[]): boolean {
let hasFailure = false;
for (const file of files) {
/* (a) */
const testFile = mapToTestFile(file);
if (testFile) {
if (fs.existsSync(testFile)) {
console.log(`테스트 실행: ${testFile}`);
try {
/* (b) */
execSync(`${TEST_RUN_SCRIPT} ${testFile}`, { stdio: 'inherit' });
} catch (error) {
/* (c) */
console.error(`테스트 실패: ${testFile}`);
hasFailure = true;
}
} else {
console.error(`테스트 실패: 테스트 파일이 존재하지 않습니다: ${testFile}`);
hasFailure = true;
}
} else {
/* (d) */
console.log(`테스트를 작성해야 합니다: ${file}`);
}
}
/* (e) */
return hasFailure;
}
번호 | 설명 |
---|---|
(a) | 전달받은 파일 목록을 순회하면서 각 파일에 대해 대응하는 테스트 파일을 찾는다.(mapToTestFile 함수 사용). |
(b) | 테스트 파일이 존재하면 vitest 를 통해 테스트를 실행한다. |
(c) | 테스트 실행 중 오류가 발생하면 에러 메시지를 출력하고 hasFailure 플래그를 true 로 설정한다. |
(d) | 테스트 파일이 존재하지 않으면 해당 파일에 대한 테스트 코드가 필요함을 알린다. |
(e) | 테스트가 하나라도 실패하면 true , 모두 성공하면 false 를 반환한다. |
E. mapToTestFile 함수
- 소스 파일 경로를, 대응되는 테스트 파일 경로로 변환한다.
function mapToTestFile(file: string): string | null {
const testExtensions = ['.spec.js', '.test.js', '.spec.ts', '.test.ts'];
const isTestFile = testExtensions.some(ext => file.endsWith(ext));
/* (a) */
if (isTestFile) return file;
/* (b) */
if (!file.startsWith(SRC_PREFIX)) return null;
/* (c) */
const relativePath = file.slice(SRC_PREFIX.length);
/* (d) */
const dir = path.dirname(relativePath);
const ext = path.extname(relativePath);
const baseName = path.basename(relativePath, ext);
const testPatterns = testExtensions.map(ext => `${baseName}${ext}`);
/* (e) */
for (const pattern of testPatterns) {
const testFilePath = path.join(TEST_PREFIX, dir, pattern);
if (fs.existsSync(testFilePath)) {
return testFilePath;
}
}
/* (f) */
return null;
}
번호 | 설명 |
---|---|
(a) | 현재 파일이 테스트 파일인지 확인하고, 테스트 파일이라면 그대로 반환한다. |
(b) | 파일이 SRC_PREFIX 로 시작하지 않으면 null 을 반환하여 테스트 파일 매핑을 생략한다. |
(c) | 소스 파일의 상대 경로를 계산한다. (ex. project/ 이후의 경로) |
(d) | 상대 경로에서 디렉토리와 파일의 기본 이름을 추출한다. |
(e) | 정의된 테스트 파일 확장자를 기반으로 테스트 파일 패턴을 생성한다. 각 패턴에 대해 테스트 파일 경로를 생성하고, 해당 파일이 존재하는지 확인한다. 그리고, 존재하는 첫 번째 테스트 파일 경로를 반환한다. |
(f) | 모든 패턴을 확인했음에도 테스트 파일이 존재하지 않으면 null 을 반환한다. |
- 위와 같은 함수를 구동하면, 수정한 파일에 해당하는 테스트 파일을 구동시킬 수 있다!
- 만약 코드 전체를 보고 싶다면, [전체코드 보기]를 클릭해보자.
전체코드 보기
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* SRC_PREFIX: 소스 파일이 project/ 폴더에 있다면 'project/'로 설정
* TEST_PREFIX: 테스트 파일은 SRC_PREFIX를 제외한 경로에 'tests/' 폴더를 추가한 경로로 설정
* TEST_RUN_SCRIPT: 테스트 실행 스크립트
*/
const SRC_PREFIX = 'project/';
const TEST_PREFIX = `${SRC_PREFIX}tests/`;
const TEST_RUN_SCRIPT = 'pnpm vitest run';
(function () {
const files = getCommittedFiles();
if (files.length === 0) {
console.log('수정된 파일이 없습니다. 푸시를 계속합니다.');
process.exit(0);
}
const hasFailure = runTests(files);
if (hasFailure) {
console.error('푸시를 중단합니다. 위의 오류를 확인하세요.');
process.exit(1);
}
console.log('모든 테스트가 성공적으로 통과했습니다. 푸시를 계속합니다.');
process.exit(0);
})();
function getCommittedFiles(): string[] {
try {
const input = fs.readFileSync(0, 'utf-8');
const lines = input.trim().split('\n');
let committedFiles: string[] = [];
lines.forEach(line => {
const [_, localSha, __, remoteSha] = line.split(' ');
const isLocalBranch = remoteSha === '0000000000000000000000000000000000000000';
if (isLocalBranch) {
const listCommand = `git ls-tree -r --name-only ${localSha}`;
const output = execSync(listCommand, { encoding: 'utf-8' });
committedFiles = committedFiles.concat(output.split('\n').filter(file => file.trim() !== ''));
} else {
const diffCommand = `git diff --name-only ${remoteSha} ${localSha}`;
const output = execSync(diffCommand, { encoding: 'utf-8' });
committedFiles = committedFiles.concat(output.split('\n').filter(file => file.trim() !== ''));
}
});
const uniqueFiles = Array.from(new Set(committedFiles))
return uniqueFiles;
} catch (error) {
console.error('푸시된 파일을 가져오는 중 오류가 발생했습니다.');
process.exit(1);
}
}
/**
* 테스트 파일이 존재하는지 확인하고, 존재하면 테스트를 실행합니다.
* @param {string[]} files 소스 파일 목록
* @returns {boolean} 테스트 실패 여부
*/
function runTests(files: string[]): boolean {
let hasFailure = false;
for (const file of files) {
const testFile = mapToTestFile(file);
if (testFile) {
if (fs.existsSync(testFile)) {
console.log(`테스트 실행: ${testFile}`);
try {
execSync(`${TEST_RUN_SCRIPT} ${testFile}`, { stdio: 'inherit' });
} catch (error) {
console.error(`테스트 실패: ${testFile}`);
hasFailure = true;
}
} else {
console.error(`테스트 실패: 테스트 파일이 존재하지 않습니다: ${testFile}`);
hasFailure = true;
}
} else {
console.log(`테스트를 작성해야 합니다: ${file}`);
}
}
return hasFailure;
}
/**
* 소스 파일 경로를 테스트 파일 경로로 변환합니다.
* 예: project/folder1/folder2/A.vue -> project/tests/folder1/folder2/A.spec.js
* @param {string} file 소스 파일 경로
* @returns {string | null} 대응하는 테스트 파일 경로 또는 null
*/
function mapToTestFile(file: string): string | null {
const testExtensions = ['.spec.js', '.test.js', '.spec.ts', '.test.ts'];
const isTestFile = testExtensions.some(ext => file.endsWith(ext));
if (isTestFile) return file;
if (!file.startsWith(SRC_PREFIX)) return null;
const relativePath = file.slice(SRC_PREFIX.length);
const dir = path.dirname(relativePath);
const ext = path.extname(relativePath);
const baseName = path.basename(relativePath, ext);
const testPatterns = testExtensions.map(ext => `${baseName}${ext}`);
for (const pattern of testPatterns) {
const testFilePath = path.join(TEST_PREFIX, dir, pattern);
if (fs.existsSync(testFilePath)) {
return testFilePath;
}
}
return null;
}
3. 마치며…
이번 시간에는 Pre-Push 단계에서 커밋한 파일의 테스트를 자동으로 실행하도록 설정해보았다. 생각보다 작업은 단순했지만, 코드를 푸시하기 전에 자동으로 테스트를 거치게 되어, 코드의 안정성과 품질을 향상시킬 수 있었다. 이러한 자동화는 버그를 사전에 발견하고, 일관된 코드 품질을 유지하는 데 중요한 역할을 한다.
하지만, 이 작업만으로 아직은 아쉽다. 장기적인 코드 품질 유지를 위해서 지속적인 테스트 코드의 관리와 커버리지 모니터링이 필수적이다. 그래서 다음 포스팅에서는 커버리지 감소를 확인하는 방법을 배우면서, 테스트 커버리지를 지속 관리하고 향상시키는 방법을 만들어 볼 예정이다. 🎯
'개발 기술 > 개발 이야기' 카테고리의 다른 글
테스트 코드를 관리하는 법 2: 커버리지 감소 검사하기 (0) | 2025.01.24 |
---|---|
아이콘 컴포넌트 렌더링 방식, 정말 좋을까? (with. 빌드 시간, FID, TBT 등 비교) (0) | 2024.08.11 |
[JS/CSS] corner smoothing을 구현하는 법(feat. 부드러운 둥근 모서리) (0) | 2024.06.18 |
이미지, 배경이미지의 지연 로드 구현 방법(with. Vue) (2) | 2024.06.10 |
이미지, 배경이미지의 지연 로드 구현 방법 (with. intersectionObserver API) (0) | 2024.05.30 |
댓글