Wprowadzenie do szyderstwa w Pythonie

Opublikowany: 2022-03-11

Jak uruchomić testy jednostkowe w Pythonie bez testowania swojej cierpliwości?

Najczęściej oprogramowanie, które piszemy, wchodzi w bezpośrednią interakcję z usługami, które nazwalibyśmy „brudnymi”. Mówiąc wprost: usługi, które są kluczowe dla naszej aplikacji, ale których interakcje mają zamierzone, ale niepożądane skutki uboczne — czyli niepożądane w kontekście autonomicznego uruchomienia testu.

Na przykład: być może piszemy aplikację społecznościową i chcemy przetestować naszą nową funkcję „Opublikuj na Facebooku”, ale nie chcemy publikować na Facebooku za każdym razem, gdy uruchamiamy nasz zestaw testowy.

Biblioteka unittest.mock unittest lub, jeśli zadeklarujesz go jako zależność, po prostu mock — który zapewnia niezwykle potężne i przydatne środki do wyśmiewania i eliminowania tych niepożądanych efektów ubocznych.

mockowanie i testy jednostkowe w bibliotece unittest python

Uwaga: mock jest nowo dołączony do standardowej biblioteki od Pythona 3.3; wcześniejsze dystrybucje będą musiały korzystać z biblioteki Mock, którą można pobrać za pośrednictwem PyPI.

Wywołania systemowe a naśmiewanie się z Pythona

Aby dać ci inny przykład, z którym będziemy korzystać w dalszej części artykułu, rozważ wywołania systemowe . Nietrudno zauważyć, że są to główni kandydaci do kpin: czy piszesz skrypt wysuwający napęd CD, serwer sieciowy, który usuwa przestarzałe pliki pamięci podręcznej z /tmp , czy serwer gniazd, który łączy się z portem TCP, te wszystkie wywołują niepożądane skutki uboczne w kontekście testów jednostkowych.

Jako programista bardziej zależy ci na tym, aby twoja biblioteka z powodzeniem wywołała funkcję systemową wysuwania płyty CD, w przeciwieństwie do otwierania szuflady CD za każdym razem, gdy uruchamiany jest test.

Jako programista bardziej zależy ci na tym, aby twoja biblioteka pomyślnie wywołała funkcję systemową wysuwania płyty CD (z poprawnymi argumentami itp.), w przeciwieństwie do faktycznego otwierania szuflady CD za każdym razem, gdy uruchamiany jest test. (Lub, co gorsza, wielokrotnie, ponieważ wiele testów odwołuje się do kodu wysuwania podczas pojedynczego uruchomienia testu jednostkowego!)

Podobnie, utrzymanie wydajności i wydajności testów jednostkowych oznacza utrzymanie jak największej ilości „powolnego kodu” poza automatycznymi przebiegami testów, a mianowicie systemu plików i dostępu do sieci.

W naszym pierwszym przykładzie dokonamy refaktoryzacji standardowego przypadku testowego Pythona z oryginalnej postaci do postaci za pomocą mock . Pokażemy, w jaki sposób napisanie przypadku testowego z próbkami sprawi, że nasze testy będą mądrzejsze, szybsze i będą w stanie ujawnić więcej o tym, jak działa oprogramowanie.

Prosta funkcja usuwania

Wszyscy musimy od czasu do czasu usuwać pliki z naszego systemu plików, więc napiszmy funkcję w Pythonie, która ułatwi to nieco naszym skryptom.

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

Oczywiście nasza metoda rm w tym momencie nie zapewnia dużo więcej niż podstawowa metoda os.remove , ale nasza baza kodu ulegnie poprawie, co pozwoli nam dodać więcej funkcji w tym miejscu.

Napiszmy tradycyjny przypadek testowy, czyli bez mocków:

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

Nasz przypadek testowy jest dość prosty, ale za każdym razem, gdy jest uruchamiany, tworzony jest plik tymczasowy, a następnie usuwany. Dodatkowo nie mamy możliwości sprawdzenia, czy nasza metoda rm prawidłowo przekazuje argument do wywołania os.remove . Możemy założyć , że tak jest na podstawie powyższego testu, ale wiele pozostaje do życzenia.

Refaktoryzacja za pomocą Python Mocks

Zrefaktoryzujmy nasz przypadek testowy za pomocą 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")

Dzięki tym refaktorom zasadniczo zmieniliśmy sposób działania testu. Teraz mamy insidera , obiekt, którego możemy użyć do sprawdzenia funkcjonalności innego.

