All Articles

단위 테스트 (1)

이 내용은 “단위 테스트” 를 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다.

1장은 해당 코드를 살펴봐주세요. 코드 링크

Chapter 1. 단위 테스트의 목표

커맨드로 테스트를 직접 실행하기 위해선 현재 디렉토리로 이동한다. cd pt1/ch01


들어가며

단위 테스트(Unit testing)를 배우는 것은 테스트 프레임워크, 목 라이브러리(Mockery library) 등을 쓰는 것 이상의 개념이다. 테스트도 시간을 투자하는 것이니 이득을 봐야겠죠? 그렇다면 드는 시간을 최소화하고 이득을 많이 보는 방향을 생각해야함.

그걸 이루려고 노력하는 오픈소스를 보는 것이 크게 도움된다. 어떤게 있는가 찾아보자. 여기서부터 하나씩 찬찬히 보는 것도 좋을 것이다. 가장 좋은건 필요에 따라 하나씩 보는거고…

이 책에서는 어떤 단위테스트 기술이 좋고, 비용편익을 살펴본다. 안티패턴을 피하는방법도 살펴본다.

1.1 단위 테스트 현황

엔터프라이즈 앱의 프로덕션 코드와 테스트 코드 비율은 많으면 1:1, 1:3, 많으면 1:10까지도 간다고 한다.

좋은 테스트코드를 고려하는 것은 어떤 단위 테스트를 수행하는 것으로 최대 이득을 끌어내는지를 살펴보는 것이다.

이 책에서는 “좋은 테스트”를 짜는 것을 종합적으로 논의한다. 짜니 못한 테스트코드는 분명 지양해야 하기 때문이다. 그와 동시에 노력 대비 최대의 이익을 끌어내는 테스트를 살펴볼 것이다.

1.2 단위 테스트의 목표

코드 베이스에 대해 테스트를 짜면 더 나은 설계로 이어진다. 하지만 이는 테스트 코드의 사이드이펙트 중 하나고, 주 목표는 소프트웨어가 지속가능한 성장을 가능하게 하는 것이다.

🍅 tips

단위 테스트와 코드 설계의 관계

  • 코드를 단위 테스트하는 것은 충분히 좋은 방법이고 강결합(tight coupling) 된 코드에서 저품질이 나타나는 것은 잡을 수 있다!
  • 하지만 쉽게 단위테스트할 수 있는 코드가 “좋은 품질”의 코드를 의미하지는 않는다.

테스트 코드를 추가하면 할 수록 소프트웨어 엔트로피1 증가의 속도를 완화시킨다. 코드베이스의 변경은 엔트로피를 증가시킨다. 엔트로피가 겉잡을 수 없이 상승한다면 코드베이스마저 믿을 수 없게 된다.

테스트는 이런 경향을 뒤집을 수 있다. 회귀(regression)에 대한 보험이라 할 수 있다. 다만 이런 이점을 얻기 위해서는 지속적으로, 확장할 수 있는 테스트를 작성해야한다.

회귀(regression)?

  • 특정 사건(코드수정 등) 후 코드가 의도대로 작동하지 않는 경우를 의미한다
  • 소프트웨어 버그라고 생각하면 된다

1.2.1 좋은 테스트와 좋지 않은 테스트를 가르는 요인

모든 테스트 코드를 짤 필요는 없다. 일부 테스트는 아주 중요하고 소프트웨어 품질에 기여를 한다. 그 밖의 테스트코드는 그렇지 않다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움되지 않는다. 유지보수가 어렵고 느리다. 테스트를 위한 테스트를 하게 된다.

즉, 테스트의 가치와 유지 비용 요소를 생각해야 한다. 비용 요소는 다양한 활동에 필요한 시간에 따라 결정된다.

  • 기반 코드 테스트 시 테스트도 함께 리팩토링하라
  • 각 코드 변경 시 테스트를 실행하라
  • 테스트가 잘못된 경고를 발생시키면 처리하라
  • 기반 코드가 어떻게 도는지 이해하려 할 때는 테스트를 읽는데 시간을 들이라

잘못된 테스트코드는 오히려 독이 된다. 항상 가치있는 테스트를 고민하자. 좋은 테스트에 집중하자! (4장에서 이어짐)

1.3 테스트 스위트 품질 측정을 위한 커버리지 지표

테스트 스위트 품질 측정엔 크게 두가지 커버리지 지표가 있다.

  • 코드 커버리지(code coverage)
  • 분기 커버리지(branch coverage)

커버리지 지표? 테스트 스위트가 소스코드를 얼마나 실행하는지에 대한 백분율

커버리지 지표는 테스트가 충분한지에 대한 피드백을 제공하지만, 이 것이 100%에 가깝다고 해서 양질의 테스트 스위트를 보장하는 것은 아니다.

