How to write testcase to verify 500 response for flask apis - python

I am trying to write unit test cases for flask APIs. I am unable to figure out how to write test cases for 500 internal server errors and custom exceptions in API response.
Here are my books api to get the book by id
#BOOKS_BP.route('/<book_id>', methods=['GET'])
def get_book(book_id):
try:
book = Book.query.filter(Book.id == book_id).scalar()
if not book:
raise DBRecordNotFound('No Record Found with ID - {}'.format(book_id))
book_schema = BookSchema()
return jsonify({'status': 'success', 'book': book_schema.dump(book)}), HTTPStatus.OK
except DBRecordNotFound as ex:
return jsonify({'status': 'error', 'message': str(ex)}), HTTPStatus.NOT_FOUND
except Exception as ex:
logger.error(ex, exc_info=True)
return jsonify({'status': 'error', 'message': str(ex)}), HTTPStatus.INTERNAL_SERVER_ERROR
Here is my get book by id test case.
import json
from tests.test_base import BaseTestCase
from tests.factories import BookFactory
from http import HTTPStatus
class BooksTestCase(BaseTestCase):
#classmethod
def setUpClass(cls):
super(BooksTestCase, cls).setUpClass()
book = BookFactory(name='Test Book')
book.save()
cls.test_book_id = book.id
def test_book_by_id(self):
# For 200 Status
response = self.client.get('/api/books/{}'.format(self.test_book_id))
data = json.loads(response.data)
assert response.status_code == HTTPStatus.OK
assert data['book']['name'] == 'Test Book'
# For 404 Status
response = self.client.get('/api/books/123456')
data = json.loads(response.data)
assert response.status_code == HTTPStatus.NOT_FOUND
assert data['message'] == 'No Record Found with ID - 123456'
# For 500 Status

Related

Flask App Route returning "Bad Request" error

