[Python] 테스트 기반 개발 준비하기

글쓴이 Engineer Myoa 날짜

본 글은, 이호성님의 slideshare 게시물중 “Python 테스트 시작하기” 프레젠테이션을 참고하여 작성되는 글입니다.

 

들어가기에 앞서

일전부터 손보고 싶은 부분도 있었고 입맛에 맞도록 youtube-dl 레파지토리를 수정해보려고 계획을 했었습니다. (Media Downloader 업데이트 때문) 근래 시간적 여유가 생겨 기여를 위한 시간 투자를 시작했습니다.

데이터 플로우를 읽으면서 프로젝트 구조를 파악하려고 했는데 생각보다 프로젝트 규모가 커서 테스트 툴을 만들 필요가 생겼습니다. 흔히 말하는 TDD(Test Driven Development) 방법론에 따라서 체계적으로 테스트를 해보자! 라는 생각이 들었습니다.

따라서 본 포스팅에서는

제대로 된 테스트를 시작하기 전에 가볍게 읽을 수 있는 이호성님의 “Python 테스트 시작하기” 를 참고해 아카이빙을 하고자 합니다.

 

목차

1. unittest 시작하기

2. Test Case

3. 여러 테스트 파일 실행하기

4. Exception 테스트하기

5. 테스트에서의 생성자 소멸자 정의하기

6. 반복되는 테스트하기

7. 의존성이 발생하는 테스트는?

 

8. Appendix

1) 전체적인 unittest 구조

 

1. unittest 시작하기

unittest를 위한 더미 클래스를 하나 만듭니다.

class Portfolio(object): # portfolio.py
    """간단한 주식 포트폴리오"""
    def __init__(self):
        # stocks is a list of lists:
        # [[name, shares, price], ...]
        self.stocks = []

    def buy(self, name, shares, price):
        """name 주식을 shares 만큼 주당 price 에 삽니다"""
        self.stocks.append([name, shares, price])

        # except raise
        if not isinstance(shares, int):
            raise TypeError("[err] type error")

    def cost(self):
        """이 포트폴리오의 총액은 얼마일까요?"""
        amt = 0.0
        for name, shares, price in self.stocks:
            amt += shares * price
        return amt


 

이 클래스의 멤버 함수인 buy와 cost가 정상적으로 동작하는지 테스트 해봅시다.

구글“고글”사의 주식을 주당 123.45에 100주를 매수합니다.

p = Portfolio()
p.buy("Gogle", 100, 123.45)

 

제대로 매수가 됐는지, cost함수를 호출해봅니다.

print(p.cost())
>>> 12345

문제는 출력되는 결과값이 expected한지는 알 수 없네요

 

그렇다면 기대값과 isEqual인지 확인해봅시다.

print(p.cost() == 12345)
>>> True

코드가 구질구질하게 바뀌었지만 테스트는 수행됐네요.

 

다음으로 다른 값을 넣으려면? 아니, 그저 같은 값을 다시 테스트를 하려면?

아 생각보다 귀찮을 것 같네요.

 

이런 하드코딩스러운 테스트는 작은 규모의 프로그램에서는 적당한 시간과 노력을 요구합니다. 오버엔지니어링이 일어나지 않습니다.

 

그런데 좀 규모가 커지면… 함수가 함수를 호출하고… 또 다른 객체를 생성하고… 객체에 의존성이 발생하고…

결과적으로, 스파게티처럼 엉켜있는 코드가 만들어지기 마련입니다.

이런 테스트를 자동화하기 위한 도구가 존재합니다! 많은 언어에서요.

 

import unittest

일단 구문을 작성해봅시다. python에서는 unittest란 모듈을 통해 테스트 자동화 도구를 지원합니다.

다음으로, unittest는 자동화된 테스트를 위해 test case를 요구합니다.

def test_gogle(self):
    p = Portfolio()
    p.buy("Gogle", 100, 123.45)
    self.assertEqual(12345, p.cost())

그런데 이렇게 달랑 함수만 존재한다고 테스트가 자동으로 이루어지지 않습니다.

 

 

2. Test Case

바로 unittest의 TestCase 클래스를 상속받는 TestCase 클래스를 만들어야 합니다.

여기서, 독립적으로 이루어지는 테스트 단위를 Test Case라고 합니다.

import unittest
from portfolio import Portfolio

