How To Create Automated Tests For Strapi API using PactumJS

How To Create Automated Tests For Strapi API using PactumJS

In this tutorial, we will use Strapi to build the backend of a simple To-Do list application, then set up PactumJS, which is a testing framework to create automated tests for our backend. We will learn the basics of Strapi while creating the backend, dive into the basics of automated testing using PactumJS and learn about making different types of requests and validating the responses.

This tutorial does not cover all the details of the Strapi or PactumJS framework, it’s a guide to get you started and take the first steps.

Outline

  • Introduction

  • Prerequisites

  • What is Strapi?

  • Setting Up Strapi Locally

  • Building a To-do List Back-end

  • What is PactumJS?

  • Setting Up PactumJS

  • Hello World PactumJS

  • Making Requests and Basic Response Validation

  • Response Validation Techniques

  • Conclusion

Prerequisites

Before you can jump into this content, you need to have a basic understanding of the following.

  • Basic JavaScript knowledge

  • NPM installed

  • Basic knowledge of Strapi and RESTful APIs

What is Strapi?

Strapi is an open-source headless CMS (Content Management System) that allows you to quickly create and maintain RESTful JavaScript APIs. Strapi helps in creating both simple and complex back-ends, either as an individual or an organization. Strapi is built on NodeJS, which provides high performance in processing large amounts of requests simultaneously.

Setting up Strapi locally

We will create a new Strapi application that will provide us with an admin dashboard that allows us to create and handle the back-end operations including the database schema, the API endpoints, and the records in the database.

We can create a Strapi application using npm, or yarn using the following commands:

  • npm

    npx create-strapi-app todo-list --quickstart

  • yarn

    yarn install global create-strapi-app

    yarn create-strapi-app todo-list --quickstart

  • yarn v3 or above

    yarn dlx create-strapi-app todo-list --quickstart

After creating the Strapi application successfully, we will run the application in the development environment which will create a local server for us by default to allow us to create endpoints, and data collections, and set up an authentication for our back-end and handle it through the admin dashboard.

To run the Strapi application in development mode, navigate to the project folder, fire up your favorite terminal, and run the following commands according to your package manager:

  • npm

    npm run develop

  • yarn

    yarn run develop

Then open http://localhost:1337/admin on your browser, the application should be loaded on a registration page, that looks like this:

On the welcome page, we will create your admin account which we will use to access the Strapi admin dashboard. Only this user will have access to the Strapi dashboard, until you create other users with the same privileges using the admin account.

When we create the admin account and hit Let’s start, we will get directed to the dashboard that contains all the possible options for us to build our back-end in the left panel.

Strapi CMS Dashboard Page

Building a To-do List back-end

To learn how to build a full To-do List application using Strapi and React, I recommend reading this article How to Build a To-Do List Application with Strapi and ReactJS, which goes into more detail in explaining how to build a CMS using Strapi.

We will build a simple to-do list back-end, which will contain a few endpoints that are attached to data collections stored in our database, Strapi will handle most of the work for us, we will just define what we need which are:

  • Todo collection

    • contains one field called item of type text.
  • A few test entries in the Todo collection

  • API that performs CRUD operations on the Todo collection

What will Strapi take care of:

  • Handle creating the actual endpoints and the code that performs each CRUD operation, we will just enable them for certain visitors (in our case everyone - public)

  • Handle creating and connecting to the actual database which is SQLite by default.

  • Handle creating the database schema, inserting, retrieving, altering, and deleting the data from it with only a few buttons or an API call from us.

So, let’s see how can we define these needs.

Step 1 - Creating a Todo Collection

A data collection in Strapi is a group of data that represents something or an entity in your application, for example, if you create an online clothing store you would create a collection called Item, that holds the information of each item you have in the store, and the information could be for example: id, type, color, available sizes, available, and so on. These information are called attributes, they define the item you have.