1.3.1 코드 커버리지에 대한 이해

코드 커버리지는 하나 이상의 테스트로 실행된 코드 라인 수와 제품 코드 베이스의 전체 라인 수의 비율이다. 아래 수식으로 산출된다.

$코드 커버리지(테스트 커버리지) = \dfrac{제품 코드 라인 수}{전체 라인 수} * 100$

요컨대 이런 코드가 있다고 치자:

def is_string_long(input_val: str):
    if len(input_val) > 5:
        return True
    return False


def test_is_string_long():
    assert is_string_long("abc") is False

테스트는 아래 명령으로 구동한다:

pytest test\test_01.py --cov

test\test_01.py .                                                                                                    [100%]

---------- coverage: platform win32, python 3.10.11-final-0 ----------
Name              Stmts   Miss  Cover
-------------------------------------
test\test_01.py       6      1    83%
-------------------------------------
TOTAL                 6      1    83%


==================================================== 1 passed in 0.05s ====================================================

이러면 커버리지가 83% 나온다. 코드를 이렇게 바꾸면? 커버리지를 100%로 달성시킬 수 있다.

def is_string_long(input_val: str):
    return len(input_val) > 5


def test_is_string_long():
    assert is_string_long("abc") is False

테스트는 아래 명령으로 구동한다:

pytest test\test_02.py --cov

test\test_02.py .                                                                                                    [100%] 

---------- coverage: platform win32, python 3.10.11-final-0 ----------
Name              Stmts   Miss  Cover
-------------------------------------
test\test_02.py       4      0   100%
-------------------------------------
TOTAL                 4      0   100%


==================================================== 1 passed in 0.08s ====================================================

근데 이게 테스트 스위트를 개선한 건 아니다(저자는 이를 커버리지 수치로 ‘장난친다’ 라고 표현했다). 코드가 작으면 커버리지 지표가 좋아지기 때문이다. 그리고 이렇다고 해도 테스트 스위트의 가치나, 코드베이스의 유지보수성이 변경되진 않는다.

1.3.2 분기 커버리지 지표에 대한 이해

다른 지표는 분기 커버리지(branch coverage)이다. 이 지표는 if 문이나 switch 문같은 제어구조에 중점을 둔다. 테스트 스위트 내 하나 이상의 테스트가 통과하는 제어구조의 수를 나타낸다.

$분기 커버리지 = \dfrac{통과 분기}{전체 분기 수}$

분기 커버리지 지표를 계산하려면 코드베이스에서 모든 가능한 분기를 합산하고 그 중 테스트가 얼마나 실행되는지를 확인한다. 분기 개수만 다루며, 해당 분기를 구현하는데 얼마나 코드가 필요한지 고려하진 않는다.

def is_string_long(input_val: str):
    """
    if/else 구문이 기재되어있어야 아래의 Branch를 카운트한다.
    """
    if len(input_val) > 5:
        return True
    return False


def test_is_string_long():
    assert is_string_long("abc") is False

테스트는 해당 명령으로 구동한다: pytest test\test_03_1.py --cov --cov-branch

(unit-testing-py3.10) PS C:\unit_testing\pt1\ch01> pytest test\test_03_1.py --cov --cov-branch
=================================================== test session starts =================================================== 
platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0
rootdir: C:\unit_testing\pt1\ch01
plugins: cov-4.1.0
collected 1 item                                                                                                            

test\test_03_1.py .                                                                                                    [100%] 

---------- coverage: platform win32, python 3.10.11-final-0 ----------
Name              Stmts   Miss Branch BrPart  Cover
---------------------------------------------------
test\test_03.py       6      1      2      1    75%
---------------------------------------------------
TOTAL                 6      1      2      1    75%


==================================================== 1 passed in 0.07s ====================================================

1.3.3 커버리지 지표에 관한 문제점

분기 커버리지로 코드 커버리지보다 나은 결과를 얻을 수 있지만, 테스트 스위트의 품질을 결정할 때 어떠한 커버리지 지표든 정답일 수는 없다. 이유는 다음과 같다:

  1. 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장하는 것은 아니다.
  2. 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다.

가능한 모든 결과를 보장할 수 없음

단위 테스트는 적절한 검증이 있어야 한다. 테스트 대상 시스템이 낸 결과가 정확히 예상 가능한 결과인지 확인해야 한다. 게다가 결과가 여러개일 수도 있다. 즉 모든 측정지표를 검증해야 한다.

그런데 테스트 케이스 하나만(False 조건만) 테스트 하니, 일부 실행만 보장한다. 이런 테스트는 검증을 안한다고 봐도 무방하다. 그러므로 쓸모가 없다.

#
# 일부 코드 생략
#
def test_is_string_long():
    assert is_string_long("abc") is False
    assert is_string_long("abcdef") is True

