TDD on Python with Kata - [2] Poetry + Tox 환경 설정

sjk·2022년 10월 27일
0

TDD on Python

목록 보기
2/2
post-thumbnail

Introduction

Python에서 TDD (Test Driven Development) 환경을 구성하고, 간단한 TDD Kata를 진행하며 익숙해져 보겠습니다.

In this Series ...

  • 테스트 프레임워크로 Pytest 를 사용합니다.
  • 패키징과 의존성 관리 도구로 Poetry 를 사용합니다.
  • 다양한 환경에서의 테스트와 CI/CD를 위해 Tox 를 사용합니다.
  • 코드는 Github 저장소에서 관리됩니다.
  • CircleCI 를 통해 CI/CD 파이프라인을 관리합니다.

TDD Kata

이 글에서 TDD로 해결하고자 하는 예제는 다음 페이지에 소개되어 있습니다.
osherove.com/tdd-kata-1

테스트는 Arrange/Act/Assert (AAA) 패턴을 최대한 적용하여 작성하고자 합니다.
Bill Wake - 3A: Arrange, Act, Assert

Task 1

지난 글 에 이어서 Task 1을 완성시키도록 하겠습니다.

Task 1의 요구사항이었던 하나의 숫자가 포함된 문자열과, 두개의 숫자가 포함된 문자열 테스트 케이스를 추가하였습니다.
하나의 숫자가 포함된 문자열 테스트 케이스를 추가하고 테스트 결과를 확인하는 과정은 코드 수정 없이 pass를 출력하였기에 생략하였습니다.


# /tests/test_string_calculator.py

from tdd_kata.string_calculator import StringCalculator


def test_add_empty_string_expect_0():
    string_calculator = StringCalculator()

    input_string = ""
    result = string_calculator.Add(input_string)

    assert result == 0

def test_add_one_number_string_expect_same():
    string_calculator = StringCalculator()

    input_string = "1"
    result = string_calculator.Add(input_string)

    assert result == 1

def test_add_two_number_string_expect_sum():
    string_calculator = StringCalculator()

    input_string = "1,2"
    result = string_calculator.Add(input_string)

    assert result == 3

결과는 다음과 같습니다.

-> % poetry run pytest
============================================================================= test session starts =============================================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 3 items                                                                                                                                                             

tests/test_string_calculator.py ..F                                                                                                                                     [100%]

================================================================================== FAILURES ===================================================================================
____________________________________________________________________ test_add_two_number_string_expect_sum ____________________________________________________________________

    def test_add_two_number_string_expect_sum():
        string_calculator = StringCalculator()
    
        input_string = "1,2"
        result = string_calculator.Add(input_string)
    
>       assert result == 3
E       assert 0 == 3

tests/test_string_calculator.py:40: AssertionError
=========================================================================== short test summary info ===========================================================================
FAILED tests/test_string_calculator.py::test_add_two_number_string_expect_sum - assert 0 == 3
========================================================================= 1 failed, 2 passed in 0.01s =========================================================================

테스트를 통과할 수 있도록 코드를 수정합니다.

# /tdd_kata/string_calculator.py

class StringCalculator(object):
    def __init__(self) -> None:
        pass

    def Add(self, numbers: str):
        try:
            first_number, second_number = map(int, numbers.split(","))
            result = first_number + second_number
        except ValueError:
            try:
                result = int(numbers)
            except ValueError:
                result = 0

        return result

테스트 결과는 다음과 같습니다.

-> % poetry run pytest
============================================================================= test session starts =============================================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 3 items                                                                                                                                                             

tests/test_string_calculator.py ...                                                                                                                                     [100%]

============================================================================== 3 passed in 0.00s ==============================================================================

모든 테스트 케이스에 대해서 pass를 얻었습니다. 아직 코드를 리팩토링 할 필요는 없을 것 같습니다.

Task 2

Add() Method를 길이를 알 수 없는 숫자 문자열에 대해서도 작동하도록 수정하세요.

