Pengantar Mengejek dengan Python

Diterbitkan: 2022-03-11

Cara Menjalankan Pengujian Unit dengan Python Tanpa Menguji Kesabaran Anda

Lebih sering daripada tidak, perangkat lunak yang kita tulis berinteraksi langsung dengan apa yang kita sebut sebagai layanan "kotor". Dalam istilah awam: layanan yang sangat penting untuk aplikasi kita, tetapi interaksinya memiliki efek samping yang dimaksudkan tetapi tidak diinginkan—yaitu, tidak diinginkan dalam konteks uji coba otonom.

Misalnya: mungkin kami sedang menulis aplikasi sosial dan ingin menguji 'fitur Posting ke Facebook' baru kami, tetapi tidak ingin benar- benar memposting ke Facebook setiap kali kami menjalankan rangkaian pengujian kami.

Pustaka unittest Python menyertakan subpaket bernama unittest.mock —atau jika Anda mendeklarasikannya sebagai ketergantungan, cukup mock —yang menyediakan cara yang sangat kuat dan berguna untuk mengejek dan menghilangkan efek samping yang tidak diinginkan ini.

mengejek dan menguji unit di perpustakaan python unittest

Catatan: mock baru saja disertakan dalam pustaka standar pada Python 3.3; distribusi sebelumnya harus menggunakan perpustakaan Mock yang dapat diunduh melalui PyPI.

Panggilan Sistem vs. Python Mengejek

Untuk memberi Anda contoh lain, dan contoh yang akan kami gunakan untuk sisa artikel ini, pertimbangkan panggilan sistem . Tidak sulit untuk melihat bahwa ini adalah kandidat utama untuk diejek: apakah Anda sedang menulis skrip untuk mengeluarkan drive CD, server web yang menghapus file cache kuno dari /tmp , atau server soket yang mengikat ke port TCP, ini memanggil semua fitur efek samping yang tidak diinginkan dalam konteks pengujian unit Anda.

Sebagai pengembang, Anda lebih peduli bahwa perpustakaan Anda berhasil memanggil fungsi sistem untuk mengeluarkan CD daripada mengalami baki CD Anda terbuka setiap kali pengujian dijalankan.

Sebagai pengembang, Anda lebih peduli bahwa perpustakaan Anda berhasil memanggil fungsi sistem untuk mengeluarkan CD (dengan argumen yang benar, dll.) dibandingkan dengan benar-benar mengalami baki CD Anda terbuka setiap kali pengujian dijalankan. (Atau lebih buruk lagi, berkali-kali, karena beberapa tes merujuk kode eject selama pengujian unit tunggal dijalankan!)

Demikian juga, menjaga agar pengujian unit Anda tetap efisien dan berkinerja berarti menjaga sebanyak mungkin "kode lambat" dari pengujian otomatis, yaitu sistem file dan akses jaringan.

Untuk contoh pertama kami, kami akan memfaktorkan ulang kasus uji Python standar dari bentuk asli menjadi yang menggunakan mock . Kami akan mendemonstrasikan bagaimana menulis kasus uji dengan tiruan akan membuat pengujian kami lebih cerdas, lebih cepat, dan mampu mengungkapkan lebih banyak tentang cara kerja perangkat lunak.

Fungsi Hapus Sederhana

Kita semua perlu menghapus file dari sistem file kita dari waktu ke waktu, jadi mari kita menulis sebuah fungsi dengan Python yang akan membuat skrip kita lebih mudah melakukannya.

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)

Jelas, metode rm kami pada saat ini tidak menyediakan lebih dari metode os.remove yang mendasarinya, tetapi basis kode kami akan meningkat, memungkinkan kami untuk menambahkan lebih banyak fungsionalitas di sini.

Mari kita menulis kasus uji tradisional, yaitu, tanpa ejekan:

 #!/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.")

Kasus pengujian kami cukup sederhana, tetapi setiap kali dijalankan, file sementara dibuat dan kemudian dihapus. Selain itu, kami tidak memiliki cara untuk menguji apakah metode rm kami dengan benar meneruskan argumen ke panggilan os.remove . Kita dapat berasumsi bahwa hal itu didasarkan pada pengujian di atas, tetapi masih banyak yang harus diinginkan.

Memfaktorkan ulang dengan Python Mocks

Mari kita refactor test case kita menggunakan 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")