테스트는 해당 명령으로 구동한다: pytest test\test_03_2.py --cov --cov-branch

==================================================== 1 passed in 0.07s ==================================================== 
(unit-testing-py3.10) PS C:\unit_testing\pt1\ch01> pytest test\test_03_2.py --cov --cov-branch
=================================================== test session starts ===================================================
platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0
rootdir: C:\unit_testing\pt1\ch01
plugins: cov-4.1.0
collected 1 item                                                                                                            

test\test_03_2.py .                                                                                                  [100%]

---------- coverage: platform win32, python 3.10.11-final-0 ----------
Name                Stmts   Miss Branch BrPart  Cover
-----------------------------------------------------
test\test_03_2.py       7      0      2      0   100%
-----------------------------------------------------
TOTAL                   7      0      2      0   100%


==================================================== 1 passed in 0.06s ==================================================== 

이런 식의 접근으로 커버리지 수치를 높이는 것은 크게 의미없는 짓이다.

외부 라이브러리의 경로를 고려할 수 없음

테스트 대상 시스템이 메소드 호출 시, 라이브러리의 모든 경로를 쫓아갈 수 없다.

import pytest


def parse(input_val: int):
    return int(input_val)


def test_parse():
    assert parse("5") == 5

    with pytest.raises(ValueError):
        parse("bbb")

    with pytest.raises(ValueError):
        parse("0b11010010")

테스트는 해당 명령으로 구동한다: pytest test\test_04.py --cov

========================================================== test session starts ===========================================================
platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0
rootdir: C:\unit_testing\pt1\ch01
plugins: cov-4.1.0
collected 1 item

test\test_04.py .                                                                                                                   [100%]

---------- coverage: platform win32, python 3.10.11-final-0 ----------
Name              Stmts   Miss  Cover
-------------------------------------
test\test_04.py       9      0   100%
-------------------------------------
TOTAL                 9      0   100%


=========================================================== 1 passed in 0.04s ============================================================

성공이야 했다지만, 과연 int() Built-in 메소드의 모든 경우(edge case)를 테스트 한 것일까? 그렇지 않다. 이는 “모든” 결과를 검증하지 못했다2. (E.g., int()n진법으로 변환 후 의도한 값이 나오는 기능 검증)

지금 받아본 이 지표로는 단위 테스트가 좋은지 나쁜지 판단하기 어렵다. 다시말해, 커버리지 지표로 테스트가 철저한지, 테스트가 충분한지는 판단하기 어렵다.

1.3.4 특정 커버리지 숫자를 목표로 하기

테스트 커버리지는 지표로 보아야 하지, 목표로 바라보면 안 된다.

저자는 병원의 환자를 예시로 비유했다. 환자의 체온을 건강의 지표로 보기 때문에, 이를 낮추기 위해 에어컨을 빵빵하게 트는게 과연 ‘효율적’ 인지로 역설한다. 이런 식의 접근은 의미없기 때문이다.

중요한 시스템의 핵심부분을 잘 검증하는 테스트 코드가 중요하지, 커버리지 수치를 가지고 판단하는 것은 의미없다. 즉, 좋은 지표이자 나쁜 지표다.

(하지만 나는 이를 적절한 베이스캠프라고 생각한다. 품질 테스트 스위트로 가는 첫 걸음을 떼기위한 “만족할 만한” 수치라고 본다.)

1.4 무엇이 성공적인 테스트 스위트를 만드는가?

하나씩 따로 평가하는 것이 낫지만, 그렇다고 해서 모든 테스트를 평가할 필요는 없다. 그리고 테스트 스위트가 얼마나 좋은지 자동으로 확인할 수는 없고, 리뷰를 수행해야 한다.

어떻게 테스트 스위트를 성공할 수 있는지 살펴보자. 성공적인 테스트 스위트는 아래 특성을 가진다: (4장에서 이어짐)

  • 개발 주기에 통합되어있다
  • 코드베이스에서 가장 중요한 부분 “만” 을 대상으로 한다
  • 최소한의 유지비로 최대한의 가치를 끌어낸다

1.4.1 개발 주기에 통합되어있음

자동화된 테스트를 하는 방법은 끊임없이 하는 것 뿐이다. 모든 테스트는 개발 주기에 통합되어야 한다.

1.4.2 코드베이스에서 가장 중요한 부분만을 대상으로 함

결국 테스트 코드를 짜는 것도 비용이므로, 최소 유지비로 최대 가치를 달성하도록 하는 것이 중요하다. 시스템의 가장 중요한 부분에 단위 테스트를 철저히 하고, 다른 부분은 간략하게, 간접적으로 검증하는 것이 좋다.

일반적으로 애플리케이션에서는 도메인 모델이 가장 중요하다. 여기에 속한 비즈니스 로직을 1순위로 검증하는 것이 옳다. 모든 테스트의 가치는 다르기 때문이다.