# Unittest
class PortfolioTest(unittest.TestCase):
    def test_google(self):
        p = Portfolio()
        p.buy("Gogle", 100, 123.45)
        self.assertEqual(12345, p.cost())
        # self.assertEqual(123456, p.cost()) # failures=1


    """
    Ran 1 test in 0.001s

    OK
    """


if __name__ == "__main__":
    unittest.main()

unittest.main()을 __main__으로 호출하는 이유는, 테스트 파일이 다른 파일에서 import되었을 때 실행되지 않도록 입니다. 이 부분은 python 특징 정리하는 편을 별도로 마련하겠습니다.

assertEqual(a, b) 함수는 a와 b의 값이 같을 것이다! 라고 주장하는 것입니다. 만약 값이 다르다면 exception이 발생할 것입니다.

즉, 주당 123.45로 100주를 매수했다면 포트폴리오의 총 가치는 12345가 됩니다.

12345 == 12345 이기 때문에 이 test function은 통과하게 됩니다.

만약 아래 주석 처리 된  라인을 실행시킨다면 123456과 12345가 매치되지 않아 해당 test function이 failed 되게 됩니다.

이 외의 assert관련 기능은 다음 링크를 참고해주세요.

https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertEqual

저도 아직 익히고 있답니다…

 

또한, Test Case 를 상속받는 클래스는 여러개의 test function을 포함할 수 있습니다.

class PortfolioTestCase(unittest.TestCase):

    def test_gogle(self):
        p = Portfolio()
        p.buy("Gogle", 100, 123.45)
        self.assertEqual(12345, p.cost())
        # self.assertEqual(12343, p.cost())

    def test_gogle2(self):
        a = Portfolio()
        a.buy("Gogle_Minor", 100, 111.11)
        a.buy("Gogle_Major", 100, 111.11)
        self.assertEqual(22222, a.cost())

test_gogle(!)에서는 (123.45*100) == 12345로 일치합니다.

test_gogle2(!!!)에서는 (111.11*100) + (111.11*10) == 22222로 역시 일치합니다.

이렇게 PortfolioTestCase라는 Test Case는 2개의 Test를 통과하게 됩니다.

 

3. 여러 테스트 파일 실행하기

이름도 직관적인 인수 discover를 이용하면 됩니다.

# python -m unittest discover --help
# Options:

#  -s START, --start-directory=START
#  Directory to start discovery ('.' default)
#  -p PATTERN, --pattern=PATTERN
#  Pattern to match tests ('test*.py' default)
#  -t TOP, --top-level-directory=TOP
#  Top level directory of project (defaults to start
#  directory)

# $ python -m unittest discover
# ----------------------------------------------------------------------
# Ran 15 tests in 0.130s

python -m을 사용하면 모듈을 스크립트로 실행할 수 있습니다.
모듈이 하나의 .py 파일이면 실행될 것입니다 (보통 __name__ == ‘__main__’아래의 코드를 의미합니다).
모듈이 디렉토리라면 파이썬은 (__init__.py 옆에있는) __main__.py를 찾아서 실행합니다.

이를 이용해 일일히 테스트파일을 실행시킬 필요없이, unittest의 test case가 있는 py들을 자동으로 호출하게 됩니다.

점점 더 자동화에 가까워집니다.

 

4. Exception 테스트하기

때때로 비정상적인 데이터를 넣고 경계 테스트를 수행해야 할 필요가 있습니다.

1에서 작성한 portpolio.py의 Portfolio 클래스를 다시 보겠습니다.

if not isinstance(shares, int):
    raise TypeError("[err] type error")

위 코드는 shares가 int형 타입이 아니라면 Exception을 raise시킵니다.

 

# Exception을 유발하는 테스트
import unittest
from portfolio import Portfolio

class PortfolioTestCase(unittest.TestCase):
    def test_gogle(self):
        p = Portfolio()

        # with self.assertRaises(ValueError) as context:
        with self.assertRaises(TypeError) as context:
            #아래와 같은 테스트 케이스에서 TypeError가 기대된다.
            p.buy("gogle", "lots of", 123.45)
            # p.buy("gogle", 100, 123.45)

        ex = context.exception
        self.assertEqual(type(ex), TypeError) # 실제로 TypeError가 발생했는지?


if __name__ == "__main__":
    unittest.main()

# FAILED (failures=1) 대신에, errors=1이 나옴

이 test_gogle(!) 함수에서는 TypeError를 유발하는 코드를 담고있습니다.

따라서 self.assertRaises(TypeError) 구문을 통해 TypeError를 기대하고, 실제로도 p.buy(“gogle”, “lots of”, 123.45)로 함수를 호출합니다.