I'm learning full stack in Flask and am having trouble with a particular route from an API. The API being developed is a list of books and in particular I am trying to reach the data for a particular book, say book with ID = 8. The URI is http://127.0.0.1:5000/books/8. However this returns a 400 error (bad request).
I really can't spot what is going wrong. I have defined the route '/books/int:book_id' with methods GET and PATCH, so I would expect the route to work. I also see errors when I test the route with curl, for example:
curl -X PATCH -H "Content-Type: application/json" -d '{"rating":"1"}' http://127.0.0.1:5000/books/8
See below for the particular route in question:
#app.route('/books/<int:book_id>', methods=['GET', 'PATCH'])
def update_book_rating(book_id):
body = request.get_json()
try:
book = Book.query.filter_by(Book.id==book_id).one_or_none()
if book is None:
abort(404)
if 'rating' in body:
book.rating = int(body.get('rating'))
book.update() #Class book in models.py has an update method which executes a commit()
return jsonify({
'success': True,
'id': book.id
})
except Exception as e:
print(e)
abort(400)
If it helps, I am also adding the full code. Note that the Book object is defined in a separate file, which I won't put here.
import os
from flask import Flask, request, abort, jsonify
from flask_sqlalchemy import SQLAlchemy # , or_
from flask_cors import CORS
import random
from models import setup_db, Book
BOOKS_PER_SHELF = 8
# #TODO: General Instructions
# - As you're creating endpoints, define them and then search for 'TODO' within the frontend to update the endpoints there.
# If you do not update the endpoints, the lab will not work - of no fault of your API code!
# - Make sure for each route that you're thinking through when to abort and with which kind of error
# - If you change any of the response body keys, make sure you update the frontend to correspond.
def paginate_books(request, selection):
page = request.args.get('page', 1, type=int)
start = (page - 1) * BOOKS_PER_SHELF
end = start + BOOKS_PER_SHELF
books = [book.format() for book in selection]
current_books = books[start:end]
return current_books
def create_app(test_config=None):
# create and configure the app
app = Flask(__name__)
setup_db(app)
CORS(app)
# CORS Headers
#app.after_request
def after_request(response):
response.headers.add("Access-Control-Allow-Headers", "Content-Type,Authorization,true")
response.headers.add("Access-Control-Allow-Methods", "GET,PUT,PATCH,POST,DELETE,OPTIONS")
return response
# #TODO: Write a route that retrivies all books, paginated.
# You can use the constant above to paginate by eight books.
# If you decide to change the number of books per page,
# update the frontend to handle additional books in the styling and pagination
# Response body keys: 'success', 'books' and 'total_books'
# TEST: When completed, the webpage will display books including title, author, and rating shown as stars
#app.route('/books', methods=['GET'])
def get_books():
selection = Book.query.order_by(Book.id).all()
current_books = paginate_books(request, selection)
if len(current_books) == 0:
abort(404)
return jsonify({
'success': True,
'books': current_books,
'total_books': len(Book.query.all())
})
# #TODO: Write a route that will update a single book's rating.
# It should only be able to update the rating, not the entire representation
# and should follow API design principles regarding method and route.
# Response body keys: 'success'
# TEST: When completed, you will be able to click on stars to update a book's rating and it will persist after refresh
#app.route('/books/<int:book_id>', methods=['GET', 'PATCH'])
def update_book_rating(book_id):
body = request.get_json()
try:
book = Book.query.filter_by(Book.id==book_id).one_or_none()
if book is None:
abort(404)
if 'rating' in body:
book.rating = int(body.get('rating'))
book.update() #Class book in models.py has an update method which executes a commit()
return jsonify({
'success': True,
'id': book.id
})
except Exception as e:
print(e)
abort(400)
# #TODO: Write a route that will delete a single book.
# Response body keys: 'success', 'deleted'(id of deleted book), 'books' and 'total_books'
# Response body keys: 'success', 'books' and 'total_books'
#app.route('/delete/<int:book_id>', methods=['DELETE'])
def delete_book(book_id):
try:
book = Book.query.filter_by(Book.id==book_id).one_or_none()
if book is None:
abort(404)
book.delete()
selection = Book.query.order_by(Book.id).all()
current_books = paginate_books(request, selection)
return jsonify({
'success': True,
'deleted': book_id,
'books': current_books,
'total_books': len(Book.query.all())
})
except:
abort(422)
# TEST: When completed, you will be able to delete a single book by clicking on the trashcan.
# #TODO: Write a route that create a new book.
# Response body keys: 'success', 'created'(id of created book), 'books' and 'total_books'
# TEST: When completed, you will be able to a new book using the form. Try doing so from the last page of books.
# Your new book should show up immediately after you submit it at the end of the page.
#app.route('/books', methods=['POST'])
def create_book():
body = request.get_json()
new_title = body.get('title', None)
new_author = body.get('author', None)
new_rating = body.get('rating', None)
try:
book = Book(title=new_title, author=new_author, rating=new_rating)
book.insert()
selection = Book.query.order_by(Book.id).all()
current_books = paginate_books(request, selection)
return jsonify({
'success': True,
'created': book.id,
'books': current_books,
'total_books': len(Book.query.all())
})
except:
abort(422)
#app.errorhandler(400)
def bad_request(error):
return jsonify({
'success': False,
'error': 400,
'message': 'Server cannot or will not process the request due to client error (for example, malformed request syntax, invalid request message framing, or deceptive request routing).'
}), 400
#app.errorhandler(404)
def not_found(error):
return jsonify({
'success': False,
'error': 404,
'message': 'resource not found'
}), 404
#app.errorhandler(405)
def not_found(error):
return jsonify({
'success': False,
'error': 405,
'message': 'method not allowed'
}), 405
#app.errorhandler(422)
def unprocessable(error):
return jsonify({
'success': False,
'error': 422,
'message': 'unprocessable'
}), 422
return app

How to unit test Exception in APIRouter (not FastAPI)