다른 부분은 세 가지 범주로 나눌 수 있다

  • 인프라 코드
  • DB, 서드파티 시스템 등과 같은 외부서비스 및 종속성
  • 모든 것을 하나로 묶는 코드

이 중 일부는 단위 테스트를 철저히 해야할 수 있다. 예를들어 인프라 코드에 주요 로직이 존재할 수 있다. 이 경우는 테스트를 많이하는 것이 좋다. 일반적으로는 도메인 모델에 집중하는 것이 좋다.

통합 테스트와 같은 일부 테스트는 도메인 모델 너머의 시스템 전반을 테스트할 수 있다. 그렇지만 도메인 모델을 중점으로 생각해야 한다.

이 지침을 따르려면 도메인 모델을 코드베이스 중 중요한 부분/중요하지 않은 부분 으로 나누어야 한다. 도메인 모델을 다른 애플리케이션 문제와 분리해야 단위 테스트에 대한 노력을 도메인 모델에 집중할 수 있다.

1.4.3 최소 유지비로 최대 가치를 끌어냄

가치가 유지비를 상회하는 테스트만을 테스트 스위트에 유지해야 한다. 이는 아래와 같다:

  • 가치있는 테스트(가치가 낮은 테스트 포함) 식별하기
  • 가치있는 테스트 작성하기

이를 위해서는 가치 높은 테스트를 식별하는 기준틀(frame of reference)이 있어야 하고, 그것을 위해서는 코드 설계기술이 있어야 한다.

저자는 좋은 곡을 구별하는 것과 작곡을 하는 정도의 차이로 빗대었다. 음악 듣는 것보다 작곡은 훨씬 어렵다. 단위테스트도 마찬가지다. 맨땅에 테스트 없이 기반 코드를 설계해야 하기 때문이다. 그런 의미에서 이 책은 코드 설계를 같이 배운다고 할 수 있다.

1.5 이 책을 통해 배우는 것들

  • 테스트 코드를 설명하기 위한 기준틀 설명 → 리팩토링 대상 및 제거코드 파악 완료
  • 기존 테스트 기술과 실천법 및 better case에 대해 살펴봄
  • 제품코드와 관련 스위트를 리팩토링하는 방법
  • 단위 테스트의 다양한 스타일 적용법
  • 통합 테스트로 시스템 전체 동작 검증
  • 단위 테스트의 안티패턴 식별법, 예방법

Summary

  • 코드는 가면 갈 수록 나빠진다. 코드베이스가 바뀌면 소프트웨어 엔트로피가 올라간다. 테스트로 이런 경향을 뒤집을 수 있다.
  • 단위 테스트 작성은 중요하다. 좋은 단위 테스트를 짜는 것은 더 중요하다.
  • 단위 테스트는 소프트웨어 프로젝트를 지속적으로 성장시키는 방법이다. 좋은 단위 테스트는 버그를 막을 수 있다. 요구사항을 만족하는 코드이므로 이를 바탕으로 얼마든지 코드 리팩토링 및 신기능 추가가 가능하다.
  • 중요한 테스트를 우선하여 유지하라.
  • 단위 테스트 코드는 두 가지를 시사한다
    • 단위 테스트를 할 수 없는 코드는 품질이 좋지 않다
    • 단위 테스트를 할 수 있다고 해서 품질을 보장하지는 않는다
  • 커버리지 지표 또한 두 가지를 시사한다
    • 커버리지가 낮은 것은 문제의 징후
    • 커버리지가 높다고 해서 테스트 스위트의 품질이 좋은 것을 보장하지 않음
  • 분기 커버리지로 테스트 스위트의 완전성에 대한 인사이트를 얻을 수 있지만, 테스트 스위트가 충분하다고 할 수는 없다
    • 검증문이 있는지 신경쓰지 않는다
    • 라이브러리의 모든 경우를 검증하는 것은 아니다
  • 커버리지 수치를 목표로 잡아선 안 된다
  • 성공적인 테스트 스위트는 다음 특성을 나타낸다
    • 개발 주기에 통합되어있다
    • 코드베이스에서 가장 중요한 부분 “만” 을 대상으로 한다
    • 최소한의 유지비로 최대한의 가치를 끌어낸다
  • 단위 테스트의 목표를 달성하기 위한 유일한 방법은 아래와 같다
    • 좋은 테스트, 좋지 않은 테스트를 구별한다
    • 테스트를 리팩토링한다

  1. 열역학 제 2법칙의 그 엔트로피에서 따왔다. 소프트웨어 엔트로피는 소프트웨어 시스템 내의 무질서도를 의미한다고 보면 될 것이다
  2. https://docs.python.org/3/library/functions.html#int

Published Jun 28, 2023

Non scholæ sed vitæ discimus.

his/him