Building And Testing FastAPI CRUD APIs With Pytest (Hands-On Tutorial)

APIs form the backbone of your backend operations.

Every software you interact with, from mobile to web apps, uses APIs to bridge the gap between frontend and backend.

Given it’s immense importance, testing your APIs is as crucial as building them. After all, if your API doesn’t work as expected, your entire application may be rendered useless.

Not to mention upsetting your users and clients.

FastAPI is an amazing framework for building APIs in Python.

But how do you test your FastAPI APIs? Do you test each endpoint? What about the payloads and responses?

How do you make sure your API returns helpful responses in the case of client or server errors? Should you test against real data or mock it?

In this article, we’ll cover building a CRUD API (Create, Read, Update, Delete) using FastAPI, SQLite and testing it with Pytest using fixtures and automatic setup and teardown.

You’ll learn how to build a Rest API from scratch - Create, Read, Update and Delete a User from a database.

Your microservice or monolith architecture may contain several APIs but once you learn the fundamentals, you can apply the same principles to building and testing any API.

Let’s get started then.

Example Code Used

Objectives

By the end of this tutorial you should be able to:

  1. Develop a CRUD REST API in Python using the FastAPI framework
  2. Interact with an SQLite database using SQLAlchemy ORM tooling
  3. Write unit tests for your FastAPI API Endpoints using Pytest
  4. Handle Errors and Response Formatting
  5. Document your REST API with FastAPI’s in-built Swagger

About APIs

Originally developed in the 1940s, APIs have exploded in popularity during the last 30–40 years.

Back in the day, SOAP APIs were popular with Rest and GraphQL becoming the new norm.

As a means to share data between applications and databases, APIs form the building blocks of your software application.

A software application requires the ability to read and write data from a database.

To achieve this, it’s necessary that both the code and the database speak the same language. This is achieved through database drivers.

How useful is software that cannot talk to other software?

API-based microservice architecture has risen in popularity due to the ability to build independent decoupled services that can be maintained, refactored and scaled easily.

Popular API and web-building frameworks for Python include Django, Flask and more recently FastAPI (one I’m liking, a lot).

Having skimmed through the history, let’s dive into the FastAPI framework.

Overview Of FastAPI

FastAPI is a modern, fast, and highly performant Python web framework for building APIs with ease.

It was created by Sebastián Ramírez in 2018, and released in 2019.

It is built on top of two other popular Python libraries: Starlette and Pydantic.

Starlette provides the underlying web application framework, while Pydantic is used for data validation and serialization.

FastAPI is designed to be easy to use, with a focus on developer productivity, code quality, and performance.

It also has built-in support for async/await functions, making it much more efficient than the traditional synchronous threading models.

We won’t be using async/await in this tutorial, but it’s good to know that FastAPI innately supports it.

CRUD API

CRUD is an acronym that stands for Create, Read, Update, and Delete.

It’s a design principle containing HTTP methods (POST, GET, PUT/PATCH, DELETE) to perform basic operations on data in a database or other data storage system.

These are widely used in web development for implementing basic functionality in applications such as content management systems, e-commerce platforms, and social networks.

Learning to build robust, highly reliable and scalable CRUD APIs is one of the fundamental requirements of core software engineering and will form the backbone of your backend system.

With all this theory out of the way, let’s get started building our FastAPI CRUD API.

Project Setup

In this project, we’ll create a CRUD API to Create, Read, Update and Delete a User from our Relational Database (using SQLite).

First, start by cloning the repo.

Or, if you prefer to start from scratch feel free to create a folder and call it pytest-fastapi-crud-example or anything you like and run git init to convert it to a Git repository.

The project has the following structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
├── .gitignore
├── .python-version
├── README.md
├── app
│ ├── __init__.py
│ ├── database.py
│ ├── main.py
│ ├── models.py
│ ├── schemas.py
│ └── user.py
├── poetry.lock
├── pyproject.toml
├── pytest.ini
└── tests
├── __init__.py
├── conftest.py
└── test_crud_api.py

The app directory contains the source code, namely

  • database.py — Create database engine and SQLite settings.
  • main.py — Create the FastAPI client, health checker and middleware.
  • models.py — Database schema.
  • schemas.py — User Base Schema and Response Schema.
  • user.py — API Routes and response formatting.
  • tests directory contains the unit tests for the API.

The dependencies for this example are listed in the pyproject.toml file. I’ve used Poetry to handle dependencies but you can use another package manager like pip or conda if you prefer.

After installing Poetry, to install the dependencies, run

1
$ poetry install

This will install all the dependencies listed in the pyproject.toml file.

Then run

1
$ poetry shell

