..

테스트 없는 코드에 테스트 붙이기

배경

이종립님vim wiki를 참고하여 최근에 개인 블로그를 새로 만들었는데, 여기에 포함된 generateData.js 파일을 나에게 필요에 맞게 수정할 필요가 생겼다. 수정하는 것 자체는 약간의 조건문과 정규식을 추가하면 되는 일로 간단했지만, 요즘 공부 중인 테스트 코드 작성을 연습하기 위해 테스트 주도 개발(?)을 해보기로 했다.

테스트 준비

테스트 없이 이미 작성되어 있는 코드에 대해 테스트를 작성해야 하므로 점진적으로 Red, Green, Refactor을 반복하는 형태로 진행할 수는 없다. 그런데 이 코드는 /_wiki, /_posts 에 있는 .md파일들을 가지고 데이터를 생성하는 역할을 하는 Pure function이다. 따라서 React Component를 테스트 할 때 render되는 결과물만을 가지고 테스트하는 것처럼, 이것도 내부 함수 각각은 테스트하지 않고 생성되는 데이터만을 가지고 테스트하기로 했다.

수정 전 코드

여기에서 수정 전 generateData.js를 확인할 수 있다.

수정 후 코드

여기에서 현재 내가 사용하고 있는 generateData.js를 확인할 수 있다.

테스트 데이터 준비

_posts과_wiki에 글 몇 개를 올린 뒤, 아무 수정도 가하지 않은 generateData.js를 실행해 얻은 .json, .yml파일들을 이용해 다음과 같은 형태의 테스트 데이터를 준비하였다.

yamlData.js

import fs from 'fs'

export default function getYAMLTestData(name) {
    return fs.readFileSync(fixture/${name}.yml).toString();
}

tagData.js

export const trans = '{생략(JSON)}'
export const thought = '{생략(JSON)}'
export const retro = '{생략(JSON)}'
export const howto = '{생략(JSON)}'
export const latex = '{생략(JSON)}'

테스트 코드 작성

.json 테스트 코드

맨 처음에는 .json파일만을 대상으로 다음과 같은 테스트를 작성했다.

import fs from 'fs'
import {trans, thought, retro, howto, latex } from './fixture/tagdata'

describe('test data.tag/*.json', () => {
    it('번역.json', () => {
        const data = fs.readFileSync('./data/tag/번역.json','utf8').toString();
        expect(data).toBe(trans)
    })
    it('생각.json', () => {
        const data = fs.readFileSync('./data/tag/생각.json','utf8').toString();
        expect(data).toBe(thought)
    })
    it('회고.json', () => {
        const data = fs.readFileSync('./data/tag/회고.json','utf8').toString();
        expect(data).toBe(retro)
    })
    it('how-to.json', () => {
        const data = fs.readFileSync('./data/tag/how-to.json','utf8').toString();
        expect(data).toBe(howto)
    })
    it('latex.json', () => {
        const data = fs.readFileSync('./data/tag/latex.json','utf8').toString();
        expect(data).toBe(latex)
    })
})

이렇게 해서 일단 통과를 시켰다. 운영코드가 이미 작성되어있어서 테스트 코드를 먼저 실패시키는(Red)단계를 거치지 않았기 때문에 이 테스트가 정상 동작하는지 믿을 수 없다. 따라서 운영코드를 몇 군데 망가뜨렸을 때 테스트 코드가 실패하는지 몇 차례 확인했다.

Green를 보기까지 꽤나 긴 시간이 걸렸지만 테스트보다 코드가 먼저 작성되어 있었기 때문에 어쩔 수 없다. 이제부터는 짧은 주기로 Red-Green-Refactor를 돌릴 수 있을 것이다.

이제 .yaml 데이터를 대상으로 테스트 코드를 작성하기 전에, 테스트 코드에 중복이 존재하므로 이를 제거하고 싶다. 어디선가 본 parameterized test를 이용하면 될 것 같은데 한번도 제대로 사용해본 적이 없었다. 하지만 별로 두렵지 않았다. 이미 운영코드에 대하여 테스트 코드는 정상적으로 동작하므로, 테스트 코드를 수정할 때는 운영코드가 테스트 코드에 대한 테스트 코드 역할을 미약하게나마 할 수 있다. parameterized test로 테스트 코드를 고쳤을 때 테스트가 여전히 통과한다면, 테스트 코드의 중복제거는 성공적으로 이루어진 것이라고 볼 수 있다.

