Post

ViewModel과 Model 유닛 테스트하기

유닛 테스트란 어떤 단위의 모듈이 의도한 대로 동작하는지 확인하기 위한 과정입니다. 테스트 대상 단위가 정확하게 정해져있는 것은 아니지만, 이 단위의 크기를 작게 만들수록 테스트 코드를 작성하기가 쉽습니다. 데이터를 가져오는 로직과 그 데이터를 가공하는 로직, 그리고 가공한 데이터를 화면에 출력하는 로직이 하나의 컴포넌트에 전부 들어가있다고 생각해봅시다. 이 경우, 컴포넌트가 리턴하는 UI는 테스트해볼 수 있다고 하더라도, 컴포넌트 안에 얽혀있는 모든 함수들을 하나씩 뽑아내 테스트하기는 현실적으로 어렵습니다. 이러한 관점에서 MVVM 패턴은 유닛테스트를 진행하기 정말 좋은 패턴입니다. 모든 로직을 관리하던 컴포넌트에서 비즈니스 로직만을 따로 떼어내 함수 또는 클래스로 분리한 것이라고 보면 됩니다. 하나의 목적을 가지고 깔끔하게 분리된 함수와 클래스(ViewModel & Model)는 View와는 독립적으로 테스트를 진행할 수 있습니다.

유닛 테스트를 진행하게 된 이유

유닛 테스트는 당장 이 기능이 잘 돌아가는지 확인해주는 역할도 하지만, 리팩토링을 진행할 때 의도치 않게 코드가 하는 기능이 바뀌는 상황을 방지해주기도 합니다. 따라서 현재 진행하고 있는 회사 프로젝트의 코드가 예상치 못하게 변경되지 않도록 테스트 코드를 작성하게 되었습니다.

여태껏 만들었던 여러개의 Model과 ViewModel의 테스트 코드를 작성하면서, 각 함수와 클래스에 맞는 테스트 코드를 작성하기 위해 많은 고민을 했습니다. 테스트는 발생할 수 있는 상황을 최대한 고려해야하기 때문에 테스트 파일마다 정말 다른 케이스를 테스트하게 됩니다. 이번 글은 특정 함수/클래스가 어떤 데이터를 받고 어떤 값을 반환하는지를 파악하고, 어떻게 테스트를 해야하는지를 고민했던 과정을 남겨보려 합니다. 테스팅 프레임워크는 Jest를 사용했습니다.

Model 유닛 테스트

