프로그래머는 코드를 코드로 테스트해야합니다.

이 말을 하면 두 가지 반응이 있을 수 있습니다.

  1. 어떻게? 왜? 테스트는 테스터가 하는거 아냐?
  2. 너무 당연한거 아닌가?

이 글은 1번으로 생각하는 분들을 위해 쓰여집니다.

※ 이 글은 이모콘2016SS의 Test first! 세션의 내용에 영감을 받았습니다.

명세 문서와 개발품

우리는 개발 착수 전에 무엇을 개발할 것인지 명세를 꾸립니다. DB Model도 정해야하고, API도 정해야 하죠. 하지만 개발은 우리가 원하는 대로 흘러가는 경우가 많지 않습니다. 보통 바뀐 사항은 코드에 먼저 반영이 되고 문서에는 없다던가 하는 상황이 벌어지죠.

이런 상황에서, 특정 반응 A에 대해 테스터가 판단할 수 있는 기준은 무엇일까요? 갱신되지 않은 명세 문서를 봐야할까요? 테스트를 할 수 없게 됩니다.

명세 문서를 코드로 만들면 어떨까

이러므로 명세 문서를 코드로 만들고, 실제 구현과 비교검증해보면 어떨까요?

심플한 예를 들어봅시다. Python으로 곱하기를 해주는 함수를 하나 만든다고 해보죠. 우리는 이미 * 연산자가 있음은 알고 있지만 이건 쓰지 않고 구현한다고 해보죠.

일단 명세를 만들어봅시다.

import unittest  # Python에서 테스트를 할 때 쓸 수 있는 기본 내장 모듈입니다.


class MulTest(unittest.TestCase):  # unittest 대상입니다.
    def test_simple(self):
        self.assertEqual(6, mul(2, 3))  # mul(2, 3)을 실행하면 6이 나와야 한다는 의미

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

당연히 mul을 정의하지 않았으니 에러가 날 것입니다. 한번 실행해볼까요?

