[pytest] 02. pytest101

개요

저번 글인 [pytest] 01. 시작하기의 글은 pytest를 어떻게 사용하는 지에 대한 내용을 정리하였다.
이번 글은 Matt Layman님이 2019년 3월에 진행된 Python Frederick event에서 발표한 Python Testing 101 with pytest의 내용을 정리한 글이다.

 

pytest의 공식 문서를 좀 읽어 본 결과 너무 많고, 자세하게 나와 있어서 정리도 힘들고 이해가 잘 안 갔다.
그래서 간략하게 쉽게 강의한 내용을 정리하겠다.

끔찍한 계산기 테스트 만들기

끔찍한 계산기에 대한 테스트를 작성하기 전에 테스트에 대해서 알아야 한다.

 

테스트는 특정 동작의 결과를 살펴보고 결과가 예상한 것과 일치하는지 확인하기 위한 것.

여기서 특정 동작은 일부 시스템이 특정 상황 또는 자극에 반응하여 작동하는 방식이다. 그러나 정확히 왜 수행되었는지보다 무엇이 되었는지가 더 중요하다.

 

테스트는 4단계로 나눠볼 수 있다. (강의에선 3단계라고 소개했지만, 공식 문서엔 4단계라고 정리 됨.)

  1. Arrange
    테스트를 위해 모든 것을 준비하는 단계이다. Act를 제외한 거의 모든 것이라고 할 수 있다.
    도미노를 무너뜨리기 위해 블럭을 하나 씩 쌓는 것처럼, 존재하지 않는 유저의 인증을 만들거나 URL의 query를 만드는 등 모든 것을 준비하는 단계를 말한다.

  1. Act
    테스트하고자 하는 특정 동작를 시작하는 단계이다.
    이 동작은 테스트 중인 시스템의 결과를 변화시키는 것이며, 그 결과가 변경되어 행동에 대한 판단을 내릴 수 있습니다.
    일반적으로 함수/메소드 호출로 생각하면 된다.

  1. Assert
    결과적으로 변경된 결과를 보고 예상한 결과와 일치하는 지를 보는 단계이다.
    assert를 사용하여 측정한다.

  2. Cleanup
    정해진 범위 내에서만 실행되고, 테스트 이후 스스로를 정리함으로써 다른 테스트에 영향이 가지 않게 하는 단계이다.


이제 테스트의 단계를 알았으니, 테스트를 진행하도록 하겠다.

# calculator.py

class Calculator:
    """A terrible calculator"""

    def add(self, x, y):
        return x + y

위 코드는 자칭 끔찍한 계산기로 pytest를 이용하여 위 코드를 테스트할 것이다.

 

# test_calculator.py
from calculator import Calculator

def test_add():
    calculator = Calculator()
    result = calculator.add(2, 3)

    assert result == 5

위 코드는 calculator의 add 메소드를 테스트하는 코드이다.

 

단계를 살펴보자면,

  1. from calculator import Calculator, calculator = Calculator()는 Act를 실행시키기 위해 준비해야하는 코드이므로 Arrange 단계에 해당된다.
  2. result = calculator.add(2, 3)는 add 메소드에 인자 값을 넣어서 실행하여 메소드의 결과를 얻어낸다. 이는 Act 단계에 해당된다.
  3. assert result == 5는 add 메소드의 결과와 내가 예상한 결과와 일치하는지 확인는 것이므로 Assert 단계에 해당된다.

4번째 단계는 pytest로 테스트 후 종료가 되는 것이 Cleanup 단계에 해당된다.

 

테스트 검사를 진행하도록 하겠다.

테스트 결과 Error 없이 통과하는 것을 확인할 수 있다.

 

여기서 테스트를 더 확장하도록 하겠다.

# calculator.py

class Calculator:
    """A terrible calcurator"""

    def add(self, x, y):
        return x + y

    def subtract(self, x, y):
        return x - y

    def multiply(self, x, y):
        return x * y

    def divide(self, x, y):
        return x / y
# test_calculator.py
from calculator import Calculator

def test_add():
    calculator = Calculator()
    result = calculator.add(2, 3)

    assert result == 5

def test_subtract():
    calculator = Calculator()
    result = calculator.subtract(9, 3)

    assert result == 6

def test_multiply():
    calculator = Calculator()
    result = calculator.multiply(9, 3)

    assert result == 27