Potencjalne pułapki naśladujące Pythona

Jedną z pierwszych rzeczy, które powinny rzucać się w oczy, jest to, że używamy dekoratora metody mock.patch , aby zakpić obiekt znajdujący się w mymodule.os i wstrzyknąć go do naszej metody przypadku testowego. Czy nie byłoby bardziej sensowne os samego systemu operacyjnego niż odniesienie do niego w mymodule.os ?

Cóż, Python jest trochę podstępnym wężem, jeśli chodzi o importowanie i zarządzanie modułami. W czasie wykonywania moduł mymodule ma swój własny system os , który jest importowany do własnego zakresu lokalnego w module. Tak więc, jeśli wykonamy mock os , nie zobaczymy efektów makiety w module mymodule .

Mantra do powtarzania jest taka:

Wyśmiewaj przedmiot tam, gdzie jest używany, a nie skąd pochodzi.

Jeśli chcesz zasymulować moduł tempfile dla myproject.app.MyElaborateClass , prawdopodobnie musisz zastosować makieta do myproject.app.tempfile , ponieważ każdy moduł zachowuje własne importy.

Po usunięciu tej pułapki dalej kpijmy.

Dodawanie walidacji do „rm”

Zdefiniowana wcześniej metoda rm jest dość uproszczona. Chcielibyśmy, aby sprawdzał, czy ścieżka istnieje i jest plikiem, zanim tylko na ślepo spróbuje go usunąć. Zrefaktoryzujmy rm , aby być nieco mądrzejszym:

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

Świetnie. Teraz dostosujmy nasz przypadek testowy, aby utrzymać zasięg.

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

Nasz paradygmat testowania uległ całkowitej zmianie. Teraz możemy weryfikować i walidować wewnętrzną funkcjonalność metod bez żadnych skutków ubocznych.

Usuwanie plików jako usługa z mock Patch

Do tej pory pracowaliśmy tylko z dostarczaniem mocków dla funkcji, ale nie nad metodami na obiektach lub przypadkami, w których mockowanie jest konieczne do wysyłania parametrów. Omówmy najpierw metody obiektowe.

Zaczniemy od przekształcenia metody rm w klasę usług. Naprawdę nie ma uzasadnionej potrzeby enkapsulacji tak prostej funkcji w obiekcie, ale przynajmniej pomoże nam to zademonstrować kluczowe koncepcje w mock . Zrefaktorujmy:

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

Zauważysz, że w naszym przypadku testowym niewiele się zmieniło:

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

Świetnie, więc teraz wiemy, że RemovalService działa zgodnie z planem. Stwórzmy kolejną usługę, która deklaruje ją jako zależność:

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

Ponieważ mamy już pokrycie testowe w RemovalService , nie będziemy sprawdzać wewnętrznej funkcjonalności metody rm w naszych testach UploadService . Zamiast tego przetestujemy (oczywiście bez efektów ubocznych), że UploadService wywołuje metodę RemovalService.rm , o której wiemy, że „po prostu działa” z naszego poprzedniego przypadku testowego.

Można to zrobić na dwa sposoby:

  1. Wykpij samą metodę RemovalService.rm .
  2. Podaj symulowane wystąpienie w konstruktorze UploadService .

Ponieważ obie metody są często ważne w testach jednostkowych, omówimy obie.

Opcja 1: Mockowanie metody instancji

Biblioteka mock ma specjalny dekorator metod do mockowania metod i właściwości instancji obiektów, 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")

Świetnie! Stwierdziliśmy, że UploadService pomyślnie wywołuje metodę rm naszej instancji. Zauważyłeś tam coś interesującego? Mechanizm poprawek faktycznie zastąpił metodę rm wszystkich instancji RemovalService w naszej metodzie testowej. Oznacza to, że możemy faktycznie sprawdzić same instancje. Jeśli chcesz zobaczyć więcej, spróbuj umieścić punkt przerwania w swoim szyderczym kodzie, aby dobrze wyczuć, jak działa mechanizm łatania.

Mock Patch Pułapka: Zamówienie dekoratora

W przypadku korzystania z wielu dekoratorów w metodach testowych kolejność jest ważna i jest trochę myląca. Zasadniczo, mapując dekoratory na parametry metody, pracuj wstecz. Rozważ ten przykład:

 @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