Model은 데이터를 받아오고 가공하는 역할을 합니다. 따라서 데이터가 들어온대로 잘 출력되는지, 가공하는 로직이 있다면 가공된 대로 형식이 잘 바뀌었는지를 테스트했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PostItem implements PostItemType {
    private readonly title: string

    private readonly content: string

    private readonly tags: string[]

    private readonly created: string

    private readonly thumbContent: string

    constructor(raw: PostDataType) {
        this.title = raw.title
        this.content = raw.content
        this.tags = raw.tags
        this.created = TimeLang(raw.created)
        this.thumbContent = DeleteTags.convert(raw.content)
    }

    getTitle = () => this.title

    getContent = () => this.content

    getTags = () => this.tags

    getCreated = () => this.created

    getThumbContent = () => this.thumbContent

이 Model을 한번 테스트 해보겠습니다. 게시글 데이터를 받아와서 각 속성에 따라 출력해야 하는 값을 설정해주고 있네요.

유닛 테스트를 할 때는 목업 데이터를 사용해야 합니다. 서버 데이터를 사용하게 되면 네트워크 작업으로 인한 속도 저하, 실제 데이터의 변경, 그로 인해 테스트를 진행할때마다 나오는 결과값이 달라지는 등 여러 단점들이 생기기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
describe('PostItem Model 테스트', () => {
    let item: PostItem

    beforeEach(() => {
        item = new PostItem({
            title: 'Hello',
            content: '<div>Nice to meet you!</div>',
            tags: ['flower', 'tree', 'butterfly'],
            created: '2024-05-02T00:11:17.289362+09:00',
        })
    })

    test('기본값 출력', () => {
        expect(item.getTitle()).toBe('Hello')
        expect(item.getContent()).toBe('<div>Nice to meet you!</div>')
        expect(item.getTags()).toEqual(['flower', 'tree', 'butterfly'])
        expect(item.getCreated()).toBe('2024년 5월 2일')
        expect(item.getThumbContent()).toBe('Nice to meet you!')
    })
})

게시글 데이터과 똑같은 형식을 가진 가짜 테스트 데이터를 만들어주었습니다. 그리고 beforeEach 메서드로 테스트를 진행하기 전마다 테스트 하고자 하는 변수값이 제가 만든 가짜 데이터로 초기화되도록 했습니다. 마지막으로 Model이 올바른 값을 리턴하는지 확인하기 위해 각 메서드의 반환값을 하나씩 빠짐없이 테스트를 돌려주었습니다. toBe, toEqualMatcher 함수가 결과값에 적절하게 사용되었는지도 확인해주었고요.

Reference 오류

그런데 테스트를 돌리니 에러가 발생했습니다. 어려울게 없는 코드인데 왜 에러가 났을까 하고 문제를 찾아보니 getCreated() 메서드에서 사용하는 함수 때문이었습니다. 해당 함수에서는 i18nmoment라는 외부 라이브러리를 사용해서 사용자의 위치 또는 사용자가 웹사이트에서 설정한 언어로 날짜를 반환해주고 있습니다. 그렇게 되면 위치나 사용자 설정에 따라 결과값이 달라지는 외부 의존성이 생기게 됩니다. 이런 현상을 방지하기 위해 jest.mock() 메서드를 사용해 외부 라이브러리를 mocking 해주어야 하는 것이지요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jest.mock('@src/locales/i18n', () => ({
    __esModule: true,
    use: () => {},
    init: () => {},
    default: {
        t: (k: string) => k,
        language: 'ko',
    },
}))

jest.mock('moment/dist/locale/ko', () => 'ko-kr')

describe(''PostItem Model 테스트'', () => {
    // ... Model 테스트 코드
})

i18n의 기본 설정파일을 mocking하고, 테스트를 진행하는 위치나 환경에 상관없이 한국어 날짜 형식만을 반환하도록 디폴트값을 변화시켜주었습니다. 그리고 moment 라이브러리도 테스트를 진행할 때 문제없이 텍스트 변환을 할 수 있게 한국어 파일을 mocking 했습니다. 외부 라이브러리까지 mocking해주니 테스트가 문제 없이 진행되었습니다.

마지막으로 getThumbContent()에서 태그 삭제를 위해 사용하고 있는 DeleteTags 클래스의 테스트를 진행했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
describe('DeleteTags 함수 테스트', () => {
    const deleteFn = DeleteTags.convert

    test('하나의 태그 삭제', () => {
        expect(deleteFn('<div>helloWorld</div>')).toBe('helloWorld')
        expect(deleteFn('<script>helloWorld</script>')).toBe('helloWorld')
    })

    test('이중 중첩 태그 삭제', () => {
        expect(deleteFn('<div><p>helloWorld</p></div>')).toBe('helloWorld')
        expect(deleteFn('<main><h1>helloWorld</h1></main>')).toBe('helloWorld')
    })

    test('이미지 태그 삭제', () => {
        expect(deleteFn('<img src="#" alt="" />')).toBe('')
    })
})

Model 테스트의 일부분으로 이 클래스를 테스트 한 것은 아니고, 별도의 테스트 코드로 작성했습니다. 제가 테스트 코드를 작성하면서 신경을 쓰고자 했던 부분인데, 유닛 테스트는 현재 테스트하는 모듈의 결과값만을 검증합니다. 중간 과정까지 테스트를 하면 결과값이 바뀌지 않는 (클래스 또는 함수의 목적성이 바뀌지 않은) 리팩토링을 진행할때도 테스트 코드를 수정해야 할 수 있습니다. 따라서 DeleteTags의 내부 로직이 변경되었어도 리턴값이 변하지 않았다면 PostItem Model의 테스트 코드에는 영향을 주지 않도록 테스트 코드를 분리했습니다.

ViewModel 유닛 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function PostListViewModel(data: PostList): PostListVM {
    // 모든 게시글을 반환
    function getPostList() {
        return data.getPostList()
    }

    // 특정 카테고리 아래의 게시글만 반환
    function getPostListUnderCategory(category: string) {
        return data.getPostList().filter((v) => v.getPostContent().category === category)
    }

    // 인자로 넣은 개수만큼의 게시글을 반환
    function cropPostList(count: number) {
        return data.getPostList().slice(0, count)
    }

    return { getPostList, getPostListUnderCategory, cropPostList }
}

이번에는 이 ViewModel을 테스트해보겠습니다. Model 테스트에서 사용되었던 PostItem을 호출하는 함수에 따라 조건에 맞게 배열에 담아 반환해주는 ViewModel입니다.

ViewModel 테스트도 Model 테스트와 다른 점은 없습니다. 그런데 numberstring과 같은 값을 반환한다면 위의 Model 테스트처럼 간단하게 테스트가 가능할텐데, 이 ViewModel은 PostItem 클래스의 인스턴스 배열을 반환합니다. 어떻게 테스트를 하면 좋을까요?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function instanceOfPostItem(object: PostItem): object is PostItem {
    return (
        'getTitle' in object &&
        'getContent' in object &&
        'getTags' in object &&
        'getCreated' in object &&
        'getThumbContent' in object
    )
}

describe('PostList ViewModel 테스트', () => {
    let postList: PostListVM

    beforeEach(() => {
        postList = PostListViewModel(postData)
    })

    test('함수 작동 테스트', () => {
        expect(instanceOfPostItem(postList.getPostList()[0])).toBe(true)
        expect(postList.getPostList()).toHaveLength(5)

        expect(instanceOfPostItem(postList.getPostListUnderCategory('freeboard')[0])).toBe(true)
        expect(postList.getPostListUnderCategory('freeboard')).toHaveLength(2)
        expect(postList.getPostListUnderCategory('freeboard')[0].getPostContent().category).toBe('freeboard')

        expect(instanceOfPostItem(postList.cropPostList(1)[0])).toBe(true)
        expect(postList.cropPostList(1)).toHaveLength(1)
    })
})

저는 PostItem Model을 테스트 했으니 PostItem이 돌려주는 값은 문제가 없다는 전제를 두었습니다. 그래서 ViewModel이 반환한 PostItem이 어떤값을 출력하는지를 보는 것이 아니라, ViewModel이 반환한 값이 PostItem의 배열이 맞는지를 테스트하기로 했습니다.

  1. 반환된 배열의 요소가 PostItem의 속성을 전부 가지고있는지 확인하는 함수를 작성하고 그 함수가 true를 반환하면 테스트를 통과
  2. 조건에 맞는 아이템들이 배열에 전부 포함되었는지는 배열의 length 로 판단
  3. getPostListUnderCategory() 함수의 경우, 해당 배열의 아이템들이 특정 카테고리 아래에 있어야 하기 때문에 category 속성이 원하는 값인지 확인

Model 테스트를 진행할때보다는 조금 더 고민을 거쳤지만, 위의 세가지 기준을 세우고 테스트 코드를 작성하니 금방 코드 작성을 끝낼 수 있었습니다.

마치며

테스트 코드를 작성하고 나니 아직 프로젝트가 크지 않은데도 리팩토링을 진행하다 테스트 결과가 바뀐 경험을 했습니다. 유닛 테스트를 하지 않았다면 몰랐을 변화였습니다. 이렇게 자신이 알지 못하게 코드가 바뀌면 오류를 감지하는데도 시간이 걸리고, 디버깅도 쉽지 않습니다. 작성할때는 이렇게 간단한것도 테스트 할 필요가 있을까 하다가도, 막상 실수를 마주하고 보니 테스트 코드를 촘촘하게 작성하는 일이 참 중요하다는 생각도 했습니다. 테스트를 작성해보면서 참 많은 것을 배웠다고 생각했는데, 글로 담아내려니 어렵습니다. 더 많은 공부와 경험을 통해 좋은 글을 써낼 수 있는 개발자가 되고싶다는 말과 함께 글 마치겠습니다 😊

This post is licensed under CC BY 4.0 by the author.