Quantcast
Channel: Planet Python
Viewing all articles
Browse latest Browse all 22462

Semaphore Community: Testing Python-Requests with Betamax

$
0
0

This article is brought with ❤ to you by Semaphore.

Introduction

Requests is one of the most downloaded and widely used Python libraries published on PyPI. Testing Requests, however, is something that most people attempt to avoid, or do only by using mocks and hand-written or hand-copied response data. VCRpy and Betamax are two libraries that avoid using mocks and hand-written or hand-copied data, and empower the developer to be lazy in how they write their tests by recording and replying responses for the developer.

Betamax is most useful when a developer is attempting to test their project's integration with a service, hence we will be performing integration testing. By the end of this tutorial, you will have learned how to use Betamax to write integration tests for a library which we will create together that communicates with Semaphore CI's API.

Prerequisites

Before we get started, let's make sure we have one of the versions of Python listed below and the packages listed:

  • Python 2.7, 3.3, 3.4, or 3.5
  • pip install betamax
  • pip install betamax-serializers
  • pip install pytest
  • pip install requests

Setting Up Our Project

You might want to structure your projects as follows:

.
├── semaphoreci
│   └── __init__.py
├── setup.py
└── tests
    ├── __init__.py
    ├── conftest.py
    └── integration
        ├── __init__.py
        └── cassettes

This ensures that the tests are easily visible and, as such, as visibly important as the project. You will notice that we have a subdirectory of the tests directory named integration. All of the tests that we will write using Betamax will live in the integration test folder. The cassettes directory inside the integration directory will store the interactions with our service that Betamax will record. Finally, we have a conftest.py file for py.test.

Our setup.py file should look like this:

# setup.pyimportsetuptoolsimportsemaphorecipackages=setuptools.find_packages(exclude=['tests','tests.integration','tests.unit'])requires=['requests']setuptools.setup(name="semaphoreci",version=semaphoreci.__version__,description="Python wrapper for the Semaphore CI API",author="Ian Cordasco",author_email="graffatcolmingov@gmail.com",url="https://semaphoreci.readthedocs.org",packages=packages,install_requires=requires,classifiers=['Development Status :: 5 - Production/Stable','Intended Audience :: Developers','Programming Language :: Python','Programming Language :: Python :: 2','Programming Language :: Python :: 2.7','Programming Language :: Python :: 3','Programming Language :: Python :: 3.4','Programming Language :: Python :: 3.5','Programming Language :: Python :: Implementation :: CPython',],)

Our semaphoreci/__init__.py should look like this:

"""Top level module for semaphoreci API library."""__version__='0.1.0.dev'__build__=(0,1,0)

Creating Our API Client

While you can practice test-driven development with Betamax, it is often easier to have code to test first. Let's start by creating the submodule that will hold all of our actual client object:

# semaphoreci/session.pyclassSemaphoreCI(object):pass

All of Semaphore CI's actions require the user to be authenticated, so our object will need to accept that auth_token and handle it properly for the user. Since the token is passed as a query-string parameter to the API, let's start using a Session from Requests to manage this for us:

# semaphoreci/session.pyimportrequestsclassSemaphoreCI(object):def__init__(self,auth_token):ifauth_tokenisNone:raiseValueError("All methods require an authentication token. See ""https://semaphoreci.com/docs/api_authentication.html ""for how to retrieve an authentication token from Semaphore"" CI.")self.session=requests.Session()self.session.params={}self.session.params['auth_token']=auth_token

When we now make a request using self.session in a method, it will automatically attach ?auth_token=<our auth token> to the end of the URL, and we do not have to do any extra work. Now, let's start talking to Semaphore CI's API by writing a method to retrieve all of the authenticated user's projects. This should look something like:

# semaphoreci/session.pyclassSemaphoreCI(object):# ...defprojects(self):"""List the authenticated user's projects and their current status.        See also        https://semaphoreci.com/docs/projects-api.html        :returns:            list of dictionaries representing projects and their current            status        :rtype:            list"""url='https://semaphoreci.com/api/v1/projects'response=self.session.get(url)returnresponse.json()

If we test this manually, we can verify that it works. We're now ready to start working on our test suite.

Configuring the Test Suite

First, let's think about what we need to run integration tests and what we want:

  • We'll need to tell Betamax where to save cassettes.
  • We'll want our cassettes to be fairly easy to read.
  • We'll need a way to pass a real Semaphore CI token to the tests to use but not to save (we do not want someone else using our API token for malicious purposes).

One way to safely pass the credentials to our tests is through environment variables, and luckily Betamax has a way to sanitize cassettes.

Now, let's write this together line by line:

# tests/conftest.pyimportbetamaxwithbetamax.Betamax.configure()asconfig:config.cassette_library_dir='tests/integration/cassettes'

