Writing tests for backend
Pytest is a powerful testing framework for Python, and it is the recommended testing framework for the ShipFast backend. Official documentation can be found here. The following model will be used to provide a base for examples in this article.
import hashid_field
from django.db import models
class Product(models.Model):
id = hashid_field.HashidAutoField(primary_key=True)
name = models.CharField(max_length=255)
See the "How to create a new Django app and model in back-end?" is you've missed it.
Creating Django model factories
Define factories
Inside store/tests
create factories.py
file and declare the following factory for our model.
import factory
from ..models import Product
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Faker('name')
The ProductFactory
extends the factory.django.DjangoModelFactory
class and specifies the Product
model as the target model.
The Meta
class contains the reference to the model being used. The name
attribute is set to a fake product name generated by the factory.Faker
method.
Using factories, you can easily create test data for your models in a concise and efficient manner.
Factory Boy official documentation can be found here.
Register factories as pytest fixtures
import pytest_factoryboy
from . import factories
pytest_factoryboy.register(factories.ProductFactory)
In the provided example, we have imported the pytest_factoryboy
library and registered the ProductFactory
.
The pytest_factoryboy.register
method is used to register a Factory Boy factory with Pytest. This allows you to use the factory as a fixture in your tests.
To make those factories available globally across all modules, we need to register the newly created fixtures
module inside the global conftest.py
.
pytest_plugins = [
# ...
'apps.store.tests.fixtures',
]
The pytest_plugins
variable is used in Pytest to specify additional plugins to be loaded during testing.
Fixtures are a way to provide test data or a test environment to one or more tests.
They can be used to set up the state of the application before running a test or to provide a specific input to a test.
By including the apps.store.tests.fixtures
module in the pytest_plugins
variable, the fixtures defined in that module will be available to all tests in the project.
How to test Queries
For the sake of the following examples, let's define a DjangoObjectType
and Connection
for our Product
model.
Next, create two queries: one to list all the products and the other to fetch a single product by it's id.
More information on DjangoObjectType
and Connection
can be found in "How to add a new mutation to back-end API?"
import graphene
from graphene_django import DjangoObjectType
from . import models
class ProductType(DjangoObjectType):
class Meta:
model = models.Product
interfaces = (graphene.relay.Node,)
fields = "__all__"
class ProductConnection(graphene.Connection):
class Meta:
node = ProductType
class Query(graphene.ObjectType):
product = graphene.Field(ProductType)
all_products = graphene.relay.ConnectionField(ProductConnection)
@staticmethod
def resolve_all_products(root, info, **kwargs):
return models.Product.objects.all()
Test product
query
Inside store/tests/test_schema.py
define the following class:
import pytest
pytestmark = pytest.mark.django_db
class TestProductQuery:
PRODUCT_QUERY = """
query($id: ID!) {
product(id: $id) {
id
name
}
}
"""
The TestProductQuery
class is a test case class that is used to test the GraphQL product
query for a specific product ID.
The PRODUCT_QUERY
constant is a string that defines the GraphQL query that will be sent to the server during testing.
The query takes a single argument, id
, which is of type ID!
(a non-null ID). This argument is used to specify the ID of the product that should be retrieved.
The product
field returns the ID and name of the specified product.
During testing, the PRODUCT_QUERY
is executed by sending a request to the GraphQL server with the id
variable set to a specific product ID.
The response from the server is then checked to ensure that it includes the expected product ID and name.
The test_return_product
method is a test case that verifies whether the GraphQL API correctly returns a Product
object when queried with a valid product ID and an authorized user:
import pytest
from graphql_relay import to_global_id
pytestmark = pytest.mark.django_db
class TestProductQuery:
PRODUCT_QUERY = """
query($id: ID!) {
product(id: $id) {
id
name
}
}
"""
def test_return_product(self, graphene_client, product, user):
product_global_id = to_global_id("ProductType", str(product.id))
graphene_client.force_authenticate(user)
executed = graphene_client.query(
self.PRODUCT_QUERY,
variable_values={"id": product_global_id},
)
assert executed == {
"data": {
"product": {
"id": product_global_id,
"name": product.name,
}
}
}
The test first generates a global ID for an existing product using the to_global_id
function from the graphql_relay
module.
This global ID is then used as the value for the id
variable in the PRODUCT_QUERY
.
Then, a user is authenticated using the graphene_client.force_authenticate
method, simulating a logged-in user trying to query for a specific product.
The graphene_client.query
method is used to execute the GraphQL query with the existing product's global ID as the value for the id
variable.
Finally, the test asserts that the response from the server includes the expected data for the queried product, including the product's ID and name.
Test allProducts
query
The TestAllProductsQuery
class is a test case that verifies whether the GraphQL API correctly returns a list of all products when queried with the allProducts
query.
The ALL_PRODUCTS_QUERY
query does not take any arguments and simply returns a list of all products in the database.
The allProducts
field returns a connection type, which includes a list of edges. Each edge contains a node that represents a product in the list.
The node includes the product's ID and name.
The test_return_all_products
method is a test case that verifies whether the GraphQL API correctly returns a list of all products when queried with the allProducts
query.
import pytest
from graphql_relay import to_global_id
pytestmark = pytest.mark.django_db
class TestAllProductsQuery:
ALL_PRODUCTS_QUERY = """
query {
allProducts {
edges {
node {
id
name
}
}
}
}
"""
def test_return_all_products(self, graphene_client, product_factory, user):
products = product_factory.create_batch(3)
graphene_client.force_authenticate(user)
executed = graphene_client.query(self.ALL_PRODUCTS_QUERY)
assert executed == {
"data": {
"allProducts": {
"edges": [
{
"node": {
"id": to_global_id("ProductType", str(product.id)),
"name": product.name,
}
}
for product in products
]
}
}
}
First, the test generates three products using the product_factory.create_batch
method from the factory_boy
library.
The create_batch
method is used to generate multiple Product
objects in the database.
Then, a user is authenticated using the graphene_client.force_authenticate
method, simulating a logged-in user trying to query for all products.
The graphene_client.query
method is used to execute the GraphQL allProducts
query.
Finally, the test asserts that the response from the server includes the expected list of products, including the ID and name of each product.
The test achieves this by constructing a list comprehension that generates a list of edges, where each edge contains a node representing a single product. The expected output is a dictionary with the same structure as the server response.
How to test Mutations
Let's define the following serializer to work with our mutations:
from hashid_field import rest as hidrest
from rest_framework import serializers
class ProductSerializer(serializers.ModelSerializer):
id = hidrest.HashidSerializerCharField(source_field="store.Product.id", read_only=True)
class Meta:
model = models.Product
fields = ('id', 'name',)
Detailed description on how to work with serializers can be found in "Working with serializers"
Next, define the following schema:
import graphene
from graphene_django import DjangoObjectType
from common.graphql import mutations
from . import models, serializers
class ProductType(DjangoObjectType):
class Meta:
model = models.Product
interfaces = (graphene.relay.Node,)
fields = "__all__"
class ProductConnection(graphene.Connection):
class Meta:
node = ProductType
class CreateProductMutation(mutations.CreateModelMutation):
class Meta:
serializer_class = serializers.ProductSerializer
edge_class = ProductConnection.Edge
class UpdateProductMutation(mutations.UpdateModelMutation):
class Meta:
serializer_class = serializers.ProductSerializer
edge_class = ProductConnection.Edge
class DeleteProductMutation(mutations.DeleteModelMutation):
class Meta:
model = models.Product
class Mutation(graphene.ObjectType):
create_product = CreateProductMutation.Field()
update_product = UpdateProductMutation.Field()
delete_product = DeleteProductMutation.Field()
Detailed description on how to work with mutations can be found in "How to add a new mutation to back-end API?"
Test createProduct
TestCreateProductMutation
is a Python class that represents a GraphQL mutation for creating a new product.
The mutation is defined as a string constant called CREATE_MUTATION
.
It uses the GraphQL syntax to define a createProduct
mutation that takes an input of type CreateProductMutationInput
.
The createProduct
mutation returns a product
object that contains the id
and name
of the newly created product.
import pytest
from graphql_relay import from_global_id
from .. import models
pytestmark = pytest.mark.django_db
class TestCreateProductMutation:
CREATE_MUTATION = """
mutation($input: CreateProductMutationInput!) {
createProduct(input: $input) {
product {
id
name
}
}
}
"""
def test_create_new_product(self, graphene_client, user):
input_data = {"name": "Product"}
graphene_client.force_authenticate(user)
executed = graphene_client.mutate(
self.CREATE_MUTATION,
variable_values={"input": input_data},
)
assert executed["data"]["createProduct"]
assert executed["data"]["createProduct"]["product"]
assert executed["data"]["createProduct"]["product"]["name"] == input_data["name"]
product_global_id = executed["data"]["createCrudDemoItem"]["crudDemoItem"]["id"]
_, pk = from_global_id(product_global_id)
product = models.Product.objects.get(pk=pk)
assert product.name == input_data["name"]
The test_create_new_product
first creates an input data dictionary with a name
key and a value of "Product"
.
Then it authenticates the client using the graphene_client.force_authenticate
method with the user
fixture.
Finally, it executes the mutation using the graphene_client.mutate
method with the CREATE_MUTATION
and input_data
as the variable_values
.
The response of the mutation is then tested using three assertions.
The first assertion checks if the mutation returned any data at all.
The second assertion checks if the mutation returned a product object.
The third assertion checks if the name of the returned product object matches the name of the input data.
The next section of the test retrieves the created product from the database using the from_global_id
method to get the primary key of the product from the global ID returned by the mutation.
It then uses the primary key to get the product object from the database.
Finally, it tests that the name of the product object retrieved from the database matches the name of the input data dictionary used to create the product in the mutation.
Test updateProduct
The UPDATE_MUTATION
is a GraphQL mutation that updates an existing product with the given input data.
It takes an input object of type UpdateProductMutationInput
and returns the ID and name of the updated product.
import pytest
from graphql_relay import to_global_id
pytestmark = pytest.mark.django_db
class TestUpdateProductMutation:
UPDATE_MUTATION = """
mutation($input: UpdateProductMutationInput!) {
updateProduct(input: $input) {
product {
id
name
}
}
}
"""
def test_update_product(self, graphene_client, user, product):
input_data = {
"id": to_global_id("ProductType", str(product.id)),
"name": "New item name",
}
graphene_client.force_authenticate(user)
executed = graphene_client.mutate(
self.UPDATE_MUTATION,
variable_values={"input": input_data},
)
product.refresh_from_db()
assert executed["data"]["updateProduct"]
assert executed["data"]["createProduct"]["product"]
assert executed["data"]["updateProduct"]["product"]["name"] == input_data["name"]
assert product.name == input_data["name"]
The test_update_product
first creates an input data dictionary with an id
key and a value of the global ID of the product to update, as well as a name
key and a value of "New item name"
.
Then it authenticates the client using the graphene_client.force_authenticate
method with the user
fixture.
The mutation is executed using the graphene_client.mutate
method with the UPDATE_MUTATION
and input_data
as the variable_values
.
After the mutation is executed, the product object is refreshed from the database to ensure that the update was successful.
The response of the mutation is then tested using four assertions.
The first assertion checks if the mutation returned any data at all.
The second assertion checks if the mutation returned a product object.
The third assertion checks if the name of the returned product object matches the "New item name"
value of the input data dictionary used to update the product in the mutation.
The fourth assertion checks if the name of the product object retrieved from the database matches the "New item name"
value of the input data dictionary used to update the product in the mutation.
Test deleteProduct
The DELETE_MUTATION
is a GraphQL mutation that deletes an existing product with the given input data.
It takes an input object of type DeleteProductMutationInput
and returns the ID of the deleted product.
import pytest
from graphql_relay import to_global_id
from .. import models
pytestmark = pytest.mark.django_db
class TestDeleteProductMutation:
DELETE_MUTATION = """
mutation($input: DeleteProductMutationInput!) {
deleteProduct(input: $input) {
deletedIds
}
}
"""
def test_delete_product(self, graphene_client, user, product):
product_global_id = to_global_id("ProductType", str(product.id))
graphene_client.force_authenticate(user)
executed = graphene_client.mutate(
self.DELETE_MUTATION,
variable_values={"input": {"id": product_global_id}},
)
assert executed == {"data": {"deleteProduct": {"deletedIds": [product_global_id]}}}
assert not models.Product.objects.filter(id=product.id).exists()
The test_delete_product
method first gets the global ID of the product to delete using the to_global_id
method and the product.id
attribute.
Then it authenticates the client using the graphene_client.force_authenticate
method with the user
fixture.
The mutation is executed using the graphene_client.mutate
method with the DELETE_MUTATION
and product_global_id
as the id
value of the input
dictionary.
After the mutation is executed, the response of the mutation is tested using two assertions.
The first assertion checks if the mutation returned a dictionary with a deletedIds
key and a value of a list with the global ID of the deleted product.
The second assertion checks if the product object no longer exists in the database.