I'm having difficulties doing the unit tests for the function get_data in main.py:
from fastapi import APIRouter
router = APIRouter()
#router.get("/{device_id}")
def get_data(request: Request, device_id: str, query: DataQuery = Depends(DataQuery.depends)):
data_service = DataApiService()
try:
datetime_start_timestamp, datetime_end_timestamp = data_service.validate_dates(query.start, query.end)
df_results = data_service.get_datalake_data(device_id, datetime_start_timestamp, datetime_end_timestamp)
return df_results
except EndDataLowerStartDate as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail='Internal Server Error')
This is the solution I have, test_main.py:
from main import router
client = TestClient(router)
def test_get_data_EndDataLowerStartDate():
mock.patch('main.DataApiService', side_effect = EndDataLowerStartDate)
response = client.get('/xxxxx', params = {'start': '2021-12-31', 'end': '2021-01-01'})
assert response.status_code == 400
assert response.json() == {'detail': 'End date {0} is lower or equal to start date {1}'.format(end, start)}
However, the test only works if I replace in main.py:
router = APIRouter() by app = FastAPI()
and in test_main.py:
client = TestClient(router) by client = TestClient(app).
Otherwise I get the error:
FAILED test_main.py::test_get_data_EndDataLowerStartDate - fastapi.exceptions.HTTPException
If I don't change anything, when I locally make the request, it returns:
AttributeError: 'FastAPI' object has no attribute
'default_response_class'
It is like the exception is raised before it is assert... I cannot change main.py because it is a bigger application inicially built this way. I'm sorry if I was unclear but I would like to know how to make the test pass. Do you have an idea of how I could solve this?

Token based authorization results in an Unauthorized 401

Package versioning
Flask 1.0.2
Flask-HTTPAuth 3.2.4
Flask-RESTful 0.3.8
itsdangerous 0.24
I'm working on a API project where a POST request to a Todo resource requires an user to have a token. Upon trying to test for this scenario, I'm getting the following assertion error: AssertionError: 401 != 201. Both BasicHTTPAuth and TokenHTTPAutth from flask-HTTPAuth are handling Authorization credentials.
Based on a User having a token to access this resource, I'm not clear on why I'm getting an Unauthorized error.
tests.py
class TestAuthenicatedUserPostTodo(ApiTestCase):
'''Verify that an API user successfully adds a Todo'''
def setUp(self):
super().setUp()
previous_todo_count = Todo.select().count()
user = User.get(User.id == 1)
token_serializer = Serializer(SECRET_KEY)
self.token = token_serializer.dumps({'id': user.id})
def test_todo_collection_post_todo_success(self):
with app.test_client() as client:
http_response = client.post(
"/api/v1/todos/",
headers={
'Authorization': f"Bearer {self.token}"
},
content_type="application/json",
data={
"name": "Must do a todo",
"user": 1
}
)
current_todo_count = Todo.select().count()
self.assertEqual(http_response.status_code, 201)
self.assertGreater(current_todo_count, previous_todo_count)
auth.py
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth(scheme="Bearer")
auth = MultiAuth(token_auth, basic_auth)
#basic_auth.verify_password
def verify_password(username, password):
try:
api_user = User.get(User.username == username)
except User.DoesNotExist:
return False
user_verified = api_user.check_password(password)
if user_verified:
g.user = api_user
return True
return False
#token_auth.verify_token
def verify_token(token):
timed_serializer = Serializer(SECRET_KEY)
try:
user = timed_serializer.loads(token)
api_user = User.get_by_id(user['id'])
except (SignatureExpired, BadSignature) as e:
abort(400, description=str(e))
return True
todo.py
#auth.error_handler
def errorhandler():
return jsonify(unauthorized="Cannot add Todo. Login required."), 401
class TodoCollection(Resource):
#auth.login_required
def post(self):
import pdb; pdb.set_trace()
args = self.request_parser.parse_args()
if not args['name']:
return make_response(
{'invalid_request': "Invalid todo provided"}, 400
)
new_todo = Todo.create(**args)
return (
marshal(set_todo_creator(new_todo), todo_fields, 'new_todo'),
201, {'Location': f'{new_todo.location}'}
)

FLASK REST API returns 400 on POST