해당 요구사항을 충족하는 테스트 함수를 작성합니다. 0, 1, 2개의 숫자를 포함한 문자열에 대한 테스트 코드가 작성되어 있으므로 5개의 숫자를 포함한 문자열을 테스트하도록 하겠습니다.

# /tests/test_string_calculator.py

from tdd_kata.string_calculator import StringCalculator

... 

def test_add_five_number_string_expect_sum():
    string_calculator = StringCalculator()

    input_string = "1,2,3,4,5"
    result = string_calculator.Add(input_string)

    assert result == 15

Task 1번에서 사용된 Add() 함수의 로직은 해당 테스트를 통과하지 못합니다.

-> % poetry run pytest
============================================================================= test session starts =============================================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 4 items                                                                                                                                                             

tests/test_string_calculator.py ...F                                                                                                                                    [100%]

================================================================================== FAILURES ===================================================================================
___________________________________________________________________ test_add_five_number_string_expect_sum ____________________________________________________________________

    def test_add_five_number_string_expect_sum():
        string_calculator = StringCalculator()
    
        input_string = "1,2,3,4,5"
        result = string_calculator.Add(input_string)
    
>       assert result == 15
E       assert 0 == 15

tests/test_string_calculator.py:50: AssertionError
=========================================================================== short test summary info ===========================================================================
FAILED tests/test_string_calculator.py::test_add_five_number_string_expect_sum - assert 0 == 15
========================================================================= 1 failed, 3 passed in 0.01s =========================================================================

다음과 같이 Add() 함수가 숫자에 개수와 상관없이 합을 구할 수 있도록 수정합니다.

# /tdd_kata/string_calculator.py


class StringCalculator(object):
    def __init__(self) -> None:
        pass

    def Add(self, numbers: str):
        try:
            list_numbers = list(map(int, numbers.split(",")))
            result = sum(list_numbers)
        except ValueError:
            result = 0

        return result

테스트 통과를 확인합니다.

-> % poetry run pytest
============================================================================= test session starts =============================================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 4 items                                                                                                                                                             

tests/test_string_calculator.py ....                                                                                                                                    [100%]

============================================================================== 4 passed in 0.01s ==============================================================================

Task 3

newlines 문자가 comma(,) 대신 문자열에 위치하더라도 처리가 가능하도록 수정하세요.
1. 다음 입력은 6을 반환하여야 합니다. "1\n2,3"
2. 다음 입력은 주어지지 않습니다. "1,\n"

다음과 같이 테스트 함수를 작성했습니다.

# /tests/test_string_calculator.py

from tdd_kata.string_calculator import StringCalculator

... 

def test_add_three_number_string_with_newline_expect_sum():
    string_calculator = StringCalculator()
    
    input_string = "1\n2,3"
    result = string_calculator.Add(input_string)

    assert result == 6

해당 테스트의 결과는 RED 일 것입니다.

# /tdd_kata/string_calculator.py


class StringCalculator(object):
    def __init__(self) -> None:
        pass

    def Add(self, numbers: str):
        numbers = numbers.replace("\n", ",")

        try:
            list_numbers = list(map(int, numbers.split(",")))
            result = sum(list_numbers)
        except ValueError:
            result = 0

        return result
-> % poetry run pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 5 items                                                              

tests/test_string_calculator.py .....                                    [100%]

============================== 5 passed in 0.01s ===============================

newlines 문자를 comma로 치환하여 해당 테스트를 GREEN으로 만들었습니다.

Task 4

문자열 계산기가 다른 구분자도 처리할 수 있도록 수정하세요.
1. 구분자를 변경하기 위해 다음과 같은 형식이 사용됩니다. : "//[delimiter]\n[numbers...]"
입력 문자열의 예시는 다음과 같습니다. "//;\n1;2"
위 문자열은 ; 이 구분자로 사용되었고, 결과적으로 3을 반환해야 합니다.

  1. 1번 형식은 선택적입니다. 기존의 모든 시나리오에 대한 호환성은 유지됩니다.

