etc

기존 빌더와의 호환성 검증을 위한 통합 테스트코드 작성

하리하링웹 2024. 7. 3. 22:12

개요

최근 레포지토리가 점점 커지면서 파일의 증가로 인해 전체 빌드 시 간간히 메모리 초과 문제가 발생하여, 빌드 시스템을 리팩토링하는 작업을 진행하였다. 이 빌더는 약 150명의 개발자가 사용하는 회사 전체 프로젝트 배포에 사용되므로, 기존 빌더와의 완벽한 호환성을 유지하는 것이 1순위 목표였고 개발 자체는 후순위였다. 따라서, 테스트 코드를 통해 기존 빌더와의 동기화 유무를 확실하게 잡고 개발을 시작하기로 하였다.

 

빌더는 내부에서 child_process를 사용하여 멀티 프로세서 환경에서 tsc를 사용한 컴파일과 rollup을 사용한 번들링 과정을 거쳐 빌드 파일을 생성하는 방식으로 동작하고 있었다. 단위 테스트보다는 결과 파일이 동일한지를 확인하는 것이 중요했기 때문에 빌더를 실행 시킨 뒤 최종 결과물이 일치하는지 비교해야 했다.

 

따라서, 테스트 코드 내부에서 child_process 모듈을 사용하여 Node.js 명령어를 실행시켜 각 버전의 빌더를 동작시키고, outputDir 위치를 다르게 한 뒤 생성된 두 개의 outputDir을 기반으로 내부 파일과 콘텐츠를 비교하여 일치하는지를 확인하는 방식으로 테스트 코드를 작성하였다.

코드

모듈 및 변수 선언

import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';

const fsStorageDir = '[결과물 경로]/fs-storage-builder-test';
const defaultDir = '[결과물 경로]/default-builder-test';

const commands = [
    'node --max-old-space-size=10000 [실행 파일 경로] --targetFsPath [타겟 경로] --mode [빌드 모드] --force --rootPath [루트 경로] --distPath [dist 경로] --outputDirName fs-storage-builder-test --buildStrategy fs-storage',
    'node --max-old-space-size=10000 [실행 파일 경로] --targetFsPath [타겟 경로] --mode [빌드 모드] --force --rootPath [루트 경로] --distPath [dist 경로] --outputDirName default-builder-test --buildStrategy default',
];

테스트 함수

테스트 함수는 기존 테스트 파일을 삭제하고, 두 빌더 명령어를 실행하여 결과 디렉토리를 생성한 후, 이 디렉토리들의 내용을 비교한다.

test('[fs-storage-builder] Work with fs-storage-builder', async () => {
    clearTestFiles();
    addExitHandler();

    for (const command of commands) {
        console.log(`Executing command: ${command}`);
        await new Promise((resolve, reject) => {
            exec(command, { env: { ...process.env, NODE_OPTIONS: '' } }, (error, stdout, stderr) => {
                if (error) {
                    console.error(`Error executing command: ${command}`, error);
                    return reject(error);
                }
                console.log(`Command executed successfully: ${command}`);
                resolve('');
            });
        });
    }

    const result = await compareDirectories(fsStorageDir, defaultDir);
    expect(result).toBe(true);

    console.log('\\x1b[32mTest completed successfully\\x1b[0m');
    clearTestFiles();
}, 1800000); // 30 minutes = 1800000 milli

디렉토리 정리 함수

테스트 전후로 생성된 파일들을 삭제하여 테스트 환경을 보장해준다.

jsxCopy code
function clearTestFiles() {
    fs.rmSync('D:/[outputDir 경로]/fs-storage-builder-test', { recursive: true, force: true });
    fs.rmSync('D:/[outputDir 경로]/default-builder-test', { recursive: true, force: true });
}

종료 핸들러 함수

테스트 도중 프로세스가 종료될 때도 파일을 정리하도록 다양한 종료 시그널을 추가해준다.