def test_divide():
    calculator = Calculator()
    result = calculator.divide(9, 3)

    assert result == 3.0

이처럼 테스트를 확장 시킬 수 있다.
하지만 이렇게 되면 단순 테스트만 진행될 수 밖에 없다.

 

만약 add 메소드의 인자 값으로 "two"3을 넘겨주면?
Error가 발생될 것이다.

 

이 때문에 뭔가 특이한 값을 넣었을 때 발생되는 행위를 잡아야한다.

Error 커스텀하기

# test_calculator.py
from calculator import Calculator

def test_add():
    calculator = Calculator()
    result = calculator.add(2, 3)

    assert result == 5

def test_add_str_factor():
    calculator = Calculator()
    result = calculator.add("two", 3)

    assert result == 5

def test_subtract():
    calculator = Calculator()
    result = calculator.subtract(9, 3)

    assert result == 6

def test_multiply():
    calculator = Calculator()
    result = calculator.multiply(9, 3)

    assert result == 27

def test_divide():
    calculator = Calculator()
    result = calculator.divide(9, 3)

    assert result == 3.0

test_add_weird_stuff 함수와 같이 함수명은 고유하게 지정해야한다. 또한 고유하면서도 어떤 테스트인지 알아야한다.
일단 함수의 결과를 생각하지 않고 어떤 Error가 발생하는지 살펴보겠다.

 

pytest의 결과 TypeError가 발생된 것을 알 수 있었다.
하지만 우리가 만드는 코드에서 나오는 Error와 Python에서 만든 Error의 메서지 내용이 틀릴 수도 있다. 그렇기 때문에 Error을 커스텀하여 제어하는게 필요하다.

 

# calculator.py

class CalculatorError(Exception):
    """An exception class for calcurator"""

class Calculator:
    """A terrible calcurator"""

    def add(self, x, y):
        try:
            return x + y
        except TypeError:
            raise CalculatorError("Error!!")

    def subtract(self, x, y):
        return x - y

    def multiply(self, x, y):
        return x * y

    def divide(self, x, y):
        return x / y

에러를 커스텀하기 위해 Exception을 상속한 클래스인 CalculatorError클래스를 만들어줬다.
그리고 add 함수에 Error를 제어하기 위해 Try, except 문을 사용하여 TypeError가 발생 시 Error!!라는 문구를 출력하도록하였다.

 

이렇게 하면 해당 Error가 발생 시 Error!!라는 문구를 볼 수 있을 것이다.

확인을 위해 직접 출력을 해본 결과 맨 밑에 Error!!이 출력되는 것을 볼 수 있다.

 

# test_calculator.py
import pytest
from calculator import Calculator, CalculatorError

def test_add():
    calculator = Calculator()
    result = calculator.add(2, 3)

    assert result == 5

def test_add_str_factor():
    calculator = Calculator()

    with pytest.raises(CalculatorError):
        result = calculator.add("two", 3)

def test_subtract():
    calculator = Calculator()
    result = calculator.subtract(9, 3)

    assert result == 6

def test_multiply():
    calculator = Calculator()
    result = calculator.multiply(9, 3)

    assert result == 27

def test_divide():
    calculator = Calculator()
    result = calculator.divide(9, 3)

    assert result == 3.0

이후 TypeError가 발생하는 지점에 pytest의 raises 함수를 이용하여 Error가 발생하는지에 대해 검사를 하겠다.

 

검사 결과 커스텀한 Error가 발생되는 것으로 확인되었다.

Error 검사 메소드 만들기

# calculator.py
 def add(self, x, y):
        try:
            return x + y
        except TypeError:
            raise CalculatorError("Error!!")

add 메소드 처럼 모든 메소드에 Try/Except를 통해 Error를 통제한다면 코드는 엄청나게 복잡해질 것이다.
특정 메소드에서만 발생되는 Error도 있을 거고, Error 제어 코드의 수정이 있다면 해당 코드가 있는 메소드들은 모두 수정을 해야하기 떄문이다. 이러한 불편함 때문에 Error 제어 메소드를 만들어서 간편하게 사용하는게 좋다.

 

# calculator.py
import numbers

class CalculatorError(Exception):
    """An exception class for calcurator"""

