Python 테스트 시작하기

이호성

http://slides.com/hosunglee-1/deck/live

에서 함께 보실 수 있습니다

누구세요?

  이호성

오늘 발표의 목적은?

테스트에 관심을 가져보세요

테스팅이 처음이신 분들께

다른 사람은 어떻게
테스트 할까?

테스팅을 이미 (잘) 하고
계신 분들께

꼭 테스트를
작성해야 하나요?

누군가는 여러분의 코드를 테스트 해야 합니다

길게 보면 시간이
절약 됩니다

변경에 대한 자신감을 얻으세요

더 나은 코드를
만들어 줍니다

 Python
컴파일러가 없잖아요

어떤 테스트를
작성해야 하나요?

수많은 종류의 테스트가 있습니다

단위 테스트

(Unit Test)

기능 테스트

(Function Test)

  • 개발자 뷰
  • 함수 단위
  • Mock 을 사용 
  • 빠름
  • 더 좋은 코드에 기여
  • 사용자 뷰
  • 요구사항 단위
  • Fixture 를 사용
  • 느림
  • 퇴근에 기여

일단 하나만
보여줘 봐요

Portfolio

# portfolio.py from http://nedbatchelder.com/text/st.html#7

class Portfolio(object):
    """간단한 주식 포트폴리오"""
    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])
 
    def cost(self):
        """이 포트폴리오의 총액은 얼마일까요?"""
        amt = 0.0
        for name, shares, price in self.stocks:
            amt += shares * price
        return amt

첫번째 테스트 - 쉘

Python 3.4.0 (default, Jun 19 2015, 14:20:21) 
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from portfolio import Portfolio
>>> p = Portfolio()
>>> print(p.cost())
0.0
>>> p.buy('Google', 100, 176.48)
>>> p.cost()
17648.0
>>> p.buy('Yahoo', 100, 36.15)
>>> p.cost()
21263.0
  • Good
    • 테스트 했어요! 
  •   Bad
    • 다시 테스트 하려면? 
    • 직접 입력 
    • 그래서 잘 된건가?

두번째 테스트 - 기대값

from portfolio import Portfolio

p = Portfolio()
print("Empty portfolio cost: %s, should be 0.0" % p.cost())
p.buy('Google', 100, 176.48)
print("With 100 Google @ 176.48: %s, should be 17648.0" % p.cost())
p.buy('Yahoo', 100, 36.15)
print("With 100 Yahoo @ 36.15: %s, should be 21263.0" % p.cost())
  • Good
    • 테스트 했음
    • 다시 테스트 할 수 있음
    • 잘 된건지 확인 가능 
  •   Bad
    • 눈으로 확인 해야 함
Empty portfolio cost: 0.0, should be 0.0
With 100 Google @ 176.48: 17648.0, should be 17648.0
With 100 Yahoo @ 36.15: 21263.0, should be 21263.0

세번째 테스트 - 결과 확인

from portfolio import Portfolio

p = Portfolio()
print("Empty portfolio cost: %s, should be 0.0" % p.cost())
p.buy('Google', 100, 176.48)
assert p.cost() == 17649.0 # Failed
print("With 100 Google @ 176.48: %s, should be 17648.0" % p.cost())
p.buy('Yahoo', 100, 36.15)
assert p.cost() == 21263.0
print("With 100 Yahoo @ 36.15: %s, should be 21263.0" % p.cost())
  • Good
    • 다시 테스트 할 수 있음
    • 잘 된건지 자동으로 확인 가능 
  •   Bad
    • 왜 틀렸는지 알기 힘듬
    • 두번째 테스트가 실행 되지 않음
Empty portfolio cost: 0.0, should be 0.0
Traceback (most recent call last):
  File "portfolio_test2.py", line 6, in <module>
    assert p.cost() == 17649.0 # Failed
AssertionError

        (개발 시작시의 마음과 달리)

테스트를 지속하지
못하는 이유?

점점 복잡해 진다. (코드와 함께)

반복되는 노력이 아깝다.

framework 의 사용을
고민해 볼 때!

어떻게 테스트 해야 할지 모른다.

  Unittest !

     (들어봤지만 친하지 않은 그대)

unittest

  • 파이썬 표준 라이브러리

  • 테스트 자동화 

  • 테스트 구조화

  • 테스트 결과 보고 

TestCase

독립적인  테스트 단위