function addExitHandler() {
    process.on('exit', () => {
        console.log('[exit] clear directory');
        clearTestFiles();
    });

    process.on('SIGINT', () => {
        console.log('Process interrupted');
        process.exit(0);
    });

    process.on('SIGHUP', () => {
        console.log('Terminal closed');
        process.exit(0);
    });

    process.on('uncaughtException', (error) => {
        console.error('Uncaught exception:', error);
        process.exit(1);
    });
}

디렉토리 비교 함수

두 디렉토리의 파일 목록과 각 파일의 내용을 비교하여 일치하는지 확인한다.

async function compareDirectories(dir1, dir2) {
    try {
        const files1 = await getFiles(dir1);
        const files2 = await getFiles(dir2);

        if (files1.length !== files2.length) {
            console.error('\\x1b[31mFile count is different.\\x1b[0m');
            return false;
        }

        for (let i = 0; i < files1.length; i++) {
            const file1 = files1[i];
            const file2 = files2[i];

            if (file1.relativePath !== file2.relativePath) {
                console.error(`\\x1b[31mFile path is different: ${file1.relativePath} !== ${file2.relativePath}\\x1b[0m`);
                return false;
            }
            console.log(`[File path is same] ${file1.relativePath}`);

            const content1 = fs.readFileSync(file1.fullPath, 'utf8');
            const content2 = fs.readFileSync(file2.fullPath, 'utf8');

            if (content1 !== content2) {
                console.error(`\\x1b[31mFile content is different: ${file1.relativePath}\\x1b[0m`);
                return false;
            }

            console.log(`\\x1b[32m[File content is same] ${file1.relativePath}\\x1b[0m`);
        }

        console.log('\\x1b[32mAll files are same.\\x1b[0m');
        return true;
    } catch (error) {
        console.error('\\x1b[31mUnexpected error occurred while comparing directories.\\x1b[0m', error);
        return false;
    }
}

디렉토리 내부 파일 가져오기

재귀적으로 디렉토리를 순회하며 파일 목록을 가져온다.

async function getFiles(dir) {
    const files = [];

    function traverse(directory, relativePath = '') {
        const entries = fs.readdirSync(directory, { withFileTypes: true });

        for (const entry of entries) {
            const fullPath = path.join(directory, entry.name);
            const entryRelativePath = path.join(relativePath, entry.name);

            if (entry.isDirectory()) {
                traverse(fullPath, entryRelativePath);
            } else {
                files.push({ fullPath, relativePath: entryRelativePath });
            }
        }
    }

    traverse(dir);
    return files;
}

전체코드

import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';

const fsStorageDir = '[결과물 경로]/fs-storage-builder-test';
const defaultDir = '[결과물 경로]/default-builder-test';

const commands = [
    'node --max-old-space-size=10000 [실행 파일 경로] --targetFsPath [타겟 경로] --mode [빌드 모드] --force --rootPath [루트 경로] --distPath [dist 경로] --outputDirName fs-storage-builder-test --buildStrategy fs-storage',
    'node --max-old-space-size=10000 [실행 파일 경로] --targetFsPath [타겟 경로] --mode [빌드 모드] --force --rootPath [루트 경로] --distPath [dist 경로] --outputDirName default-builder-test --buildStrategy default',
];

test('[fs-storage-builder] Work with fs-storage-builder', async () => {
    // remove test files when test is started, and add exit handler to remove test files when test is finished
    clearTestFiles();
    addExitHandler();

    for (const command of commands) {
        console.log(`Executing command: ${command}`);
        await new Promise((resolve, reject) => {
            exec(command, { env: { ...process.env, NODE_OPTIONS: '' } }, (error: any, stdout: any, stderr: any) => {
                if (error) {
                    console.error(`Error executing command: ${command}`, error);
                    return reject(error);
                }
                console.log(`Command executed successfully: ${command}`);
                resolve('');
            });
        });
    }

    const result = await compareDirectories(fsStorageDir, defaultDir);
    expect(result).toBe(true);

    console.log('\\x1b[32mTest completed successfully\\x1b[0m');
    clearTestFiles();
}, 1800000); // 30 minutes = 1800000 milli