Zauważ, że nasze parametry są dopasowywane do odwrotnej kolejności dekoratorów? Częściowo wynika to ze sposobu działania Pythona. W przypadku wielu dekoratorów metod, oto kolejność wykonywania w pseudokodzie:

 patch_sys(patch_os(patch_os_path(test_something)))

Ponieważ łata do sys jest łatą najbardziej zewnętrzną, zostanie wykonana jako ostatnia, co czyni ją ostatnim parametrem w rzeczywistych argumentach metody testowej. Zwróć na to uwagę i użyj debugera podczas uruchamiania testów, aby upewnić się, że właściwe parametry są wstrzykiwane we właściwej kolejności.

Opcja 2: Tworzenie próbnych instancji

Zamiast naśladować konkretną metodę wystąpienia, moglibyśmy zamiast tego dostarczyć zafałszowaną instancję do UploadService z jej konstruktorem. Preferuję opcję 1 powyżej, ponieważ jest ona bardziej precyzyjna, ale jest wiele przypadków, w których opcja 2 może być skuteczna lub konieczna. Zrefaktoryzujmy ponownie nasz test:

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

W tym przykładzie nie musieliśmy nawet poprawiać żadnej funkcjonalności, po prostu tworzymy automatyczną specyfikację dla klasy RemovalService , a następnie wstrzykujemy to wystąpienie do naszego UploadService , aby sprawdzić poprawność funkcjonalności.

Metoda mock.create_autospec tworzy funkcjonalnie równoważną instancję do dostarczonej klasy. Oznacza to, praktycznie rzecz biorąc, że gdy zwrócona instancja zostanie wchodząc w interakcję, spowoduje to zgłoszenie wyjątków, jeśli zostanie użyte w nielegalny sposób. Dokładniej, jeśli metoda zostanie wywołana z niewłaściwą liczbą argumentów, zostanie zgłoszony wyjątek. Jest to niezwykle ważne, ponieważ zdarzają się refaktory. Gdy biblioteka się zmienia, testy kończą się przerwaniem i jest to oczekiwane. Bez użycia auto-specyfikacji nasze testy i tak przejdą pomyślnie, nawet jeśli podstawowa implementacja jest zepsuta.

Pułapka: mock.Mock i mock.MagicMock Classes

Biblioteka mock zawiera również dwie ważne klasy, na których opiera się większość wewnętrznej funkcjonalności: mock.Mock i mock.MagicMock . Gdy masz możliwość użycia instancji mock.Mock , instancji mock.MagicMock lub auto-specyfikacji, zawsze preferuj używanie auto-specyfikacji, ponieważ pomaga to zachować rozsądek testów na przyszłe zmiany. Dzieje się tak, ponieważ mock.Mock i mock.MagicMock akceptują wszystkie wywołania metod i przypisania właściwości niezależnie od bazowego interfejsu API. Rozważ następujący przypadek użycia:

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

Możemy to przetestować za pomocą instancji mock.Mock w następujący sposób:

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

Ta logika wydaje się rozsądna, ale zmodyfikujmy metodę Target.apply , aby przyjmowała więcej parametrów:

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

Przeprowadź ponownie test, a przekonasz się, że nadal jest zdany. Dzieje się tak, ponieważ nie jest zbudowany na podstawie Twojego rzeczywistego interfejsu API. Dlatego należy zawsze używać metody create_autospec i parametru autospec z dekoratorami @patch i @patch.object .

Przykład Pythona Mock: Mockowanie wywołania API Facebook

Na koniec napiszmy bardziej odpowiedni przykład prawdziwego pytona, o którym wspomnieliśmy we wstępie: publikowanie wiadomości na Facebooku. Napiszemy ładną klasę opakowującą i odpowiadający jej przypadek testowy.

 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)

Oto nasz przypadek testowy, w którym sprawdzamy, czy wysyłamy wiadomość bez jej faktycznego wysyłania:

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

Jak widzieliśmy do tej pory, rozpoczęcie pisania inteligentniejszych testów z mock w Pythonie jest naprawdę proste.

Wniosek

mock biblioteka Pythona, choć trochę myląca w obsłudze, zmienia zasady gry w testowanie jednostkowe. Zademonstrowaliśmy typowe przypadki użycia dla rozpoczęcia używania mock w testach jednostkowych i mamy nadzieję, że ten artykuł pomoże programistom Pythona pokonać początkowe przeszkody i napisać doskonały, przetestowany kod.