Note: This article was originally published on the official Artillery blog.
The popularity of GraphQL for building and querying APIs has been on the rise for years with no sign of slowing down. Developers can now build robust and flexible APIs that give consumers more choices when accessing their data. This flexibility, in turn, can help with application performance compared to traditional RESTful APIs. By providing API consumers the flexibility to fetch the data it needs, it avoids the overhead of processing unnecessary information.
Despite all the advantages GraphQL provides to developers, it's also easy to introduce potential performance issues when constructing APIs:
- Its flexibility can allow clients to craft queries that are inefficient when fetching server-side data, especially when using nested queries that need to get data from multiple database tables or sources. This problem is more prevalent when using ORMs with GraphQL, since it can mask any inefficiencies in your database calls.
- Since GraphQL allows consumers to build different queries as they need, it's difficult to implement caching effectively. Since each request made to the API is potentially different, developers can't use standard caching techniques. Some libraries provide built-in caching, but it may not work in all scenarios.
Most developers building GraphQL APIs eventually run into one or both of these issues. While these performance problems are simple to deal with, they're also easily overlooked. It's also not uncommon to find yourself with a sluggish API after deploying to production when dealing with real data. That's why load testing your GraphQL APIs before and after deploying is more important than ever.
With Artillery, you can create test scripts that can test common workflows for GraphQL to smoke out any potential performance issues that can creep up when you least expect. Running regular load tests on your APIs both during development and against your production servers will keep your APIs performant and resilient for your customers. This article shows you how to use Artillery to keep your GraphQL services in check.
GraphQL API example
For this article, let's assume we have a GraphQL API with a data store, some queries to fetch this data, and mutations to manage data manipulation based on this diagram:
The data store is a database consisting of two tables to store user information and messages associated to that user that performs standard CRUD operations. There's a single query to fetch a specific user by their data store ID. The GraphQL API also has some mutations to create, update and delete a user, and create messages associated to a single user.
Let's imagine that most consumers of the GraphQL API perform two primary flows after creating and logging in as the new user. The first flow will create new messages through the API and later fetch the user's account information along with their messages. The other flow handles updating a user. We want to make sure these flows remain performant under load for whoever accesses the API. Let's create a few load tests using Artillery.
Creating our first GraphQL load test
Most GraphQL APIs handle requests via standard HTTP GET
and POST
methods, meaning we can easily use Artillery's built-in HTTP engine to make requests for load testing. Both methods work similar, with some differences related to how HTTP works. For instance, GET
requests are easier to cache while POST
responses aren't cacheable unless they include specific headers. For simplicity in our load tests, we'll use the POST
method to make our GraphQL API requests.
When served over HTTP, a GraphQL POST
request accepts a JSON-encoded body with a query
field containing the GraphQL query to execute on the server. The body can also contain an optional variables
field to set and use variables in the specified GraphQL query. We'll use both of these fields for our load test.
For our first test, we'll go through one of the flows described above:
- First, we'll create a new user record in through the GraphQL API and capture their email address from the GraphQL response. To ensure we don't run into uniqueness constraints when creating users during the test, we'll generate randomized usernames and email address using Artillery's
$randomString()
method. - Next, we'll log in as that user to test the login process and capture their ID from the data store.
- With the user's ID in hand, we'll then use the
loop
construct to create 100 new messages associated to the user. - After creating those messages, we'll make a request to return the user data and their messages to verify how the API performs when fetching associated data between different database tables.
- Finally, to clean up the database, we'll delete the user at the end of the flow, which will also delete the user's associated messages.
Let's see how this test script looks for executing this flow:
config:
target: "https://graphql.test-app.dev"
phases:
- duration: 600
arrivalRate: 50
scenarios:
- name: "Create and fetch messages flow"
flow:
- post:
url: "/"
json:
query: |
mutation CreateUserMutation($input: CreateUserInput) {
createUser(input: $input) {
email
}
}
variables:
input:
username: "{{ $randomString() }}"
email: "user-{{ $randomString() }}@artillery.io"
password: "my-useful-password"
capture:
json: "$.data.createUser.email"
as: "userEmail"
- post:
url: "/"
json:
query: |
mutation LoginUserMutation($email: String!, $password: String!) {
loginUser(email: $email, password: $password) {
id
}
}
variables:
email: "{{ userEmail }}"
password: "my-useful-password"
capture:
json: "$.data.loginUser.id"
as: "userId"
- loop:
- post:
url: "/"
json:
query: |
mutation CreateMessageMutation($authorId: ID!, $body: String!) {
createMessage(authorId: $authorId, body: $body) {
id
body
}
}
variables:
authorId: "{{ userId }}"
body: "Message Body {{ $loopCount }}"
count: 100
- post:
url: "/"
json:
query: |
query Query($userId: ID!) {
user(id: $userId) {
username
email
messages {
id
body
}
}
}
variables:
userId: "{{ userId }}"
- post:
url: "/"
json:
query: |
mutation DeleteUserMutation($deleteUserId: ID!) {
deleteUser(id: $deleteUserId) {
id
}
}
variables:
deleteUserId: "{{ userId }}"
For this example, we have our GraphQL API set up at https://graphql.test-app.dev
. The Artillery test will run for ten minutes, generating 50 virtual users per second. In our scenario, we have our defined flow with a few POST
requests made to the root URL (/
) where the GraphQL API handles our requests.
Each step in our flow
section works in a similar way. We'll have a JSON request to the GraphQL server with the query
and variables
field (where necessary). The query
field contains the GraphQL query or mutation that's sent to the server for processing. To make the body more readable in our test script, we'll use a pipe (|
) when sending the data.
Each query and mutation in our script is set up to handle variables, since we'll use them for keeping track of the dynamic data generated throughout the test phase. That's where the variables
field comes into play. This field can set up the variables we'll use through a standard JSON object. The GraphQL server will handle setting up the values where specified in a query or mutation.
For instance, in the step where we create a user, the createUser
mutation expects an input
argument, which is an object containing the username
, email
and password
fields. The variables
field will construct the input
object using fields required in the mutation argument and pass it to the query. This allows us to dynamically generate unique usernames and emails for each virtual user.
Assuming we save the test script in a file called graphql.yml
, we can run it with Artillery using artillery run graphql.yml
. After Artillery completes all the requests, you'll see how the GraphQL API performs under this load:
All VUs finished. Total time: 10 minutes, 39 seconds
--------------------------------
Summary report @ 15:31:12(+0900)
--------------------------------
vusers.created_by_name.Create and fetch messages flow: ...... 30000
vusers.created.total: ....................................... 30000
vusers.completed: ........................................... 30000
vusers.session_length:
min: ...................................................... 947.6
max: ...................................................... 20767.2
median: ................................................... 1465.6
p95: ...................................................... 20136.3
p99: ...................................................... 20136.3
http.request_rate: .......................................... 29/sec
http.requests: .............................................. 3120000
http.codes.200: ............................................. 3120000
http.responses: ............................................. 3120000
http.response_time:
min: ...................................................... 173
max: ...................................................... 4870
median: ................................................... 186.8
p95: ...................................................... 1312
p99: ...................................................... 3899
This example shows how straightforward it is to create a fully-fledged load test for GraphQL services. Artillery's HTTP engine has everything you need out of the box to check the most important actions of your GraphQL API. It's a simple yet powerful way to ensure your GraphQL services are resilient to any spikes in traffic and remain performant throughout. Although the performance of our GraphQL service aren't close to what we'd like, it gives us an excellent starting point for improvement.
Expanding the GraphQL load test
To expand our load test, let's add another scenario to further validate the GraphQL API's performance. For this flow, we want to validate that the service properly handles password updates. For security purposes, the GraphQL service uses bcrypt hashing to ensure we don't store plain-text passwords in the database. Depending on how a service uses bcrypt, the hashing function can slow down password-related functionality, so it's a good candidate to check for performance purposes.
The second scenario for our test goes through the following steps:
- We'll create a new user record and log in as that user, the same way as we did in the first scenario.
- Next, we'll log in as that user to test the login process and capture their ID from the data store.
- With the user's ID in hand, we'll call the mutation for updating a user's database record to update their password. This step helps us verify that the request works as expected along with testing its performance.
- Just like before, we'll delete the user at the end of the flow to keep the database clean of any test records.
The full test script with both flows is as follows:
config:
target: "https://graphql.test-app.dev"
phases:
- duration: 600
arrivalRate: 50
scenarios:
- name: "Create and fetch messages flow"
flow:
- post:
url: "/"
json:
query: |
mutation CreateUserMutation($input: CreateUserInput) {
createUser(input: $input) {
email
}
}
variables:
input:
username: "{{ $randomString() }}"
email: "user-{{ $randomString() }}@artillery.io"
password: "my-useful-password"
capture:
json: "$.data.createUser.email"
as: "userEmail"
- post:
url: "/"
json:
query: |
mutation LoginUserMutation($email: String!, $password: String!) {
loginUser(email: $email, password: $password) {
id
}
}
variables:
email: "{{ userEmail }}"
password: "my-useful-password"
capture:
json: "$.data.loginUser.id"
as: "userId"
- loop:
- post:
url: "/"
json:
query: |
mutation CreateMessageMutation($authorId: ID!, $body: String!) {
createMessage(authorId: $authorId, body: $body) {
id
body
}
}
variables:
authorId: "{{ userId }}"
body: "Message Body {{ $loopCount }}"
count: 100
- post:
url: "/"
json:
query: |
query Query($userId: ID!) {
user(id: $userId) {
username
email
messages {
id
body
}
}
}
variables:
userId: "{{ userId }}"
- post:
url: "/"
json:
query: |
mutation DeleteUserMutation($deleteUserId: ID!) {
deleteUser(id: $deleteUserId) {
id
}
}
variables:
deleteUserId: "{{ userId }}"
- name: "Update password flow"
flow:
- post:
url: "/"
json:
query: |
mutation CreateUserMutation($input: CreateUserInput) {
createUser(input: $input) {
email
}
}
variables:
input:
username: "{{ $randomString() }}"
email: "user-{{ $randomString() }}@artillery.io"
password: "my-useful-password"
capture:
json: "$.data.createUser.email"
as: "userEmail"
- post:
url: "/"
json:
query: |
mutation LoginUserMutation($email: String!, $password: String!) {
loginUser(email: $email, password: $password) {
id
}
}
variables:
email: "{{ userEmail }}"
password: "my-useful-password"
capture:
json: "$.data.loginUser.id"
as: "userId"
- post:
url: "/"
json:
query: |
mutation UpdateUserMutation($updateUserId: ID!, $input: UpdateUserInput) {
updateUser(id: $updateUserId, input: $input) {
id
}
}
variables:
updateUserId: "{{ userId }}"
password: "my-new-password"
- post:
url: "/"
json:
query: |
mutation DeleteUserMutation($deleteUserId: ID!) {
deleteUser(id: $deleteUserId) {
id
}
}
variables:
deleteUserId: "{{ userId }}"
Running the test script with Artillery as we did previously (artillery run graphql.yml
) shows the generated VUs going through both scenarios:
All VUs finished. Total time: 11 minutes, 20 seconds
--------------------------------
Summary report @ 15:43:19(+0900)
--------------------------------
vusers.created_by_name.Update password flow: ................ 18469
vusers.created.total: ....................................... 30000
vusers.created_by_name.Create and fetch messages flow: ...... 11531
vusers.completed: ........................................... 30000
vusers.session_length:
min: ...................................................... 899.1
max: ...................................................... 30451.4
median: ................................................... 1300.1
p95: ...................................................... 29445.4
p99: ...................................................... 29445.4
http.request_rate: .......................................... 28/sec
http.requests: .............................................. 1966900
http.codes.200: ............................................. 1966900
http.responses: ............................................. 1966900
http.response_time:
min: ...................................................... 172
max: ...................................................... 5975
median: ................................................... 186.8
p95: ...................................................... 1620
p99: ...................................................... 4867
As seen in our less-than-ideal performance results, GraphQL helps developers build flexible APIs with ease, but that flexibility comes at a cost if you're not careful. It's really easy to accidentally introduce N+1 queries or implement ineffective caching that bring your GraphQL API to a grinding halt with just a handful of consumers. This example test script gives you an idea on how to use Artillery to run robust load tests against your GraphQL services to find any bottlenecks or other issues during periods of high traffic.
The example GraphQL application and Artillery test script used for this article is available in the Artillery Examples repository on on GitHub.