บทนำสู่การเยาะเย้ยใน Python
เผยแพร่แล้ว: 2022-03-11วิธีเรียกใช้การทดสอบหน่วยใน Python โดยไม่ต้องทดสอบความอดทนของคุณ
บ่อยครั้งกว่านั้น ซอฟต์แวร์ที่เราเขียนโต้ตอบโดยตรงกับสิ่งที่เราจะติดป้ายว่าเป็นบริการที่ "สกปรก" ในแง่ของคนธรรมดา: บริการที่มีความสำคัญต่อแอปพลิเคชันของเรา แต่มีปฏิสัมพันธ์ที่ตั้งใจไว้แต่เกิดผลข้างเคียงที่ไม่ต้องการ นั่นคือ ไม่พึงประสงค์ในบริบทของการทดสอบการทำงานอัตโนมัติ
ตัวอย่างเช่น เราอาจ กำลัง เขียนแอปโซเชียลและต้องการทดสอบ "ฟีเจอร์โพสต์ไปที่ Facebook" ใหม่ของเรา แต่ไม่ต้องการโพสต์ไปที่ Facebook ทุกครั้งที่เราเรียกใช้ชุดทดสอบ
unittest
ของ Python มีแพ็คเกจย่อยที่ชื่อ unittest.mock
หรือหากคุณประกาศว่าเป็นการพึ่งพา ก็เพียงแค่ mock
ซึ่งให้วิธีการที่ทรงพลังและมีประโยชน์อย่างยิ่งในการเยาะเย้ยและขจัดผลข้างเคียงที่ไม่ต้องการเหล่านี้
หมายเหตุ: mock
อคเพิ่งรวมอยู่ในไลบรารีมาตรฐานของ Python 3.3; การแจกแจงก่อนหน้านี้จะต้องใช้ไลบรารีจำลองที่ดาวน์โหลดผ่าน PyPI
การโทรของระบบเทียบกับการเยาะเย้ยของ Python
เพื่อให้ตัวอย่างอื่นแก่คุณ และอีกตัวอย่างหนึ่งที่เราจะใช้กับส่วนที่เหลือของบทความ ให้พิจารณา การเรียกของระบบ ไม่ยากเลยที่จะเห็นว่าสิ่งเหล่านี้เป็นตัวเลือกหลักสำหรับการเยาะเย้ย: ไม่ว่าคุณจะเขียนสคริปต์เพื่อนำไดรฟ์ซีดีออก, เว็บเซิร์ฟเวอร์ที่ลบไฟล์แคชที่ล้าสมัยออกจาก /tmp
หรือเซิร์ฟเวอร์ซ็อกเก็ตที่ผูกกับพอร์ต TCP สิ่งเหล่านี้ เรียกคุณลักษณะทั้งหมดที่มีผลข้างเคียงที่ไม่พึงประสงค์ในบริบทของการทดสอบหน่วยของคุณ
ในฐานะนักพัฒนา คุณใส่ใจมากกว่าที่ไลบรารีของคุณเรียกฟังก์ชันระบบสำหรับการดีดแผ่นซีดีได้สำเร็จ (ด้วยอาร์กิวเมนต์ที่ถูกต้อง ฯลฯ) เมื่อเทียบกับการเปิดถาดซีดีทุกครั้งที่ทำการทดสอบ (หรือแย่กว่านั้นคือ หลายครั้ง เนื่องจากการทดสอบหลายรายการอ้างอิงรหัสการดีดออกระหว่างการทดสอบหน่วยเดียว!)
ในทำนองเดียวกัน การรักษาหน่วยการทดสอบของคุณอย่างมีประสิทธิภาพและประสิทธิภาพหมายถึงการรักษา "โค้ดที่ช้า" ให้มากจากการทดสอบอัตโนมัติ ซึ่งก็คือระบบไฟล์และการเข้าถึงเครือข่าย
สำหรับตัวอย่างแรกของเรา เราจะจัดโครงสร้างกรณีทดสอบ Python มาตรฐานใหม่จากรูปแบบเดิมเป็นรูปแบบโดยใช้ mock
เราจะสาธิตว่าการเขียนกรณีทดสอบด้วยการจำลองจะทำให้การทดสอบของเราฉลาดขึ้น เร็วขึ้น และสามารถเปิดเผยเพิ่มเติมเกี่ยวกับวิธีการทำงานของซอฟต์แวร์ได้
ฟังก์ชันลบอย่างง่าย
เราทุกคนจำเป็นต้องลบไฟล์ออกจากระบบไฟล์ของเราเป็นครั้งคราว ดังนั้นเรามาเขียนฟังก์ชันใน Python กัน ซึ่งจะทำให้สคริปต์ของเราทำได้ง่ายขึ้นเล็กน้อย
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
เห็นได้ชัดว่าวิธีการ rm
ของเรา ณ เวลานี้ไม่ได้ให้อะไรมากไปกว่าวิธีการ os.remove
พื้นฐาน แต่ codebase ของเราจะปรับปรุง ทำให้เราสามารถเพิ่มฟังก์ชันการทำงานเพิ่มเติมได้ที่นี่
มาเขียนกรณีทดสอบแบบเดิมๆ กัน นั่นคือ ไม่มีการเยาะเย้ย:
#!/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 Mocks
มาปรับโครงสร้างกรณีทดสอบของเราใหม่โดยใช้ 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
และฉีดการเยาะเย้ยนั้นเข้าไปในวิธีทดสอบของเรา มันสมเหตุสมผลกว่าหรือไม่ที่จะแค่ล้อเลียน os
เอง แทนที่จะอ้างอิงไปที่ mymodule.os
Python ค่อนข้างเป็นงูส่อเสียดเมื่อพูดถึงการนำเข้าและจัดการโมดูล ขณะรันไทม์ โมดูล mymodule
มี os
ของตัวเอง ซึ่งนำเข้ามาในขอบเขตภายในของโมดูลเอง ดังนั้น หากเราจำลอง os
เราจะไม่เห็นผลของการเยาะเย้ยในโมดูล mymodule
มนต์ที่จะให้ทำซ้ำคือ:
จำลองรายการที่ใช้ไม่ได้มาจากไหน
หากคุณต้องการจำลองโมดูล tempfile
สำหรับ myproject.app.MyElaborateClass
คุณอาจต้องใช้การจำลองกับ 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 Patch
จนถึงตอนนี้ เราได้ทำงานเฉพาะกับการจัดหาการจำลองสำหรับฟังก์ชัน แต่ไม่ใช่สำหรับวิธีการในวัตถุหรือกรณีที่จำเป็นต้องมีการเยาะเย้ยสำหรับการส่งพารามิเตอร์ มาพูดถึงวิธีการของวัตถุกันก่อน
เราจะเริ่มด้วยการปรับโครงสร้างใหม่ของเมธอด 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
เราจะไม่ตรวจสอบการทำงานภายในของเมธอด rm
ในการทดสอบ UploadService
ของเรา แต่เราจะทำการทดสอบ (ไม่มีผลข้างเคียงแน่นอน) ที่ 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
ของอินสแตนซ์ได้สำเร็จ สังเกตเห็นสิ่งที่น่าสนใจในนั้นไหม กลไกการแพตช์ได้แทนที่เมธอด rm
ของอินสแตนซ์ RemovalService
ทั้งหมดในวิธีการทดสอบของเรา นั่นหมายความว่าเราสามารถตรวจสอบอินสแตนซ์ได้จริง หากคุณต้องการดูเพิ่มเติม ให้ลองวางเบรกพอยต์ในโค้ดจำลองของคุณเพื่อทำความเข้าใจวิธีการทำงานของกลไกการแพตช์
Mock Patch Pitfall: คำสั่งซื้อมัณฑนากร
เมื่อใช้มัณฑนากรหลายคนในวิธีทดสอบของคุณ ลำดับมีความสำคัญ และอาจทำให้สับสนได้ โดยพื้นฐานแล้ว เมื่อจับคู่มัณฑนากรกับพารามิเตอร์เมธอด ให้ทำงานย้อนกลับ พิจารณาตัวอย่างนี้:
@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 ด้วยมัณฑนากรหลากหลายวิธี นี่คือลำดับของการดำเนินการใน pseudocode:
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
สร้างอินสแตนซ์ที่เทียบเท่าการทำงานกับคลาสที่ให้มา ความหมายในทางปฏิบัติคือเมื่อมีการโต้ตอบกับอินสแตนซ์ที่ส่งคืน จะทำให้เกิดข้อยกเว้นหากใช้ในลักษณะที่ผิดกฎหมาย โดยเฉพาะอย่างยิ่ง หากมีการเรียกเมธอดด้วยจำนวนอาร์กิวเมนต์ที่ไม่ถูกต้อง ข้อยกเว้นจะถูกยกขึ้น นี่เป็นสิ่งสำคัญอย่างยิ่งเมื่อมีการสร้างปัจจัยใหม่ เมื่อห้องสมุดมีการเปลี่ยนแปลง การทดสอบหยุดชะงักและนั่นคือสิ่งที่คาดหวัง โดยไม่ใช้ข้อกำหนดอัตโนมัติ การทดสอบของเราจะยังคงผ่านแม้ว่าการใช้งานพื้นฐานจะใช้งานไม่ได้
หลุมพราง: The mock.Mock
and mock.MagicMock
Classes
ไลบรารี 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 จริงของคุณ นี่คือเหตุผลที่คุณ ควร ใช้วิธี create_autospec
และพารามิเตอร์ autospec
กับตัว @patch
และ @patch.object
ตัวอย่าง Python Mock: การเยาะเย้ยการโทร Facebook API
สุดท้ายนี้ เรามาเขียนตัวอย่างจำลอง python ในโลกแห่งความเป็นจริง ที่เรากล่าวถึงในบทนำ: การโพสต์ข้อความไปยัง Facebook เราจะเขียนคลาส wrapper ที่ดีและกรณีทดสอบที่เกี่ยวข้อง
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!")
ดังที่เราได้เห็นมา แล้ว การเริ่มต้นเขียนการทดสอบที่ชาญฉลาดขึ้นด้วยการ mock
ใน Python นั้นง่ายมาก
บทสรุป
ไลบรารี mock
ของ Python หากใช้งานสับสนเล็กน้อย เป็นตัวเปลี่ยนเกมสำหรับการทดสอบหน่วย เราได้สาธิตกรณีการใช้งานทั่วไปสำหรับการเริ่มต้นใช้งาน mock
ในการทดสอบหน่วย และหวังว่าบทความนี้จะช่วยให้นักพัฒนา Python เอาชนะอุปสรรคเริ่มต้นและเขียนโค้ดที่ยอดเยี่ยมและผ่านการทดสอบแล้ว