This will activate the virtual environment - allowing you to run pytest or uvicorn commands within the shell.

Let’s explore the example code to understand what’s going on.

Example Code

database.py — Create database engine and SQLite settings.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

SQLITE_DATABASE_URL = "sqlite:///./user.db"

engine = create_engine(
SQLITE_DATABASE_URL, echo=True, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()


def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

The above code uses SQLite in-memory database for simplicity.

In a production setting, you would use a more robust database like PostgreSQL, MySQL or MariaDB.

The get_db function is a dependency that will be used to create a new database session for each request following the principles of Dependency Injection.

models.py — Database schema

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from app.database import Base
from sqlalchemy import TIMESTAMP, Column, String, Boolean
from sqlalchemy.sql import func
from sqlalchemy_utils import UUIDType
import uuid

class User(Base):
__tablename__ = "users"

# Primary key and GUID type
id = Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4)

# String types with appropriate non-null constraints
first_name = Column(
String(255), nullable=False, index=True
) # Indexed for faster searches
last_name = Column(
String(255), nullable=False, index=True
) # Indexed for faster searches
address = Column(String(255), nullable=True)

# Boolean type with a default value
activated = Column(Boolean, nullable=False, default=True)

# Timestamps with timezone support
createdAt = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
updatedAt = Column(TIMESTAMP(timezone=True), default=None, onupdate=func.now())

Our data model in this example is extremely simple - a User table with columns - id, first_name, last_name, address, activated, createdAt and updatedAt.

Using SQLAlchemy ORM, we define the table schema and the columns.

Now that we have the database models, let’s create the User and response models using Pydantic.

schemas.py — User Base Schema and Response Schema

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from enum import Enum
from datetime import datetime
from typing import List
from pydantic import BaseModel, Field
from uuid import UUID


class UserBaseSchema(BaseModel):

id: UUID | None = None
first_name: str = Field(
..., description="The first name of the user", example="John"
)
last_name: str = Field(..., description="The last name of the user", example="Doe")
address: str | None = None
activated: bool = False
createdAt: datetime | None = None
updatedAt: datetime | None = None

class Config:
from_attributes = True
populate_by_name = True
arbitrary_types_allowed = True


class Status(Enum):
Success = "Success"
Failed = "Failed"


class UserResponse(BaseModel):
Status: Status
User: UserBaseSchema


class GetUserResponse(BaseModel):
Status: Status
User: UserBaseSchema


class ListUserResponse(BaseModel):
status: Status
results: int
users: List[UserBaseSchema]


class DeleteUserResponse(BaseModel):
Status: Status
Message: str

The above contains the Pydantic models for the User schema and the response schemas. These will be used in the API routes to validate the request and response payloads.

user.py — API Routes and response formatting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import app.schemas as schemas
import app.models as models
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import Depends, HTTPException, status, APIRouter
from app.database import get_db

router = APIRouter()


@router.post(
"/", status_code=status.HTTP_201_CREATED, response_model=schemas.UserResponse
)
def create_user(payload: schemas.UserBaseSchema, db: Session = Depends(get_db)):
try:
# Create a new user instance from the payload
new_user = models.User(**payload.model_dump())
db.add(new_user)
db.commit()
db.refresh(new_user)

except IntegrityError as e:
db.rollback()
# Log the error or handle it as needed
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A user with the given details already exists.",
) from e
except Exception as e:
db.rollback()
# Handle other types of database errors
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while creating the user.",
) from e

# Convert the SQLAlchemy model instance to a Pydantic model
user_schema = schemas.UserBaseSchema.from_orm(new_user)
# Return the successful creation response
return schemas.UserResponse(Status=schemas.Status.Success, User=user_schema)


@router.get(
"/{userId}", status_code=status.HTTP_200_OK, response_model=schemas.GetUserResponse
)
def get_user(userId: str, db: Session = Depends(get_db)):
user_query = db.query(models.User).filter(models.User.id == userId)
db_user = user_query.first()

if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No User with this id: `{userId}` found",
)

try:
return schemas.GetUserResponse(
Status=schemas.Status.Success, User=schemas.UserBaseSchema.model_validate(db_user)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while fetching the user.",
) from e


@router.patch(
"/{userId}",
status_code=status.HTTP_202_ACCEPTED,
response_model=schemas.UserResponse,
)
def update_user(
userId: str, payload: schemas.UserBaseSchema, db: Session = Depends(get_db)
):
user_query = db.query(models.User).filter(models.User.id == userId)
db_user = user_query.first()

if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No User with this id: `{userId}` found",
)