아래는 parameterized test를 이용해 중복을 제거한 코드이다.

import fs from 'fs'
import {trans, thought, retro, howto, latex } from './fixture/tagdata'

describe('test data.tag/* .json', () => {
    it.each
        source      |   expected
        ${'번역'}    |   ${trans}   
        ${'생각'}    |   ${thought}
        ${'회고'}    |   ${retro}
        ${'how-to'} |   ${howto}
        ${'latex'}  |   ${latex}
    
    ($source .json, ({source, expected}) => {
        const output = fs.readFileSync(./data/tag/${source}.json,'utf8').toString();
        expect(output).toBe(expected)
    })
})

.yml 테스트 코드

앞선 과정과 동일한 과정으로, .yml 파일에 대한 테스트 코드도 작성하였다. 이번에는 each에 배열을 넘기는 형태로 작성해봤다.

import getYAMLTestData from './fixture/yamlData'

describe('test _data/* .yml', () => {
    it.each(
        [
            'pageMap', 
            'tagCount',
            'tagMap'
        ]
        )(%s .yml, filename => {
        const output = fs.readFileSync(./_data/${filename}.yml,'utf8').toString();
        expect(output).toBe(getYAMLTestData(filename))
    })
}) 

리팩터링

generateData.js를 수정(기능추가)하기 전에, 몇 가지 리팩터링을 하기로 했다. 이제 generateData.js를 검증해주는 테스트코드가 존재하므로, 리팩터링은 쉽다. 리팩터링 전후 운영코드의 기능이 동일함이 보장되기 때문이다. 그 기능이라함은 올바른 내용의 .json과 .yml파일을 생성하는 것을 말한다.

콜백함수를 화살표함수로 작성하거나 var나 let를 const로 바꾸는 등의 자잘한 작업 외에, 크게 두 가지 부분에서 리팩터링하였다.

비교함수 중복 제거

원래 코드에서 toLowerCase().localeCompare를 검색하면 총 4개 부분이 검색된다. 이는 비슷한 비교함수로 sort를 하는 부분인데, 새롭게 lexicalOrderingBy함수를 다음과 같이 선언하면 이 중복들을 모두 제거할 수 있다.

function lexicalOrderingBy(property) {
    return (a, b) => a[property].toLowerCase().localeCompare(b[property].toLowerCase())
}

예를 들어

dataList.sort(function(a, b) {
    return a.url.toLowerCase().localeCompare(b.url.toLowerCase());
})

위 코드는 아래와 같이 바꿀 수 있다.

dataList.sort(lexicalOrderingBy('url'))

화살표 함수를 이용한 체이닝 가독성 개선

앞서 언급한 lexicalOrderingBy함수를 함께 이용하면, 아래 코드를

const dataList = list.map(function collectData(file) {

    const data = fs.readFileSync(file.path, 'utf8');
    return parseInfo(file, data.split('---')[1]);

}).filter(function removeNullData(row) {

    return row != null;

}).filter(function removePrivate(row) {

    return row.public != 'false';

}).sort(function sortByFileName(a, b) {

    return a.fileName.toLowerCase().localeCompare(b.fileName.toLowerCase());

});

다음과 같이 바꿀 수 있다. collectData함수는 따로 뺐다.

const dataList = list.map(file => collectData(file))
                     .filter((row) => row != null)
                     .filter((row) => row.public != 'false')
                     .sort(lexicalOrderingBy('fileName'))

기능 수정

원래 코드에서 .json파일에 저장되는 url은 /blog/2020/04/12/equal 의 형태이다. 하지만 나는 /equal의 형태로 저장하고 싶었다. 이를 달성하기 위해 테스트 데이터에 있는 url부분을 수정한 뒤, 테스트가 실패함을 확인하였다. 이후 generateData.js를 다음과 같이 수정하여 테스트가 통과함을 확인하고, 작업을 마쳤다.

기능 추가 전

dataList.sort(lexicalOrderingBy('url'))
        .forEach((page) => { 
            pageMap[page.fileName] = 
                        {
                            type: page.type,
                            title: page.title,
                            summary: page.summary,
                            parent: page.parent,
                            url: page.url,
                            updated: page.updated || page.date,
                            children: [],
                        };
        });