class Calculator:
    """A terrible calcurator"""

    def add(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x + y

    def subtract(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x - y

    def multiply(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x * y

    def divide(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x / y

    def _check_operand(self, operand):
        if not isinstance(operand, numbers.Number):
            raise CalculatorError(f'"{operand}" was not a number')

나는 _check_operand 메소드를 생성하여 isinsttance 함수를 사용하여 Error를 발생시켰다.
이러면 아주 간단하게 각 메소드에 _check_operand 메소드를 호출하여 인자 값을 넣어주면 해당 인자 값이 숫자형인지 아닌지를 검사할 수 있다.

 

잘 작동하는지 확인을 위해 calculator.py에 메인 함수를 실행시켜서 확인해보도록 하겠다.

# calculator.py
import numbers, sys

class CalculatorError(Exception):
    """An exception class for calcurator"""

class Calculator:
    """A terrible calcurator"""

    def add(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x + y

    def subtract(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x - y

    def multiply(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x * y

    def divide(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x / y

    def _check_operand(self, operand):
        if not isinstance(operand, numbers.Number):
            raise CalculatorError(f'"{operand}" was not a number')

if __name__ == '__main__':
    calculator = Calculator()
    operations = [
        calculator.add,
        calculator.subtract,
        calculator.multiply,
        calculator.divide
    ]

    while True:
        for i, operation in enumerate(operations, start=1):
            print(f'{i} : {operation.__name__}')
        print('q : quit')
        operation = input('Pick an operation: ')
        if operation == 'q':
            sys.exit()

        op = int(operation)
        x = float(input('What is x? '))
        y = float(input('What is y? '))
        print(f'result : {operations[op-1](x, y)}\n')

테스트를 해본 결과 새로운 Error를 발생된다는 것을 볼 수 있었다.

 

test_calculator.py에 해당 Error가 발생되는 인자를 선언하여 메소드를 호출해보겠다.

# test_calculator.py
import pytest
from calculator import Calculator, CalculatorError

def test_add():
    calculator = Calculator()
    result = calculator.add(2, 3)

    assert result == 5

def test_add_str_factor():
    calculator = Calculator()

    with pytest.raises(CalculatorError):
        result = calculator.add("two", 3)

def test_subtract():
    calculator = Calculator()
    result = calculator.subtract(9, 3)

    assert result == 6 

def test_multiply():
    calculator = Calculator()
    result = calculator.multiply(9, 3)

    assert result == 27

def test_divide():
    calculator = Calculator()
    result = calculator.divide(9, 3)

    assert result == 3.0

def test_divide_by_zero():
    calculator = Calculator()

    with pytest.raises(CalculatorError):
        result = calculator.divide(9, 0)

현재 발생되는 Error는 Python에서 이미 만들어 놓은 Error 메세지이기 때문에 직접 커스텀하여 바꾸어 보겠다.

 

# calculator.py
import numbers, sys

class CalculatorError(Exception):
    """An exception class for calcurator"""

class Calculator:
    """A terrible calcurator"""

    def add(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x + y

    def subtract(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x - y

    def multiply(self, x, y):
        self._check_operand(x)
        self._check_operand(y)
        return x * y

    def divide(self, x, y):
        self._check_operand(x)
        self._check_operand(y)

        try:
            return x / y
        except:
            raise CalculatorError("Can't divide by zero.")

    def _check_operand(self, operand):
        if not isinstance(operand, numbers.Number):
            raise CalculatorError(f'"{operand}" was not a number')

if __name__ == '__main__':
    calculator = Calculator()
    operations = [
        calculator.add,
        calculator.subtract,
        calculator.multiply,
        calculator.divide
    ]

    while True:
        for i, operation in enumerate(operations, start=1):
            print(f'{i} : {operation.__name__}')
        print('q : quit')
        operation = input('Pick an operation: ')
        if operation == 'q':
            sys.exit()

        op = int(operation)
        x = float(input('What is x? '))
        y = float(input('What is y? '))
        print(f'result : {operations[op-1](x, y)}\n')

divide 메소드를 try/except로 Error 제어를 하도록 수정하였다.

 

이제 해당 Error가 커스텀된 Error로 실행되는지 확인하였는데, 아무 이상 없이 잘 실행되었다.

'Development > 내용 정리' 카테고리의 다른 글

[pytest] 03. pytest201  (0) 2021.10.06
[pytest] 01. 시작하기  (0) 2021.10.04