#!/usr/bin/env python # coding: utf-8 샘플 코드는 최종 파일만 배포합니다 # # 단위 테스트 도입 # ## 단일 모듈 테스트 (주:booksearch_module.py) import unittest # 애플리케이션 코드 def booksearch(): # 임의 처리 return {} class BookSearchTest(unittest.TestCase): # booksearch() 테스트 코드 def test_booksearch(self): self.assertEqual({}, booksearch()) # In[1]: get_ipython().system('python3 -m unittest booksearch_module.py') # In[2]: # -v 옵션으로 상세 정보 표시 get_ipython().system('python3 -m unittest -v booksearch_module.py') # ### 테스트 실행 명령어 간략화 # In[1]: get_ipython().system('cat booksearch_module.py') # In[4]: get_ipython().system('python3 booksearch_module.py -v') # ## 패키지 테스트 # ### 디렉터리 구성 # In[2]: # booksearch/, tests/를 준비한 디렉터리로 이동합니다 get_ipython().run_line_magic('cd', 'workspace') $ python3 -m unittest ---------------------------------------------------------------------- Ran 0 tests in 0.000s OK # ### 샘플 애플리케이션 작성 # In[5]: get_ipython().system('cat booksearch/__init__.py') # In[3]: get_ipython().system('cat booksearch/api.py') # In[4]: get_ipython().system('cat booksearch/core.py') # In[6]: from booksearch import get_books books = get_books(q='python') # In[7]: # 실행 시 얻은 데이터에 따라 결과가 달라짐 books[0] # # unittest 모듈 ── 표준 단위 테스트 라이브러리 # ## 테스트 케이스 구현 (주:tests/test_api.py) import unittest class BuildUrlTest(unittest.TestCase): def test_build_url(self): # build_url()이 테스트 대상 처리 from booksearch.api import build_url expected = 'https://www.googleapis.com/books/v1/volumes?q=python' actual = build_url({'q': 'python'}) # assert 메서드 이용 self.assertEqual(expected, actual)(주:tests/test_api.py) (생략) actual = build_url({'q': 'python'}) # assert 메서드 이용 self.assertEqual(expected, actual) # 좋지 않은 예시 # 위 행이 실패하면 아래 행이 실행되지 않음 expected2 = 'https://www.googleapis.com/books/v1/volumes?' actual2 = build_url({}) self.assertEqual(expected2, actual2)(주:tests/test_api.py) (생략) actual = build_url({'q': 'python'}) # assert 메서드 이용 self.assertEqual(expected, actual) # 좋은 예 def test_build_url_empty_param(self): from booksearch.api import build_url expected = 'https://www.googleapis.com/books/v1/volumes?' actual = build_url({}) self.assertEqual(expected, actual) # ### 전처리, 후처리가 필요한 테스트 케이스 (주:tests/test_core.py) import pathlib import unittest from tempfile import TemporaryDirectory THUMBNAIL_URL = ( 'http://books.google.com/books/content' '?id=OgtBw76OY5EC&printsec=frontcover' '&img=1&zoom=1&edge=curl&source=gbs_api' ) class SaveThumbnailsTest(unittest.TestCase): def setUp(self): # 임시 디렉터리 작성 self.tmp = TemporaryDirectory() def tearDown(self): # 임시 디렉터리 정리 self.tmp.cleanup() def test_save_thumbnails(self): from booksearch.core import Book book = Book({'id': '', 'volumeInfo': { 'imageLinks': { 'thumbnail': THUMBNAIL_URL }}}) # 처리를 실행하고 파일이 작성되었음을 확인 filename = book.save_thumbnails(self.tmp.name)[0] self.assertTrue(pathlib.Path(filename).exists()) # ## 테스트 실행과 결과 확인 $ python3 -m unittest ... ---------------------------------------------------------------------- Ran 3 tests in 0.899s OK$ python3 -m unittest -v test_build_url (test_api.BuildUrlTest) ... ok test_build_url_empty_param (test_api.BuildUrlTest) ... ok test_save_thumbnails (test_core.SaveThumbnailsTest) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.337s OK # ### 테스트 실패 시 결과 (주:tests/test_api.py) class BuildUrlTest(unittest.TestCase): (생략) def test_build_url_fail(self): from booksearch.api import build_url expected = 'https://www.googleapis.com/books/v1/volumes' actual = build_url({}) self.assertEqual(expected, actual, msg='이 테스트는 실패합니다')$ python3 -m unittest -v test_build_url (test_api.BuildUrlTest) ... ok test_build_url_empty_param (test_api.BuildUrlTest) ... ok test_build_url_fail (test_api.BuildUrlTest) ... FAIL test_save_thumbnails (test_core.SaveThumbnailsTest) ... ok ====================================================================== FAIL: test_build_url_fail (test_api.BuildUrlTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/.../workspace/tests/test_api.py", line 24, in test_build_url_fail msg='이 테스트는 실패합니다') AssertionError: 'https://www.googleapis.com/books/v1/volumes' != 'https://www.googleapis.com/books/v1/volumes?' - https://www.googleapis.com/books/v1/volumes + https://www.googleapis.com/books/v1/volumes? ? + : 이 테스트는 실패합니다 ---------------------------------------------------------------------- Ran 4 tests in 0.397s FAILED (failures=1) # ### 테스트 실패 시 결과 억제하기 (주:tests/test_api.py) class BuildUrlTest(unittest.TestCase): (생략) @unittest.expectedFailure def test_build_url_fail(self):$ python3 -m unittest -v test_build_url (test_api.BuildUrlTest) ... ok test_build_url_empty_param (test_api.BuildUrlTest) ... ok test_build_url_fail (test_api.BuildUrlTest) ... expected failure test_save_thumbnails (test_core.SaveThumbnailsTest) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.400s OK (expected failures=1) # ## 특정 테스트만 실행하기 # ### 테스트 케이스 직접 실행 # 모듈 지정 $ python3 -m unittest -v tests.test_api test_build_url (tests.test_api.BuildUrlTest) ... ok test_build_url_empty_param (tests.test_api.BuildUrlTest) ... ok test_build_url_fail (tests.test_api.BuildUrlTest) ... expected failure ---------------------------------------------------------------------- Ran 3 tests in 0.049s OK (expected failures=1) # 클래스 지정 $ python3 -m unittest -v tests.test_api.BuildUrlTest (생략) # 메서드 지정 $ python3 -m unittest -v tests.test_api.BuildUrlTest.test_build_url (생략) # 여러 테스트 지정 $ python3 -m unittest -v tests.test_api tests.test_core (생략) # ### 테스트 디스커버리 # 실행할 테스트 모듈 이름을 지정 $ python3 -m unittest discover -s tests -p 'test_c*.py' -v test_save_thumbnails (test_core.SaveThumbnailsTest) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.362s OK # # unittest.mock 모듈 ── 목(모의) 객체 이용 # ## 목 객체 기본 사용법 # ### 임의의 값을 반환하는 호출 가능 객체로서 이용 # In[8]: from unittest.mock import Mock # 인수에 반환값을 설정 mock = Mock(return_value=3) mock() # In[9]: # return_value는 나중에도 설정할 수 있음 mock.return_value=4 # 호출 시 인수는 반환값에 영향 없음 mock(1) # In[10]: # 함수에서는 인수가 그대로 전달됨 mock = Mock(side_effect=lambda x: x % 2) mock(3) # In[11]: # side_effect는 나중에도 설정할 수 있음 # 이터러블을 사용하면 호출할 때마다 앞에서부터 순서대로 반환됨 mock.side_effect=[2, 1] mock() # In[12]: mock() # In[14]: # 예외 클래스나 그 인스턴스에서는 해당 예외가 전송됨 mock.side_effect = ValueError('에러입니다') mock() # ### Assert 메서드로 호출 여부 테스트 # In[15]: mock = Mock(return_value=3) # 아직 한 번도 호출되지 않았음을 확인 mock.assert_not_called() # In[17]: # 한 번만 호출되었음을 확인 # 아직 한 번도 호출되지 않았으므로 에러가 발생함 mock.assert_called_once() # In[18]: # 호출해 봄 mock(1, a=2) # In[24]: # 호출되었으므로 에러 발생 mock.assert_not_called() # In[25]: # 한 번만 호출된 것을 확인 mock.assert_called_once() # In[26]: # 호출 여부를 확인 mock.assert_called_once_with(1, a=2) # In[27]: mock.assert_called_once_with(1, a=3) # In[23]: # 호출된 횟수는 확인하지 못하며, 일부 인수만 확인 from unittest.mock import ANY mock.assert_called_with(1, a=ANY) # ## patch를 사용한 객체 치환 # In[29]: from booksearch import get_books from unittest.mock import patch # 대화형 모드에서는 __main__ 모듈에서 이름을 지정 with patch('__main__.get_books') as mock_get_books: mock_get_books.return_value = [] print(get_books()) # In[30]: @patch('__main__.get_books') def test_use_mock(mock_get_books): mock_get_books.return_value = [] return get_books() # In[31]: test_use_mock() # ## mock을 이용한 테스트 케이스 실제 사례 # In[28]: from booksearch import get_books book = get_books(q='python')[0] # In[29]: # 실행 시에 얻은 데이터에 따라 결과는 다름 book.save_thumbnails('tests/data') (주:tests/test_core.py) from unittest.mock import patch (생략) class SaveThumbnailsTest(unittest.TestCase): (생략) # 테스트 대상의 save_thumbnail()가 이용할 참조 이름을 지정 @patch('booksearch.core.get_data') def test_save_thumbnails(self, mock_get_data): from booksearch.core import Book # 앞에서 얻은 섬네일 이미지 데이터를 모의 객치의 반환값으로 설정 data_path = pathlib.Path(__file__).with_name('data') mock_get_data.return_value = ( data_path / 'YkGmfbil6L4C_thumbnail.jpeg').read_bytes() book = Book({'id': '', 'volumeInfo': { 'imageLinks': { 'thumbnail': THUMBNAIL_URL }}}) filename = book.save_thumbnails(self.tmp.name)[0] # get_data() 호출 시의 인수 확인 mock_get_data.assert_called_with(THUMBNAIL_URL) # 저장된 데이터 확인 self.assertEqual(data, filename.read_bytes())$ python3 -m unittest -v test_build_url (test_api.BuildUrlTest) ... ok test_build_url_empty_param (test_api.BuildUrlTest) ... ok test_build_url_fail (test_api.BuildUrlTest) ... expected failure test_save_thumbnails (test_core.SaveThumbnailsTest) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.040s OK (expected failures=1) # # 유스 케이스 별 테스트 케이스 구현 # ## 환경 의존 테스트 건너 뛰기 (주:tests/test_api.py) import sys (생략) class BuildUrlTest(unittest.TestCase): (생략) # 인수로 테스트를 건너 뛰는 이유를 전달 @unittest.skip('this is a skip test') def test_nothing_skip(self): pass # 실행 중인 파이썬 버전이 3.6보다 높으면 건너뜀 @unittest.skipIf(sys.version_info > (3, 6), 'this is a skipIf test') def test_nothing_skipIf(self): pass$ python3 -m unittest -v test_build_url (test_api.BuildUrlTest) ... ok test_build_url_empty_param (test_api.BuildUrlTest) ... ok test_build_url_fail (test_api.BuildUrlTest) ... expected failure test_nothing_skip (test_api.BuildUrlTest) ... skipped 'this is a skip test' test_nothing_skipIf (test_api.BuildUrlTest) ... skipped 'this is a skipIf test' test_save_thumbnails (test_core.SaveThumbnailsTest) ... ok ---------------------------------------------------------------------- Ran 6 tests in 0.038s OK (skipped=2, expected failures=1) # ## 예외 발생 테스트 (주:tests/test_core.py) from urllib.error import URLError (생략) class GetBooksTest(unittest.TestCase): def test_get_books_no_connection(self): from booksearch.core import get_books # 임시로 네트워크 접속 단절 with patch('socket.socket.connect') as mock: # connect()가 호출되면 정확하지 않은 값을 반환함 mock.return_value = None with self.assertRaisesRegex(URLError, 'urlopen error'): # 예외가 발생하는 처리를 with 블록 안에서 실행 get_books(q='python') # In[30]: get_ipython().system('python3 -m unittest tests.test_core.GetBooksTest -v') # ## 다른 파라미터로 동일한 테스트 반복하기 (주:tests/test_api.py) (생략) class BuildUrlMultiTest(unittest.TestCase): def test_build_url_multi(self): from booksearch.api import build_url base = 'https://www.googleapis.com/books/v1/volumes?' expected_url = f'{base}q=python' # 2번째, 3번째 테스트는 실패함 params = ( (expected_url, {'q': 'python'}), (expected_url, {'q': 'python', 'maxResults': 1}), (expected_url, {'q': 'python', 'langRestrict': 'en'}), ) for expected, param in params: with self.subTest(**param): actual = build_url(param) self.assertEqual(expected, actual) # In[31]: get_ipython().system('python3 -m unittest tests.test_api.BuildUrlMultiTest') # ## 컨텍스트 관리자 테스트하기 (주:tests/test_api.py) import json from io import StringIO from unittest.mock import patch, MagicMock (생략) class GetJsonTest(unittest.TestCase): def test_get_json(self): from booksearch.api import get_json with patch('booksearch.api.request.urlopen') as mock_urlopen: # 컨텍스트 관리자의 목(mock)을 준비 # API 응답이 될 JSON 데이터 준비 expected_response = {'id': 'test'} fp = StringIO(json.dumps(expected_response)) # MagicMock 클래스를 사용하면 __exit__를 추가할 필요 없음 mock = MagicMock() mock.__enter__.return_value = fp # urlopen()의 반환값이 컨텍스트 관리자 mock_urlopen.return_value = mock actual = get_json({'q': 'python'}) self.assertEqual(expected_response, actual) # In[32]: get_ipython().system('python3 -m unittest tests.test_api.GetJsonTest -v') # # 정리