수량 대신 “lots of”라는 string 값을 넣어줬으니, Portfolio 클래스의 buy함수는 isinstance(shares, int) 를 통해 TypeError를 raise하게 됩니다.

만약, raise되지 않는 정상적인 코드라면 assertRaises에 따라 테스트에 실패하게 됩니다.

따라서 context는 TypeError와 관련된 assert Context를 담고있습니다.

그 밑에 assertEqual 코드를 통해 다시 한 번 체크할 수 있다.  굳이 이렇게 코드를 적은 이유는 다음과 같습니다.

assertRaises에 상위 Exception이나 커스텀 Exception 클래스를 기대하고, 실제로 발생한 exception이 어떤 것인지 추가적으로 테스트할 수 있기 때문입니다.

 

즉 위의 test_gogle함수는

p.buy(“gogle”, “lots of”, 123.45)가 assertRaises에서 지정한 Exception에 맞게 raise를 유발하는지  테스트합니다.

 

5. 테스트에서의 생성자 소멸자 정의하기

Test 구조에서는 다음과 같이 setUp과 tearDown을 통해 클래스의 생성자 소멸자를 흉내됩니다. (조금 더 정확하게는 1) 테스트 전 초기화, 2) 테스트 후 후처리 에 해당)

import unittest
class A():
    def __init__(self, val):
        self.reCal(val)
    def reCal(self, val):
        self.data = val * val

class ATestCase1(unittest.TestCase):
    def test1(self):
        a = A(3)
        self.assertEqual(a.data, 9)
    def test2(self):
        a = A(2)
        self.assertEqual(a.data, 4)


class ATestCase2(unittest.TestCase):
    def setUp(self):
        self.a = A(0)
    def tearDown(self):
        self.a = None

    def testSquare2(self):
        print(self.a.data)
        self.a.reCal(2)
        self.assertEqual(self.a.data, 4,
                         'incorrect val')
    def testSquare6(self):
        print(self.a.data)
        self.a.reCal(6)
        self.assertEqual(self.a.data, 36,
                         'incorrect val')

# ATestCase2의 각 testcase에서 print되는 a.data값은 0 이다! (생성자에 의해)

반복되는 초기화는 ATestCase2의 setUp처럼 초기화 로직을 정의해주면 됩니다.

setUp, tearDown 함수는 자동으로 호출됩니다.

unittest.TestCase에 setUp함수가 정의되어 있기 때문에, 구현 시 필요에 의해 별도로 override하는 것입니다.

 

6. 반복되는 테스트하기

python을 사용하다보면 반복문 내에서 뻗는 경우가 종종 있습니다.

문제는, 테스트를 돌리는 코드에서는 반복문에서 뻗으면 안된다는 것이죠

 

무슨말이냐? 입력으로 들어오는 값이 무조건 짝수여야 한다고 합시다.

테스트를 위해서 0부터 5까지 assertEqual해봅시다.

import unittest

class NumberTest(unittest.TestCase):
    def test_even(self): # 중간에(i==1일때) assertEqual을 통과하지 못해서 중단된다.
        for i in range(0,6):
            print("test even - " + str(i))
            self.assertEqual(i%2, 0)

    def test_even_with_subtest(self):
        for i in range(0,6):
            print("test subset - " + str(i))
            with self.subTest(i=i):
                self.assertEqual(i%2, 0)


if __name__ == "__main__":
    unittest.main()

test_even은 i==1일때, AssertionError가 나서 테스트가 중단 됩니다. 왜냐하면 함수는 하나의 기능만 하기 때문입니다. 숫자 하나가 짝수인지 판별하는 것이기 때문에..

반면에 test_even_with_subtest은 AssertionError가 나도 중단되지 않습니다. test_even_with_subtest라는 함수 내에서 또 다른 subtest를 돌리기 때문이죠.

i==1일 때 테스트가 AssertionError가 발생하더라도, failures를 단지 1추가하고 해당 with문을 빠져나갑니다.

이런식으로 반복문을 이용한 테스트 까지 가능합니다.

 

7. 의존성이 발생하는 테스트는?

테스트를 하기 위해 실질적인 인스턴스가 필요하거나 외부 파일을 참조하는 경우가 있습니다.

다시 말해서 테스트를 위해 운여되는 실제 DB 인스턴스가 있어야 한다던지, 특정 파일을 삭제한다던지….

