บทนำสู่การเยาะเย้ยใน Python

เผยแพร่แล้ว: 2022-03-11

วิธีเรียกใช้การทดสอบหน่วยใน Python โดยไม่ต้องทดสอบความอดทนของคุณ

บ่อยครั้งกว่านั้น ซอฟต์แวร์ที่เราเขียนโต้ตอบโดยตรงกับสิ่งที่เราจะติดป้ายว่าเป็นบริการที่ "สกปรก" ในแง่ของคนธรรมดา: บริการที่มีความสำคัญต่อแอปพลิเคชันของเรา แต่มีปฏิสัมพันธ์ที่ตั้งใจไว้แต่เกิดผลข้างเคียงที่ไม่ต้องการ นั่นคือ ไม่พึงประสงค์ในบริบทของการทดสอบการทำงานอัตโนมัติ

ตัวอย่างเช่น เราอาจ กำลัง เขียนแอปโซเชียลและต้องการทดสอบ "ฟีเจอร์โพสต์ไปที่ Facebook" ใหม่ของเรา แต่ไม่ต้องการโพสต์ไปที่ Facebook ทุกครั้งที่เราเรียกใช้ชุดทดสอบ

unittest ของ Python มีแพ็คเกจย่อยที่ชื่อ unittest.mock หรือหากคุณประกาศว่าเป็นการพึ่งพา ก็เพียงแค่ mock ซึ่งให้วิธีการที่ทรงพลังและมีประโยชน์อย่างยิ่งในการเยาะเย้ยและขจัดผลข้างเคียงที่ไม่ต้องการเหล่านี้

การเยาะเย้ยและการทดสอบหน่วยใน python unittest library

หมายเหตุ: 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 ซึ่งเราทราบดีว่า "ใช้งานได้" จากกรณีทดสอบครั้งก่อนของเรา

มีสองวิธีในการดำเนินการนี้:

  1. จำลองวิธีการ RemovalService.rm เอง
  2. จัดหาอินสแตนซ์ที่เยาะเย้ยในตัวสร้างของ 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 เอาชนะอุปสรรคเริ่มต้นและเขียนโค้ดที่ยอดเยี่ยมและผ่านการทดสอบแล้ว