try:
update_data = payload.dict(exclude_unset=True)
user_query.update(update_data, synchronize_session=False)
db.commit()
db.refresh(db_user)
user_schema = schemas.UserBaseSchema.model_validate(db_user)
return schemas.UserResponse(Status=schemas.Status.Success, User=user_schema)
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A user with the given details already exists.",
) from e
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while updating the user.",
) from e


@router.delete(
"/{userId}",
status_code=status.HTTP_202_ACCEPTED,
response_model=schemas.DeleteUserResponse,
)
def delete_user(userId: str, db: Session = Depends(get_db)):
try:
user_query = db.query(models.User).filter(models.User.id == userId)
user = user_query.first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No User with this id: `{userId}` found",
)
user_query.delete(synchronize_session=False)
db.commit()
return schemas.DeleteUserResponse(
Status=schemas.Status.Success, Message="User deleted successfully"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while deleting the user.",
) from e


@router.get(
"/", status_code=status.HTTP_200_OK, response_model=schemas.ListUserResponse
)
def get_users(
db: Session = Depends(get_db), limit: int = 10, page: int = 1, search: str = ""
):
skip = (page - 1) * limit

users = (
db.query(models.User)
.filter(models.User.first_name.contains(search))
.limit(limit)
.offset(skip)
.all()
)
return schemas.ListUserResponse(
status=schemas.Status.Success, results=len(users), users=users
)

The above code defines the API routes for creating, reading, updating and deleting a user. You can get as sophisticated as you want in terms of error handling, logging and response formatting.

Here we have 4 main routes:

  • create_user - Create a new user. Returns a 201 status code on success and 409 on conflict.
  • get_user - Get a user by ID. Returns a 200 status code on success and 404 if not found.
  • update_user - Update a user by ID. Returns a 202 status code on success and 409 on conflict.
  • delete_user - Delete a user by ID. Returns a 202 status code on success and 404 if not found.
  • get_users - Get a list of users. Returns a 200 status code on success.

main.py — Create the FastAPI client, health checker and middleware.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from app import models, user
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine

models.Base.metadata.create_all(bind=engine)

app = FastAPI()

origins = [
"http://localhost:3000",
]

app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


app.include_router(user.router, tags=["Users"], prefix="/api/users")


@app.get("/api/healthchecker")
def root():
return {"message": "The API is LIVE!!"}

The above code creates the FastAPI client, sets up the CORS middleware and includes the API routes defined in user.py.

Running the API

Now that our API is ready, let’s run it and test via the VSCode-Postman extension or Postman itself.

To run the API server, execute the following command

1
uvicorn app.main:app --host localhost --port 8000 --reload

This will start the API on the localhost at port 8000. The --reload flag is used to automatically reload the server when changes are made to the code.

You’ll see the server start as shown below

pytest-fastapi-api-server

You can quickly test the API by visiting http://localhost:8000/api/healthchecker in your browser.

pytest-fastapi-api-live

Now, let’s run each of the endpoints to make sure it works.

Create User

To create a user, send a POST request to http://localhost:8000/api/users with the following payload

1
2
3
4
5
6
{
"first_name": "Eric",
"last_name": "SDA",
"address": "Fake St",
"activated": true
}

Response:

pytest-fastapi-create-user

Read User

To read a user, send a GET request to http://localhost:8000/api/users/{userId} with the userId of the user you want to read. You can pass the same userId from the response above.

Response:

pytest-fastapi-read-user

Update User

To update a user, send a PATCH request to http://localhost:8000/api/users/{userId} with the userId of the user you want to update and the payload

1
2
3
4
5
6
{
"first_name": "Eric",
"last_name": "Sales De Andrade",
"address": "Real St",
"activated": false
}

Response:

pytest-fastapi-update-user

You can see the user’s last_name, address and activated have been updated along with the updatedAt timestamp.

Delete User

To delete a user, send a DELETE request to http://localhost:8000/api/users/{userId} with the userId of the user you want to delete.

Response:

pytest-fastapi-delete-user

You can verify this by sending another GET request to the same endpoint to fetch the same user.

pytest-fastapi-delete-user-verify

List Users

Let’s create a couple more users to test the get_users endpoint.

1
2
3
4
5
6
{
"first_name": "John",
"last_name": "Doe",
"address": "Fake St",
"activated": true
}
1
2
3
4
5
6
{
"first_name": "Jane",
"last_name": "Doe",
"address": "Fake St",
"activated": true
}

Send a GET request to http://localhost:8000/api/users to get a list of users.

Response:
pytest-fastapi-read-users

This somewhat trivial example demonstrates the power of FastAPI in building APIs quickly and efficiently.