테스트를 위해 garbage파일을 만들고 실제로 지워지는지 확인하고 또 만들고 반복할 순 없겠죠.

 

이 때 의존성이 발생하는 것들을 실제로 호출하거나 참조하지 않고, 호출 여부와 인터페이스, 경계만 확인 하자는 방법입니다.

이런 경우 Monkey Patch를 동반하여 mock객체로 가짜 인스턴스 혹은 가짜 파일을 만들어 냅니다.

Monkey Patch란 런타임에 클래스, 함수등을 변경하는 것이다.

mock이란 모조품이란 뜻으로, 특정 용도로 사용되어지는 가짜의 무언가이다. 스마트폰에도 목업(mockup)이란 단어를 사용하지 않던가

 

1) Monkey Patch 예제

# Monkey Patch
# 런타임에 클래스, 함수등을 변경하는 것
class Class():
    def add(self, x,y):
        return x+y

instance = Class()
def not_exactly_add(self, x, y):
    return x * y

Class.add = not_exactly_add
instance.add(3,3)
# >>> 9 # 곱하기로 동작

 

2) mock을 이용한 테스트

파일을 삭제하는 어떤 함수가 있고, 이 함수가 정상적으로 동작하는지 테스트해봅시다. 좀 더 정확하게는 호출이 되는지 확인 해봅시다. 

먼저 Rm.py입니다. 4줄 밖에 되지 않아 너무 간단하죠?

# Rm.py
import os
def rm(filename):
    print("reference object >>> " + str(os.remove))
    os.remove(filename)

 

import unittest
from unittest import mock
import os

from Rm import rm

class RmTestCase2(unittest.TestCase):
    # patch(target ,new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)
    @mock.patch("Rm.os") # Rm의 os모듈을 mock으로 변경
    def test_rm(self, mock_os):
        rm("/tmp/tmpfile") # 가상의 /tmp/tmpfile 생성
        mock_os.remove.assert_called_with("/tmp/tmpfile")


if __name__ == "__main__":
    unittest.main()

위 코드를 통해 다음과 같은 결과를 얻을 수 있습니다.

1) setUp, tearDown이 없고 테스트를 위한 사전 데이터 생성이 사라졌습니다. 대신, 실제로 실행시키지 말고 호출 여부, 인터페이스를 테스트 합니다.

2) 실제로 os.remove가 호출되지 않았습니다. (production에서 검증해야할 파일을 가비지로 생성하지 않아도 됩니다.) 또한 생성해둔 데이터가 사라질 필요도 없죠?

3) mock객체로 Monkey patch한 os.remove가 호출되었는지 확인 했습니다.    reference object >>> <MagicMock name=’os.remove’ id=’197997328′>

 

만약, monkey patch를 하지않고 mock을 사용하지 않는다면 아래와 같이 함수를 작성해야 했을 것입니다.(읽지 않고 넘어가도 되는 부분입니다.)

import unittest
import os

from Rm import rm

class RmTestCase(unittest.TestCase):
    tmpFilePath = "test.test"

    def setUp(self):
        with open(self.tmpFilePath, "w") as f:
            f.write("Delete me!")

    def tearDown(self):
        if os.path.isfile(self.tmpFilePath):
            os.remove(self.tmpFilePath)

    def test_rm(self):
        rm(self.tmpFilePath)
        self.assertFalse(os.path.isfile(self.tmpFilePath), "Failed to remove")


# reference object >>> <built-in function remove>
# 가 출력된다.

 

8. Appendix

1) 전체적인 unittest 구조

 

출처: http://www.drdobbs.com/testing/unit-testing-with-python/240165163

가장 깔끔하게 설명되어있는 이미지를 찾았습니다만, DB설계를 위해 ERD를 그려본 경험이 없다면 곤란할 이미지 입니다.

 

따라서 이호성님께서 설명해주시는 구조를 참고하여 다이어 그램을 다시 만들어 보았습니다.

이를 코드로 나타낸다면, 다음과 같습니다.

def do_test():
    for testcase in testsuite:
        for test_method in testcase.test_methods:
            try:
                testcase.setUp()
            except:
                [record error]
            else:
                try:
                    test_method()
                except AssertionError:
                    [record failure]
                except:
                    [record error]
                else:
                    [record success]
                finally:
                    try:
                        testcase.tearDown()
                    except:
                        [record error]

print(do_test())

 

 

참고문헌

https://cjh5414.github.io/why-pytest/

 

 


1개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다