두 가지 요구사항을 충족하기 위해, 다음과 같이 두 테스트를 추가하였습니다.

# /tests/test_string_calculator.py

from tdd_kata.string_calculator import StringCalculator

... 

def test_add_two_number_string_with_semicolon_delimiter_expect_sum():
    string_calculator = StringCalculator()

    input_string = "//;\n1;2"
    result = string_calculator.Add(input_string)

    assert result == 3

def test_add_three_number_string_with_full_comma_delimiter_expect_sum():
    string_calculator = StringCalculator()

    input_string = "//.\n1.2.3"
    result = string_calculator.Add(input_string)

    assert result == 6

해당 테스트를 통과하기 위해서 다음과 같이 Add() 함수를 수정하였습니다.

# /tdd_kata/string_calculator.py


class StringCalculator(object):
    def __init__(self) -> None:
        pass

    def Add(self, numbers: str):
        if numbers.startswith("//"):
            delimiter = numbers[2]
            numbers = numbers[4:]
        else:
            delimiter = ","

        numbers = numbers.replace("\n", delimiter)

        try:
            list_numbers = list(map(int, numbers.split(delimiter)))
            result = sum(list_numbers)
        except ValueError:
            result = 0

        return result
-> % poetry run pytest
==================================================== test session starts ====================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 7 items                                                                                                           

tests/test_string_calculator.py .......                                                                               [100%]

===================================================== 7 passed in 0.01s =====================================================

테스트를 통과하였습니다.
여기까지의 테스트 코드에서 다음과 같은 인스턴스 생성 코드가 반복되는 것을 확인할 수 있습니다.

# /tests/test_string_calculator.py

def test_add_two_number_string_with_semicolon_delimiter_expect_sum():
    string_calculator = StringCalculator() # 중복 

    input_string = "//;\n1;2"
    result = string_calculator.Add(input_string)

    assert result == 3

def test_add_three_number_string_with_full_comma_delimiter_expect_sum():
    string_calculator = StringCalculator() # 중복 

    input_string = "//.\n1.2.3"
    result = string_calculator.Add(input_string)

    assert result == 6

다음과 같이 fixture를 정의하여 각 테스트의 패러미터로 사용하는 것으로 코드의 반복을 제거하겠습니다.

# /tests/test_string_calculator.py

import pytest

from tdd_kata.string_calculator import StringCalculator


@pytest.fixture(scope="module")
def string_calculator():
    string_calculator = StringCalculator()
    yield string_calculator


def test_add_empty_string_expect_0(string_calculator):
    input_string = ""
    result = string_calculator.Add(input_string)

    assert result == 0

... 

Tox

지금까지의 테스트는 전역 Python 인터프리터 환경으로부터 생성된 poetry 가상 환경에서 실행되었습니다. (제 경우는 Python 3.9.13 이었습니다.)

많은 파이썬 실행 환경은 제 로컬 환경과 같지 않을 것입니다. 다양한 Python 인터프리터 버전에서 코드를 테스트 하기 위해 tox를 사용하겠습니다.

-> % poetry add --group dev tox   
Using version ^3.27.0 for tox

Updating dependencies
Resolving dependencies... (2.0s)

Writing lock file

Package operations: 6 installs, 0 updates, 0 removals

  • Installing distlib (0.3.6)
  • Installing filelock (3.8.0)
  • Installing platformdirs (2.5.2)
  • Installing six (1.16.0)
  • Installing virtualenv (20.16.6)
  • Installing tox (3.27.0)

tox는 개발환경에서만 필요한 종속성이기 때문에 --group dev 인자값을 추가하였습니다.

# /pyproject.toml

[tool.poetry]
name = "tdd-kata"
version = "0.1.0"
description = ""
authors = ["sjk <18274655+sejkimm@users.noreply.github.com>"]
readme = "README.md"
packages = [{include = "tdd_kata"}]

[tool.poetry.dependencies]
python = "^3.9"


