Package: pytest-call-checker
pytest-call-checkerpytest-call-checkeris a pytest plugin providing acheckerfixture
that allows creating test doubles with interesting properties.Stating the problemImagine you're writing a library that makes HTTP calls to an API. If you follow
usual practices of separating I/O from logic, chances are you will use some
form of dependency injection: the functions you will write will receive a HTTP
client object and will route HTTP calls to this object. The idea being that in
real context, they will receive a real HTTP client, and in test code, they will
receive a fake client that will not actually perform HTTP calls.This fake client is a test double. What you'll usually want to check is that:The function made exactly as many calls as expected;The arguments of each call are correct.Additionally, you want to be able to control the output of each call given the
input and/or the order of the call.The usual way to do this is with mocks or specialized libs such asrequests-mock,responses, etc.This library provides another way, that can also work in other contexts: for
example, maybe you call subprocesses and you would like to not call them
in your unit tests.The ideaThis library provides a low-level fixture namedcheckerthat you can use
as a foundation for your own specialized fixtures.When defining yourcheckerfixture, you will tell it what the calls you're
expecting look like, and how to create appropriate responses.In your tests, you'll register one or more expected calls usingchecker.register, each with the corresponding response.When you call the function under test, you'll pass it thecheckerinstance.
Each time the instance is called, we'll check that the call arguments match one
of the expected calls you registered, and answer with the corresponding response.At the end of the test, if all the expected calls were not received, we will
ensure the test fails.You'll find concrete examples below.CodeInstallation$pipinstallpytest-call-checkerSimple usage: Http clientIn this example, we create a test double forhttpx.Client.
In the test, we register a call to thegetmethod of the
client.
We then run our function. We can be sure that:Our function called the get methodWith the right argumentsAnd nothing else on the clientAnd when it called the method, it received a fake response that looked like
what we wanted.importhttpximportpytestdefget_example_text(client:httpx.Client):returnclient.get("https://example.com").text@pytest.fixturedefhttp_client(checker):classFakeHttpClient(checker.Checker):returnchecker(checker.Checker(call=httpx.Client.request,response=httpx.Response))deftest_get_example_text(http_client):http_client.register.get("https://example.com")(text="foo")assertget_example_text(client=http_client)=="foo"More advanced usage: SubprocessIn this example, we create a test double not for an object but for a callable
(subprocess.run). This usage is slightly more advanced because in order to
instantiate our response object,subprocess.CompletedProcessobject, we need
to know the commandargsthat were passed to thesubprocess.runcall. This
could be slightly annoying if we needed to repeat theargsboth in the call
and theresponseso we'll introduce a technique here that will let us keep
our tests as simple as possible.defget_date(run:Callable):returnrun(["date"]).stdout@pytest.fixturedefsubprocess_run(checker):classSubprocessRun(checker.Checker):call=subprocess.rundefresponse(self,returncode,stdout=None,stderr=None):# When the response is implemented as a method of a `checker.Checker`# subclass, you can access `self.match`. This lets you construct# the output object using elements from the input kwargs, see# the `args` argument below.returnsubprocess.CompletedProcess(args=self.match.match_kwargs["popenargs"],returncode=returncode,stdout=stdout,stderr=stderr,)returnchecker(SubprocessRun())deftest_get_date(subprocess_run):subprocess_run.register(["date"])(returncode=0,stdout="2022-01-01")assertget_date(run=subprocess_run)=="2022-01-01"As you can see, in the code above, there are two ways to create youchecker.Checkerinstance:You can create an instance directlyOr subclasschecker.Checker. In this case, instead of passingcallandresponsein the constructor, you can also define them as methods.In case you go the subclass route, this lets you access additional elements
throughselfif you need it. This is an advanced usage, we'll do our best to
avoid breaking this, but it touches the inner workings of our objects so if you
really do complicated things, it might break.The most useful attributes ofchecker.Checkerthat you can access indef reponse(self, ...)should be:self.match: TheMatchobject that was associated with the response we're
building.self.request_kwargs: The keyword arguments with which the test double
was calledOther interesting featuresMatching with functionsSometimes, you can't perform an exact match on the input parameters, but you
still want to check some properties in order to perform the match.In this case, use a callable instead of the value for the argument you want
to check.importuuiddefcreate_object(client:ApiClient)->ApiResponse:# Create object with a random IDreturnclient.create(id=uuid.uuid4())@pytest.fixturedefapi_client(checker):classFakeAPIClient(checker.Checker):returnchecker(checker.Checker(call=ApiClient,response=ApiResponse))deftest_get_date(api_client):defis_uuid(value):returnisinstance(value,uuid.UUID)api_client.register(id=is_uuid)()assertcreate_object(client=api_client)==ApiResponse()Allowing calls in different orderBy default, it's expected that the calls will be done in the same order as
they were registered. You can actually change that by passingordered=Falsewhen creating the fixture.importuuiddeffetch_objects(client:ApiClient,ids:set[int])->set[ApiResponse]:# Because it's in a set, we can't be sure of the call orderreturn{client.get(id=id)foridinids}@pytest.fixturedefapi_client(checker):classFakeAPIClient(checker.Checker):returnchecker(checker.Checker(call=ApiClient,response=ApiResponse,ordered=False,))deftest_get_date(api_client):api_client.register(id=1)(id=1)api_client.register(id=2)(id=2)responses=fetch_objects(client=api_client,ids={1,2})assertresponses=={ApiResponse(id=1),ApiResponse(id=2)}CaveatsSome things are not ideal, and could be improved:There is no way to mark one call as optional. It's assumed that if the
tests are reproducible, then we should always know whether they'll do
calls or not.It's probably not possible yet to create test doubles for modules. The usual
way of doing dependency injection is through functions or class instances.