Now that we have a working API, let’s write some tests to ensure it works as expected.

Writing Tests with Pytest

In the tests folder, we have 2 files - conftest.py and test_crud_api.py.

Before writing any tests, we want to follow the best practices of keeping tests isolated, independent and repeatable.

This means tests can run in any order. It’s also a good idea to abstract the implementation detail.

We handle all of this using Pytest fixtures and setup and teardown functions.

We create a test database in SQLite to avoid using the real database and cause side effects.

tests/conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import pytest
import uuid
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from fastapi.testclient import TestClient
from app.main import app
from app.database import Base, get_db

# SQLite database URL for testing
SQLITE_DATABASE_URL = "sqlite:///./test_db.db"

# Create a SQLAlchemy engine
engine = create_engine(
SQLITE_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)

# Create a sessionmaker to manage sessions
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# Create tables in the database
Base.metadata.create_all(bind=engine)


@pytest.fixture(scope="function")
def db_session():
"""Create a new database session with a rollback at the end of the test."""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()


@pytest.fixture(scope="function")
def test_client(db_session):
"""Create a test client that uses the override_get_db fixture to return a session."""

def override_get_db():
try:
yield db_session
finally:
db_session.close()

app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client

The above code creates a test database in SQLite and sets up the test client for the FastAPI app. We also have transaction rollback and table clean up at the end of each test.

We have a few more fixtures to create sample data. All fixtures have a scope of function to ensure they run for each test.

To learn more about Pytest fixture scopes, check out this article which goes into more detail with practical examples.

tests/conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Fixture to generate a random user id
@pytest.fixture()
def user_id() -> uuid.UUID:
"""Generate a random user id."""
return str(uuid.uuid4())


# Fixture to generate a user payload
@pytest.fixture()
def user_payload(user_id):
"""Generate a user payload."""
return {
"id": user_id,
"first_name": "John",
"last_name": "Doe",
"address": "123 Farmville",
}


@pytest.fixture()
def user_payload_updated(user_id):
"""Generate an updated user payload."""
return {
"first_name": "Jane",
"last_name": "Doe",
"address": "321 Farmville",
"activated": True,
}

Now that we have the fixtures set up, let’s write the tests.

tests/test_crud_api.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import time


def test_root(test_client):
response = test_client.get("/api/healthchecker")
assert response.status_code == 200
assert response.json() == {"message": "The API is LIVE!!"}


def test_create_get_user(test_client, user_payload):
response = test_client.post("/api/users/", json=user_payload)
response_json = response.json()
assert response.status_code == 201

# Get the created user
response = test_client.get(f"/api/users/{user_payload['id']}")
assert response.status_code == 200
response_json = response.json()
assert response_json["Status"] == "Success"
assert response_json["User"]["id"] == user_payload["id"]
assert response_json["User"]["address"] == "123 Farmville"
assert response_json["User"]["first_name"] == "John"
assert response_json["User"]["last_name"] == "Doe"


def test_create_update_user(test_client, user_payload, user_payload_updated):
response = test_client.post("/api/users/", json=user_payload)
response_json = response.json()
assert response.status_code == 201

# Update the created user
time.sleep(
1
) # Sleep for 1 second to ensure updatedAt is different (datetime precision is low in SQLite)
response = test_client.patch(
f"/api/users/{user_payload['id']}", json=user_payload_updated
)
response_json = response.json()
assert response.status_code == 202
assert response_json["Status"] == "Success"
assert response_json["User"]["id"] == user_payload["id"]
assert response_json["User"]["address"] == "321 Farmville"
assert response_json["User"]["first_name"] == "Jane"
assert response_json["User"]["last_name"] == "Doe"
assert response_json["User"]["activated"] is True
assert (
response_json["User"]["updatedAt"] is not None
and response_json["User"]["updatedAt"] > response_json["User"]["createdAt"]
)


def test_create_delete_user(test_client, user_payload):
response = test_client.post("/api/users/", json=user_payload)
response_json = response.json()
assert response.status_code == 201

# Delete the created user
response = test_client.delete(f"/api/users/{user_payload['id']}")
response_json = response.json()
assert response.status_code == 202
assert response_json["Status"] == "Success"
assert response_json["Message"] == "User deleted successfully"

# Get the deleted user
response = test_client.get(f"/api/users/{user_payload['id']}")
assert response.status_code == 404
response_json = response.json()
assert response_json["detail"] == f"No User with this id: `{user_payload['id']}` found"


def test_get_user_not_found(test_client, user_id):
response = test_client.get(f"/api/users/{user_id}")
assert response.status_code == 404
response_json = response.json()
assert response_json["detail"] == f"No User with this id: `{user_id}` found"


