오늘 GUI에서의 잠시 일탈(?)하여 그동안 공부하고 싶었던 단위 테스트를 공부해보고자 했다.

 

Q. 테스트를 왜 해야할까?

 

A. 소프트웨어가 잘 작동하는지 확인하려면, 테스트가 필요하다.

 

 

Q. 마지막 한번만 통과하면 되는거 아닌가?

 

A. 예를 들어, 마지막에 추가한 기능이 다른 요소에도 영향을 미쳤다면? 원래 되던 동작이 안될수도 있는데?

매번 모든 동작과 기능을 확인할 필요가 있다.

 

 

Q. 현실적으로 매번 모든 동작을 어떻게 확인하겠는가? 불가능하지 않을까?

 

A. "Unit Test"를 사용하면 가능하다.

 

 

 

얼리 억세스나 베타 테스트 같이 출시 전 유저가 테스트로 플레이해보는 거의 완성 단계에 테스트들이 일반인에겐 익숙할 것이다.

 

하지만, 개발자라면, 가장 많이 진행해야할 테스트가 바로 유닛 테스트(Unit Test)이어야 한다.

 

내가 방금 만든 함수가 멀쩡하게 작동하는지 어떻게 확신할 수 있을까?

 

 

 

가정해보자.

 

중도에 테스트 없이 기능을 몽땅 한꺼번에 만들어놓았다.

 

그러다 갑자기 쌔해서, 실행시켜본 결과 에러가 생겼다....

 

이것도 수정해보고... 저것도 수정해보고... 결국 모두 지웠다가 처음 쓰던지, 하던 프로젝트를 접어버린다.

 

이렇게 어디서 잘못됐는지 몰라 답답한 경우가 있지 않는가?

 

 

그래서 선대의 개발자들은 코딩 스킬만큼 테스트도 잘 만들어왔다. A=> B => C 로 가는 로직이 있는데

 

함수 A부터 짠다고 생각해보자.

 

그리고 A에 대한 테스트 A` 을 만든다.

 

다양한 오류가 날 수 있는 상황에도 A는 어떻게든 우리가 의도한대로 작동해야한다.

(예를 들어, 잘못된 data type이 들어갈 경우 console에 무엇이 잘못됐다고 print하고 raise 를 일으킨다.)

 

함수 B를 만들면서 테스트 B`를 만든다. 그리고 테스트 A`, B`를 시행한다. 문제가 없다.

 

함수 C를 만들고, 테스트 C`를 만든다. 그리고 테스트 A`, B`, C` 를 수행한다.

 

별 것 아닌 것 같지만, 함수와 클래스가 점점 많아지고 두꺼워질수록, 기능 하나 넣는 것이 두려워진다.

 

어디선가 잘못 값을 건드리고 있는 것이 아닌가.. 하고 말이다.

 

개발하는 와중에 테스트들을 차곡차곡 쌓아왔다면?? 새로운 기능을 추가하는게 안두렵지 않을까?

 

이미 테스트 로직들이 국밥처럼 버티고 있으니 새로운 모듈을 붙인다던지, 기능을 추가하더라도 금방금방

 

어디서 문제가 발생하는지 알아낼 수 있게 된다.

 

 

저기서 순서만 조금 바꾸면 바로 Tdd (Test Driven Development)가 된다.

오늘의 주제는 TDD가 아니기 때문에 오늘 내가 직접 파이썬에서 Unit test 한 자료에 대해 정리해보려 한다.

"""
    FileName: My_Error.py
    Created: 2023-05-09
    Description :
        내가 맘대로 정의한 예외 클래스

"""

class MyError(Exception):
    pass
 

이것은 내가 정의한 예외이다.

"""
    FileName: LottoNumberMaker.py
    Created: 2023-05-09
    Description :
        로또 추출기 클래스
"""
import random
from My_Error import MyError


class LottoExtractor():
    def __init__(self):
        self.extracted_list = []

    def get_new_lotto_num_list(self):
        self.lotto_num_reset()
        return self.extracted_list

    def lotto_num_reset(self):
        self.extracted_list = random.sample(range(1, 46), 7)

    def lotto_result(self, list_):
        self.verifying_input_list(list_)
        same_count = 0
        has_bonus = False
        for user_num in list_:
            if user_num in self.extracted_list[:6]:
                same_count += 1
            if user_num == self.extracted_list[6]:
                has_bonus = True

        if same_count == 6:
            return 1
        elif same_count == 5:
            if has_bonus is True:
                return 2
            else:
                return 3
        elif same_count == 4:
            return 4
        elif same_count == 3:
            return 5
        else:
            return 6

    def verifying_input_list(self, list_):
        if len(list_) != 6:
            raise MyError("6개의 숫자가 들어와야합니다.")

        for i in list_:
            if type(i) != "int":
                raise MyError("숫자가 아닙니다.")
 

어제 만든 로또 번호 추출 클래스를 간단하게 만들어봤다.

 

로또 번호를 매번 새로 갱신해서 갖고오는 get_new_lotto_num_list 함수와

 

선택번호 6자리 리스트를 입력하면 당첨 결과를 주는 lotto_result 함수만 테스트 진행했다.

(원래는 함수 정의한 개수만큼 만들어야함)

 

 

