Friday, January 4, 2019

Patrick Kennedy: Monkeypatching with pytest

Introduction

I was really excited when I figured out how to use the monkeypatch fixture in pytest, so I wanted to write a blog post about how it works.  This blog post describes what monkeypatching is and provides two examples of using it with pytest.

All of the source code presented in this blog post can be found on GitLab: https://gitlab.com/patkennedy79/pytest_monkeypatch_example

What is Monkeypatching?

Monkeypatching is dynamically changing a piece of software (such as a module, object, method, or function) at runtime.  Monkeypatching is often used for bug fixes or prototyping software, especially when using external APIs or libraries.  Pytest uses this feature to allow you to test out interfaces that you don’t want to actually execute.  For example, you can create a monkeypatched version of the requests module that doesn’t do the actual HTTP transactions during testing, but just returns fixed data that you set.

One of the benefits of monkeypatching is that you can greatly decrease the amount of time your tests take to execute, as you can avoid doing slow network calls and/or complex external API calls.  Faster tests are more likely to be run!

Is Monkeypatching the same as Mocking?

Yes-ish.  Mocking is very similar to monkeypatching in the context of testing.  However, I always think of mocking only in terms of testing, whereas monkeypatching has a broader scope beyond just testing.

Monkeypatching with pytest (Example #1)

The first example illustrates how to use monkeypatching with pytest involves changing the behavior of the getcwd() method (Get Current Working Directory) from the os module that is part of the Python standard library.

Here’s the source code to be tested:

def example1():
"""
Retrieve the current directory

Returns:
Current directory
"""
current_path = os.getcwd()
return current_path

When testing this function, it would be desirable to be able to specify what ‘os.getcwd()’ returns instead of actually calling this function from the Python standard library.  By specifying what ‘os.getcwd()’ returns, you can write predictable tests and you can exercise different aspects of your code by returning off-nominal results.

Here’s the integration test that uses monkeypatching to specify a return value for ‘os.getcwd()’ in pytest:

def test_get_current_directory(monkeypatch):
"""
GIVEN a monkeypatched version of os.getcwd()
WHEN example1() is called
THEN check the current directory returned
"""
def mock_getcwd():
return '/data/user/directory123'

monkeypatch.setattr(os, 'getcwd', mock_getcwd)
assert example1() == '/data/user/directory123'

This test function utilizes the ‘monkeypatch’ fixture that is part of pytest, which means that the ‘monkeypatch’ fixture is passed into the function as an argument.

The test function starts by creating a mock version of the getcwd() function (the ‘mock_getcwd()’ function) which returns a specified value.  This mock function is then set to be called when ‘os.getcwd()’ is called by using ‘monkeypatch.setattr()’.  What’s really nice about how pytest does monkeypatching is that this change to ‘os.getcwd()’ is only applicable within the ‘test_get_current_directory()’ function.

Finally, the test function does the actual check (ie. the assert call) to check that the value returned from ‘example1()’ matches the specified value.

Monkeypatching with pytest (Example #2)

The second example illustrates how to use monkeypatching with pytest when working with an external module, which happens to be the ‘requests‘ module in this case.  The ‘requests’ module is an amazing python module that allows for easily working with HTTP requests.

Here’s the source code to be tested:

def example2():
"""
Call GET for http://httpbin.org/get

Returns:
Status Code of the HTTP Response
URL in the Text of the HTTP Response
"""
r = requests.get(BASE_URL + 'get')

if r.status_code == 200:
response_data = r.json()
return r.status_code, response_data["url"] else:
return r.status_code, ''

This function performs a GET to ‘http://bit.ly/2F9TvqM; and then checks that response. As an aside, http://httpbin.org is a great resource for testing API calls and it’s from the same author (Kenneth Reitz) that wrote the ‘requests’ module.

In order to test out this function, it would be desirable to be able to test the GET response being both successful and failing.  You can do this with monkeypatching in pytest!

Here’s the test function that tests the successful GET call:

def test_get_response_success(monkeypatch):
"""
GIVEN a monkeypatched version of requests.get()
WHEN the HTTP response is set to successful
THEN check the HTTP response
"""
class MockResponse(object):
def __init__(self):
self.status_code = 200
self.url = 'http://httpbin.org/get'
self.headers = {'blaa': '1234'}

def json(self):
return {'account': '5678',
'url': 'http://www.testurl.com'}

def mock_get(url):
return MockResponse()

monkeypatch.setattr(requests, 'get', mock_get)
assert example2() == (200, 'http://www.testurl.com')

Just like in the first example, this test function utilizes the ‘monkeypatch’ fixture that is part of pytest, which means that the ‘monkeypatch’ fixture is passed into the function as an argument.

The test function starts by creating a new class (‘MockResponse’) that specifies fixed values to be returned from an HTTP response.  An instance of this class is then returned by the ‘mock_get()’ function.

This mock function (‘mock_get()’) is then set to be called when ‘requests.get()’ is called by using ‘monkeypatch.setattr()’.

Finally, the actual check (ie. the assert call) is performed to check that the returned values from ‘example2()’ are the expected values.

A failed HTTP GET response can be tested in a similar manner:

def test_get_response_failure(monkeypatch):
"""
GIVEN a monkeypatched version of requests.get()
WHEN the HTTP response is set to failed
THEN check the HTTP response
"""
class MockResponse(object):
def __init__(self):
self.status_code = 404
self.url = 'http://httpbin.org/get'
self.headers = {'blaa': '1234'}

def json(self):
return {'error': 'bad'}

def mock_get(url):
return MockResponse()

monkeypatch.setattr(requests, 'get', mock_get)
assert example2() == (404, '')

This test function is similar to the success case, except it is now returning a status code of 404 (Internal Server Error) to test that the negative path in ‘example2()’ works as expected.

Running the Tests

To run the tests using pytest, I recommend running in your top-level project directory and using the verbose flag:

$ pytest -v
 
This should result in all of test cases passing:

========================================= test session starts================================= platform darwin — Python 3.7.0, pytest-4.0.2, py-1.7.0, pluggy-0.8.0 … tests/integration/test_example1.py::test_get_current_directory PASSED                                                                                      [ 33%] tests/integration/test_example2.py::test_get_response_success PASSED                                                                                      [ 66%] tests/integration/test_example2.py::test_get_response_failure PASSED                                                                                      [100%] =========================================3 passed in 0.27 seconds ============================

Conclusion

I was really happy to get monkeypatching working in pytest, as I feel this can greatly improve the quality of my testing.  The idea of being able to specify a fixed value for an external call is really powerful to help with testing as many paths through your code as possible.  Plus, the significant decrease in execution time of your tests is an added benefit.

References

pytest documentation for monkeypatching: https://docs.pytest.org/en/3.1.1/monkeypatch.html

Brian Okken’s book about pytest:
https://pragprog.com/book/bopytest/python-testing-with-pytest

Excellent blog post about monkeypatching in pytest: https://www.angelaambroz.com/blog/posts/2018/Jan/24/a_few_of_my_favorite_pytest_things/



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...