소프트웨어 공학

[Go with TDD] 테이블 주도 테스트

개요

이전에 '깔끔하게 go test의 테스트케이스를 짜는 법'이라는 글을 쓴 적이 있다. 오늘 설명할 테이블 주도 테스트는 해당 글의 내용을 정형화한 기법이다.

테이블 주도 테스트

테이블 주도 테스트(table-driven test)는 메소드에 대한 여러 개의 테스트 케이스를 하나의 테이블로 구성하는 테스트 기법이다.

테스트할 메소드 하나에는 여러 개의 테스트 케이스가 존재한다. 일반적으로 테스트를 작성하면 하나의 테스트 함수는 하나의 테스트 케이스를 실행한다. 이렇게 작성하는 것이 잘못된 것은 아니지만 호출할 메소드가 같기 때문에 코드의 중복이 생긴다. 테이블 주도 테스트는 하나의 테스트 함수에 테스트할 메소드의 테스트 케이스를 묶어 테이블로 만든 후 해당 테이블을 순회하며 테스트를 진행하는 구조이다.

기존의 테스트 방식에서 테스트 함수와 테스트 케이스가 1대 1 매칭이 된다면, 테이블 주도 테스트 방식에서는 테스트  함수와 테스트할 메소드가 1대 1 매칭이 된다. 테스트할 하나의 메소드에는 하나 이상의 테스트 케이스가 존재하기 때문에 전체적인 테스트 함수의 수는 줄어든다.

구조

테이블은 익명 구조체의 배열을 자료형으로 쓴다. 테이블을 구성하는 익명 구조체는 테스트 케이스의 자료형으로 해당 구조체의 멤버 변수로 해당 테스트 케이스에 대한 설명, 테스트할 객체와 매개변수, 결과 등이 들어간다. 테스트는 해당 테이블을 순회하며 테스트할 함수를 호출하는 것으로 이루어진다.

장점

  • 중복 코드를 제거할 수 있다. 메소드와 테스트 함수가 1대 1로 매칭되기 때문에 테스트할 메소드를 여러번 호출할 필요가 없다.
  • 가독성이 높아진다. 코드 측면에서 보자면 테스트 케이스가 구조체 형식이기 때문에 각 케이스의 변수가 무엇을 의미하는지 파악하기 쉽다. 실행 측면에서 보자면 테스트가 실패했을 때 테스트 케이스의 설명 항목을 통해 어떤 테스트 케이스가 오류를 내는지 알 수 있다.
  • 테스트 케이스를 추가하기 쉽다. 테이블에 원소를 추가하는 것 외의 작업이 필요가 없다.

구현

해당 코드는 개인 토이 프로젝트에서 가져왔다. 사실 진정한 의미의 TDD를 위해서는 아래의 구현보다 유닛 테스트 작성이 선행되어야 한다.

//github.com/simp7/wordReminder-core/voca
package voca

type Meaning interface {
    Unit
    Type() Class
}

type Class uint8

//github.com/simp7/wordReminder-core/voca/class
package class

import "github.com/simp7/wordReminder-core/voca"

const (
    Noun voca.Class = iota
    Verb
    Adverb
    Adjective
    Conjunction
    Pronoun
    Preposition
    Interjection
    Idiom
)

//github.com/simp7/wordReminder-core/voca/meaning
package meaning

import (
    "github.com/simp7/wordReminder-core/voca"
    "github.com/simp7/wordReminder-core/voca/class"
)

type meaning struct {
    Class voca.Class
    Mean  string
}

func Noun(mean string) voca.Meaning {
    return meaning{Class: class.Noun, Mean: mean}
}

func Verb(mean string) voca.Meaning {
    return meaning{Class: class.Verb, Mean: mean}
}

func Adjective(mean string) voca.Meaning {
    return meaning{Class: class.Adjective, Mean: mean}
}

func Adverb(mean string) voca.Meaning {
    return meaning{Class: class.Adverb, Mean: mean}
}

func Conjunction(mean string) voca.Meaning {
    return meaning{Class: class.Conjunction, Mean: mean}
}

func Interjection(mean string) voca.Meaning {
    return meaning{Class: class.Interjection, Mean: mean}
}

func Preposition(mean string) voca.Meaning {
    return meaning{Class: class.Preposition, Mean: mean}
}

func Pronoun(mean string) voca.Meaning {
    return meaning{Class: class.Pronoun, Mean: mean}
}