This tells Betamax where to look for cassettes or to store new ones, so it satisfies our first requirement. Next, let's take care of our second requirement:

# tests/conftest.pyimportbetamaxfrombetamax_serializersimportpretty_jsonbetamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer)withbetamax.Betamax.configure()asconfig:config.cassette_library_dir='tests/integration/cassettes'config.default_cassette_options['serialize_with']='prettyjson'

This imports a custom serializer from the betamax_serializers project we installed earlier and registers that serializer with Betamax. Then, we tell Betamax that we want it to default the serialize_with cassette option to 'prettyjson' which is the name of the serializer we registered. Finally, let's split the last item into two portions. We'll retrieve the token first as follows:

# tests/conftest.pyimportosapi_token=os.environ.get('SEMAPHORE_TOKEN','frobulate-fizzbuzz')

This will look for an environment variable named SEMAPHORE_TOKEN, and if it doesn't exist, it will return 'frobulate-fizzbuzz'. Next, we'll tell Betamax to look for that value and replace it with a placeholder:

# tests/conftest.pywithbetamax.Betamax.configure()asconfig:# ...config.define_cassette_placeholder('<AUTH_TOKEN>',api_token)

Finally, our conftest.py file should look like:

importosimportbetamaxfrombetamax_serializersimportpretty_jsonapi_token=os.environ.get('SEMAPHORE_TOKEN','frobulate-fizzbuzz')betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer)withbetamax.Betamax.configure()asconfig:config.cassette_library_dir='tests/integration/cassettes'config.default_cassette_options['serialize_with']='prettyjson'config.define_cassette_placeholder('<AUTH_TOKEN>',api_token)

We're now all set to write our first test.

Writing Our First Test

Again, let's see what we need and want out of this test:

  • We need to use an API token to interact with the API and record the response.
  • We need to use our SemaphoreCI class to list the authenticated user's projects.
  • We want to use Betamax to record the request and response interaction.
  • We need to make assertions about what is returned from listing projects.

Now, let's start writing our test. First, we'll retrieve our API token like we do in conftest.py:

# tests/integration/test_session.pyimportosAPI_TOKEN=os.environ.get('SEMAPHORE_TOKEN','frobulate-fizzbuzz')

Next, let's write a test class to hold all of our integration tests related to our SemaphoreCI object:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):deftest_projects(self):pass

In test_projects, we will need to create our SemaphoreCI object. Let's go ahead and do that:

# tests/integration/test_session.pyimportosfromsemaphoreciimportsessionAPI_TOKEN=os.environ.get('SEMAPHORE_TOKEN','frobulate-fizzbuzz')classTestSemaphoreCI(object):deftest_projects(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)

Next, we want to start using Betamax. We know that the session attribute on our SemaphoreCI instance is our Session instance from Requests. Betamax's main API is through the Betamax object. The Betamax object takes an instance of a Session from Requests (or a subclass thereof). Knowing this, let's create our Betamax recorder:

# tests/integration/test_session.pyimportosimportbetamaxfromsemaphoreciimportsessionAPI_TOKEN=os.environ.get('SEMAPHORE_TOKEN','frobulate-fizzbuzz')classTestSemaphoreCI(object):deftest_projects(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)

The recorder we now have needs to know what cassette it should use.
To tell it what cassette to use, we call the use_cassette method. This method will return the recorder again, so it can be used as a context manager as follows:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):deftest_projects(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)withrecorder.use_cassette('SemaphoreCI_projects'):

Inside of that context, Betamax is recording and saving all of the HTTP requests made through the session it is wrapping. Since our instance of the SemaphoreCI class uses the session created for that instance, we can now complete our test by calling the projects method and making an assertion (or more than one assertion) about the items returned.

# tests/integration/test_session.pyclassTestSemaphoreCI(object):deftest_projects(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)withrecorder.use_cassette('SemaphoreCI_projects'):projects=semaphore_ci.projects()assertisinstance(projects,list)

Our complete test file now looks as follows:

# tests/integration/test_session.pyimportosimportbetamaxfromsemaphoreciimportsessionAPI_TOKEN=os.environ.get('SEMAPHORE_TOKEN','frobulate-fizzbuzz')classTestSemaphoreCI(object):deftest_projects(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)withrecorder.use_cassette('SemaphoreCI_projects'):projects=semaphore_ci.projects()assertisinstance(projects,list)

We can now run our tests:

$ SEMAPHORE_TOKEN='<our-private-token>' py.test

After that, your tests/integration directory should look similar to the following:

tests/integration
├── __init__.py
├── cassettes
│   └── SemaphoreCI_projects.json
└── test_session.py

We can now re-run our tests without the environment variable:

$ py.test