[tool.poetry.group.dev.dependencies]
pytest = "^7.1.3"
tox = "^3.27.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

pyproject.toml 파일에 tox dependency가 추가된 것을 확인할 수 있습니다.

tox를 사용하기 위해서는 tox.ini 파일이 필요합니다. 프로젝트 루트 디렉토리에 tox.ini 파일을 추가하겠습니다.

# /tox.ini

[tox]
envlist = py{39,311}
skipsdist = True

[testenv]
whitelist_externals = poetry
commands =
    poetry run pytest

Python 3.9 버전과 Python 3.11 버전에서 각각 pytest를 통해 테스트를 실행하도록 작성하였습니다.

tox.ini 파일의 설정값은 다음 페이지에서 확인 가능합니다.
official - tox configuration

다음 명령어를 통해 tox를 실행하겠습니다.

-> % poetry run tox
py39 run-test-pre: PYTHONHASHSEED='455114939'
py39 run-test: commands[0] | poetry run pytest
==================================================== test session starts ====================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
cachedir: .tox/py39/.pytest_cache
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 7 items                                                                                                           

tests/test_string_calculator.py .......                                                                               [100%]

===================================================== 7 passed in 0.01s =====================================================
py311 create: /Users/sejkimm/dev/project/tdd-kata/.tox/py311
ERROR: InterpreterNotFound: python3.11
__________________________________________________________ summary __________________________________________________________
  py39: commands succeeded
ERROR:  py311: InterpreterNotFound: python3.11

Python 3.9 버전에 대해서는 이전과 동일한 테스트 결과를 보여주지만, Python 3.11 버전의 인터프리터는 찾지 못해 테스트를 진행하지 못하였습니다.

현재 제 환경에는 다음과 같은 Python 인터프리터가 존재합니다.

-> % pyenv versions                        
  system
  3.7.14
* 3.9.13 (set by /Users/sejkimm/.pyenv/version)
  3.11.0rc1

다른 버전의 Python 인터프리터를 설치하기 위해서는 다음 명령어를 사용합니다.

-> % pyenv install <설치할 버전>

Python 인터프리터가 준비되었으면 다음과 같이 로컬 환경에 추가할 수 있습니다.

-> % pyenv local 3.9.13 3.11.0rc1

-> % pyenv versions              
  system
  3.7.14
* 3.9.13 (set by /Users/sejkimm/dev/project/tdd-kata/.python-version)
* 3.11.0rc1 (set by /Users/sejkimm/dev/project/tdd-kata/.python-version)

이제 tox 버전에서 명시한 Python 3.9와 Python 3.11 버전 모두에서 독립적으로 테스트를 실행하게 됩니다.

-> % poetry run tox                       
py39 run-test-pre: PYTHONHASHSEED='3176824038'
py39 run-test: commands[0] | poetry run pytest
==================================================== test session starts ====================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
cachedir: .tox/py39/.pytest_cache
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 7 items                                                                                                           

tests/test_string_calculator.py .......                                                                               [100%]

===================================================== 7 passed in 0.01s =====================================================
py311 create: /Users/sejkimm/dev/project/tdd-kata/.tox/py311
py311 run-test-pre: PYTHONHASHSEED='3176824038'
py311 run-test: commands[0] | poetry run pytest
==================================================== test session starts ====================================================
platform darwin -- Python 3.9.13, pytest-7.1.3, pluggy-1.0.0
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/sejkimm/dev/project/tdd-kata
collected 7 items                                                                                                           

tests/test_string_calculator.py .......                                                                               [100%]

===================================================== 7 passed in 0.01s =====================================================
__________________________________________________________ summary __________________________________________________________
  py39: commands succeeded
  py311: commands succeeded
  congratulations :)

Next Article

다음 Task를 계속해서 진행하며, CircleCI를 이용해 CI/CD 환경을 추가해보도록 하겠습니다.

profile
Backend, Infra, ML Engineering

0개의 댓글