4 minute read

파이썬 - 단위 테스트와 리팩토링

단위 테스트 개념

단위 테스트는 다른 코드의 일부분이 유효한지를 검사하는 코드로, 소프트웨어의 핵심이 되는 필수기능으로서 일반 비즈니스 로직과 동일한 수준으로 다뤄져야합니다.
단위 테스트로 일반 비즈니스 로직이 특정 조건을 보장하는 지 확인하기 위해 여러 시나리오를 검증하는 코드를 작성합니다.
이러한 단위 테스트는 소프트웨어 유지보수에 있어 중요한 역할을 담당하며 품질관리에 있어 필수항목 중 하나입니다.

단위테스트의 특징

  • 격리: 단위 테스트는 다른 외부 에이전트와 완전히 독립적이어야 하며 비즈니스 로직에만 집중해야합니다.
    • 데이터베이스 연결 or HTTP 요청 X
    • 테스트가 독립적이어야하기 때문에 이전 상태와 관계 없이 임의의 순서로 실행 가능 보장
  • 성능: 단위 테스트는 신속하게 수행되어야 하며 반복적으로 여러번 실행 가능하도록 설계되어야 합니다.
  • 자체검증: 단위테스트의 실행만으로 결과 검증이 가능해야하며 단위 테스트 처리를 위한 추가 단계가 없어야 합니다.

단위테스트의 범위

단위 테스트는 함수 또는 메서드 같은 매우 작은 단위를 확인하여 코드를 최대한 자세하게 검사하는 용도입니다.
만약 클래스를 테스트하고 싶은 경우라면 단위테스트의 집합인 테스트 스위트(test suite)를 이용하여 테스트 스위트를 구성하는 테스트들로 메서드처럼 보다 작은 단위를 테스트하면 됩니다.

단위 테스트 외에도 통합테스트, 인수테스트 등이 있습니다.

  • 통합 테스트: 한 번에 여러 컴포넌트를 테스트하는 테스트입니다. 종합적으로 코드가 예상대로 동작하는 지를 검증합니다.
    • HTTP 요청이나 데이터베이스 연결 등이 모두 가능합니다.
  • 인수 테스트: 유스케이스(use case)를 활용하여 사용자의 관점에서 시스템의 유효성을 검사하는 자동화된 테스트입니다.

통합테스트와 인수테스트는 단위테스트의 중요한 요소인 ‘속도’를 잃게 됩니다.
따라서 테스트 실행에 더 많은 시간이 걸리기 때문에 덜 자주 실행하게 됩니다.

단위테스트와 애자일 소프트웨어 개발

최근의 소프트웨어 개발은 신속하고 지속적인 가치 제공을 목표로 합니다.
이런 목표를 가지고 개발하는 이유는 더 빠르게 피드백을 받아 더 쉽게 코드를 수정하기 위함입니다.
여러 변화 상황에 쉽게 대응하기 위해서는 유연하고 확장 가능한 코드를 작성해두어야 합니다.

그러나 코드 자체만 가지고 변경에 충분히 유연하다는 보장을 할 수는 없습니다.
소프트웨어 개발론을 잘 지킨 코드를 만들었고 쉽게 리팩토링이 가능하도록 작성되어있다고 하더라도 해당 코드에 대한 변경 작업이 아무런 버그 없이 작동할 수 있다는 보장 방법이 필요합니다.
이에 대한 공식적인 증거를 제시할 수 있는 것이 바로 단위 테스트입니다.
단위테스트는 프로그램이 명세에 따라 정확하게 동작한다는 것을 보장할 수 있습니다.
좋은 단위테스트는 코드가 기대한 것처럼 동작한다는 확신을 줄 수 있으며 버그에 의해 프로젝트가 중단되지 않고 신속하게 가치를 제공할 가능성이 높아집니다.

단위테스트와 소프트웨어 디자인

소프트웨어 디자인과 단위테스트는 긴밀한 관계가 있습니다.
좋은 소프트웨어는 테스트 가능한 소프트웨어이고 테스트의 용이성은 클린코드의 핵심 가치이기 때문입니다.

단위 테스트는 기본 코드 보완 용도라기 보다는 실제 코드의 작성 방식에 직접적인 영향을 미칩니다.
단위 테스트는 특정 코드에 단위 테스트를 해야겠다고 발견하는 단계에서부터 더 나은 코드를 작성하는 단계, 궁극적으로 모든 코드가 테스트에 의해 작성되는 TDD 단계까지 여러 단계가 있습니다.

단위 테스트 예제 코드를 살펴보며 단위 테스트를 통해 코드를 어떻게 개선시켜나갈 수 있는지 확인해보도록 하겠습니다.
다음 예제는 프로세스 실행 중 오류가 생기면 에러 내용을 전송하는 클라이언트를 이용해 관리자에게 에러를 알리는 상황을 구현한 코드입니다.

import random

class ErrorAlertClient:
    """오류 발생 메시지 전송 클라이언트"""

    def send(self, error_channel, error_msg):
        if not isinstance(error_channel, str):
            raise TypeError("error_channel로 문자열 타입을 사용해야 함")
        if not isinstance(error_msg, str):
            raise TypeError("error_msg로 문자열 타입을 사용해야 함")

        print(f"{error_channel} 채널에 {error_msg} 오류 발생")