Dengan refactor ini, kami secara mendasar mengubah cara pengujian beroperasi. Sekarang, kita memiliki insider , sebuah objek yang dapat kita gunakan untuk memverifikasi fungsionalitas objek lain.

Potensi Jebakan Mengejek Python

Salah satu hal pertama yang harus diperhatikan adalah bahwa kita menggunakan dekorator metode mock.patch untuk mengolok-olok objek yang terletak di mymodule.os , dan menyuntikkan tiruan itu ke dalam metode uji kasus kita. Bukankah lebih masuk akal untuk hanya mengejek os itu sendiri, daripada merujuknya di mymodule.os ?

Yah, Python agak licik dalam hal mengimpor dan mengelola modul. Saat runtime, modul mymodule memiliki os sendiri yang diimpor ke lingkup lokalnya sendiri di modul. Jadi, jika kita mock os , kita tidak akan melihat efek mock di modul mymodule .

Mantra yang harus terus diulang adalah ini:

Mengejek item di mana ia digunakan, bukan dari mana asalnya.

Jika Anda perlu meniru modul tempfile untuk myproject.app.MyElaborateClass , Anda mungkin perlu menerapkan tiruan ke myproject.app.tempfile , karena setiap modul menyimpan impornya sendiri.

Dengan jebakan itu, mari kita terus mengejek.

Menambahkan Validasi ke 'rm'

Metode rm yang didefinisikan sebelumnya cukup disederhanakan. Kami ingin memvalidasinya bahwa ada jalur dan merupakan file sebelum mencoba menghapusnya secara membabi buta. Mari kita refactor rm menjadi sedikit lebih pintar:

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)

Besar. Sekarang, mari kita sesuaikan test case kita untuk menjaga cakupan.

 #!/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")

Paradigma pengujian kami telah sepenuhnya berubah. Kami sekarang dapat memverifikasi dan memvalidasi fungsionalitas internal metode tanpa efek samping.

Penghapusan File sebagai Layanan dengan Mock Patch

Sejauh ini, kami hanya bekerja dengan menyediakan tiruan untuk fungsi, tetapi tidak untuk metode pada objek atau kasus di mana ejekan diperlukan untuk mengirim parameter. Mari kita bahas metode objek terlebih dahulu.

Kita akan mulai dengan refactor metode rm ke dalam kelas layanan. Sebenarnya tidak ada kebutuhan yang dapat dibenarkan, per se, untuk merangkum fungsi sederhana seperti itu ke dalam suatu objek, tetapi setidaknya akan membantu kami menunjukkan konsep-konsep kunci dalam mock . Mari kita refactor:

 #!/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)

Anda akan melihat bahwa tidak banyak yang berubah dalam kasus pengujian kami:

 #!/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")

Bagus, jadi kami sekarang tahu bahwa RemovalService bekerja sesuai rencana. Mari buat layanan lain yang mendeklarasikannya sebagai dependensi:

 #!/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)

Karena kami telah memiliki cakupan pengujian pada RemovalService , kami tidak akan memvalidasi fungsionalitas internal metode rm dalam pengujian UploadService kami. Sebaliknya, kami hanya akan menguji (tanpa efek samping, tentu saja) bahwa UploadService memanggil metode RemovalService.rm , yang kami tahu "hanya berfungsi" dari kasus pengujian kami sebelumnya.

Ada dua cara untuk melakukannya:

  1. Mengejek metode RemovalService.rm itu sendiri.
  2. Berikan contoh tiruan di konstruktor UploadService .

Karena kedua metode sering kali penting dalam pengujian unit, kami akan meninjau keduanya.

Opsi 1: Metode Instance Mengejek

Pustaka mock memiliki dekorator metode khusus untuk mengejek metode dan properti instance objek, dekorator @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")

Besar! Kami telah memvalidasi bahwa UploadService berhasil memanggil metode rm instance kami. Perhatikan sesuatu yang menarik di sana? Mekanisme tambalan sebenarnya menggantikan metode rm dari semua RemovalService dalam metode pengujian kami. Itu berarti bahwa kita benar-benar dapat memeriksa instance itu sendiri. Jika Anda ingin melihat lebih banyak, coba masukkan breakpoint dalam kode mocking Anda untuk memahami cara kerja mekanisme patching.

Mock Patch Pitfall: Perintah Dekorator

Saat menggunakan beberapa dekorator pada metode pengujian Anda, urutan itu penting , dan itu agak membingungkan. Pada dasarnya, saat memetakan dekorator ke parameter metode, bekerja mundur. Pertimbangkan contoh ini:

 @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

