본문 바로가기

컴린이 탈출기/Python

유닛 테스트(Unit Test)의 중요성과 다양한 작성 예시 (feat. Python) 🕵

반응형

들어가기에 앞서...

이번 인턴십 기간 동안 나의 주된 업무 중 하나는 테스트 코드 작성이었다. 덕분에 이제는 테스트 코드 작성에 꽤나 익숙해졌지만, 처음에는 관련 자료를 찾을 수가 없어서 어려움을 많이 겪었었다. 나와 비슷한 어려움을 겪을 사람들을 위해, 또 인턴십 동안 배운 내용을 정리해보기 위해 테스트 코드, 그중에서도 유닛 테스트 작성에 대한 글을 써보고자 한다. 


유닛 테스트 (Unit Test)란?

유닛 테스트는 가능한 가장 작은 모듈(보통 클래스 또는 메소드 수준)이 의도한 대로 작동하는지 검증하는 테스트이다. 보통 테스트 대상 단위가 작을수록 테스트 코드가 간단해지고 디버깅하기도 쉬워지기 때문에, 모듈 크기를 적절히 작게 설정하는 것이 유닛 테스트의 좋은 시작점이라고 볼 수 있다. 

 

유닛 테스트 (Unit Test)는 어떻게 써야할까?

백문이불여일견. 간단한 예시를 살펴보도록 하자. 

# scripts/range_utils.py
def arange_incl(start, stop, step=1):
    return np.arange(start, stop + step, step)

위는 특정 범위의 Numpy의 array를 return 하는 함수이다. 

# tests/test_range_utils.py
import scripts.range_utils as ru


def test_arange_incl():
    assert np.array_equal(ru.arange_incl(1, 3), np.array([1, 2, 3]))

그리고 위는 arange_incl 함수를 테스트하는 유닛 테스트 코드이다. 기본적으로 assert 문을 사용하여 조건을 만족시키는지를 검사하고, 이때 조건은 모듈(여기서는 함수)의 결과가 매뉴얼 하게 적어준 정답과 일치하는지가 된다. 

테스트 코드는 단순히 내가 짠 코드가 이렇게 잘 돌아가요! 하고 자랑하기 위해 작성하는 코드가 아니다. 따라서 일반적인 상황부터 엣지 케이스들까지 여러 상황에 대해 테스트 코드를 작성하는 것이 중요하다. 위의 arange_incl 함수를 예로 들면, start가 1이고 stop이 3인 평범한 상황 외에도 step에 0이 들어갈 경우에는 어떤 array를 return 하는지, stop 값이 start 값보다 작은 경우는 어떻게 처리하는지 등을 테스트해보아야 한다.

그럼 지금부터는 유닛 테스트를 쓰면서 마주할 수 있는 다양하고도 복잡한 상황들을 어떻게 효율적으로 잘 대응할 수 있을지 구체적인 예시와 함께 알아보도록 하자.

 

1. 인풋 단순화 하기

위의 예시에서도 알 수 있다시피, 유닛 테스트의 대상이 되는 유닛, 모듈은 test 파일에서 단독으로 실행된다. 따라서 인풋 역시 test 파일에서 모두 명시되어야 한다. 클래스 내부의 메소드의 경우, __init__ 함수 혹은 앞서 실행된 다른 함수에서 만들어진 오브젝트를 인풋으로 쓸 때가 많은데, 그 인풋들 역시 재현이 되어야 한다. 

다음은 클래스 인스턴스나 DataFrame, Dictionary와 같이 형식을 갖춰 넣어줘야 하는 인풋을 요구하는 함수의 테스트 코드 예시이다. DataFrame의 경우 column 명, data type은 유지한 채 단순한 값들을 넣어주었다. Dictionary 역시 반드시 필요로 하는 값들만 채워주었고, 특히 실행에만 영향을 미치고 사용되지 않는 value에는 의미 없는 값을 채워주었다.

def test():
    train_df = pd.DataFrame({'test': [1, 1, 2, 2, 2, 3, 4, 4]})
    val_df = pd.DataFrame({'test': [1, 2, 3, 4]})

    train_params = TrainParams(train_df, val_df)
    map_metadata = {
        'resolution': 0.009,
        'width': 500,
        'origin': {
            'position': {
                'x': 0,
                'y': 0
            }
        }
    }
    tu.test_example(train_params,
                    1.5,
                    map_metadata,
                    max_hops=1)
    ...

 

2. 함수 (로직) 단순화 하기

때로는 테스트하려는 코드 내부에서 또 다른 함수의 실행을 필요로 할 수도 있다. 그런데 그 함수가 클래스 내부의 인스턴스 메소드이거나 많은 인풋을 필요로 하는 복잡한 함수라면 어떨까? 이런 경우에는 인스턴스 생성 혹은 인풋 재현을 시도하기 전에, 함수 (로직) 단순화를 시도해볼 수 있다.

def get_additional_func(ids):
    ids_set = set(ids)
    return lambda x, y: (x, y) in id_set or (y, x) in id_set


def test():
    id_list = [(1, 10), (2, 20), (3, 30)]
    add_func = get_additional_func([(1, 3)])
    res = [1, 2]
    assert res == tu.test_example(id_list, add_func)

 

위의 예시 코드는 실제로 내가 업무를 위해 작성했던 코드 일부를 가져온 예시이다. 나의 경우에는 테스트하려는 함수의 내부에서 동작하는 함수가 id 값을 2개 받아 두 id가 특정 조건을 만족하는지 True/False를 반환하는 함수였다. 그런데 이를 조건을 만족하는지 따지는 알고리즘을 그냥 ids_set라는 집합(set)이 두 id 조합을 포함하는지 체크하는 함수로 단순화해 테스트 코드를 작성하였다.

