파이썬에서 조롱하기 소개
게시 됨: 2022-03-11인내심을 테스트하지 않고 Python에서 단위 테스트를 실행하는 방법
종종 우리가 작성하는 소프트웨어는 "더러운" 서비스로 분류되는 것과 직접 상호 작용합니다. 평신도의 용어로: 우리 애플리케이션에 중요하지만 상호 작용이 의도했지만 바람직하지 않은 부작용이 있는 서비스, 즉 자율 테스트 실행의 맥락에서 바람직하지 않습니다.
예를 들어, 소셜 앱을 작성 중이고 새로운 'Facebook에 게시 기능'을 테스트하고 싶지만 테스트 제품군을 실행할 때마다 실제로 Facebook에 게시하고 싶지는 않을 수 있습니다.
Python unittest
라이브러리에는 unittest.mock
이라는 하위 패키지가 포함되어 있습니다. 또는 종속성으로 선언하는 경우 간단히 mock
을 사용하면 이러한 원치 않는 부작용을 조롱하고 제거할 수 있는 매우 강력하고 유용한 수단을 제공합니다.
참고: mock
는 Python 3.3부터 표준 라이브러리에 새로 포함되었습니다. 이전 배포판은 PyPI를 통해 다운로드할 수 있는 Mock 라이브러리를 사용해야 합니다.
시스템 호출 대 Python 조롱
기사의 나머지 부분에서 사용할 또 다른 예를 제공하려면 시스템 호출 을 고려하십시오. 이것이 조롱의 주요 후보라는 것을 아는 것은 어렵지 않습니다. CD 드라이브를 꺼내는 스크립트를 작성하든, /tmp
에서 오래된 캐시 파일을 제거하는 웹 서버 또는 TCP 포트에 바인딩하는 소켓 서버를 작성하든, 단위 테스트의 맥락에서 모든 기능을 원하지 않는 부작용을 호출합니다.
개발자는 테스트가 실행될 때마다 실제로 CD 트레이가 열리는 것과는 대조적으로 라이브러리가 CD를 꺼내기 위한 시스템 기능(올바른 인수 등 포함)을 성공적으로 호출했는지에 더 신경을 씁니다. (또는 더 나쁜 것은 여러 테스트가 단일 단위 테스트 실행 중에 배출 코드를 참조하기 때문에 여러 번입니다!)
마찬가지로 단위 테스트를 효율적이고 성능 있게 유지한다는 것은 자동화된 테스트 실행, 즉 파일 시스템 및 네트워크 액세스에서 "느린 코드"를 최대한 유지하는 것을 의미합니다.
첫 번째 예에서는 표준 Python 테스트 케이스를 원래 형식에서 mock
을 사용하여 리팩터링할 것입니다. 모의 테스트 케이스를 작성하는 것이 테스트를 더 스마트하고 빠르게 만들고 소프트웨어 작동 방식에 대해 더 많이 드러낼 수 있는 방법을 보여줄 것입니다.
간단한 삭제 기능
우리 모두는 때때로 파일 시스템에서 파일을 삭제해야 하므로 Python으로 함수를 작성하여 스크립트에서 이를 좀 더 쉽게 만들어 보겠습니다.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
분명히 이 시점에서 rm
메서드는 기본 os.remove
메서드보다 훨씬 더 많은 것을 제공하지 않지만 코드베이스가 개선되어 여기에 더 많은 기능을 추가할 수 있습니다.
전통적인 테스트 케이스를 작성해 봅시다. 즉, mock 없이:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
우리의 테스트 케이스는 매우 간단하지만 실행할 때마다 임시 파일이 생성되고 삭제됩니다. 또한 rm
메서드가 인수를 os.remove
호출로 적절하게 전달하는지 여부를 테스트할 방법이 없습니다. 위의 테스트를 기반으로 한다고 가정 할 수 있지만 아직 많이 남아 있습니다.
Python Mock으로 리팩토링하기
mock
을 사용하여 테스트 케이스를 리팩토링합시다.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
이러한 리팩터링을 통해 테스트 작동 방식을 근본적으로 변경했습니다. 이제 다른 객체의 기능을 확인하는 데 사용할 수 있는 내부자 객체가 있습니다.
잠재적인 Python 조롱 함정
가장 먼저 눈에 띄어야 할 것 중 하나는 mock.patch
메소드 데코레이터를 사용하여 mymodule.os
에 있는 객체를 조롱하고 해당 모의를 테스트 케이스 메소드에 주입한다는 것입니다. mymodule.os
에서 참조하는 것보다 os
자체를 조롱하는 것이 더 합리적이지 않을까요?
글쎄요, 파이썬은 모듈을 가져오고 관리할 때 다소 교활한 뱀입니다. 런타임에 mymodule
모듈에는 모듈의 자체 로컬 범위로 가져온 자체 os
가 있습니다. 따라서 os
를 모의하면 mymodule
모듈에서 모의 효과를 볼 수 없습니다.
계속 반복해야 하는 주문은 다음과 같습니다.
항목이 어디에서 왔는지가 아니라 사용된 곳을 조롱하십시오.
myproject.app.MyElaborateClass
에 대한 tempfile
모듈을 모의해야 하는 경우 각 모듈이 고유한 가져오기를 유지하므로 모의를 myproject.app.tempfile
에 적용해야 할 수 있습니다.
그 함정을 없애고 계속 조롱합시다.
'rm'에 유효성 검사 추가
앞에서 정의한 rm
방법은 상당히 단순화되었습니다. 우리는 맹목적으로 그것을 제거하기 전에 경로가 존재하고 파일인지 검증하기를 원합니다. rm
을 좀 더 똑똑하게 리팩토링합시다.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
엄청난. 이제 테스트 케이스를 조정하여 적용 범위를 유지해 보겠습니다.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
우리의 테스트 패러다임이 완전히 바뀌었습니다. 이제 부작용 없이 메서드의 내부 기능을 확인하고 검증할 수 있습니다.
모의 패치를 사용한 서비스로서의 파일 제거
지금까지 우리는 함수에 대한 모의(mock)를 제공하는 작업만 했고, 매개변수를 보내기 위해 모의(mocking)가 필요한 경우나 객체에 대한 메소드는 제공하지 않았습니다. 먼저 객체 메서드를 살펴보겠습니다.
rm
메소드를 서비스 클래스로 리팩터링하는 것으로 시작하겠습니다. 그러한 간단한 기능을 객체로 캡슐화해야 하는 정당한 필요성 자체가 실제로는 없지만 최소한 mock
의 핵심 개념을 설명하는 데 도움이 될 것입니다. 리팩토링하자:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
테스트 케이스에서 많이 변경되지 않았음을 알 수 있습니다.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
좋습니다. 이제 RemovalService
가 계획대로 작동한다는 것을 알게 되었습니다. 종속성으로 선언하는 다른 서비스를 만들어 보겠습니다.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
RemovalService
에 대한 테스트 커버리지가 이미 있으므로 UploadService
테스트에서 rm
메서드의 내부 기능을 검증하지 않을 것입니다. 오히려 우리는 UploadService
가 이전 테스트 사례에서 "그냥 작동"하는 것으로 RemovalService.rm
메서드를 호출 하는지 테스트(물론 부작용 없이)할 것입니다.

