Package: coveo-ref
Make mocking simple, free of hardcoded trings and therefore... refactorable!TutorialCommon Mock RecipesMock something globally without contextOption 1: by leveraging the import mechanismOption 2: By wrapping a hidden functionMock something for a given contextBrief Example:Detailed Example:Mock something for the current contextMock a method on a classMock a method on one instance of a classMock an attribute on a class/instance/module/function/object/etcMock a propertyMock a classmethod or staticmethod on a specific instanceTutorialConsider this common piece of code:fromunittest.mockimportpatch,MagicMock@patch("mymodule.clients.APIClient._do_request")deftest(api_client_mock:MagicMock)->None:...Because the mock target is a string, it makes it difficult to move things around without breaking the tests. You need a
tool that can extract the string representation of a python objet. This is whatrefwas built for:fromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrommymodule.clientsimportAPIClient@patch(*ref(APIClient._do_request))deftest(api_client_mock:MagicMock)->None:...🚀 This way, you can rename or movemymodule,clients,APIClientor even_do_request, and your IDE should find
these and adjust them just like any other reference in your project.Let's examine a more complex example:fromunittest.mockimportpatch,MagicMockfrommymodule.tasksimportprocess@patch("mymodule.tasks.get_api_client")deftest(get_api_client_mock:MagicMock)->None:assertprocess()isNone# pretend this tests the process functionThe interesting thing in this example is that we're mockingget_api_clientin thetasksmodule.
Let's take a look at thetasksmodule:fromtypingimportOptionalfrommymodule.clientsimportget_api_clientdefprocess()->Optional[bool]:client=get_api_client()return...As we can see,get_api_clientis defined in another module.
The test needs to patch the functionin the tasks modulesince that's the context it will be called from.
Unfortunately, inspectingget_api_clientfrom thetasksmodule at runtime leads us back tomymodule.clients.This single complexity means that hardcoding the contextmymodule.tasksand symbolget_api_clientinto a string
for the patch is the straightforward solution.But withref, you specify the context separately:fromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrommymodule.clientsimportget_api_clientfrommymodule.tasksimportprocess@patch(*ref(get_api_client,context=process))deftest(get_api_client_mock:MagicMock)->None:assertprocess()isNone# pretend this tests the process function🚀 By giving a context toref, the symbolget_api_clientwill be resolved from the context ofprocess, which is themymodule.tasksmodule. The result ismymodule.tasks.get_api_client.If either objects (get_api_clientorprocess) are moved or renamed using a refactoring tool, the mock will still
point to the correct name and context.🚀 And a nice bonus is that your IDE can jump toget_api_client's definition from the test file now!It should be noted that this isn't just some string manipulation.refwill import and inspect modules and objects
to make sure that they're correct. Here's a more complex case with a renamed symbol:The module:fromtypingimportOptionalfrommymodule.clientsimportget_api_clientasclient_factory# it got renamed! 😱defprocess()->Optional[bool]:client=client_factory()return...The test:fromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrommymodule.clientsimportget_api_clientfrommymodule.tasksimportprocess@patch(*ref(get_api_client,context=process))deftest(get_api_client_mock:MagicMock)->None:assertprocess()isNone# pretend this tests the process functionNotice how the test and patch did not change despite the renamed symbol?🚀 This is becauserefwill findget_api_clientasclient_factorywhen inspectingmymodule.tasksmodule,
and returnmymodule.tasks.client_factory.We can also use ref withpatch.object()in order to patch a single instance. Consider the following code:fromunittest.mockimportpatchfrommymodule.clientsimportAPIClientdeftest()->None:client=APIClient()withpatch.object(client,"_do_request"):...🚀 By specifyingobj=Truetoref, you will obtain aTuple[instance, attribute_to_patch_as_a_string]that you
can unpack topatch.object():fromunittest.mockimportpatchfromcoveo_refimportreffrommymodule.clientsimportAPIClientdeftest()->None:client=APIClient()withpatch.object(*ref(client._do_request,obj=True)):...Please refer to the docstring ofreffor argument usage information.Common Mock RecipesMock something globally without contextOption 1: by leveraging the import mechanismTo mock something globally without regards for the context, it has to be accessed through a dot.by the context.For instance, consider this test:fromhttp.clientimportHTTPResponsefromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrommymodule.tasksimportprocess@patch(*ref(HTTPResponse.close))deftest(http_response_close_mock:MagicMock)->None:assertprocess()The target isHTTPResponse.close, which lives in thehttp.clientmodule.
The context of the test is theprocessfunction, which lives in themymodule.tasksmodule.
Let's take a look atmymodule.tasks's source code:fromhttpimportclientdefprocess()->bool:_=client.HTTPResponse(...)# of course this is fake, but serves the examplereturn...Sincemymodule.tasksreachesHTTPResponsethrough a dot (i.e.:client.HTTPResponse), we can patchHTTPResponsewithout usingmymodule.tasksas the context.However, ifmymodule.taskswas written like this:fromhttp.clientimportHTTPResponsedefprocess()->bool:_=HTTPResponse(...)return...Then the patch would not affect the object used by theprocessfunction anymore. However, it would affect any other
module that uses the dot to reachHTTPResponsesince the patch wasstillapplied globally.Option 2: By wrapping a hidden functionAnother approach to mocking things globally is to hide a function behind another, and mock the hidden function.
This allows modules to use whatever import style they want, and the mocks become straightforward to setup.Pretend this ismymodule.clients:classAPIClient:...defget_api_client()->APIClient:return_get_api_client()def_get_api_client()->APIClient:returnAPIClient()And this ismymodule.tasks:frommymodule.clientsimportget_api_clientdefprocess()->bool:returnget_api_client()isnotNoneSo youknowthis works globally, because no one will (should?) import the private one except the test:fromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrommymodule.tasksimportprocessfrommymodule.clientsimport_get_api_client@patch(*ref(_get_api_client))deftest(api_client_mock:MagicMock)->None:assertprocess()Mock something for a given contextIf you don't use a global mock, then youmustspecify the context of the mock.The context is a reference point forref.
Most of the time, the class or function you're testing should be the context.
Generally speaking, pick a context as close to your implementation as possible to allow seamless refactoring.Brief Example:fromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrom...importthing_to_mockfrom...importthing_to_test@patch(*ref(thing_to_mock,context=thing_to_test))deftest(mocked_thing:MagicMock)->None:assertthing_to_test()mocked_thing.assert_called()Detailed Example:mymodule.tasks:frommymodule.clientsimportget_api_clientdefprocess()->bool:client=get_api_client()return...The test, showing 3 different methods that work:fromunittest.mockimportpatch,MagicMockfromcoveo_refimportreffrommymodule.clientsimportget_api_clientfrommymodule.tasksimportprocess# you can pass the module as the contextimportmymodule@patch(*ref(get_api_client,context=mymodule.tasks))deftest(get_api_client_mock:MagicMock)->None:assertprocess()# you can pass the module as the context, version 2frommymoduleimporttasks@patch(*ref(get_api_client,context=tasks))deftest(get_api_client_mock:MagicMock)->None:assertprocess()# you can also pass a function or a class defined in the `tasks` modulefrommymodule.tasksimportprocess@patch(*ref(get_api_client,context=process))deftest(get_api_client_mock:MagicMock)->None:assertprocess()The 3rd method is encouraged: provide the function or class that is actually using theget_api_clientimport.
In our example, that's theprocessfunction.
Ifprocesswas ever moved to a different module, it would carry theget_api_clientimport, and the mock would
be automatically adjusted to targetprocess's new module without changes. 🚀Mock something for the current contextSometimes, the test fileisthe context. When that happens, just pass__name__as the context:fromunittest.mockimportpatchfromcoveo_refimportreffrommymodule.clientsimportget_api_client,APIClientdef_prepare_test()->APIClient:client=get_api_client()...returnclient@patch(*ref(get_api_client,context=__name__))deftest()->None:client=_prepare_test()...Mock a method on a classSince a method cannot be imported and can only be accessed through the use of a dot.on a class or instance,
you can always patch methods globally:withpatch(*ref(MyClass.fn)):...This is because no module can importfn; it has to go through an import ofMyClass.Mock a method on one instance of a classSimply addobj=Trueand usepatch.object():withpatch.object(*ref(instance.fn,obj=True)):...Mock an attribute on a class/instance/module/function/object/etcrefcannot help with this task:You cannot refer an attribute that exists (you would pass the value, not a reference)You cannot refer an attribute that doesn't exist (because it doesn't exist!)For this, there's no going around hardcoding the attribute name in a string:classMyClass:def__init__(self)->None:self.a=1deftest_attr()->None:instance=MyClass()withpatch.object(instance,"a",new=2):assertinstance.a==2assertMyClass().a==1This sometimes work when patchinginstances.
The example works becauseais a simple attribute that lives ininstance.__dict__andpatch.objectknows
about that.But if you tried to patchMyClassinstead ofinstance,mock.patchwould complain that there's no
such thing asaover there.
Thus, patching an attribute globally will most likely result in a lot of wasted time, and should be avoided.There's no way to make the example work withrefbecause there's no way to referinstance.awithout actually
getting the value ofa, unless we hardcode a string, which defeats the purpose ofrefcompletely.Mock a propertyYou can only patch a property globally, through its class:classMyClass:@propertydefget(self)->bool:returnFalsefromunittest.mockimportPropertyMock,patch,MagicMockfromcoveo_refimportreffrommymoduleimportMyClass@patch(*ref(MyClass.get),new_callable=PropertyMock,return_value=True)deftest(my_class_get_mock:MagicMock)->None:assertMyClass().get==Truemy_class_get_mock.assert_called_once()Youcannotpatch a property on an instance, this is a limitation ofunittest.mockbecause of the way
properties work.
If you try,mock.patch.object()will complain that the property is read only.Mock a classmethod or staticmethod on a specific instanceWhen inspecting these special methods on an instance,refends up finding the class instead of the instance.Therefore,refis unable to return aTuple[instance, function_name].
It would returnTuple[class, function_name], resulting in a global patch. 😱Butrefwill detect this mistake, and will raise a helpful exception if it cannot return an instance when you
specifiedobj=True.For this particular scenario, the workaround is to provide the instance as the context:fromunittest.mockimportpatchfromcoveo_refimportrefclassMyClass:@staticmethoddefget()->bool:returnFalsedeftest()->None:instance=MyClass()withpatch.object(*ref(instance.get,context=instance,obj=True))asfn_mock:assertinstance.get==TrueassertMyClass().get==False# new instances are not affected by the object mockfn_mock.assert_called_once()Some may prefer a more semantically-correct version by specifying the target through the class instead of the
instance. In the end, these are all equivalent:withpatch.object(instance,"get"):...withpatch.object(*ref(instance.get,context=instance,obj=True)):...withpatch.object(*ref(MockClass.get,context=instance,obj=True)):...In this case, the version without ref is much shorter and arguably more pleasant for the eye, butgetcan no longer
be renamed without altering the tests.