Skip to main content

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.

packages/backend/apps/store/models.py
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)

Creating Django model factories

Define factories

Inside store/tests create factories.py file and declare the following factory for our model.

packages/backend/apps/store/tests/factories.py
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.

info

Factory Boy official documentation can be found here.

Register factories as pytest fixtures

packages/backend/apps/store/tests/fixtures.py
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.

packages/backend/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.

tip

More information on DjangoObjectType and Connection can be found in "How to add a new mutation to back-end API?"

packages/backend/apps/store/schema.py
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:

packages/backend/apps/store/tests/test_schema.py
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:

packages/backend/apps/store/tests/test_schema.py
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.

packages/backend/apps/store/tests/test_schema.py
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:

packages/backend/apps/store/serializers.py
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',)
tip

Detailed description on how to work with serializers can be found in "Working with serializers"

Next, define the following schema:

packages/backend/apps/store/schema.py
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()
tip

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.

packages/backend/apps/store/tests/test_schema.py
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.

packages/backend/apps/store/tests/test_schema.py
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.

packages/backend/apps/store/tests/test_schema.py
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.