TestSuite

TestCase 의 묶음

TestRunner

테스트를 실제로 수행하고
 결과를 레포트 한다.

 테스트 흐름

First Unittest

# portfolio_test3.py
import unittest
from portfolio import Portfolio
    
class PortfolioTest(unittest.TestCase):
    def test_google(self):
        p = Portfolio()
        p.buy("Google", 100, 176.48)
        self.assertEqual(17648.0, p.cost())
    
if __name__ == '__main__':
    unittest.main()
$ python portfolio_test3.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

테스트가 실패 했어요!

- 실패하라고 테스트는 만드는 겁니다.

테스트 추가

# portfolio_test4.py
import unittest
from portfolio import Portfolio

class PortfolioTestCase(unittest.TestCase):
    def test_google(self):
        p = Portfolio()
        p.buy("Goole", 100, 176.48)
        self.assertEqual(17648.0, p.cost())

    def test_google_yahoo(self):
        p = Portfolio()
        p.buy("Google", 100, 176.48)
        p.buy("Yahoo", 100, 36.15)
        self.assertEqual(21264.0, p.cost()) # 21263.0

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

Unittest 실패

$ python portfolio_test4.py
.F
======================================================================
FAIL: test_google_yahoo (__main__.PortfolioTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "portfolio_test4.py", line 15, in test_google_yahoo
    self.assertEqual(21264.0, p.cost())
AssertionError: 21264.0 != 21263.0

----------------------------------------------------------------------
Ran 2 tests in 0.005s

FAILED (failures=1)
  •  Good
    • 테스트 실패가 다른 테스트에 영향을 미치지 않음
    • 실패한 위치와 이유를 알 수 있음

실패한 테스트만
다시 돌리고 싶어요.

Test 고르기

$ python portfolio_test4.py PortfolioTestCase.test_google_yahoo
F
======================================================================
FAIL: test_google_yahoo (__main__.PortfolioTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "portfolio_test4.py", line 15, in test_google_yahoo
    self.assertEqual(21264.0, p.cost())
AssertionError: 21264.0 != 21263.0

----------------------------------------------------------------------
Ran 1 test in 0.005s

FAILED (failures=1)
  •  Good
    • 원하는 테스트만 빠르게 실행 해 볼 수 있음
    • 출력이 간단해짐

테스트 파일이 늘어나면 어떻게 한꺼번에 실행 시키죠?

 테스트 한꺼번에 실행하기

$ python -m unittest discover --help
Usage: python -m unittest discover [options]

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

OK
  •  Good
    • 복수개의 파일을 한꺼번에 테스트를 실행 할 수있음

 Exception 도 테스트
할 수 있나요?

Portfolio - 타입 확인

class Portfolio(object):
    """간단한 주식 포트폴리오"""
    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])
        if not isinstance(shares, int):
            raise Exception("shares must be an integer")
 
    def cost(self):
        """이 포트폴리오의 총액은 얼마일까요?"""
        amt = 0.0
        for name, shares, price in self.stocks:
            amt += shares * price
        return amt

  Exception 을 발생시키는 테스트

import unittest
from portfolio import Portfolio
    
class PortfolioTestCase(unittest.TestCase):
    def test_google(self):
        p = Portfolio()
        p.buy("Goole", "many", 176.48)
        self.assertEqual(17648.0, p.cost())
    
if __name__ == '__main__':
    unittest.main()