Let’s create our first data collection “Todo Collection”

  1. Navigate to “Content-Type Builder” under “plugins” at the left. This will open the app the page that enables us to manage our data collections.

  2. Click on “Create new collection type” to create a new collection.

  3. Type “Todo” (you can name it whatever you want) in the display name field, this will be the collection name.

  4. Click Continue.

  5. Click on the “Text” button to add a text field to your Todo collection.

  6. Type “task” (you can name it whatever you want) in the name field, this will be the name of the single attribute we need in the data collection.

  7. Select “long-text” because a Todo can be as long as it needs, we don’t want to limit it to a short text.

  8. Click “Finish”.

  9. Click on “save” to register the collection in our application. Note: Registering a collection makes the server to restart.

Step 2 - Adding Test Entries to the Todo Collection

The test entries will be the actual data in the todo collection, the records itself. We need a few meaningless entries so we can test the collection because it’s just an empty collection now.

  1. Navigate to “Content-Manager”. This will take us to where we can manipulate the actual data in the collections.

  2. Click on Todo at the left to display the current entries, which are empty “No content found”.

  3. Click on “Create new entry”

  4. You will find the attribute above, the “task” attribute, now type in any meaningless words, “task A” for example.

  5. Hit “Save” to save the entry as a draft.

  6. Hit “Publish” to register the entry in the collection and make it visible to the API we will enable in the next step.

  7. Repeat the steps at least twice to have at least three tasks in our Todo collection.

Step 3 - Creating API Endpoints for our Collection

Creating API endpoints enables us to build up our back-end and use it by calling these endpoints and using the data by performing CRUD operations on our collections from the front-end for example, in our case we will call the API from our testing script.

To create endpoints, follow the following steps:

  1. Navigate to “Settings” under “general”.

  2. Click on “Roles” under “User permission & roles”.

  3. Click on “public” to open the permissions given to the public.

  4. Toggle the “Todo” drop-down under “Permissions”. This controls public access to the “Todo” collection.

  5. Click on “Select all” to allow public access to the collection without authentication through the endpoints.

  6. Hit “Save”.

The following endpoints will be created for each of the permissions we enabled, let’s try to request each endpoint and take a look at the request and response.

  • Find - GET /api/todos/

    Gets all the todos in our Todo collection, if we call it from a browser or an HTTP client, we will get a JSON object containing all the todos in our collection.

  • Response


{
        "data": [
            {
                "id": 1,
                "attributes": {
                    "task": "task A",
                    "createdAt": "2022-12-19T10:33:44.577Z",
                    "updatedAt": "2022-12-19T10:33:45.723Z",
                    "publishedAt": "2022-12-19T10:33:45.718Z"
                }
            },
            {
                "id": 2,
                "attributes": {
                    "task": "task B",
                    "createdAt": "2022-12-19T10:33:56.381Z",
                    "updatedAt": "2022-12-19T10:33:58.147Z",
                    "publishedAt": "2022-12-19T10:33:58.144Z"
                }
            }
        ],
        "meta": {
            "pagination": {
                "page": 1,
                "pageSize": 25,
                "pageCount": 1,
                "total": 2
            }
        }
    }

Notice the additional data like id, createdAt, updatedAt, and publishedAt, those are metadata Strapi injects in the database by default.

  • Create - POST /api/todos

    Creates a new todo with the data specified in the POST request, saves the new entry into the database, and publishes it in the API.

  • Request

{
            "data": {
                    "task": "task C"
            }
}
  • Response
{
        "data": {
            "id": 3,
            "attributes": {
                "task": "task C",
                "createdAt": "2022-12-19T10:17:36.082Z",
                "updatedAt": "2022-12-19T10:17:36.082Z",
                "publishedAt": "2022-12-19T10:17:36.079Z"
            }
        },
        "meta": {}
}
  • Find One - GET /api/todos/{id}

    Gets one entry with the specific id supplied in the URL if it exists. for example, if we requested /api/todos/1 We will get a single entry of the todo with id 1.

  • Response