The tests will still pass, but they will not talk to Semaphore CI.
Instead, they will use the response stored in tests/cassettes/SemaphoreCI_projects.json. They will continue to use that file until we do something so that Betamax will have to re-record it.

Adding More API Methods

Now that we've added tests, let's add methods to:

  • List the branches on a project and
  • Rebuild the last revision of a branch.

We want the former method in order to be able to get the appropriate branch information to make the second request. Let's get started.

Listing Branches on a Project

According to Semaphore CI's documentation about Branches, we need the project's hash_id. So when we write our new method, it will look mostly like our last method but it will need to take a parameter. If we take the same steps we took to write our branches method, then we should arrive at something that looks like

# semaphoreci/session.pyclassSemaphoreCI(object):# ...defbranches(self,project):"""List branches for for a project.        See also        https://semaphoreci.com/docs/branches-and-builds-api.html#project_branches        :param project:            the project's ``hash_id``        :returns:            list of dictionaries representing branches        :rtype:            list"""url='https://semaphoreci.com/api/v1/projects/{hash_id}/branches'response=self.session.get(url.format(hash_id=project))returnresponse.json()

Now, let's write a new test for this method. To write this test, we'll need a project's hash_id. We can approach this in a couple ways:

  • We can hard-code the hash_id we choose to use,
  • We can select one at random from the user's projects listing or
  • We can search the user's projects for one with a specific name.

Since this is a simple use case, any of these options is perfectly valid. In other projects you will need to use your best judgment. For this case, however, we're going to search our own projects for the project named betamax.

Now, let's start adding to our TestSemaphoreCI class that we created earlier:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):# ...deftest_branches(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)withrecorder.use_cassette('SemaphoreCI_branches'):forprojectinsemaphore_ci.projects():ifproject['name']=='betamax':hash_id=project['hash_id']breakelse:hash_id=None

You'll note that we've duplicated some of the code from our last test.
We'll fix that later. You'll also note that we're using a different cassette in this test - SemaphoreCI_branches. This is intentional. While Betamax does not require it, it is advisable that each test have its own cassette (or even more than one cassette) and that no two tests rely on the same cassette.

Now that we have our project's hash_id, let's list its branches:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):# ...deftest_branches(self):semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)withrecorder.use_cassette('SemaphoreCI_branches'):forprojectinsemaphore_ci.projects():ifproject['name']=='betamax':hash_id=project['hash_id']else:hash_id=Nonebranches=semaphore_ci.branches(hash_id)assertlen(branches)>=1

You'll notice that we're making a second API request while recording the same cassette. This is perfectly normal usage of Betamax. In the simplest case, a cassette will record one interaction, but one cassette can have multiple interactions.

If we run our tests now:

$ SEMAPHORE_TOKEN=<our-token> py.test

We'll find a new cassette - tests/integration/cassettes/SemaphoreCI_branches.json.

Rebuilding the Latest Revision of a Branch

Now that we can get our list of projects and the list of a project's branches, we can write a way to trigger a rebuild of the latest revision of a branch.

Let's write our method:

# semaphoreci/session.pyclassSemaphoreCI(object):# ...defrebuild_last_revision(self,project,branch):"""Rebuild the last revision of a project's branch.        See also        https://semaphoreci.com/docs/branches-and-builds-api.html#rebuild        :param project:            the project's ``hash_id``        :param branch:            the branch's ``id``        :returns:            dictionary containing information about the newly triggered build        :rtype:            dict"""url='https://semaphoreci.com/api/v1/projects/{hash_id}/{id}/build'response=self.session.post(url.format(hash_id=project,id=branch))returnresponse.json()

Now, let's write our last test in this tutorial:

classTestSemaphoreCI(object):# ...deftest_rebuild_last_revision(self):"""Verify we can rebuild the last revision of a project's branch."""semaphore_ci=session.SemaphoreCI(API_TOKEN)recorder=betamax.Betamax(semaphore_ci.session)withrecorder.use_cassette('SemaphoreCI_rebuild_last_revision'):forprojectinsemaphore_ci.projects():ifproject['name']=='betamax':hash_id=project['hash_id']breakelse:hash_id=Noneforbranchinsemaphore_ci.branches(hash_id):ifbranch['name']=='master':branch_id=branch['id']breakelse:branch_id=Nonerebuild=semaphore_ci.rebuild_last_revision(hash_id,branch_id)assertrebuild['project_name']=='betamax'assertrebuild['result']isNone

Like our other tests, we build a SemaphoreCI instance and a Betamax instance. This time we name our cassette SemaphoreCI_rebuild_last_revision and, in addition to looking for our project named betamax, we also look for a branch named master. Once we have our project's hash_id and our branch's id, we can rebuild the last revision on master for betamax. Semaphore CI's API responds immediately so that you know the request to rebuild the last revision was successful. As such, there is no result in the response body and it will be returned as null in JSON, which Python translates to None.