"""
    FileName: my_first_unit_test.py
    Created: 2023-05-09
    Description :
        로또 추출기 클래스에 대한 테스트 케이스
"""
from unittest import TestCase
from LottoNumberMaker import LottoExtractor
from My_Error import MyError


class MyTests(TestCase):

    def setUp(self) -> None:
        """
        로또 추출기 객체 생성, 테스트마다 새로운 객체로 매번 갱신됨
        :return:
        """
        self.extractor = LottoExtractor()

    def test_lotto_making_seven_elements(self):
        """
        로또 추출기는 7자리 수를 만들어내야한다.
        :return: None
        """
        self.extractor = LottoExtractor()
        lotto_num_list_1 = self.extractor.get_new_lotto_num_list()
        lotto_num_list_2 = self.extractor.get_new_lotto_num_list()
        self.assertEqual(len(lotto_num_list_1), 7)
        self.assertEqual(len(lotto_num_list_2), 7)

    def test_lotto_nums_all_different(self):
        """
        로또 추출기의 모든 요소들은 서로 다른 값을 가지고 있다.
        :return: None
        """
        lotto_num_list_1 = self.extractor.get_new_lotto_num_list()
        for _ in range(100):
            for i, e1 in enumerate(lotto_num_list_1):
                for j, e2 in enumerate(lotto_num_list_1):
                    if i != j:
                        self.assertNotEqual(e1, e2)

    def test_extractor_takes_only_numbers(self):
        """
        로또 추출기에 넣을 리스트는 숫자만 집어 넣을 수 있다.
        :return: None
        """
        self.assertRaises(Exception, self.extractor.lotto_result, ['a', 1, 2, 3, 4, 5])
        self.assertRaises(Exception, self.extractor.lotto_result, ['a', "b", "c", "d", "e", "f"])
        self.assertRaises(Exception, self.extractor.lotto_result, ['1', "2", "3", "4", "5", "6"])
        # with self.assertRaises(Exception) as context:
        #     self.extractor.lotto_result(['a', 1, 2, 3, 4, 5])

    def test_extractor_raise_when_take_string_list(self):
        """
        숫자가 아닌 것이 들어가면 예외가 발생한다.
        :return: None
        """
        error_message = "숫자가 아닙니다."
        with self.assertRaises(Exception) as assert_error:
            self.extractor.lotto_result("123456")
        self.assertEqual(assert_error.exception.args[0], error_message)
        # self.assertRaises(MyError, self.extractor.lotto_result, "123456")

    def test_extractor_return_one_to_six(self):
        """
        6개 숫자가 아닌 것이 들어가면 예외가 발생한다.
        :return: None
        """
        error_message = "6개의 숫자가 들어와야합니다."
        with self.assertRaises(MyError) as assert_error_1:
            self.extractor.lotto_result([1, 2, 3, 4, 5])
        with self.assertRaises(MyError) as assert_error_2:
            self.extractor.lotto_result([1])
        with self.assertRaises(MyError) as assert_error_3:
            self.extractor.lotto_result([])
        self.assertIn(assert_error_1.exception.args[0], error_message)
        self.assertIn(assert_error_2.exception.args[0], error_message)
        self.assertIn(assert_error_3.exception.args[0], error_message)

    def test_extractor_take_num_range_1_to_45(self):
        """
        범위 밖 숫자가 들어올 경우 에러를 던진다.
        :return: None
        """
        error_message = "1~45 범위의 숫자가 들어와야합니다."
        with self.assertRaises(MyError) as assert_error_1:
            self.extractor.lotto_result([100, 1, 2, 3, 4, 5])
        self.assertEqual(assert_error_1.exception.args[0], error_message)

        with self.assertRaises(MyError) as assert_error_2:
            self.extractor.lotto_result([1, 0, 2, 3, 4, 45])
        self.assertEqual(assert_error_2.exception.args[0], error_message)

        with self.assertRaises(MyError) as assert_error_3:
            self.extractor.lotto_result([-1, 1, 2, 3, 4, 45])
        self.assertEqual(assert_error_3.exception.args[0], error_message)
 

 

각 함수에 대한 설명은 내부에서 확인할 수 있다.

 

 

Pycharm에서는 각각의 정의한 테스드들을 따로 돌릴 수 있게 해준다.

 

초록 버튼을 누르면 이런 결과창이 뜬다.

 

또한 해당 클래스 전체를 한꺼번에 테스트를 할 수 있다.

 
 

 

 
 

 

아쉽게도 Community Version의 Pycharm에서는 Code Coverage의 제한이 있어 각각의 테스트들이 몇 초나 걸렸는지 보여주지는 않는다. 다만, 밀리 세컨드에 대한 데이터가 필요할 정도 서비스라면 Professional Version을 사는게 맞을 것 같다.

 

그리고, 유닛 테스트도 만능이 아니라는걸 늘 잊지말자.

적절한 유닛테스트와 통합테스트는 늘 필요하다는 것.

 

모든 테스트를 통과했다고 좋은 SW도 아니며, 사용자의 요구를 기반으로 테스트가 만들어진다는 것이다.

 

이상 파이썬으로 만들어본 첫 유닛 테스트 경험기였습니다.

 

감사합니다.

 

+ Recent posts