{
        "data": {
            "id": 1,
            "attributes": {
                "task": "task A",
                "createdAt": "2022-04-19T13:15:10.869Z",
                "updatedAt": "2022-04-19T13:15:11.839Z",
                "publishedAt": "2022-04-19T13:15:11.836Z"
            }
        },
        "meta": {}
}
  • Update - PUT /api/todos/{id}

    Updates a certain entry with the id supplied in the URL to the new data specified in the request. For example, let's update the second task. We send a PUT request for /api/todos/2

  • Request

{
            "data": {
                    "task": "task B - updated"
            }
}
  • Response
{
        "data": {
            "id": 3,
            "attributes": {
                "task": "task B - updated",
                "createdAt": "2022-12-19T10:17:36.082Z",
                "updatedAt": "2022-12-19T10:17:36.082Z",
                "publishedAt": "2022-12-19T10:17:36.079Z"
            }
        },
        "meta": {}
}
  • Delete - DELETE /api/todos/{id}

    Deletes a certain entry with the id supplied in the URL to the new data specified in the request. For example, let's delete the third task we just created in the Create example. We send a DELETE request for /api/todos/3 we should get the task data in the response if the deletion operation was successful.

  • Response


{
        "data": {
            "id": 2,
            "attributes": {
                "task": "task - C",
                "createdAt": "2022-12-19T13:17:36.082Z",
                "updatedAt": "2022-12-19T13:15:11.839Z",
                "publishedAt": "2022-12-19T13:15:11.836Z"
            }
        },
        "meta": {}
}

Now that we have the API ready, let’s dig into the testing framework.

What is PactumJS?

According to PactumJS Documentation, it’s a next-generation free and open-source REST API automation testing tool for all levels in a Test Pyramid. It makes back-end testing a productive and enjoyable experience. This library provides all the necessary ingredients for the most common things to write better API automation tests in an easy, fast & fun way.

In simple words, it’s an easy web testing framework that enables automated testing via writing JEST scripts, that have many use cases.

Use Cases for PactumJS

  • API Testing

  • Component Testing

  • Contract Testing

  • Integration Testing

  • E2E Testing

  • Mock Server

Setting Up PactumJS

PactumJS is built on top of NodeJS, so we need NPM installed to run install it. Fire up your terminal and type in the following commands to install PactumJS and a test runner called Mocha.


mkdir todo-test
cd todo-test

# install pactum as a dev dependency
npm install --save-dev pactum 

# install a test runner to run pactum tests
# (mocha) / jest / cucumber
npm install --save-dev mocha

After installing the dependencies, create a new file in todo-test/tests directory, we can call it test.js for example, then update the scripts in package.json file to use mocha when we run the test script.

{
  "scripts": 
    {
    "test": "mocha tests"
    }
}

While specifying the script command mocha tests you can either specify a directory path like tests here which will run mocha for each file in the directory, or specify a js file like tests/test.js which then will run mocha for that specific file only.

Make sure your Strapi application is up and running, the default URL for Strapi is localhost:1337. So, we will import the required module and set the base URL first thing in test.js file.


const { spec, request } = require('pactum');

/* Set base url to the backend URL */
request.setBaseUrl('<http://localhost:1337>')

Now that everything is ready, we can start working on our first test!

Hello World in PactumJS

To write a new test you need a

  • describe block which you will describe what you testing for documentation and clearance, you can define which endpoint you’ll test in this block for example.

  • one or more blocks inside the describe block, which will have the actual API call and response validation code.

  • you need the route of the endpoint, but not the full route because we already specified a base URL, we need the route after the base only, for example, we will use /api/todos to get all the todos, this route will get appended to the base URL to be localhost:1337/api/todos

Let’s write our first test. We will write a simple test that will hit the endpoint at /api/todos and validate that the status code in the response is 200 or OK.

