Pregunta

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 expect process_foo to be a black-box, so I'd like to replace it with something during testing, so I can refactor process_foo without breaking unit tests for view.
  • In process_foo, I'd like to replace the call remote_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).

¿Fue útil?

Solución

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

Otros consejos

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.

Licenciado bajo: CC-BY-SA con atribución
scroll top