Test Driven Api Development With Cypress
Anthony Santonocito
Posted on September 16, 2024
Delivering reliable and high-quality APIs is crucial for ensuring seamless integration and functionality. Test-Driven Development (TDD) has emerged as a powerful methodology to enhance code quality and streamline the development process.
This article will review technical requirements and develop Cypress test code for TDD. The following guidelines will be illustrated in the test code.
Testing Guidelines
- Test code should build upon previous test code
- Test whenever possible for early bug detection
- Develop the API between each test for faster debugging
- Tests should not impact the database
- Ensure your tests are robust against unrelated changes
- Failed Tests Improve Refactoring Confidence
Guideline - Test code should build upon previous test code
Check that POST returns the proper status/properties:
POSTCheck that POST results in properly stored data:
POST > GET checks posted dataCheck that DELETE results in the deletion of the data:
POST > DELETE > GET confirms a bad request for deleted information
Requirements:
Model the following database:
Seed the database with the following 3 products:
[
0: {
id: 1
name: "Dog Food"
description: "Your dog will love our beef and rice flavored dry dog food."
price: 9.99
}
1: {
id: 2
name: "Cat Food"
description: "Your cat will passively enjoy our salmon flavored wet cat food."
price: 12.99
}
2: {
id: 3
name: "Lizard Food"
description: "Your lizard likes to eat bugs. So this is made of bugs."
price: 3.99
}
]
Requirement 1:
- Create a GET endpoint returning all products in a JSON array of objects.
- Return all fields including (id, name, description, price).
TEST NAME: cy.gets-all-products
network_requests.cy.js
const base = "http://localhost:5090/api/";
context("Network Requests", () => {
it("cy.gets-all-products", () => {
cy.request(`${base}product`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body[0]).to.include.keys(
"id",
"name",
"description",
"price"
);
});
});
});
This test makes an GET api call then checks the status and keys of the call. Requirement 3 will show a more thorough GET test.
Guideline - Develop the api between each test for faster debugging
Testing and development should be continuous
Writing all of the test code before the API code is similar to writing all of the API code before the test code.
Requirement 2:
- Create a Post endpoint accepting lineitem fields (id, quantity, userId).
- This endpoint must create an orderheader and add this lineitem to that order.
- It should also set the total field on the orderheader.
- It must return the orderheaderid of the orderheader created.
network_requests.cy.js
const base = "http://localhost:5090/api/";
const item = { productid: 1, quantity: 2, userid: 1 };
...
it("cy.posts-order", () => {
// Create Order
cy.request("POST", `${base}order`, item).then((response) => {
expect(response.status).to.eq(201);
expect(response.body).to.include.keys("id");
});
// Delete Order
});
A POST request sent lineitem information along with the userid to create an order. The status and keys of a POST call are checked.
Guideline - Test whenever possible for early bug detection:
Even if the test seems partial, it should still be written. We do not have a way to delete this POST yet. We also cannot properly check the data in the database without a way to retrieve an orderheader.
Requirement 3:
- Create a GET endpoint accepting a query labeled "id" which will be used to pass the orderheaderid.
- Use the orderheaderid to query the database and return the orderheader and related orderlines as well as the orderlines related product.
- Return all fields of all tables involved.
TEST NAME: cy.retrieves-order-by-id
network_requests.cy.js
const base = "http://localhost:5090/api/";
const item = { productid: 1, quantity: 2, userid: 1 };
...
it("cy.retrieves-order-by-id", () => {
// Create Order
cy.request("POST", `${base}order`, item).then((response) => {
cy.wrap(response.body.id).as("id");
});
// Get Order By ID
cy.get("@id").then((id) => {
cy.request({
url: `${base}order/byid`,
qs: {
id: id,
},
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.lineitems[0]).to.include.keys(
"id",
"quantity",
"price",
"productid",
"product",
"orderheaderid",
"orderheader"
);
expect(response.body.lineitems[0].product).to.include.keys(
"id",
"name",
"description",
"price"
);
expect(response.body.lineitems).to.be.an("array").and.have.lengthOf(1);
expect(response.body.total).eq(
response.body.lineitems[0].price * response.body.lineitems[0].quantity
);
expect(response.body.customer).eq(1);
expect(response.body.lineitems[0].productid).eq(1);
expect(response.body.lineitems[0].quantity).eq(2);
expect(response.body.lineitems[0].price).eq(
response.body.lineitems[0].product.price
);
});
// Delete Order
});
});
While within a single test block we can save values with
cy.wrap(<value>).as("<key>")
the key can be retrieved with cy.get("@<key>").then((<value>) => {})
This test confirms every field is properly populated during our POST.
Guideline - Ensure your tests are robust against unrelated changes:
Avoid hardcoding values that might change.
In the above code, the price of the orderline is tested against the price of the product. The total of the orderheader is linked to the price and quantity of the orderline. This test will not fail if the product price is change. It will fail if the API is not properly updating the lineitem price.
Requirement 4:
- Create a DELETE endpoint accepting a query labeled "id" which will be used to pass the orderheaderid.
- This endpoint must delete an orderheader.
- It should return JSON with key "success" and value true.
Guideline - Tests should not impact the database:
After running all API tests, the database should be left unchanged
You will notice delete in all the relevant positions in the test code. Failed tests may impact the database.
This is the final code.
TEST NAME: cy.deletes-an-order
network_requests.cy.js
const base = "http://localhost:5090/api/";
const item = { productid: 1, quantity: 2, userid: 1 };
context("Network Requests", () => {
it("cy.gets-all-products", () => {
cy.request(`${base}product`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body[0]).to.include.keys(
"id",
"name",
"description",
"price"
);
});
});
it("cy.posts-order", () => {
// Create Order
cy.request("POST", `${base}order`, item).then((response) => {
expect(response.status).to.eq(201);
expect(response.body).to.include.keys("id");
cy.wrap(response.body.id).as("id");
});
// Delete Order
cy.get("@id").then((id) => {
cy.request("DELETE", `${base}order?id=${id}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.include.keys("success");
});
});
});
it("cy.retrieves-order-by-id", () => {
// Create Order
cy.request("POST", `${base}order`, item).then((response) => {
cy.wrap(response.body.id).as("id");
});
// Get Order By ID
cy.get("@id").then((id) => {
cy.request({
url: `${base}order/byid`,
qs: {
id: id,
},
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.lineitems[0]).to.include.keys(
"id",
"quantity",
"price",
"productid",
"product",
"orderheaderid",
"orderheader"
);
expect(response.body.lineitems[0].product).to.include.keys(
"id",
"name",
"description",
"price"
);
expect(response.body.lineitems).to.be.an("array").and.have.lengthOf(1);
expect(response.body.total).eq(
response.body.lineitems[0].price * response.body.lineitems[0].quantity
);
expect(response.body.customer).eq(1);
expect(response.body.lineitems[0].productid).eq(1);
expect(response.body.lineitems[0].quantity).eq(2);
expect(response.body.lineitems[0].price).eq(
response.body.lineitems[0].product.price
);
// Delete Order
cy.request("DELETE", `${base}order?id=${id}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.include.keys("success");
});
});
});
});
it("cy.deletes-an-order", () => {
// Create Order
cy.request("POST", `${base}order`, item).then((response) => {
cy.wrap(response.body.id).as("id");
});
cy.get("@id").then((id) => {
// Delete Order
cy.request("DELETE", `${base}order?id=${id}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.include.keys("success");
});
// Get Deleted Order By Id
cy.request({
url: `${base}order/byid`,
failOnStatusCode: false,
qs: {
id: id,
},
}).then((response) => {
expect(response.status).to.eq(400);
});
});
});
});
Failed API calls result in a Cypress test failure. Including failOnStatusCode: false
in the request, negates that behavior.
The next requirement might force us to ensure a cascading delete of all lineitems upon deletion of an orderheader. Try writing that test. Here is the sudo code.
- POST an order
- GET orderheader
- Save the lineitemid
- DELETE the orderheader
- GET lineitem by passing the lineitemid to an endpoint the returns a lineitem by id - expect this get to fail
Guideline - Failed Tests Improve Refactoring Confidence:
Testing for functionality should include tests that expect to fail.
In the repo, you will see many tests expecting to fail after the order status is changed. Note that this results in a test passing.
View the finished code including the API on github:
Posted on September 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.