E
======================================================================
ERROR: test_simple (__main__.MulTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "t.py", line 6, in test_simple
    self.assertEqual(6, mul(2, 3))  # mul(2, 3)을 실행하면 6이 나와야 한다는 의미
NameError: name 'mul' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

정말 예상한대로 에러가 나왔습니다.

구현을 해봅시다

이제 구현을 해볼 생각입니다. 일단 우리는 명세에 2와 3을 넣으면 6이 나오면 된다고 했으니 그냥 6을 뱉어줘보죠.

import unittest


def mul(a, b):
    return 6  # 우린 이게 비정상적이란 것을 이미 알고 있습니다만, 어디까지나 예시입니다.


class MulTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual(6, mul(2, 3))

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

한번 실행해봅시다.

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

어머, 괜찮다고 합니다. 그럼 우리 할 일은 끝난걸까요?

보다 정밀한 테스트를 위해

하지만 명세를 만들어놓고 보니 mul의 인자로 2, 3 외의 다른 것도 올 수 있을 것 같습니다. 예시를 좀 더 늘려보기로 합니다.

import unittest


def mul(a, b):
    return 6


class MulTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual(6, mul(2, 3))
        self.assertEqual(6, mul(3, 2))
        self.assertEqual(4, mul(1, 4))
        self.assertEqual(20, mul(4, 5))
        self.assertEqual(0, mul(10, 0))

if __name__ == '__main__':
    unittest.main()
F
======================================================================
FAIL: test_simple (__main__.MulTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "t.py", line 12, in test_simple
    self.assertEqual(4, mul(1, 4))
AssertionError: 4 != 6

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)

에러가 납니다. 1과 4의 곱은 4인데 우리 함수는 6만 반환하고 있으니까 어쩔 수 없죠.

구현을 옳게

이번엔 구현이 정상적이게 만들어볼 차례입니다.

import unittest


def mul(a, b):
    result = 0  # 초기값은 0이라고 치고
    for x in range(b):  # b번 반복해서
        result +=  a  # 결과값에 더해주고
    return result  # 리턴합시다


class MulTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual(6, mul(2, 3))
        self.assertEqual(6, mul(3, 2))
        self.assertEqual(4, mul(1, 4))
        self.assertEqual(20, mul(4, 5))
        self.assertEqual(0, mul(10, 0))

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

테스트 해봅시다.

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

잘 된다고 합니다.

이런 코드를 짜서 무슨 득이 있지?

만약에 외부 요청에 따라 mul 함수의 구현을 바꾸게 되었다고 칩시다. 그런데 mul 함수가 한두곳에 쓰인 것이 아니라면 변경점마다 모두 테스트를 해야하죠. 이때, mul 함수의 테스트가 빛을 발합니다.

import unittest


def mul(a, b):
    return sum(a for x in range(b))  # 문법이 복잡해졌어요!


class MulTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual(6, mul(2, 3))
        self.assertEqual(6, mul(3, 2))
        self.assertEqual(4, mul(1, 4))
        self.assertEqual(20, mul(4, 5))
        self.assertEqual(0, mul(10, 0))

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

역시 테스트를 돌려봅니다.

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

구현을 바꿨음에도 잘 동작함이 검증되었습니다.

아직 감이 잘 안와요

조금 더 실용적인 예시를 들어볼까 합니다. 주어진 GitHub 사용자 이름이 있을 때, 해당 사용자의 email 주소를 얻어오는 소스를 만들어봅시다.

또다시 막장 코드

import unitest


def get_email(username):
    return 'armin.ronacher@active-4.com'


class GetEmailTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual('armin.ronacher@active-4.com', get_email('mitsuhiko'))

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

일단 지금 상태에선 잘 동작할 것입니다. 하지만 다른 이름을 넣으면 바로 깨지겠죠.

import unitest


def get_email(username):
    return 'armin.ronacher@active-4.com'


class GetEmailTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual('armin.ronacher@active-4.com', get_email('mitsuhiko'))
        self.assertEqual('neosoyn@gmail.com', get_email('camsong'))  # 여기서 에러!

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

구현

우리는 이 문제를 해결하기 위해 GitHub API를 사용하기로 합니다. 실제로 실행해보실 분은 의존성 설치를 위해 pip install requests를 실행해주세요.

import json  # JSON을 처리하기 위해서 불러왔어요
import unittest

import requests  # 보다 쉽게 외부 자료를 가져오기 위한 의존성입니다.


def get_email(username):
    data = requests.get('https://api.github.com/users/' + username).text
    user = json.loads(data)
    return user['email']


class GetEmailTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual('armin.ronacher@active-4.com', get_email('mitsuhiko'))
        self.assertEqual('neosoyn@gmail.com', get_email('camsong'))

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

테스트를 돌려보니 잘 됩니다.

.
----------------------------------------------------------------------
Ran 1 test in 2.123s

OK

사실 완전한 구현이 아니었다

사실 이 구현엔 헛점이 있습니다. 바로 존재하지 않는 사용자를 맞닥드렸을 경우입니다.

import json
import unittest

import requests


def get_email(username):
    data = requests.get('https://api.github.com/users/' + username).text
    user = json.loads(data)
    return user['email']


class GetEmailTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual('armin.ronacher@active-4.com', get_email('mitsuhiko'))
        self.assertEqual('neosoyn@gmail.com', get_email('camsong'))
        self.assertEqual('alphago@sky.net', get_email('alphago_will_destroy_the_world'))  # 이런 사용자는 없어요!

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

실행해보면 당연히 에러가 납니다.

E
======================================================================
ERROR: test_simple (__main__.GetEmailTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "t.py", line 17, in test_simple
    self.assertEqual('alphago@sky.net', get_email('alphago_will_destroy_the_world'))  # 이런 사용자는 없어요!
  File "t.py", line 10, in get_email
    return user['email']
KeyError: 'email'

----------------------------------------------------------------------
Ran 1 test in 3.001s

FAILED (errors=1)

구현 변경

이런 추가적인 경우를 맞이하여 이전에 없던 경우에 대한 새로운 정의를 만들기로 합니다. 존재하지 않는 사용자를 넣으면 RuntimeError가 발생하도록 만들어봅시다.

import json
import unittest

import requests


def get_email(username):
    response = requests.get('https://api.github.com/users/' + username)
    if response.status_code != 200:
        raise RuntimeError()
    user = json.loads(response.text)
    return user['email']


class GetEmailTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual('armin.ronacher@active-4.com', get_email('mitsuhiko'))
        self.assertEqual('neosoyn@gmail.com', get_email('camsong'))
        with self.assertRaises(RuntimeError):  # Exception 발생 여부를 검사해요
            get_email('alphago_will_destroy_the_world')

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

실행해봅시다.

.
----------------------------------------------------------------------
Ran 1 test in 2.943s

OK

잘 되는 것을 확인했습니다.

구현이 바뀌었다면?

하지만 코드리뷰를 해봤는데 소스가 개선이 가능하다고 합니다. 조금 더 고쳐보려고 하는데 과연 안전할까요? 이 역시 테스트를 통해 검증할 수 있습니다.

import unittest

import requests


def get_email(username):
    response = requests.get('https://api.github.com/users/' + username)
    if response.status_code != 200:
        raise RuntimeError()
    user = response.json()  # requests 라이브러리가 자체 지원하는 것을 사용해요
    return user['email']


class GetEmailTest(unittest.TestCase):
    def test_simple(self):
        self.assertEqual('armin.ronacher@active-4.com', get_email('mitsuhiko'))
        self.assertEqual('neosoyn@gmail.com', get_email('camsong'))
        with self.assertRaises(RuntimeError):
            get_email('alphago_will_destroy_the_world')

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

실행해봅니다.

.
----------------------------------------------------------------------
Ran 1 test in 2.918s

OK

실험결과 안전한 것으로 밝혀졌습니다.

마무리

테스트를 미리 작성해서 개발의 흐름을 정하는 것을 TDD, 테스트 등을 작성해서 프로그램의 명세를 구체화하고 명세대로 구현하는 것을 BDD라고 합니다.1 테스트를 작성하는 것 만으로도 개발 대상이 상당히 명확해지고, 추후 검증에 유리합니다. 보다 나은 개발을 위해 테스트를 작성해보는 것은 어떨까요?


  1. 정확한 설명은 아닐 수 있습니다.