Create ToDo API Endpoint
In this chapter, we will create endpoint /api/todos
to create Task
based on user needs.
Write the Test
Writing test for Flask application actually like writing function, but it have a few rules
- Test file name should starts with
test_*.py
or end with*_test.py
, in this tutorial we will use latest pattern*_test.py
- Test method or function should start with
test_
- In this tutorial we will cover using python standard library for unit testing
unittest
Before create test case for our project, we need to define our base class that hold our test app. Let's create tests/base.py
import unittest
from app.factory import create_app
from app.config import TestingConfig
from app.routes.api import api_routes
class BaseAPITestCase(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app("test-app", config=TestingConfig, routes=api_routes)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
def tearDown(self) -> None:
self.ctx.pop()
This class will hold our app with TestingConfig
, which connected to our testing specific database todos_testing
.
Let's write our first test by create new file create_task_api_test.py
to tests/features/tasks
directory.
# tests/features/task/create_task_api_test.py
from http import HTTPStatus
from tests.base import BaseAPITestCase
from app.tasks.models.task import Task
from app.factory import db
class CreateTaskAPITest(BaseAPITestCase):
def test_create_task(self):
endpoint = "/api/todos"
json_data = {
"name": "Fix handrawer",
}
resp = self.client.post(
endpoint,
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.CREATED)
tasks = Task.query.all()
self.assertEqual(1, len(tasks))
Task.query.delete()
db.session.commit()
Ensure your container is up and running by invoke command.
$ docker compose up -d
Then run the test with python unittest
module.
$ docker compose exec todos python -m unittest tests/features/task/create_task_api.py
It will return failure message
F
======================================================================
FAIL: test_create_task (tests.features.task.create_task_api.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/create_task_api.py", line 20, in test_create_task
self.assertEqual(resp.status_code, HTTPStatus.CREATED)
AssertionError: 404 != <HTTPStatus.CREATED: 201>
----------------------------------------------------------------------
Ran 1 test in 0.006s
FAILED (failures=1)
This occured because we not define our Task
resource yet. To define Task
resource, first we need to create table to store the data by creating new migration, first we need to define our models.
First we need to create test againts our Task
model, create file tests/unit/task/task_model_test.py
from app.factory import db
from app.tasks.models.task import Task
from tests.base import BaseAPITestCase
class TaskModelTest(BaseAPITestCase):
def test_create_task(self):
task = Task(name="Finish Homework")
db.session.add(task)
db.session.commit()
tasks = Task.query.all()
assert len(tasks) == 1
db.session.delete(task)
db.session.commit()
Then run the test
$ docker compose exec todos python -m unittest tests/unit/task/task_model_test.py
It will return failure message
E
======================================================================
ERROR: task_model_test (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: task_model_test
Traceback (most recent call last):
File "/usr/local/lib/python3.8/unittest/loader.py", line 154, in loadTestsFromName
module = __import__(module_name)
File "/usr/src/app/tests/unit/task/task_model_test.py", line 2, in <module>
from app.modules.tasks.models import Task
ImportError: cannot import name 'Task' from 'app.modules.tasks.models' (/usr/src/app/app/modules/tasks/models.py)
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
Create Task
model to file app/tasks/models/task.py
# app/tasks/models/task.py
from app.factory import db
class Task(db.Model): # type: ignore
__tablename__ = "tasks"
id = db.Column(db.BigInteger, primary_key=True)
name = db.Column(db.String(255))
completed = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
updated_at = db.Column(
db.DateTime,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp(),
)
Apparently our Models is not known to alembic
migrations until it loaded to our app via routing registration, we need to create empty Handler for our routing.
Create file app/tasks/views/create_task_api.py
contains basic handler view.
# app/tasks/views/create_task_api.py
from flask import jsonify
from flask.typing import ResponseReturnValue
from flask.views import View
from app.tasks.models.task import Task
class CreateTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
return jsonify({})
Then register the handler to our app/routes/api.py
# app/routes/api.py
from app.http.route import Route
from app.tasks.views.create_task_api import CreateTaskAPI
from app.views.main_api import MainView
api_routes = [
Route.get("/", MainView),
Route.group("/api", routes=[
Route.post("/todos", view=CreateTaskAPI)
]),
]
In this routing file, we group our /todos
endpoint to /api
group.
Generate db migration by running command
$ docker compose exec todos flask db migrate -m "create tasks table"
This will create new generated file on migrations/versions/{hash_number}_{message}.py
Then we can run our migration againts testing
environment with command
$ docker compose exec -e APP_ENV=testing todos flask db upgrade
When we run our model test againts newly created table, it will return success
$ docker compose exec todos python -m unittest tests/unit/task/task_model_test.py
It will return message
.
----------------------------------------------------------------------
Ran 1 test in 0.027s
OK
Next try re-running our feature test tests/features/task/create_task_api_test.py
$ docker compose exec todos python -m unittest tests/features/task/create_task_api_test.py
It does not return HTTP Not Found Error (404) anymore but still fail our test
F
======================================================================
FAIL: test_create_task (tests.features.task.create_task_api_test.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/src/app/tests/features/task/create_task_api_test.py", line 16, in test_create_task
self.assertEqual(resp.status_code, HTTPStatus.CREATED)
AssertionError: 200 != <HTTPStatus.CREATED: 201>
----------------------------------------------------------------------
Ran 1 test in 0.007s
FAILED (failures=1)
Let's make it green by implementing code at app/tasks/views/create_task_api.py
# app/tasks/views/create_task_api.py
from http import HTTPStatus
from flask import jsonify, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
class CreateTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
json_data = request.get_json()
task = Task(**json_data)
db.session.add(task)
db.session.commit()
resp = {
"msg": "Created"
}
return jsonify(resp), HTTPStatus.CREATED
Re-run the test tests/features/task/create_task_api_test.py
$ docker compose exec todos python -m unittest tests/features/task/create_task_api_test.py
It will pass
.
----------------------------------------------------------------------
Ran 1 test in 0.031s
OK
But this test only covers happy path only. We need to add tests for following scenario
- Empty json paylod
- Wrong json attributes
Before adding new tests, lets create helper to help invoke our test. Create file bin/test
on our application directory.
#!/usr/bin/env bash
docker compose exec todos python -m unittest $@
Make file executable with
$ chmod +x bin/test
Now we can invoke our test with bin/test unit_test.py
.
Let's add test for empty json payload to our test case
# tests/features/task/create_task_api_test.py
...
def test_create_task_when_payload_empty(self):
endpoint = "/api/todos"
json_data = {}
resp = self.client.post(
endpoint,
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST)
Task.query.delete()
db.session.commit()
Re-run test with
$ bin/test tests/features/task/create_task_api_test.py
It will raise error NotNullViolation
, because attribute name is not allowed to be null.
.E
======================================================================
ERROR: test_create_task_when_payload_empty (tests.features.task.create_task_api_test.CreateTaskAPITest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/local/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
self.dialect.do_execute(
File "/usr/local/lib/python3.8/site-packages/sqlalchemy/engine/default.py", line 736, in do_execute
cursor.execute(statement, parameters)
psycopg2.errors.NotNullViolation: null value in column "name" of relation "tasks" violates not-null constraint
To validate user request schema, let's add marshmallow
package to our application code base by update requirements.txt
.
Flask==2.2.2
SQLAlchemy==1.4.45
Flask-SQLAlchemy==3.0.2
Flask-Migrate==4.0.0
psycopg2==2.9.5
marshmallow==3.19.0
marshmallow-sqlalchemy==0.28.1
flask-marshmallow==0.14.0
Then rebuild our container with docker compose build
command, then update our running container with docker compose up -d
.
After add flask-marshmallow
package, then register to our application factory.
# app/factory.py
from typing import List, Type, Union
from flask import Blueprint, Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_marshmallow import Marshmallow
from app.config import Config
from app.http.route import Route
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
def create_app(
app_name: str, config: Type[Config], routes: List[Union[Route, Blueprint]]
):
app = Flask(app_name)
app.config.from_object(config)
db.init_app(app)
ma.init_app(app)
migrate.init_app(app, db)
for route in routes:
if isinstance(route, Blueprint):
app.register_blueprint(route)
else:
app.add_url_rule(
route.url_rule, view_func=route.view_func(), methods=route.methods()
)
return app
Then create our TaskSchema
on app/tasks/schemas/task_schema.py
# app/tasks/schemas/task_schema.py
from marshmallow import fields, Schema
class TaskSchema(Schema):
id = fields.Integer(dump_only=True)
name = fields.String(required=True)
completed = fields.Boolean()
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
Next implement validation on our view at app/tasks/views/create_task_api.py
# app/tasks/views/create_task_api.py
from http import HTTPStatus
from flask import jsonify, make_response, request
from flask.typing import ResponseReturnValue
from flask.views import View
from app.factory import db
from app.tasks.models.task import Task
from app.tasks.schemas.task_schema import TaskSchema
class CreateTaskAPI(View):
def dispatch_request(self) -> ResponseReturnValue:
task_schema = TaskSchema()
json_data = request.get_json() or dict()
errors = task_schema.validate(json_data)
if errors:
return make_response(
jsonify(errors=str(errors)),
HTTPStatus.BAD_REQUEST
)
task = Task(**json_data)
db.session.add(task)
db.session.commit()
resp = {
"data": task_schema.dump(task)
}
return jsonify(resp), HTTPStatus.CREATED
Re-run test with
$ bin/test tests/features/task/create_task_api_test.py
Test passing
..
----------------------------------------------------------------------
Ran 2 tests in 0.041s
OK
Next let's create test by passing wrong attributes
...
def test_create_task_when_payload_has_unknown_attribute(self):
endpoint = "/api/todos"
json_data = {
"name": "Fix new car",
"completed": False,
"other": "value",
}
resp = self.client.post(
endpoint,
json=json_data
)
self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST)
Task.query.delete()
db.session.commit()
Re-run test with
$ bin/test tests/features/task/create_task_api_test.py
Test passing
...
----------------------------------------------------------------------
Ran 3 tests in 0.052s
OK
After our scenario has been implemented, we can run all our test we've created so far by running command
$ bin/test discover -s tests -p '*_test.py'
Ensure all test are passed
....
----------------------------------------------------------------------
Ran 4 tests in 0.064s
OK
Your final project structure should be look like this
flask-todo
├── app
│ ├── config.py
│ ├── factory.py
│ ├── http
│ │ ├── __init__.py
│ │ └── route.py
│ ├── __init__.py
│ ├── routes
│ │ ├── api.py
│ │ └── __init__.py
│ ├── tasks
│ │ ├── __init__.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ └── task.py
│ │ ├── schemas
│ │ │ ├── __init__.py
│ │ │ └── task_schema.py
│ │ └── views
│ │ ├── create_task_api.py
│ │ └── __init__.py
│ └── views
│ ├── __init__.py
│ └── main_api.py
├── bin
│ └── test
├── db
│ ├── create.sql
│ └── Dockerfile
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env.dev
├── .gitignore
├── migrations
│ ├── alembic.ini
│ ├── env.py
│ ├── README
│ ├── script.py.mako
│ └── versions
│ └── 213adaf34061_create_tasks_tables.py
├── requirements.txt
├── serve.py
└── tests
├── base.py
├── features
│ ├── __init__.py
│ └── task
│ ├── create_task_api_test.py
│ └── __init__.py
├── __init__.py
└── unit
├── __init__.py
└── task
├── __init__.py
└── task_model_test.py
17 directories, 39 files
We can commit our works before continuing to next chapter.
(venv)$ git add .
(venv)$ git commit -m "Create CreateTaskAPI endpoint"