I'm building a REST API for a simple Todo application using flask and SQLAlchemy as my ORM. I am testing my API using Postman. I'm on a windows 10 64-bit machine.
A GET request works and returns the data that I've entered into my database using python.
I'd like to try to add a task now. But when I POST my request, I receive an error.
My route in flask looks like this.
#add task
#app.route('/todo/api/v1.0/tasks', methods=['POST'])
def create_task():
if not request.json or not 'title' in request.json:
raise InvalidUsage('Not a valid task!', status_code=400)
task = {
'title': request.json['title'],
'description': request.json['description'],
'done': False
}
Todo.add_todo(task)
return jsonify({'task': task}), 201
And the method it's calling on the Todo object looks like this.
def add_todo(_title, _description):
new_todo = Todo(title=_title, description=_description , completed = 0)
db.session.add(new_todo)
db.session.commit()
What I've tried
I thought that maybe the ' in my Postman Params was causing an issue so I removed them. But I still get the same error.
Then I thought that maybe the way that Postman was sending the POST was incorrect so I checked to make sure that the Content-Type headers was correct. It is set to application/json
Finally, to confirm that the issue was that flask didn't like the request, I removed the check in the add task route to make sure the request had a title. So it looks like this.
if not request.json:
And I get the same error. So I think that the problem must be with how I'm actually sending the POST rather than some kind of formatting issue.
My entire code looks like this.
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import json
from flask import jsonify
from flask import request
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todo.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class Todo(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(300), unique=False, nullable=False)
description = db.Column(db.String(), unique=False, nullable=False)
completed = db.Column(db.Boolean, nullable=False)
def json(self):
return {'id': self.id,'title': self.title, 'description': self.description, 'completed': self.completed}
def add_todo(_title, _description):
new_todo = Todo(title=_title, description=_description , completed = 0)
db.session.add(new_todo)
db.session.commit()
def get_all_tasks():
return [Todo.json(todo) for todo in Todo.query.all()]
def get_task(_id):
task = Todo.query.filter_by(id=_id).first()
if task is not None:
return Todo.json(task)
else:
raise InvalidUsage('No task found', status_code=400)
def __repr__(self):
return f"Todo('{self.title}')"
class InvalidUsage(Exception):
status_code = 400
def __init__(self, message, status_code=None, payload=None):
Exception.__init__(self)
self.message = message
if status_code is not None:
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
rv['message'] = self.message
return rv
#app.route('/')
def hello_world():
return 'Hello to the World of Flask!'
#get all tasks
#app.route('/todo/api/v1.0/tasks', methods=['GET'])
def get_tasks():
return_value = Todo.get_all_tasks()
return jsonify({'tasks': return_value})
#get specific task
#app.route('/todo/api/v1.0/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
task = Todo.get_task(task_id)
#if len(task) == 0:
#raise InvalidUsage('No such task', status_code=404)
return jsonify({'task': task})
#add task
#app.route('/todo/api/v1.0/tasks', methods=['POST'])
def create_task():
if not request.json or not 'title' in request.json:
raise InvalidUsage('Not a valid task!', status_code=400)
task = {
'title': request.json['title'],
'description': request.json['description'],
'done': False
}
Todo.add_todo(task)
return jsonify({'task': task}), 201
#update task
#app.route('/todo/api/v1.0/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
task = [task for task in tasks if task['id'] == task_id]
if len(task) == 0:
raise InvalidUsage('No provided updated', status_code=400)
if not request.json:
raise InvalidUsage('request not valid json', status_code=400)
if 'title' in request.json and type(request.json['title']) != unicode:
raise InvalidUsage('title not unicode', status_code=400)
if 'description' in request.json and type(request.json['description']) != unicode:
raise InvalidUsage('description not unicode', status_code=400)
if 'done' in request.json and type(request.json['done']) is not bool:
raise InvalidUsage('done not boolean', status_code=400)
task[0]['title'] = request.json.get('title', task[0]['title'])
task[0]['description'] = request.json.get('description', task[0]['description'])
task[0]['done'] = request.json.get('done', task[0]['done'])
return jsonify({'task': task[0]})
#delete task
#app.route('/todo/api/v1.0/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
task = [task for task in tasks if task['id'] == task_id]
if len(task) == 0:
raise InvalidUsage('No task to delete', status_code=400)
tasks.remove(task[0])
return jsonify({'result': True})
#app.errorhandler(InvalidUsage)
def handle_invalid_usage(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
return response
if __name__ == '__main__':
app.run(debug=True)
EDIT:
Turns out I wasn't setting the request type in POSTMAN correctly. I've updated it to 'application/json' in the header. Now I'm receiving a different error.
Bad Request Failed to decode JSON object: Expecting value: line 1
column 1 (char 0)
I've tried all the previous steps as before but I continue to get this error.
EDIT 2:
Per a response below, I tried putting the values into the body of the POST. But I still get back a 400 response.
From the image [second postman screenshot] it looks like you pass data in query string but create_task() expects them in request body.
Either replace all occurrences of request.json with request.args in create_task() (to make it work with query params) or leave it as it is and send data in request body.
curl -X POST http://localhost:5000/todo/api/v1.0/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Learn more flask","description":"its supper fun"}'
Also, take a look at Get the data received in a Flask request.
EDITED
Update your add_todo to something like
#classmethod
def add_todo(cls, task):
new_todo = cls(title=task["title"], description=task["description"], completed=0)
db.session.add(new_todo)
db.session.commit()
Related: generalised insert into sqlalchemy using dictionary.

Preserve response context testing flask app with pytest

I'm testing a flask application with py.test with the following code:
response = flask_app_test_client.post('/users', data=json.dumps(user))
assert response.status_code == 201
assert response.content_type == 'application/json'
assert isinstance(response.json, dict)
assert set(response.json.keys()) >= {'id', 'status', 'created_at', 'updated_at'}
assert response.json['name'] == user['name']
assert response.json['status'] == 'pending'
When some assertion fails I'm getting something like this:
response = test_client.post('/users', data=json.dumps(user))
> assert response.status_code == 201
E assert 400 == 201
E + where 400 = <JSONResponse streamed [400 BAD REQUEST]>.status_code
============== 1 failed, 3 passed in 0.10 seconds ===================
I do a lot of TDD so I expect my test fails frequently while developing. My problem is the assertion error message is kind of useless without the rest of the response data (body, headers, etc).
I only get in the output that the response.status_code is 400 but I don't get the error description that is in the response body: {"errors": ["username is already taken", "email is required"]}. Ideally I would like a full dump of the request and response (headers + body) when an assertion fails.
How I can print a summary of the response on each failed assertion?
Assert statement graamar
assert response.status_code == 201, "Anything you want"
You can be as verbose as you want. You can also use UnitTest's suite of helper methods -
without test case classes through this bit of abuse - https://github.com/nose-devs/nose2/blob/master/nose2/tools/such.py#L34
I'm came up with two different solutions.
Solution #1: try/catch
try:
assert response.status_code == 201
assert response.content_type == 'application/json'
assert isinstance(response.json, dict)
assert set(response.json.keys()) >= {'id', 'status', 'created_at', 'updated_at'}
assert response.json['name'] == user['name']
assert response.json['status'] == 'pending'
except AssertionError as e:
except AssertionError as e:
raise ResponseAssertionError(e, response)
class ResponseAssertionError(AssertionError):
def __init__(self, e, response):
response_dump = "\n + where full response was:\n" \
"HTTP/1.1 {}\n" \
"{}{}\n".format(response.status, response.headers, response.json)
self.args = (e.args[0] + response_dump,)
Solution #2: no try/catch needed (if repr is too long sometimes is cut off...)
Extend and override Flask response object
import json
class JSONResponse(Response):
def __repr__(self):
headers = {}
while len(self.headers) > 0:
tuple_ = self.headers.popitem()
headers[tuple_[0]] = tuple_[1]
data = {
'status': self.status,
'headers': headers,
'body': self.json
}
return json.dumps(data)
and
#pytest.fixture(scope='session')
def test_client(flask_app):
flask_app.response_class = JSONResponse
return flask_app.test_client()
I know this is an older question, but Pytest has an option --pdb that will pop you into a PDB shell should your test fail. Very handy way to "just look around" rather than having to pass tons of stuff to an exception message.

Categories