First, let’s take a general look at the status codes and their meanings. Status codes are a variable returned with any response that implies the status of the request we sent, each code has a different meaning but in general, they’re classified like this according to MDN web docs.

  1. Informational responses (100 – 199)

  2. Successful responses (200 – 299)

  3. Redirection messages (300 – 399)

  4. Client error responses (400 – 499)

  5. Server error responses (500 – 599)

Now, let’s write the script.

const { spec, request } = require('pactum');

/* Set base url to the backend URL */
request.setBaseUrl('<http://localhost:1337>')

describe('GET Todos Tests - Retrieve all todos' , () =>
{
    it('Should return 200 - Validate hitting get all todos endpoint', async() => 
    {
        await spec()
            .get('/api/todos')
            .expectStatus(200)
    })
})

Let’s dig into this describe block, then run it and take a look at the output.

  • describe('GET Todos Tests - Retrieve all todos' , () => The describe block takes a string that defines the block, and a function that may contain multiple it blocks.

  • it('Should return 200 - Validate hitting get all todos endpoint', async() => The it block also takes a string and an Async function, it’s essential to be async here because we will make an API call and we need that call to happen on a separate thread. this block will test if the status code of hitting the /api/todos endpoint is 200, which implies the success of the request.

  • await spec().get('/api/todos').expectStatus(200)

    • spec() exposes all methods offered by PactumJS to construct a request.

    • .get() is the request type, which in our case is a regular GET request.

    • .expectStatus(200) The response validation, we’re expecting a status of 200. Think of it as an if condition, if the status is 200 then the test passes, if not it fails!

Let’s run our test against our API and see the result! To run the script, type the following command in the terminal.

npm run test
> mocha tests

  GET Todos Tests - Retrieve all todos
    ✔ Should return 200 - Validate hitting get all todos endpoint (82ms)

  1 passing (89ms)

YAY! Our test passed!

let’s dig deeper and try other request types and different ways of validating responses!

Making Requests and Validating Responses

GET Request with Parameters

Let’s say we want to test the Find One endpoint, we need to supply a parameter (which is the ID in our case) in the URL. We can put it directly in the URL like this /api/todos/1 or we can write it in a more elegant way using a PactumJS function called withPathParams, let’s write the test block, and validate that the status code is 200, and the JSON object in the response is not empty! For that, we will require notNull from a module called pactum-matchers that contains different matching functions (you can read about them in the API docs).

Let’s also add multiple it blocks to see how the output will look like.

const { spec, request } = require('pactum');
const { notNull } = require('pactum-matchers');

describe('GET Todo Tests - Retrieve a signle todo by id', () =>
{
    it('Should return 200 - Validate retrieving a single todo', async() => 
    {

        await spec()
            .get('/api/todos/{id}')
            .withPathParams('id', 1)
            .expectStatus(200)
    })

    it('Should not be empty - Validating returning a single non-empty todo', async() => 
    { 
        await spec()
            .get('/api/todos/{id}')
            .withPathParams('id', 2)
            .expectStatus(200)
            .expectJsonMatch(
                {
                    "data": notNull()
                });
    })
})

So, what’s new here?

  • .withPathParams('id', 1) takes a string and matches it to a string in the route inside a curly bracket {id} and replaces it with a value (the second parameter).

  • .expectJsonMatch( { "data": notNull() } ); A new response validation function. This function takes a JSON object as a parameter and matches it with the response, we can inject special functions in the object like notNull(), which means it doesn’t matter what is the value of “data”, it just has to be not null! There are other matching types like this including any(), regex(), uuid(), string().

Let’s run the tests and see if it passes, remember that we have 2 describe blocks now and the second one has 2 it blocks

> mocha tests

  GET Todos Tests - Retrieve all todos
    ✔ Should return 200 - Validate hitting get all todos endpoint

  GET Todo Tests - Retrieve a signle todo by id
    ✔ Should return 200 - Validate retrieving a single todo
    ✔ Should not be empty - Validating returning a single non-empty todo

  3 passing (69ms)

POST Request

Let’s test the Create endpoint to create a new todo, and chain it with a retrieval test to make sure it got stored in the database.

We need to make a POST request and send a JSON object with the request, then retrieve the ID of the newly created task, store it and make a GET request to the Find One endpoint, to validate that the task was created successfully.


describe('POST Todo Tests - Create todo', () =>
{
                const taskDescription = 'Newly created task';
    it('Should return 200 - Create a new todo and validate it exists', async() => 
    {
        const id = await spec()
            .post('/api/todos')
            .withJson(
                {
                    'data':
                    { 
                        'task': taskDescription
                    }
                }
            )
            .expectStatus(200)
            .returns('data.id')

            await spec()
            .get('/api/todos/{id}')
            .withPathParams('id', id)
            .expectStatus(200)
            .expectJson('data.attributes.task', taskDescription);
    })

})
  • .withJson( { 'data': { 'task': taskDescription } } ) Takes a JSON object as a parameter and sends it with the post request to the endpoint, Here we sent an object that holds a task, with the text “Newly created task".

  • .returns('data.id') We need the id of the newly created entry, so we use returns() function that takes the specific object inside the JSON response and returns it, which we will store in const id to use in the next part.

  • The next part is a regular GET test that asks to retrieve a single todo with the recently acquired ID, expects a 200 status code, and expects the task attribute in the JSON data response to be the same string sent in the previous POST request .expectJson('data.attributes.task', taskDescription);

Let’s see if our tests pass

> mocha tests
  GET Todos Tests - Retrieve all todos
    ✔ Should return 200 - Validate hitting get all todos endpoint

  GET Todo Tests - Retrieve todo
    ✔ Should return 200 - Validate retrieving a single todo
    ✔ Should not be empty - Validating returning a single non-empty todo

  POST Todo Tests - Create todo
    ✔ Should return 200 - Create a new todo and validate it exists (247ms)

  4 passing (652ms)

Great! Let’s move on.

DELETE Request

Let’s create a new task, then delete it, then confirm it got deleted by trying to retrieve it and expect a not found status code!

We will reuse the previous code of the post request again and add the DELETE request in the middle.

describe('DELETE Todo Tests - Delete todo', () =>
{
    const taskDescription = 'Newly created task - 2';
    it('Should return 200 - Create a new todo and validate it exists', async() => 
    { 
        const id = await spec()
            .post('/api/todos')
            .withJson(
                {
                    'data':
                    {
                        'item': taskDescription
                    }
                }
            )
            .expectStatus(200)
            .returns('data.id');

            await spec()
            .delete('/api/todos/{id}')
            .withPathParams('id', id)
            .expectStatus(200);

            await spec()
            .get('/api/todos/{id}')
            .withPathParams('id', id)
            .expectStatus(404);
    })

})

We added .delete('/api/todos/{id}') request to delete the recently created task, then a .get('/api/todos/{id}') request that expects a 404 status code (not found) to validate the entry got deleted, which means if the response status code is 404, the test will pass, if not; it will fail.

Let’s run and see if our tests pass.

> mocha tests

  GET Todos Tests - Retrieve all todos
    ✔ Should return 200 - Validate hitting get all todos endpoint

  GET Todo Tests - Retrieve todo
    ✔ Should return 200 - Validate retrieving a single todo
    ✔ Should not be empty - Validating returning a single non-empty todo

  POST Todo Tests - Create todo
    ✔ Should return 200 - Create a new todo and validate it exists (192ms)

  DELETE Todo Tests - Delete todo
    ✔ Should return 200 - Create a new todo and validate it exists (573ms)

  5 passing (831ms)

Looks like all of our tests pass, Great!

Response Validation Techniques

We’ve learned how to send different types of requests, pass parameters with the requests, validate the response status code, and match the JSON. PactumJS has a lot more to offer, in this section we’ll discuss some of these methods to validate the API response.

Response validation is making sure that the API response to a certain request is valid and correct!

Basic Assertions Techniques

Assertions are a way to make sure that the response (or part of it) matches a certain pattern or rule. There are a lot of assertion functions offered by PactumJS, we will discuss some of them but you can find the full list documented at PactumJS API Documentation - Assertions.

  • expectStatus(code) We’ve used expectStatus earlier, it asserts that the status code of the response equals specific code.

  • expectHeaderContains(key, value) It asserts that a specific header key in the response is present, and equals a specific value. for example, if we want to make sure that the response content-type is Json:

      await spec() 
              .get('/api/todos/')
              .expectHeaderContains('content-type', 'application/json');
    
  • .expectBodyContains(string) Performs partial equal between the supplied string and the response body and passes if it exists. For example, if the response status code is 200, the body will contain ‘OK’, So, we can check if the response body has OK:

      await spec() 
              .get('/api/todos/')
              .expectBodyContains('OK');
    
  • .expectResponseTime(milliseconds) Passes if the response time is less than the specified milliseconds. Example:

      await spec()
        .get('/api/todos')
        .expectResponseTime(100);
    
  • .expectJsonMatch([path], json) Passes if the specified JSON object matches the JSON response. We can also specify a certain JSON path to match with, for example, if we have a first name variable in the JSON object and it should be ‘Ahmed’, we can check this by:

      await spec() 
        .get('/api/users/1')
        .expectJsonMatch('data.first_name', 'Ahmed');
    

Basic Matching Techniques

Most of the time we don’t want to match specific strings in the response as we did in the previous example, we want to assert certain types (string, int, float,), that the value is not empty (using notNull that we used above), that the value in a certain range (lt, lte, gt, gte), or one of several options (oneOf). These are some of the matching techniques PactumJS provides, let’s take a look at some of them.

  • string(), int(), float() Matches the data type, here’s an example:

      await spec()
        .get('/api/users/1')
        .expectJsonMatch('data.first_name', string()) 
              .expectJsonMatch('data.id', int())
              .expectJsonMatch('data.salary', float());
    
  • oneOf([]) Matches one of the values specified in the array supplied as the parameter, here’s an example:

      await spec()
        .get('<https://randomuser.me/api>')
        .expectJsonMatch({
          "results": 
          [
            {
              "gender": oneOf(["male", "female"]),
            }
          ]
        });
    

You can see how this can be handy when having fields that carry enum values.

  • uuid() Matches a UUID format, which is the Universal Unique Identifier.

      await spec()
        .get('<https://randomuser.me/api>')
        .expectJsonMatch({
          "results": 
          [
            {
              "login": 
              {
                "uuid": uuid()
              }
            }
          ]
        });
    
  • lt(), lte(), gt(), gte() Performs integer comparison! Let’s see an example:

      await spec()
        .get('<https://randomuser.me/api>')
        .expectJsonMatch({
          "results": 
          [
            {
              "dob": 
              {
                "age": lt(100), 
                "children" : lte(2),
                "salary" : gte(4000)
              }
            }
          ]
        });
    
  • notEquals() Checks if the actual value is not equal to the expected one, and comes in handy when testing for wrong inputs! Example:

    
      await spec()
        .get('<https://randomuser.me/api>')
        .expectJsonMatch({
          "results": [
            {
              "name": notEquals('jon'),
            }
          ]
        });
    

There are many more different matching functions to make your life easier, you can find at PactumJS API Documentation - Matching.

Conclusion

In this tutorial, we learned:

  • How to build a simple backend using Strapi.

  • How to access the API created by Strapi.

  • How to send different API request types using PactumJS.

  • How to validate API responses using PactumJS.

  • How to use Assertions to test different parts of the response.

  • How to use Matching techniques to test specific fields.

  • PactumJS has more and more potential that we can take advantage of once we understand the basics.