class Process:
    def __init__(self):
        self.client = ErrorAlertClient()
        self.channel = "channel_1"

    def process_iteration(self, n_interations):
        for i in range(n_interations):
            result = self.run_process()
            self.client.send(self.channel, result)

    def run_process(self):
        result_value = ["abc", "가나다", 0.1, 3]
        return random.choice(result_value)


if __name__ == "__main__":
    process = Process()
    process.process_iteration(4)

위 코드에서 ErrorAlertClient를 통해 에러 오류 메세지를 발송하고 싶다면 send 메서드의 인자로 string 형의 error_channel, error_msg 2개의 파라미터를 전달해야합니다.
만약 파라미터로 문자열이 아닌 값을 전달한다면 오류가 발생하게 됩니다.
ErrorAlertClient가 만약 외부 라이브러리라 직접 제어가 불가능한 상황이라면 더더욱 이러한 인터페이스 규약을 맞춰주어야 합니다.
이러한 부분은 단위테스트를 통해 점검하고 문제 여부를 확인할 수 있습니다.

테스트 방법으로는

  1. Process 객체의 client를 Mock 객체로 대체하여 테스트 하기
  2. 필요한 부분만 테스트하기 위해 wrapper 메서드에 위임하여 client를 간접적으로 다루기
    두 가지를 시도해볼 수 있습니다.

첫번째 방법을 이용하는 경우에는 테스트를 위해 더 많은 코드를 사용해야합니다.
위의 예제에서는 메서드가 작기 때문에 부작용이 덜하지만 만약 메서드가 크다면 모의 과정에서 불필요한 것들을 더 많이 실행해야하기 때문에 테스트를 하기 어려워집니다.
테스트를 위한 좋은 코드들은 메서드들이 작고 응집력이 높게 설계된 코드입니다.

두번째 방법을 이용하여 Wrapper 객체를 만든 후 테스트를 수행하는 코드를 살펴보도록 하겠습니다.

import random
import unittest
from unittest.mock import Mock


class WrappedClient:
    def __init__(self):
        self.client = ErrorAlertClient()

    def send(self, error_channel, error_msg):
        return self.client.send(str(error_channel), str(error_msg))

"""
ErrorAlertClient 클래스 상동
"""

class Process:
    def __init__(self):
        self.client = WrappedClient()
        self.channel = "channel_1"
    """
    나머지 코드 상동
    """

class TestWrappedClient(unittest.TestCase):
    def test_send_converts_types(self):
        wrapped_client = WrappedClient()
        wrapped_client.client = Mock()
        wrapped_client.send("value", 1)
        wrapped_client.client.send.assert_called_with("value", "1")

위의 코드에서는 ErrorAlertClient 객체를 직접 사용하지 않고 Wrapper 클래스를 client로 사용하였습니다.
이 때 Wrapper 클래스는 ErrorAlertClient 클래스와 동일한 인터페이스를 가지고 있습니다.

이러한 방식은 메인 코드 대신 Wrapper 클래스를 이용해 단위 테스트를 진행함으로써 더 간단한 테스트를 가능하게 해줍니다.
만약 메인코드에 직접 단위 테스트를 작성한다면 가장 중요한 속성 중 하나인 추상화를 하지 못하게 되므로 위와 같은 방식을 지향하는 것이 더 바람직합니다.

또한 테스트를 위해 unittest 모듈의 Mock을 활용하였는데 Mock은 어떤 종류의 타입에도 사용할 수 있는 객체입니다.
만약 확인하고 싶은 메인 코드 대신 Mock 객체를 이용하면 앞의 코드와 같이 메인 로직으로의 호출이 예상대로 동작하는 지 확인할 수 있습니다.

테스트의 경계 정하기

사용한 모든 코드에 대해 테스트를 하는 것은 끝이 없을 뿐더러 유의미한 결과를 얻기도 어렵습니다.
따라서 테스트를 할 범위를 정하는 것도 중요한 일인데요, 기본적으로 테스트를 할 부분은 본인이 작성한 코드로 범위를 한정해야 합니다.
만약 외부 라이브러리나 모듈 등의 의존성까지 확인해야한다면 너무 많은 의존성들을 확인해야하고, 테스트 범위는 끝없이 늘어나게 되기 때문입니다.
따라서 외부 라이브러리의 경우 자체적인 테스트가 있다고 가정하고 올바른 파라미터를 사용하면 정상적으로 실행된다는 것을 확인하는 수준으로도 충분합니다.

또한 테스트의 경계를 명확히 할 수 있다는 것은 시스템의 기준이 명확히했다는 의미가 됩니다.
이는 인터페이스를 사용해 외부 컴포넌트와의 결합력을 낮추고 의존성을 역전시킴으로써 좋은 코드를 디자인했음을 시사합니다.

잘 작성된 단위 테스트는 시스템의 경계에는 패치를 적용해 넘어가고 핵심 기능에 초점을 맞춥니다.
외부 라이브러리나 모듈을 테스트하지는 않지만 제대로 호출되었는지 여부는 확인하고, 이러한 확인은 mock 객체와 assertion을 수행하기 위한 도구들을 이용하여 이루어집니다.