def test_create_user_wrong_payload(test_client):
response = test_client.post("/api/users/", json={})
assert response.status_code == 422


def test_update_user_wrong_payload(test_client, user_id, user_payload_updated):
user_payload_updated["first_name"] = (
True # first_name should be a string not a boolean
)
response = test_client.patch(f"/api/users/{user_id}", json=user_payload_updated)
assert response.status_code == 422
response_json = response.json()
assert response_json == {
"detail": [
{
"type": "string_type",
"loc": ["body", "first_name"],
"msg": "Input should be a valid string",
"input": True,
}
]
}


def test_update_user_doesnt_exist(test_client, user_id, user_payload_updated):
response = test_client.patch(f"/api/users/{user_id}", json=user_payload_updated)
assert response.status_code == 404
response_json = response.json()
assert response_json["detail"] == f"No User with this id: `{user_id}` found"

The above tests cover the basic CRUD operations for the API. We test the endpoints for creating, reading, updating and deleting a user.

For each test we check the HTTP Status Code and output response. We also test for edge cases like wrong payloads, non-existent users and more.

Now there’s a lot more to test and various edge cases which are outside the scope of this article.

If you pay close attention to the requirements, we’re using the pytest-randomly plugin to randomize the order of the tests. This is really important to ensure statelessness and independence of the tests.

Let’s run our tests to see if everything works as expected.

1
$ pytest -v

pytest-fastapi-tests

Use Mock Database?

Perhaps you’re wondering if you should you mock or write to a real database in practice.

There is no right answer.

In a production setting, you would want to test against a real database to ensure everything works as expected.

Mocking is a good idea when you’re working with dependencies that are slow, unreliable or expensive to use and out of your control.

When building your own APIs, you want to make sure your ORM tool and data transformations, models work as expected.

In this case, it’s a good idea to represent the production environment as closely as possible.

Other factors include the type of data you’re handling, the permissions and security, the data usage and retention policy and the delivery deadlines.

Error Handling and Response Formatting

As an API developer, one of the most helpful things you can do for your clients is produce easy to understand documentation.

This is simply information on how to use the API and give the client useful error messages with the correct HTTP response codes.

  • Informational responses (100–199)
  • Successful responses (200–299)
  • Redirection messages (300–399)
  • Client error responses (400–499)
  • Server error responses (500–599)

You can go into very specific error codes, but keep these ranges in mind, test and include error codes and correct responses for variations of client input.

Documenting Your Rest API with Swagger and OpenAPI

What good is an API if consumers don’t know how to use it?

Documentation is very important, and personally, I’m a big fan of the FastAPI documentation itself.

The bare necessary documentation you should provide is a Swagger and a list of endpoints with acceptable payloads and response formats.

Loosely defined, Swagger is

“Swagger is a set of rules, specifications and tools that help us document our APIs.”

This includes information about the endpoints, how to use them, status codes, response formatting, payloads etc.

Lucky for us, the creator of FastAPI has already thought this through.

It has an inbuilt Swagger UI to test your API should you choose not to use Postman.

Once you run the API server, you can access the inbuilt Swagger UI on http://localhost:8000/docs or whatever port you started it on.

You can even run tests within the Swagger UI itself.

pytest-fastapi-swagger

Conclusion

In conclusion, building and testing a FastAPI CRUD API with Pytest is a powerful way to create a reliable and efficient API.

In this article, you went through creating your own CRUD API to create, read, update and delete a user using FastAPI, SQLite, SQLAlchemy.

You also learned how to write tests for your API using Pytest and fixtures to ensure your API works as expected with good setup teardown practices.

You learned how to define database and model schemas, write routes with HTTP status codes and handle responses.

Lastly, you learned how to document your API with Swagger and OpenAPI.

With FastAPI’s intuitive syntax and Pytest’s powerful testing capabilities, you can quickly and easily build scalable, robust and reliable APIs.

Go ahead and try the example code, build new APIs, test them against various edge cases and you’ll get a good grasp of how to build and test APIs.

If you have ideas for improvement or like for me to cover anything specific, please send me a message via Twitter, GitHub or Email.

Till the next time… Cheers!

Additional Reading

Example Code Used
FastAPI Docs
SQlite Docs
What is Setup and Teardown in Pytest? (Importance of a Clean Test Environment)
Mocking Vs. Patching (A Quick Guide For Beginners)
What Are Pytest Fixture Scopes? (How To Choose The Best Scope For Your Test)
How Pytest Fixtures Can Help You Write More Readable And Efficient Tests
Python Unit Testing Best Practices For Building Reliable Applications