기능 추가 후

dataList.sort(lexicalOrderingBy('url'))
        .forEach((page) => {
   const name = page.url.match(/([^\/]*?)(?:--.*)?$/g);
   
            pageMap[page.fileName] = 
                        {
                            type: page.type,
                            title: page.title,
                            summary: page.summary,
                            parent: page.parent,
                            url: (page.url.includes('blog')) ? '/' + name[0] : page.url,
                            updated: page.updated || page.date,
                            children: [],
                        };
        });

느낀 점

이 과정에서 나는 이 코드에 대해 깊게 이해할 필요도, 작성자의 의도에 맞지 않게 코드를 수정할까봐 걱정할 필요도 없었다. 나는 이 코드가 어떻게 돌아가는지는 정확히 몰라도 이게 무엇을 하기 위해 존재하는지는 확실히 알았다. 그리고 그것은 테스트 코드가 검증해줄 것이므로, 테스트가 통과하는 한 걱정할 것은 없고, 통과하지 않더라도 jest가 제공한 정보를 바탕으로 고쳐서 통과시키면 그만이다.

기타

약한 테스트 데이터

테스트 데이터로 사용한 글의 수가 너무 적어서, generateData.js의 정렬과 관련된 코드 일부를 망가뜨려도 테스트가 통과하는 문제가 발견되었다. 일단 코드의 동작에는 문제가 없는 것으로 보이고 테스트가 코드의 기능을 충분히 반영하지 못해서 생기는 문제로 보인다. 원래는 정렬 기능이 없을 때 통과 -> 정렬 기능을 반영하도록 테스트 수정 -> 테스트 실패 -> 정렬 기능을 운영 코드에 추가 -> 테스트 통과 의 과정을 거쳤어야 하는데. 이미 작성된 코드에 테스트를 붙이다보니 어쩔 수 없었다. 역시 이래서 실제 개발 할 때에도 한번에 코드를 다 작성하고 테스트를 작성하는 것이 아니라, 점진적으로 운영코드와 테스트코드 사이를 오가야 하는 것이다.

추후 블로그에 올리는 글 수가 충분히 쌓인 뒤에 테스트 데이터를 강화해 개선할 것이다.

테스트 모드

현재 generateData.js는 getFiles(‘./_wiki’, ‘wiki’, list), getFiles(‘./_posts’, ‘blog’, list) 와 같이 전체 문서를 가져온다. 그런데 항상 이렇게 실행해서는 테스트 환경에서 사용한 파일 외에 다른 파일들이 추가된 상황에서는 이미 만들어두었던 테스트 데이터를 활용할 수 없다.

이를 해결하기 위해서 다음과 같이 getFile.js에 4번째 매개변수를 추가하였다.

function getFiles(path, type, array, testFileList = null) {

    fs.readdirSync(path).forEach(fileName => {
        
        const subPath = path + '/' + fileName;

        if (isDirectory(subPath)) {
            return getFiles(subPath, type, array, testFileList);
        }
        if (isMarkdown(fileName)) {
            if(testFileList && !testFileList.includes(fileName)) return;

            const obj = {
                'path': path + '/' + fileName,
                'type': type,
                'name': fileName,
                'children': [],
            };
            return array.push(obj);
        }
    });
}

이렇게 수정하면, getFiles.js를 수정할 때와 사용할 때를 구분하여 getFile를 다음과 같이 호출하면 된다.

테스트 모드

//4번째 인자는 테스트 데이터 생성 시에 사용했던 파일들 목록이다
getFiles('./_wiki', 'wiki', list, ['how-to.md', 
                                   'index.md', 
                                   'mathjax-latex.md']);

getFiles('./_posts', 'blog', list, ['2020-04-01-genius.md',
                                    '2020-04-12-equal.md',
                                    '2020-04-22-life-is-short.md',
                                    '2020-04-25-like.md',
                                    '2020-06-14-belief.md',
                                    '2020-06-29-let-them-in.md',
                                    '2021-01-08-planning.md',
                                    '2021-04-11-210411ps.md']);

사용 모드

getFiles('./_wiki', 'wiki', list);
getFiles('./_posts', 'blog', list);