Unit testing about API endpoints and parameters
https://softwareengineering.stackexchange.com/questions/386006
-
19-02-2021 - |
Pergunta
Consider a simple web application, I'll use a Python(ish) example, but i think the question is relevant for other languages as well.
The user is trying to fetch a page, and in order to render that page, the application has to make an external call to a remote service. In the example, I'll try to separate concerns: collecting input parameters, and calling the actual remote service.
def view(request):
foo = float(request.POST['foo'])
return Response(process_foo(foo))
def process_foo(foo):
return remote_service.get('/bar', data={'foo': foo})
In the example, view
is a function that's responsible for transforming the incoming request
to a Response
, and process_foo
is responsible for performing some business logic.
What kind of unit tests make sense here?
A few preferences:
- In
view
, I'd expectprocess_foo
to be a black-box, so I'd like to replace it with something during testing, so I can refactorprocess_foo
without breaking unit tests forview
. - In
process_foo
, I'd like to replace the callremote_service.get
, as it's an expensive operation.
Considering the restrictions above, I'm not sure what kinds of unit tests make sense here. In view
, I could make an assertion about that process_foo
was called with foo
, but when I make changes to process_foo
, this test will not break. The same is true for testing process_foo
: if I make an assertion on that remote_service.get
was called with the right URL and the right parameters, that'll not break when the remote URL changes (or it no longer accepts the same parameters).
To me it feels like that somehow I should test that foo
was extracted from request.POST
, but it seems that there's no reasonable way to do this.
I'm aware that integration tests can solve this problem, I'm looking for a possible solution about how the problem above could be solved with unit tests (if there's a solution at all).
Solução
Your ideas make sense.
In view, I could make an assertion about that process_foo was called with foo, but when I make changes to process_foo, this test will not break.
So it is a good thing that process_foo
has its own tests that will fail on an improper refactoring.
if I make an assertion on that remote_service.get was called with the right URL and the right parameters, that'll not break when the remote URL changes (or it no longer accepts the same parameters).
If you are concerned about how an external program responds to what you give it, you pretty much by definition are writing an integration test, not a unit test. It is not in the scope of unit testing to detect if an external API changes.
To me it feels like that somehow I should test that foo was extracted from request.POST, but it seems that there's no reasonable way to do this.
Python makes this easy. You just make your own object that has a dictionary attribute named POST
and load it with whatever data you need. When you call view
with it, mock out process_foo
, and use assert_called_once_with()
Finally, if you wish to treat process_foo()
as a black-blox within view()
, consider refactoring to make it clear this is an "external" dependency. I generally consider using mock.patch
to be a code smell (which doesn't mean that it's never the right choice!)
Outras dicas
I'm aware that integration tests can solve this problem, I'm looking for a possible solution about how the problem above could be solved with unit tests (if there's a solution at all).
Where I would start from is a minor re-design.
def process_foo(foo):
return remote_service.get('/bar', data={'foo': foo})
The problem here is that we're combining computation and logic (which you want to test) with an expensive side effect (which you don't).
A common answer here is "configurable dependencies", which is a hipster spelling of "dependency injection", which in turn is just a pretentious way of saying "pass an argument".
def process_foo(foo, remote_service):
return remote_service.get('/bar', data={'foo': foo})
This implementation allows you, in your test, to provide an inexpensive implementation of the remote service (for instance, a test double that just returns a constant).
If the expensive side effect is coming by way of a third party library, then remote_service
won't usually be the third party client itself, but your own wrapper around it.
def get(uri, data):
return third_party_client.get(uri, data)
This kind of wrapper is probably more common in languages like Java with a compiler that is fussy about types. It sometimes looks a bit odd (why bother?), but the motivation is that only a relatively small amount of our code should be coupled to the decision that we have made about how to implement our connection -- see Parnas 1972.
Note that this wrapper is deliberately simple; the third party client being relatively expensive to test means that this code isn't going to be tested as often, and we want to mitigate the risks.