blob: a1867828c4cbc1319d682e93174d917d8ccb80be [file] [view]
# Testing with Endpoints 101
This document assumes familiarity with writing Endpoints APIs in Python
and with App Engine testing. The intention is to follow one path toward
baseline App Engine and Endpoints testing knowledge, saving one hours/days
searching for solutions to the non-trivial problems which may
arise. Edifying links can be found throughout.
Users already comfortable with Endpoints (or just in need of
plug-and-play example code) can skip to
Conclusion: Use `test_case.EndpointsTestCase` or return to
[Testing](testing.md).
## Introduction: Setting up Test Cases Is Subtle Work
Assume that an Endpoints service is defined as
```python
class QuietRequest(messages.Message):
whispered = messages.StringField(1)
class LoudResponse(messages.Message):
yelled = messages.StringField(1)
@endpoints.api(name='someendpoint', version='v1')
class UpToElevenEndpoint(remote.Service):
@endpoints.method(QuietRequest, LoudResponse,
path='/fortissimo', http_method='POST',
name='fortissimo')
def yell(self, request):
response = LoudResponse(yelled=request.whispered.upper())
```
This service will be deployed with an invocation like
```python
app = endpoints.api_server([UpToElevenEndpoint])
```
In test code, one would then have to wrap such an app as a
`webtest.TestApp`, taking care to set `restricted=False`, e.g.
```python
class VolumeTestSuite(unittest.TestCase):
def setUp(self):
super(VolumeTestSuite, self).setUp()
# testbed setup, stub initialization, etc. should go here
app = endpoints.api_server([UpToElevenEndpoint], restricted=False)
self.source_ip = 'some.ip.address'
self.test_app = webtest.TestApp(
app, extra_environ={'REMOTE_ADDR', self.source_ip})
def testYellMethod(self):
response = self.test_app.post_json(
'/_ah/spi/UpToElevenEndpoint.yell', {'whispered': 'yes, I am data'})
self.assertEqual(
json.loads(response.body).get('yelled', ''), 'YES, I AM DATA')
```
With `restricted=False`, test clients (and other clients from outside
Google's serving infrastructure) can access the server. `self.source_ip`
is an IP at which an App Engine instance can be run.
If questions have arisen for you about any of the above, it may be
beneficial to read on; if not, the punchline lies at
Conclusion: Use `test_case.EndpointsTestCase`.
### Quirk the First: Webtest Will Ignore Your API Method's Specifications
Let us assume that one has defined the following service and messages in
file `something.py`:
```python
class GoodRequest(messages.Message):
data = messages.IntegerField(1)
class GreatResponse(messages.Message):
data = messages.IntegerField(1)
@endpoints.api(name='someendpoint', version='v1')
class SomeEndpoint(remote.Service):
@endpoints.method(GoodRequest, GreatResponse,
path='/exalt', http_method='POST',
name='exalt')
def glorify(self, request):
response = GreatResponse(data=request.data ** 2)
```
We define `SomeEndpoint` as a subclass of `remote.Service` and we give
it a single method, `glorify`, which takes `GoodRequest` and
`GreatResponse` as its request and response types, respectively. It
reads the numeric data provided by the request and squares it (the most
glorious of arithmetic operations).
We include the pattern in `app.yaml` (assuming our Endpoints methods
from above are in `something.py` and that an `api_server` called `app` is
defined therein):
```yaml
- url: /_ah/spi/.*
script: something.app
```
When we try to test on a local dev server, we find that neither `path`
nor `name` will avail us, nor can we make calls to `/_ah/api/<method>`.
Instead, the URLs to be called in our tests will be of the form
`/_ah/spi/<ClassName>.<methodname>`. In this example, to test `glorify`,
we would make POST requests to `/_ah/spi/SomeEndpoint.glorify`,
completely disregarding the `path` and `name` fields. A little more on
this topic can be found
[here](http://stackoverflow.com/questions/20384743/how-to-unit-test-google-cloud-endpoints).
One final oddity: all API calls will be converted to POST. Thus,
whatever `http_method` one declares in the API method definition, one
must call `self.test_app.post` (or its analogs, e.g. `post_json`) when
testing the API.
### Quirk the Second: Endpoints Error Handling Has a Known Bug
Details can be found
[here](https://code.google.com/p/googleappengine/issues/detail?id=10544).
Essentially, any exceptions raised and not handled during Endpoints code
execution will be converted within endpoints/apiserving to a JSON
response and served to the client. However, this code does not yet
update the `Content-Length` header, so a different error
(`AssertionError` from some place in the code that tries to assert that
`Content-Length` is correct) will almost certainly be raised.
This is easy to fix locally, but, until the bug report is addressed,
there will be no way to accomplish proper error handling in Endpoints
code.
#### Error Example
Let's add error cases to `something.py`:
```python
@endpoints.api(name='someendpoint', version='v1')
class SomeEndpoint(remote.Service):
@endpoints.method(GoodRequest, GreatResponse,
path='/exalt', http_method='POST',
name='exalt')
def glorify(self, request):
glorious_number = request.data
if glorious_number < 0:
raise endpoints.BadRequestException(
'Perhaps you wanted to make a PessimisticRequest?')
if glorious_number == 109:
raise endpoints.ForbiddenException(
'That number is deprecated. Use 28 instead.')
response = GreatResponse(data=request.data ** 2)
```
We raise two kinds of exceptions: `endpoints.BadRequestException`, which
will set the response's status to `400`, and
`endpoints.ForbiddenException`, which corresponds to HTTP status `403`.
One can check [this
page](https://cloud.google.com/appengine/docs/python/endpoints/exceptions)
for a mapping of Endpoints' exception types to the HTTP status codes
they use.
We would expect to be able to add code like
```python
with self.assertRaises(endpoints.ForbiddenException):
response = self.test_app.post_json(
'/_ah/spi/SomeEndpoint.glorify', {'data': 109})
```
or manually check that the status code in the response is `403`;
however, either method will yield the rather cryptic
`AppError: Bad response: 500 Internal Server Error (not 200)` due to the
content-length problem described above.
#### Solution
It is thus recommended to inherit from
[EndpointsTestCase](https://chromium.googlesource.com/infra/swarming/+/master/appengine/components/support/test_case.py),
instead of `unittest.TestCase`. Among other things (e.g. properly wrapping
the `endpoints.api_service` as a `webtest.TestApp`), this class exposes
the `call_should_fail` context manager. While this method is still
subject to the mentioned bug, it will be more fully implemented pending
the bug's resolution. One simply asserts that the correct error status
is raised, e.g.
```python
with self.call_should_fail('403'):
response = self.test_app.post_json(
'/_ah/spi/SomeEndpoint.glorify', {'data': 109})
```
## Conclusion: Use `test_case.EndpointsTestCase`
The previous section gestured toward a test module that addresses some
of the shortcomings concerning Endpoints testing. In fact, much of the
above complexity can be circumvented by using
[`test_case` module](https://chromium.googlesource.com/infra/swarming/+/master/appengine/components/support/test_case.py).
For more on EndpointsTestCase, return to [testing](testing.md).