Writing Less Test Code

At this point, you probably noticed that we're repeating some portions of each of our tests and we can improve this by taking advantage of the fact that all of our tests are methods on a class. Before we start refactoring our tests, let's enumerate what's being repeated in each test:

  • We create a SemaphoreCI instance in each test,
  • We create a Betamax instance in each test,
  • We retrieve a project by its name in some tests,
  • We retrieve a branch from a project by its name in some tests.

The last two items could actually be summarized as "We retrieve objects from the API by their name".

Let's start with the first two items. We will take advantage of py.test's autouse fixtures and create a setup method on the class to mimic the xUnit style of testing. We will need to add pytest to our list of imports.

# tests/integration/test_session.pyimportosimportbetamaximportpytestfromsemaphoreciimportsession

Then, we'll add our method to our TestSemaphoreCI class.

# tests/integration/test_session.pyclassTestSemaphoreCI(object):"""Integration tests for the SemaphoreCI object."""@pytest.fixture(autouse=True)defsetup(self):pass

Remember that we create our SemaphoreCI instance the same way in every test. Let's do that in setup and store it on self so the tests can all access it:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):# ...@pytest.fixture(autouse=True)defsetup(self):self.semaphore_ci=session.SemaphoreCI(API_TOKEN)

Now, let's tackle the way we create our Betamax instances (which we also do in exactly the same way each time).

# tests/integration/test_session.py@pytest.fixture(autouse=True)defsetup(self):"""Create SemaphoreCI and Betamax instances."""self.semaphore_ci=session.SemaphoreCI(API_TOKEN)self.recorder=betamax.Betamax(self.semaphore_ci.session)

Our test_projects test will now look as follows:

# tests/integration/test_session.pydeftest_projects(self):"""Verify we can list an authenticated user's projects."""withself.recorder.use_cassette('SemaphoreCI_projects'):projects=self.semaphore_ci.projects()assertisinstance(projects,list)

Note that we use self.recorder in our context manager instead of recorder and self.semaphore_ci instead of semaphore_ci. If we make similar changes to our other tests and re-run the tests, we can be confident that this continued to work.

Next, let's tackle finding objects in the API by name. Since our pattern is fairly simple, let's enumerate it to ensure that we will not refactor it incorrectly.

  1. We need to iterate over the items in a collection (or list),
  2. We need to check the 'name' attribute of each object to see if it is the name that we want,
  3. If the current item is the correct one, we need to break the loop and return it,
  4. Otherwise, we want to return None.

With that in mind, let's write a generic method to find something by its name:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):# ...@staticmethoddef_find_by_name(collection,name):foritemincollection:ifitem['name']==name:breakelse:returnNonereturnitem

For now, we'll make this a method on the class, but since it does not need to know about self, we can make it a static method with the staticmethod decorator.

Now, let's create methods that will find a project by its name and a branch by its name using _find_by_name:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):# ...deffind_project_by_name(self,project_name):"""Retrieve a project by its name from the projects list."""returnself._find_by_name(self.semaphore_ci.projects(),project_name)deffind_branch_by_name(self,project,branch_name):"""Retrieve a branch by its name from the branches list."""returnself._find_by_name(self.semaphore_ci.branches(project),branch_name)

Notice that we do use self in these methods because we use self.semaphore_ci. We can now rewrite our test_rebuild_last_revision method:

# tests/integration/test_session.pyclassTestSemaphoreCI(object):# ...deftest_rebuild_last_revision(self):"""Verify we can rebuild the last revision of a project's branch."""withself.recorder.use_cassette('SemaphoreCI_rebuild_last_revision'):project=self.find_project_by_name('betamax')branch=self.find_branch_by_name(project,'master')rebuild=self.semaphore_ci.rebuild_last_revision(project,branch)assertrebuild['project_name']=='betamax'assertrebuild['result']isNone

That's much easier to read now. We can go ahead and refactor our test_branches method also so that it uses find_project_by_name, and then run our tests to make sure they pass.

Conclusion

We now have a good, strong base to add support for more of Semaphore CI's endpoints, while writing integration tests for each method and endpoint. Our test class has methods to help us write very clean and easy-to-read test methods. It allows us to continue to improve our testing of this small library we have created together.

Now that we've laid a good foundation, you can go ahead and add some more methods and tests and start looking into how you might write unit tests for our small library.

For those of you curious, we did start building a client library. The project can be installed:

pip install semaphoreci

Or contributed to on GitLab.

This article is brought with ❤ to you by Semaphore.


Viewing all articles
Browse latest Browse all 22462

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>