Python unittest와 mocking

햄도·2021년 1월 25일
0

계기

테스트가 필요하다는 것을 어디서 많이 주워들어서 알고는 있었다.

하지만 회사 레파지토리에서는 테스트 코드를 찾을 수 없었고, 나도 테스트 코드를 접해본적이 없어 테스트 코드를 작성할 생각을 하지 않고 개발을 계속했다. 그러다보니 테스트가 안된 코드가 커밋되는 일이 비일비재했고 큰 단위의 모듈을 테스트하며 오류가 발생하면 어디서 발생하는 오류인지도 찾기가 힘들었다.

무엇보다 리팩토링이나 기능추가 등 코드를 수정할때마다 데이터를 확인하며 테스트를 해야하는 반복작업이 생겼다. 물론 귀찮은 건 누구나 똑같기때문에 그 반복작업은 잘 이루어지지 않았다. 그래서 테스트 코드를 추가하기로 마음먹었다. 그런데 테스트 코드는 어떻게 작성해야 하는걸까? 먼저 테스트 코드에 대한 가이드라인을 찾아봤다.

코드 테스트 가이드라인

🧐 각 기능의 가장 작은 단위에 집중하여 해당 기능이 정확히 동작하는지 증명한다.
🚩 각 테스트 유닛은 독립적이어야 한다. 순서나 같이 실행되는 유닛과 상관없어야 한다.
⏰ 테스트는 빠르게 돌아야 한다. 무거운 테스트는 따로 분리하여 별도의 테스트를 만든다.
🧷 저장소에 코드를 넣기 전에 자동으로 모든 테스트를 수행하도록 하는 훅을 구현하는 것도 좋은 생각이다.
🆎 테스트 함수의 이름은 테스트가 실패할 때에나 보이므로, 길고 서술적인 이름을 사용한다.
👩‍💻 무언가 잘못되었거나 뜯어고쳐야 하는 경우, 괜찮은 테스트 셋이 있다면 유지보수 담당자들은 그 테스트 셋에 전적으로 의지할 것이다.

테스트 코드 작성하기

테스트해야 하는 기능

  • input list를 받아 DB에서 해당 list에 들어있는 원소들의 속성을 조회한 후, 속성에 따라 list를 분할하여 다시 nested list로 반환한다.
    • [a, b, c] → [[a, b], [c]] 와 같은 형태
  • 먼저 DB에서 그때그때 정보를 조회해오면 테스트가 느려지기도 하고, DB에 있는 데이터가 바뀌는 경우 정답 여부가 달라질 수도 있으니 DB에 보내는 요청은 mocking하고 고정된 테스트 데이터를 만들어서 대신 넣어줘야 한다.
  • 함수 수행 후 반환된 nested list와 비교할 정답이 필요하고, 정답은 순서와 상관없이 각 원소들이 잘 분할되었는지 확인해야 한다.

테스트 데이터 만들기

  • 테스트 데이터와 정답이 클래스 변수로 들어있는 클래스를 하나 만들고, 테스트 데이터를 db에서 가져올 수 있는 메소드를 추가했다.
  • 이렇게 하는게 best practice는 아닌것같다.. 나는 테스트 데이터를 따로 파싱하는 등의 과정을 거치지 않고, 파이썬 자료구조로 정의한 후 그대로 가져오고 싶어서 클래스 변수로 저장했다.
class TestDataset():
		
    test_data = [
        ('a', 'Red'),
        ('b', 'Red'),
        ('c', 'Blue')
    ]

    test_data_result = [
        ['a', 'b'],
        ['c']
    ]


    def get_test_data(self):
        # DB에서 실제 데이터 조회 후 출력
        ...

테스트 코드

import unittest
from unittest.mock import MagicMock, patch
from test_dataset import TestDataset as dataset
from my_module import function_to_test

class ProcessTest(unittest.TestCase):
    ...
    
    # 1. 
    @patch('sqlalchemy.engine.Connection.execute')
    def test_decompose_by_color(self, mock_execute):
    
        # 2. 
        MockResultProxy = namedtuple('MockResultProxy', 'name, color')
        mock_execute.return_value = [MockResultProxy(name, color) for name, color in dataset.test_data]
        self.result = function_to_test([name for name, color in dataset.test_data])
        
        # 3. 
        test_result = map(sorted, self.result)
        test_data_result = map(sorted, dataset.test_data_result)
        
        # 4.
        self.assertCountEqual(test_result, test_data_result)
  1. @patch 데코레이터는 인자로 받은 객체를 mocking해 데코레이팅한 함수에 인자로 넘긴다. 즉, test_decompose_by_color 함수는 sqlalchemy.engine.Connection.execute 를 mocking한 MagicMock 객체를 mock_execute라는 이름으로 받게 된다.
  2. 이렇게 하면 테스트할 함수에 들어가있는 sqlalchemy.engine.Connection.execute 호출이 실제로 이루어지지 않고, 대신 호출된 MagicMock 객체가 return_value로 받은 값을 반환한다. 나는 sqlalchemy에서 쿼리의 결과로 반환하는 ResultProxy값과 유사하게 namedtuple을 만들어 넣어주었다.
  3. 수행 결과가 nested list이므로 수행 결과와 실제 정답의 리스트 내부 원소들을 정렬해준다.
  4. 마지막으로 결과 리스트 원소들이 같은지 비교한다.

마무리

  • 하기 전에는 귀찮지만 막상 작성하고 나면 몇 줄도 안되는 테스트 코드가 미래의 많은 노가다를 없애줄 것 같다.
  • 본 코드를 깔끔하게 잘 짜야 테스트 코드도 더 쉬워지는 것 같다. 복잡한 기능들은 아직 테스트를 작성할 엄두도 나지 않는다.. 클린 코드로 가는 길은 멀고도 험하다.

참고

profile
developer hamdoe

0개의 댓글