Perhatikan bagaimana parameter kami dicocokkan dengan urutan kebalikan dari dekorator? Itu sebagian karena cara kerja Python. Dengan beberapa dekorator metode, berikut urutan eksekusi dalam kodesemu:

 patch_sys(patch_os(patch_os_path(test_something)))

Karena tambalan ke sys adalah tambalan terluar, itu akan dieksekusi terakhir, menjadikannya parameter terakhir dalam argumen metode pengujian yang sebenarnya. Catat ini dengan baik dan gunakan debugger saat menjalankan pengujian Anda untuk memastikan bahwa parameter yang tepat dimasukkan dalam urutan yang benar.

Opsi 2: Membuat Instance Mock

Alih-alih mengejek metode instance tertentu, kita bisa saja menyediakan instance tiruan ke UploadService dengan konstruktornya. Saya lebih suka opsi 1 di atas, karena jauh lebih tepat, tetapi ada banyak kasus di mana opsi 2 mungkin efisien atau perlu. Mari kita refactor pengujian kita lagi:

 #!/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")

Dalam contoh ini, kami bahkan tidak perlu menambal fungsionalitas apa pun, kami cukup membuat spesifikasi otomatis untuk kelas RemovalService , dan kemudian menyuntikkan instance ini ke UploadService kami untuk memvalidasi fungsionalitas.

Metode mock.create_autospec membuat instance yang secara fungsional setara dengan kelas yang disediakan. Artinya, secara praktis, adalah ketika instance yang dikembalikan berinteraksi, itu akan menimbulkan pengecualian jika digunakan dengan cara yang ilegal. Lebih khusus lagi, jika suatu metode dipanggil dengan jumlah argumen yang salah, pengecualian akan dimunculkan. Ini sangat penting karena refactors terjadi. Saat perpustakaan berubah, tes rusak dan itu diharapkan. Tanpa menggunakan spesifikasi otomatis, pengujian kami akan tetap lulus meskipun implementasi dasarnya rusak.

Jebakan: Kelas mock.Mock dan mock.MagicMock

Pustaka mock juga mencakup dua kelas penting tempat sebagian besar fungsi internal dibangun: mock.Mock dan mock.MagicMock . Saat diberi pilihan untuk menggunakan instance mock.Mock , instance mock.MagicMock , atau auto-spec, selalu pilih penggunaan auto-spec, karena membantu menjaga pengujian Anda tetap waras untuk perubahan di masa mendatang. Ini karena mock.Mock dan mock.MagicMock menerima semua panggilan metode dan penetapan properti terlepas dari API yang mendasarinya. Pertimbangkan kasus penggunaan berikut:

 class Target(object): def apply(value): return value def method(target, value): return target.apply(value)

Kita dapat menguji ini dengan contoh mock.Mock seperti ini:

 class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")

Logika ini tampaknya masuk akal, tetapi mari kita ubah metode Target.apply untuk mengambil lebih banyak parameter:

 class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None

Jalankan kembali pengujian Anda, dan Anda akan menemukan bahwa itu masih lolos. Itu karena itu tidak dibangun melawan API Anda yang sebenarnya. Inilah mengapa Anda harus selalu menggunakan metode create_autospec dan parameter autospec dengan dekorator @patch dan @patch.object .

Contoh Mock Python: Mengejek Panggilan API Facebook

Untuk menyelesaikannya, mari kita tulis contoh tiruan python di dunia nyata yang lebih dapat diterapkan, yang telah kami sebutkan di pendahuluan: memposting pesan ke Facebook. Kami akan menulis kelas pembungkus yang bagus dan kasus uji yang sesuai.

 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)

Inilah kasus pengujian kami, yang memeriksa bahwa kami memposting pesan tanpa benar- benar memposting pesan:

 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!")

Seperti yang telah kita lihat sejauh ini, sangat mudah untuk mulai menulis tes yang lebih cerdas dengan mock di Python.

Kesimpulan

Pustaka mock Python, jika sedikit membingungkan untuk digunakan, adalah pengubah permainan untuk pengujian unit. Kami telah mendemonstrasikan kasus penggunaan umum untuk mulai menggunakan mock dalam pengujian unit, dan semoga artikel ini akan membantu pengembang Python mengatasi rintangan awal dan menulis kode yang sangat baik dan teruji.