function clearTestFiles() {
    fs.rmSync('D:/[outputDir 경로]/fs-storage-builder-test', { recursive: true, force: true });
    fs.rmSync('D:/[outputDir 경로]/default-builder-test', { recursive: true, force: true });
}

// 테스트 종료 시 파일 삭제
function addExitHandler() {
    process.on('exit', () => {
        console.log('[exit] clear directory');
        clearTestFiles();
    });

    process.on('SIGINT', () => {
        console.log('Process interrupted');
        process.exit(0);
    });

    process.on('SIGHUP', () => {
        console.log('Terminal closed');
        process.exit(0);
    });

    process.on('uncaughtException', (error) => {
        console.error('Uncaught exception:', error);
        process.exit(1);
    });
}

// 디렉토리 비교
async function compareDirectories(dir1: string, dir2: string) {
    try {
        const files1 = await getFiles(dir1);
        const files2 = await getFiles(dir2);

        if (files1.length !== files2.length) {
            console.error('\\x1b[31mFile count is different.\\x1b[0m');
            return false;
        }

        for (let i = 0; i < files1.length; i++) {
            const file1 = files1[i];
            const file2 = files2[i];

            if (file1.relativePath !== file2.relativePath) {
                console.error(`\\x1b[31mFile path is different: ${file1.relativePath} !== ${file2.relativePath}\\x1b[0m`);
                return false;
            }
            console.log(`[File path is same] ${file1.relativePath}`);

            const content1 = fs.readFileSync(file1.fullPath, 'utf8');
            const content2 = fs.readFileSync(file2.fullPath, 'utf8');

            if (content1 !== content2) {
                console.error(`\\x1b[31mFile content is different: ${file1.relativePath}\\x1b[0m`);
                return false;
            }

            console.log(`\\x1b[32m[File content is same] ${file1.relativePath}\\x1b[0m`);
        }

        console.log('\\x1b[32mAll files are same.\\x1b[0m');
        return true;
    } catch (error) {
        console.error('\\x1b[31mUnexpected error occurred while comparing directories.\\x1b[0m', error);
        return false;
    }
}

// 디렉토리 내부의 모든 파일을 가져오는 함수
async function getFiles(dir: string) {
    const files: { fullPath: string; relativePath: string }[] = [];

    function traverse(directory: string, relativePath = '') {
        const entries = fs.readdirSync(directory, { withFileTypes: true });

        for (const entry of entries) {
            const fullPath = path.join(directory, entry.name);
            const entryRelativePath = path.join(relativePath, entry.name);

            if (entry.isDirectory()) {
                traverse(fullPath, entryRelativePath);
            } else {
                files.push({ fullPath, relativePath: entryRelativePath });
            }
        }
    }

    traverse(dir);
    return files;
}

마무리

이번 작업은 빌드를 기다리면서 남는 시간에 작성한 것이라 큰 내용은 없지만, 처음 개발한 알파 단계에서 유효한 방법을 찾는 데 집중했다. 아마 시간이 지나고 더 좋은 방법이 나올 수도 있겠고 당연히 더 나은 방법이 있겠지만, 이 방식이 기본 틀로써 유용할 것 같아 글로 작성해보았다.

 

테스트 코드를 많이 작성해본 경험이 없기에 최적의 방법인지는 확신할 수 없지만, 최종적인 결과물이 필요하고 코드를 유닛 단위로 테스트할 수 없는 상황에서는 이러한 접근법도 충분히 효과적일 것이라 생각한다. 앞으로도 개선의 여지가 많다고 느껴지지만, 이 글이 비슷한 문제를 겪는 개발자들에게 작은 도움이 되었으면 한다.