이에 대해 두 가지 방법이 있습니다.
-
RemovalService.rm
메서드 자체를 조롱합니다. -
UploadService
의 생성자에 모의 인스턴스를 제공하십시오.
두 방법 모두 단위 테스트에서 중요한 경우가 많으므로 두 가지를 모두 검토하겠습니다.
옵션 1: 인스턴스 메서드 모의
mock
라이브러리에는 객체 인스턴스 메서드 및 속성을 조롱하기 위한 특수 메서드 데코레이터인 @mock.patch.object
데코레이터가 있습니다.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
엄청난! UploadService
가 인스턴스의 rm
메서드를 성공적으로 호출하는지 확인했습니다. 흥미로운 점을 발견하셨습니까? 패치 메커니즘은 실제로 테스트 방법에서 모든 RemovalService
인스턴스의 rm
방법을 대체했습니다. 즉, 인스턴스 자체를 실제로 검사할 수 있습니다. 더 보고 싶다면 조롱 코드에 중단점을 넣어 패치 메커니즘이 작동하는 방식에 대한 좋은 느낌을 얻으십시오.
모의 패치 함정: 데코레이터 주문
테스트 방법에 여러 데코레이터를 사용할 때 순서가 중요 하고 혼란스럽습니다. 기본적으로 데코레이터를 메서드 매개변수에 매핑할 때 거꾸로 작업하세요. 다음 예를 고려하십시오.
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
매개변수가 데코레이터의 역순과 어떻게 일치하는지 확인하십시오. 이는 부분적으로 Python이 작동하는 방식 때문입니다. 여러 메서드 데코레이터를 사용하는 경우 의사 코드에서 실행 순서는 다음과 같습니다.
patch_sys(patch_os(patch_os_path(test_something)))
sys
에 대한 패치는 가장 바깥쪽 패치이므로 마지막으로 실행되어 실제 테스트 메서드 인수의 마지막 매개변수가 됩니다. 이것을 잘 기록하고 테스트를 실행할 때 디버거를 사용하여 올바른 매개변수가 올바른 순서로 주입되는지 확인하십시오.
옵션 2: 모의 인스턴스 생성
특정 인스턴스 메서드를 조롱하는 대신 생성자와 함께 UploadService
에 조롱된 인스턴스를 제공할 수 있습니다. 훨씬 더 정확하기 때문에 위의 옵션 1을 선호하지만 옵션 2가 효율적이거나 필요할 수 있는 경우가 많습니다. 테스트를 다시 리팩토링해 보겠습니다.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
이 예에서는 기능을 패치할 필요도 없었습니다. RemovalService
클래스에 대한 자동 사양을 만든 다음 이 인스턴스를 UploadService
에 삽입하여 기능을 검증하기만 하면 됩니다.
mock.create_autospec
메소드는 제공된 클래스와 기능적으로 동일한 인스턴스를 생성합니다. 이것이 의미하는 바는 실질적으로 말하면 반환된 인스턴스가 상호 작용할 때 불법적인 방식으로 사용되는 경우 예외가 발생한다는 것입니다. 보다 구체적으로, 잘못된 수의 인수로 메서드를 호출하면 예외가 발생합니다. 이것은 리팩터링이 발생함에 따라 매우 중요합니다. 라이브러리가 변경되면 테스트가 중단되며 이는 예상되는 일입니다. 자동 사양을 사용하지 않으면 기본 구현이 중단되더라도 테스트를 통과할 것입니다.
함정: mock.Mock
및 mock.MagicMock
클래스
mock
라이브러리에는 대부분의 내부 기능이 기반으로 하는 두 가지 중요한 클래스( mock.Mock
및 mock.MagicMock
)도 포함되어 있습니다. mock.Mock
인스턴스, mock.MagicMock
인스턴스 또는 자동 사양을 사용하도록 선택하면 항상 자동 사양을 사용하는 것이 좋습니다. 자동 사양은 향후 변경 사항에 대해 테스트를 정상 상태로 유지하는 데 도움이 되기 때문입니다. 이는 mock.Mock
및 mock.MagicMock
이 기본 API에 관계없이 모든 메서드 호출 및 속성 할당을 수락하기 때문입니다. 다음 사용 사례를 고려하십시오.
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
다음과 같이 mock.Mock
인스턴스로 테스트할 수 있습니다.
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
이 논리는 정상인 것 같지만 더 많은 매개변수를 사용하도록 Target.apply
메서드를 수정해 보겠습니다.
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
테스트를 다시 실행하면 여전히 통과함을 알 수 있습니다. 실제 API에 대해 빌드되지 않았기 때문입니다. 이것이 @patch
및 @patch.object
데코레이터와 함께 항상 create_autospec
메소드와 autospec
매개변수를 사용해야 하는 이유입니다.
Python 모의 예제: Facebook API 호출 모의
끝내기 위해 소개에서 언급한 더 적용 가능한 실제 파이썬 모의 예제를 작성해 보겠습니다. Facebook에 메시지 게시. 우리는 멋진 래퍼 클래스와 해당 테스트 케이스를 작성할 것입니다.
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
다음은 실제로 메시지를 게시하지 않고 메시지를 게시하는지 확인하는 테스트 사례입니다.
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
지금까지 살펴본 것처럼 Python에서 mock
을 사용하여 더 스마트한 테스트를 작성하는 것은 정말 간단합니다.
결론
Python의 mock
라이브러리는 작업하기가 약간 혼란스럽다면 단위 테스트를 위한 게임 체인저입니다. 우리는 단위 테스트에서 mock
사용을 시작하기 위한 일반적인 사용 사례를 시연했으며 이 기사가 Python 개발자가 초기 장애물을 극복하고 우수한 테스트를 거친 코드를 작성하는 데 도움이 되기를 바랍니다.