$ python ./portfolio_test5.py 
E
======================================================================
ERROR: test_google (__main__.PortfolioTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./portfolio_test5.py", line 8, in test_google
    p.buy("Goole", "many", 176.48)
  File "/home/leclipse/git/pycon-testing/unit_test/portfolio.py", line 14, in buy
    raise Exception("shares must be an integer")
Exception: shares must be an integer

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

  Exception 을 테스트

import unittest
from portfolio import Portfolio
    
class PortfolioTestCase(unittest.TestCase):
    def test_google(self):
        p = Portfolio()
        with self.assertRaises(Exception) as context:
            p.buy("Goole", "many", 176.48)

        self.assertTrue("shares must be an integer", context.exception)
    
if __name__ == '__main__':
    unittest.main()
$ python ./portfolio_test6.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.004s

OK

 TestCase.assert* 는 어떤것들이 있나요?

적절한 assert 의 사용은 테스트 코드를 간단하게 합니다.

TestCase.assert*

 테스트마다 반복되는 초기화는 어떻게
관리하나요? 

import unittest

class WidgetTestCase(unittest.TestCase):
    def setUp(self):
        self.widget = Widget('The widget')

    def tearDown(self):
        self.widget.dispose()
        self.widget = None

    def test_default_size(self):
        self.assertEqual(self.widget.size(), (50,50),
                         'incorrect default size')

    def test_resize(self):
        self.widget.resize(100,150)
        self.assertEqual(self.widget.size(), (100,150),
                         'wrong size after resize')

setUp(), tearDown()

테스트 흐름

비슷한 테스트를
반복 하고
싶을 때는?

 반복되는 테스트 실행하기

import unittest

class NumberTest(unittest.TestCase):

    def test_even(self):
        for i in range(0, 6):
            self.assertEqual(i % 2, 0)

    def test_even_with_subtest(self):
        for i in range(0, 6):
            with self.subTest(i=i):
                self.assertEqual(i % 2, 0)

unittest.main()
  • 0 부터 5 까지 짝수인지를 테스트 합니다.

 

 Subtest 실행 결과

$ python ./subtest.py 
F
======================================================================
FAIL: test_even (__main__.NumberTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./subtest.py", line 7, in test_even
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0

======================================================================
FAIL: test_even_with_subtest (__main__.NumberTest) (i=1)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./subtest.py", line 12, in test_even_with_subtest
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0

======================================================================
FAIL: test_even_with_subtest (__main__.NumberTest) (i=3)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./subtest.py", line 12, in test_even_with_subtest
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0

======================================================================
FAIL: test_even_with_subtest (__main__.NumberTest) (i=5)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./subtest.py", line 12, in test_even_with_subtest
    self.assertEqual(i % 2, 0)
AssertionError: 1 != 0

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=4)
  • Good
    • 중단 되지 않고 모두 테스트
    • 변경 되는 값을 확인 할 수 있다. 

python 3.4  에 추가 되었음

 Unittest 복잡하군요

# test_runner.py
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())

다시 보는 unittest 구조

의존성이 있어요.
어떻게 테스트 하죠?

 의존성이 있는 코드

def get_username(user_id):
    user = db.user.query(user_id = user_id)
    return user.name

def delete_user(user_id):
    return db.user.delete(user_id = user_id)
    
  • DB 가 있어야 테스트 할 수 있음

  • DB 에 테스트전 데이터를 넣어 주어야 함

  • CI 에서는 어떻게 실행 할까?

테스트는 여기까지 하는 것이 좋겠다.

...가 아니고

 mock
을 사용합시다.

Mock?!

Mock

unittest.mock

  • 파이썬 표준 라이브러리 (3.3 부터)
  • 이전 버젼은 pip install mock
  • python object 들을 동적으로 대체하고 
    사용 결과를 확인 하기 위한 다양한 기능들을 제공

의존성이 있는것들을 실제로 실행시키지 말고 호출 여부, 인터페이스만  확인 하자

Monkey Patch

>>> class Class():
...    def add(self, x, y):
...       return x + y
...
>>> inst = Class()
>>> def not_exactly_add(self, x, y):
...    return x * y
...
>>> Class.add = not_exactly_add
>>> inst.add(3, 3)
9
  • 런타임에 클래스, 함수등을 변경하는 것

mock 사용 예

>>> from unittest.mock import MagicMock
>>> thing = ProductionClass()
>>> thing.method = MagicMock(return_value=3)
>>> thing.method(3, 4, 5, key='value')
3
>>> thing.method.assert_called_with(3, 4, 5, key='value')
  • thing.method 가 monkey patch 되었음
  • 이를 테스트에 어떻게 활용 할까?

test rm

# Rm.py
import os

def rm(filename):
    os.remove(filename)
# test_rm.py
from Rm import rm 

class RmTestCase(unittest.TestCase):

    tmpfilepath = os.path.join(tempfile.gettempdir(), 'temp-testfile')

    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 the file')

첫번째 Mock test

import os.path
import tempfile
import unittest
from unittest import mock

from Rm import rm

class RmTestCase(unittest.TestCase):

    @mock.patch('Rm.os')
    def test_rm(self, mock_os):
        rm('/tmp/tmpfile')
        mock_os.remove.assert_called_with('/tmp/tmpfile')