func Idiom(mean string) voca.Meaning {
    return meaning{Class: class.Idiom, Mean: mean}
}

func (m meaning) IsRight(input string) bool {
    return m.Mean == input
}

func (m meaning) String() string {
    return m.Mean
}

func (m meaning) Type() voca.Class {
    return m.Class
}

패키지에 대해 간단히 설명하자면 Meaning은 단어의 뜻을 나타내는 구조체이다. Meaning은 명사, 동사 등 품사를 나타내는 Class와 뜻을 나타내는 Mean으로 구성되어 있다. String()은 뜻을 반환하고 Type()은 품사를 반환하며 IsRight(string)는 매개변수로 받은 string이 해당 뜻과 일치하는지를 반환한다.

이제 해당 유닛 테스트를 작성해보자.

//github.com/simp7/wordReminder-core/voca/meaning
package problem

import (
    "github.com/simp7/wordReminder-core/test"
    "github.com/simp7/wordReminder-core/voca/meaning"
    "github.com/simp7/wordReminder-core/voca/word"
    "github.com/stretchr/testify/assert"
    "testing"
)

func TestMeaningProblem_Question(t *testing.T) {

    scenario := []struct {
        desc    string
        problem test.Problem
        output  string
    }{
        {"a meaning", Meaning(word.New("apple", meaning.Noun("사과"))), "apple"},
        {"meanings", Meaning(word.New("go", meaning.Noun("Go 언어"), meaning.Verb("가다"))), "go"},
    }

    for _, v := range scenario {
        assert.Equal(t, v.output, v.problem.Question().String(), v.desc)
    }
}

func TestMeaningProblem_IsCorrect(t *testing.T) {

    scenario := []struct {
        desc    string
        problem test.Problem
        input   string
        output  bool
    }{
        {"a meaning right", Meaning(word.New("apple", meaning.Noun("사과"))), "사과", true},
        {"a meaning wrong", Meaning(word.New("apple", meaning.Noun("사과"))), "파인애플", false},
        {"meanings right 1", Meaning(word.New("go", meaning.Noun("Go 언어"), meaning.Verb("가다"))), "가다", true},
        {"meanings right 2", Meaning(word.New("go", meaning.Noun("Go 언어"), meaning.Verb("가다"))), "Go 언어", true},
        {"meanings wrong", Meaning(word.New("go", meaning.Noun("Go 언어"), meaning.Verb("가다"))), "하다", false},
    }

    for _, v := range scenario {
        assert.Equal(t, v.output, v.problem.IsCorrect(v.input), v.desc)
    }

}

테스트의 테이블(scenario)은 익명 구조체의 배열이다. 테이블을 구성하는 익명 구조체는 일반적으로 해당 테스트 케이스의 간략한 설명(desc)과, 테스트할 객체(problem)를 멤버변수로 가지고 있다. 여기에 메소드에 사용되는 매개변수의 값(input)과 올바르게 메소드가 작동했을 때의 출력값(output)이 추가적으로 들어간다.

테이블 주도 테스트 기법을 사용하지 않는 테스트에서 테스트 케이스를 추가하기 위해서는 테스트할 메서드에 대해 새로운 함수를 만들어야 했다. 하지만 테이블 주도 테스트 기법을 사용할 경우 테스트 케이스를 추가할 때 오직 형식에 맞춰 새로운 원소를 추가해주기만 하면 된다.

주의할 점으로 IDE의 힘을 빌리지 않고 위의 코드를 작성하면 해당 테스트 케이스의 변수들이 각자 무엇을 의미하는지 알기 힘들다는 것이다. 이 때에는 각 변수의 이름을 명시적으로 표기한다. 위의 예제에서 첫 번째 케이스의 경우에는 다음과 같이 표기하면 된다.

{desc:"a meaning right", problem:Meaning(word.New("apple", meaning.Noun("사과"))), input:"사과", output:true}

이 경우 코드의 길이가 길어지는 것은 감안해야 한다.

마치며...

테이블 주도 테스트는 여러 Go 언어 커뮤니티에서 권장하는 방식이다. 혹시 다른 방식으로 테스트를 작성하고 있었다면 테이블 주도 테스트를 한 번 쯤은 이용해보기를 바란다. Go 언어의 간결함을 좋아하는 사람이라면 아마 마음에 들 것이다.😉

참고 자료

  • 코리 스캇, Hands-On Dependency Injection in Go, 에이콘, 2020.5