한 가지 예시를 더 살펴보자. 다음은 JoinedGenerator의 index_array 함수를 위한 테스트 코드이다. 문제는 index_array가 shuffle 동작을 포함한다는 것이다. shuffle의 경우, 결과를 컨트롤할 수 없기 때문에 정답을 제시할 수 없는데 어떻게 테스트 코드를 작성할 수 있을까? 바로 shuffle과 유사한 동작을 하되, (아래 예시에서는 array element 순서 변경), 결과를 예측할 수 있는 함수를 shuffle 대신 사용해 테스트 코드를 작성할 수 있다. 

def test_joined_generator():
	... 
    JoinedGen = tu.JoinedGenerator(gens, shuffle=True, shuffle_func=np.flip)
    JoinedGen.on_epoch_end()
    res = np.array([2, 1, 0])
    assert np.array_equal(res, JoinedGen.index_array)

 

이때, JoinedGenerator는 shuffle 함수를 일일이 np.flip으로 바꿔준 것이 아니라, init 함수가 argument로 shuffle에 사용될 함수를 받도록 설계되었다. (첫 번째 예시에서도 add_func이 argument로 전달되는 것을 확인할 수 있다.) 런 Test-friendly 한 코드는 테스트 코드 작성에 큰 도움이 된다. 

 

2. 인풋이 파일일 경우에는 tempfile 라이브러리를 활용하자

산 넘어 산. 이번에 테스트해야 하는 코드는 파일을 인풋으로 받는다. 실제로 사용하는 파일을 대상으로 테스트하자니 용량도 크고, 테스트 정답도 복잡해지는데... 이런 케이스는 어떻게 대응할 수 있을까? 이런 경우에는 임시 파일과 디렉토리를 위한 tempfile 라이브러리를 사용하면 된다.

import tempfile

def test():
    with tempfile.TemporaryDirectory() as td:
        with open(os.path.join(td, 'saved_model.pb'), 'w'):
            res = td
            assert res == mu.ModelLoader.get_model_ckpt_path(td)

TemporaryDirectory()는 임시 디렉토리를 생성해주는 함수이다. 위의 예시의 경우 폴더 안에 저장된 모델 ckpt 파일이 get_model_ckpt_path 라는 메소드를 통해 잘 불러와지는지를 테스트하는 코드이다. 임시 디렉토리 안에 임시 ckpt 파일을 생성해 테스트했다. with 문과 함께 사용하면 close 함수를 호출하지 않아도, 코드를 벗어나면 자동으로 폴더와 하위 파일이 삭제된다.

import tempfile

def test():
    x_col, y_col = 'image', 'target'
    with tempfile.NamedTemporaryFile(suffix='.png') as ntf:
        path1 = ntf.name
        df = pd.DataFrame({x_col: [path1, path1], y_col: ['1', '2']})
        ...

다음은 NamedTemporaryFile() 함수이다. 이 함수의 경우 임시 파일의 이름을 지정할 때 사용한다. 위의 예시의 경우, 테스트 시에 필요한 이미지 파일 path를 생성하기 위해 NamedTemporaryFile() 함수를 사용하였다. 역시 with 문과 함께 사용할 경우 코드를 벗어날 때 자동으로 파일이 삭제된다.

 

3.  클래스의 정적 메소드 (static method)를 활용하자

마지막으로 소개할 방법은 클래스의 정적 메소드(static method)를 사용하는 것이다. 정적 메소드는 인스턴스 메소드와 달리 self 인자를 받지 않는 메소드로, 클래스 이름으로 직접 메소드를 호출할 수 있다. self 인자를 이용해 클래스의 속성이나 함수를 호출하지 않아도 되는 함수를 보통 static method로 정의한다. 이러한 함수들은 클래스 인스턴스를 생성하지 않고도 외부에서 호출이 가능하기 때문에 테스트 코드를 작성하기가 쉽다.

 


이렇게나 많고 다양한 노하우(?)를 획득할 수 있었던 이유는 바로 나의 매니저 덕분이다. 나의 매니저는 다른 여러 시니어 개발자분들도  '저렇게 꼼꼼하게 코드 리뷰하는 사람은 처음 봤다'라고 말씀하실 정도로 코드에 있어 엄격하신 분이셨다. 그리고 "테스트 코드"는 이런 나의 매니저가 입이 마르도록 강조하는 것 중 하나였다. 

매니저 : 테스트가 없으면 난 너의 코드를 믿을 수 없어. 어떻게 잘 동작한다고 확신할 수 있지? 🤔
나 : 😣


처음에는 "테스트해봤어?"라는 말이 꼭 나의 코드를 무시하는 것만 같아 조금은 짜증이 나기도 했다. 하지만 테스트 과정에서 늘 엣지 케이스는 발견되었고, 이는 미처 고려하지 못했던 부분까지 고민해보게 되는 계기를 만들어주었다. 그리고 지금은 매니저가 시키지 않아도 항상 테스트 코드를 포함해서 코드 리뷰를 요청하는 엔지니어가 되었다.오늘도 자신의 로직과 코드를 의심 또 의심하며 테스트 코드를 작성하고 있을 소프트웨어 엔지니어들에게 이 글이 조금이나마 도움이 되었으면 좋겠다
. Special thanks to. Konstantin! 🐻

반응형