if __name__ == '__main__':
    unittest.main()
  •  Good
    • setUp, tearDown 이 없어졌음
    • 실제로 os.remove 이 호출되지 않았음
    • os.remove 가 호출되었는지는 확인 했음

test_rm

Rm.rm

os.remove

mock_os.
remove

어떻게 된걸까?

# Rm.py
import os

def rm(filename):
    print(os.remove)
    os.remove(filename)
$ python ./test_rm.py 
<built-in function remove>
.
----------------------------------------------------------------------
Ran 1 test in 0.007s

OK
$ python ./mock_rm.py
<MagicMock name='os.remove' id='139901238735592'>
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

Mock  대상이 되는 것

  • 시간이 오래 걸리는 것
  • 값이 변하는 것
  • 상태가 유지 되는 것 
    (다른 테스트에 영향을 주는 것)
  • 시스템 콜
  • 네트워크로 연결된 것
  • 준비하기 복잡한 것

Realworld example

class SimpleFacebook(object):

    def __init__(self, oauth_token):
        self.graph = facebook.GraphAPI(oauth_token)

    def post_message(self, message):
        self.graph.put_object('me', 'feed', message=message)
class SimpleFacebookTestCase(unittest.TestCase):

    @mock.patch.object(facebook.GraphAPI, 'put_object', autospect=True)
    def test_post_message(self, mock_put_object):
        sf = SimpleFacebook('fake oauth token')
        sf.post_message('Hello World!')

        mock_put_object.assert_called_with('me', 'feed', message='Hello World!')

facebook 이 다운되어도 내 테스트는 실패 하지 않는다.

 Mock - 조건 확인

# 24시간이 지난 경우에만 삭제 합니다.
def rm(filename):
    file_modified = datetime.datetime.fromtimestamp(os.path.getmtime(filename))
    if datetime.datetime.now() - file_modified > datetime.timedelta(hours=24):
        os.remove(filename)
class RmTestCase(unittest.TestCase):

    @mock.patch('__main__.os')
    def test_rm(self, mock_os):
        mock_os.path.getmtime.return_value = time.time()
        rm('/tmp/tmpfile')
        self.assertFalse(mock_os.remove.called)

        mock_os.path.getmtime.return_value = time.time() - 86400*2
        rm('/tmp/tmpfile')
        mock_os.remove.assert_called_with('/tmp/tmpfile')

 보통 분기를 따라가면서 테스트 하기는 쉽지 않음

 Mock 예외

# Rm.py
class MyError(Exception):
    pass

def rm(filename):
    try:
        os.remove(filename)
    except FileNotFoundError:
        raise MyError
class RmTestCase(unittest.TestCase):

    @mock.patch.object(os, 'remove', side_effect=FileNotFoundError)
    def test_rm_without_file(self, mock_remove):
        with self.assertRaises(MyError) as context:
            rm('not_exist_file')

 Exception 이 발생되는 경우를 만들지 않아도 됨

그래도 합쳐서 테스트는 해봐야 하잖아요?

Integration Test

  • 테스트 환경이 동작하지 않아요.
  • 프로덕션 환경이랑 테스트 환경이 다른 것 같은데요?
  • 저 지금 테스트 환경에 배포 해도 돼요?
  • 제가 지금 테스트 하고 있으니, 다른 분은 나중에 테스트 해주세요.

다른 모듈,서비스 등을 붙여서 그 관계에서의 
문제점을 확인하는 과정

나만 쓰는 프로덕션 환경과 동일한 테스트 환경이 있으면 좋겠다.

 Redis Client

# client.py
import sys
import redis

class Client():
    def __init__(self):
        self.conn = redis.StrictRedis("redis")

    def set(self, key, value):
        self.conn.set(key, value)

    def get(self, key):
        return self.conn.get(key)

실제 Redis 와 연결했을 때 잘 동작하는지 확인이 필요하다.

# test_client.py
import unittest
from client import Client

class ClientTestCase(unittest.TestCase):

    def test_with_redis(self):
        client = Client()
        client.set('tomato', 2)
        self.assertEqual(2, int(client.get('tomato')))

나만 쓰는 프로덕션 환경과 동일한 테스트 환경이 있으면 좋겠다.

Host

Host

 Docker

  • https://www.docker.com/
  • Linux Container 를 이용
  • 가벼운 VM 이라고 생각하자
  • Host 와 격리시킬 수 있음

 Dockerfile

FROM python:3.4
MAINTAINER lee ho sung

RUN pip install redis

RUN mkdir /code
ADD . /code
WORKDIR /code

CMD python -m unittest discover

Docker 이미지를 정의한다.

 Docker-compose

docker-compose.yml

client:
    build: .
    links:
        - redis
redis:
    image: redis
    ports:
        - 6379:6379

192.168.0.2

192.168.0.1

/etc/hosts

 redis 192.168.0.2

docker-compose up

  • Good
    • Localhost 에서 모든 테스트가 가능
    • Host 에 영향 없음
    • CI 에서도 실행 가능
$ docker-compose up 
redis_1  | 1:M 28 Aug 06:05:53.613 # Server started, Redis version 3.0.3
redis_1  | 1:M 28 Aug 06:05:53.613 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
redis_1  | 1:M 28 Aug 06:05:53.613 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
redis_1  | 1:M 28 Aug 06:05:53.613 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
redis_1  | 1:M 28 Aug 06:05:53.614 * DB loaded from disk: 0.000 seconds
redis_1  | 1:M 28 Aug 06:05:53.614 * The server is now ready to accept connections on port 6379
client_1 | .
client_1 | ----------------------------------------------------------------------
client_1 | Ran 1 test in 1.003s
client_1 | 
client_1 | OK
integrationtest_client_1 exited with code 0
Gracefully stopping... (press Ctrl+C again to force)
Stopping integrationtest_redis_1... done
$

  UI가 잘 동작하는지
어떻게 테스트 하죠?

그냥 손으로 합니다

... 가 아니고

 Selenium

from selenium import webdriver

driver = webdriver.Firefox()
driver.get("http://www.python.org")
assert "Python" in driver.title

사람과 동일한 방식으로!

Test Pycon 2015

import unittest
from selenium import webdriver
from selenium.webdriver.common.keys import Keys

class PyconUserTestCase(unittest.TestCase):

    def setUp(self):
        self.driver = webdriver.Firefox()

    def test_log_in_pycon_2015(self):
        driver = self.driver
        driver.get("http://www.pycon.kr/2015/login")

        # US1 : 사용자는 Pycon Korea 2015 페이지 제목을 볼 수 있습니다.
        self.assertIn("PyCon Korea 2015", driver.title)

        # US2 : 사용자는 로그인을 할 수 있습니다. 
        #       로그인 하면 "One-time login token ..." 메시지를 볼 수 있습니다.
        elem = driver.find_element_by_id("id_email")
        elem.send_keys("email-me@gmail.com")
        elem.send_keys(Keys.RETURN)
        self.assertIn("One-time login token url was sent to your mail",
                      driver.page_source)

    def tearDown(self):
        self.driver.close()

if __name__ == "__main__":
    unittest.main(warnings='ignore')

어디까지 테스트 해야 하나요?

경우에 따라 많이 다릅니다. 

최소 :  테스트 케이스가 메인 로직을 검증 한다.

최대 : 개발 시간의 2배 이상을 쓰지 않는다.

 

이 사이에서 가장 가성비 높은 지점을 
찾아내는 것이 좋은 개발자!

테스트는 언제 실행
하나요?

문제는 빠르게 찾을 수록 좋습니다. 
가능한 자주 실행 합니다.

  • 개발할 때 
  • commit 할 때
  • push 할 때
  • CI 서버에서
  • 오픈 소스를 받자마자 

좋은 테스트란

무엇인가요?

  • 한번에 하나만 테스트 합니다

  • 실패가 명확해야 합니다

  • 빠르게 테스트 되어야 합니다

  • 중복 되지 않습니다

  • 자동화 되어야 합니다

  • 독립적이어야 합니다
    (다른것의 영향을 받지 않습니다)

 오늘 무슨 이야기를 했죠?

  • 테스트를 작성 하는 이유

  • 간단한 테스트

  • unittest 의 사용

  • 의존성이 있을 때 mock 사용하기 

  • docker 사용해서 통합 테스트 하기

  • selenium을 사용해서 웹 테스트 하기 

테스트는 힘들지만
가치 있는 일입니다

 

지금 시작하세요!

 참고 자료

 감사합니다.

Python 테스트 시작하기

By Ho Sung Lee

Python 테스트 시작하기

Pycon Korea 2015 발표 자료입니다.

  • 5,987