HomeServicesTeamTestimonialsBlog
company logo
ServicesTeamTestimonialsBlog

Creating an easy-to-use Cypress Integration Testing Structure

Posted on April 15, 2023
Written by Seth Nelson

For those of us who have little to no previous experience with integration or end-to-end testing, it can be a daunting task of deciding what tools and approach to start with. The chosen tool was Cypress, so my first step was finding out what was available to me out of the box.

As a senior dev told me at the start of this particular project integration:

I've seen a lot of Cypress End-To-End test structures before, but none of them were quite 100% right - you got this!

Daunting right off the bat, I know.

But what this crucial advice told me was that it wasn't so much the tool itself that was the crux of this seemingly common problem, but correctly utilizing the methods to construct a satisfactory solution for the application.

Start with a good foundation

One important concept I learned early on is to keep the test itself clean. That means trying to keep as many utility functions and actions outside of the .spec file as possible. Here is a basic structure that works very well for E2E testing and is recommended by Cypress, with a few other folders I added for this article:

cypress | |___config.ts |___fixtures | |___testIds.json | |___integration | |___myTest.spec.ts | |___myOtherTest.spec.ts | |___support | |___commands.ts | |___e2e.ts | |___test-variables | |___testVariables.ts | |___dataComponents.ts | |___utils |___myUtilityFunction.ts

Creating simple tests and reusable test functions

One clear distinction to make between unit and E2E testing is remembering that with E2E testing, you are testing the functionality of the application. But it's easy to get caught up in testing for visibility of DOM elements, but making sure the tests are as light as possible to test a specific function or user action keeps them running fast as well as keeping bloat to a minimum.

A good process I followed for this project was to keep all of the data-component and data-id variables in their own file as they could possibly be reused. This was also the case for the variables and functions that created variable names for the tests like createTimestamp() that was used inside of the component name. This makes the .spec file clean so I can directly import the name of the element and use it to confirm it exists in the DOM later in the test.

Fixtures, Commands, and Aliases - Oh My!

Cypress contains a few great methods of keeping track of test variables and test data for using data across tests or within the same spec. I will go into some detail about how I utilized them and how they made it easy to keep a clean structure for my tests.

- Commands

Commands are a great way to create custom reusable functions across multiple tests. For my example, we were able to create commands that would be used multiple times and were predictable. We wanted a command that would grab a specific data-component, but we also wanted another command to grab a data-component that included a specific data-id in the case of a list (think multiple ingredients in a recipe list).

A sample structure in a React application might look something like this:

<ShoppingCartList cartItems={cartItems} user={user} /> ... cartItems.map((item) => { <ShoppingCartItem data-component="CartItem" data-id={item.id} item={item} itemId={itemId} /> })

Since we will be potentially having multiple tests to access the shopping cart, we can create a few commands to easily access a specific id we might want to select after we add it to the cart:

Cypress.Commands.add('getComponent', (component: string, options) => { return cy.get(`[data-component=${component}]`, options) }) Cypress.Commands.add( 'getComponentWithId', (component: string, id: string, options) => { return cy.get(`[data-component="${component}"][data-id="${id}"]`, options) } )

Then we can utilize this in our .spec file. I prefer importing these as variables from a single file, but we can also just use the string value.

import { CartItem } from "../cypress/test-variables/dataComponents.ts"; import { ItemId } from "../cypress/test-variables/testVariables.ts"; ... cy.getComponent(CartItem); -- or -- cy.getComponentWithId(CartItem, ItemId)

When finalized, a sample test might look something like this:

it('Should display the newly added item in the cart list', () => { cy.getComponent(CartMenu).click() cy.getComponentWithId(CartItem).contains(CartItem) cy.url().should('contain', ItemId) cy.get( `[data-component=${CartRowItem}][data-id="${ItemId}"][aria-current=true]` ) })

We now have a clean and concise test that makes sense visually and does not have a lot of code bloat. Now we can also reuse this function in any .spec we want!

- Alias and Fixture

These methods comes in handy when you are creating a value, but you do not know what that value might be. For example, let's say we are adding a new ingredient to a recipe that does not exist in our database. After this ingredient is created, a new id might be created either incrementally or at random. In our integration test, we don't immediately know what that id is. How can we get access to that inside of our integration test? We can solve this with Aliases and Fixtures!

alias - allows you to share context within Cypress fixture - Loads data from a specified file

Let's say that when we create a new ingredient, that is mapped into the recipe automatically and now contains data-component="Okra" data-id={72}. We want to access this id, but all we have is the ingredients name, Okra. We can use an alias to get this data and select the matching item.

cy.getComponent(Recipe) .contains(Okra) .invoke('attr', 'data-id') .as('ingredientId') cy.get('@ingredientId').then((createIngredientId) => { ingredientId = createIngredientId.toString() cy.getComponentWithId(Ingredient, ingredientId).click() })

So.. what is actually happening here?! We are using the .invoke() method to grab a property's value. Here, we are grabbing the data-id property from the newly created ingredient, and assigning it to ingredientId. We now have access to the id value that we did not previously have using @ingredientId. We can then grab the correct component in the list and continue the test.

Where fixture comes into play is really neat. Say we want to use that value, and save it to the Cypress context. Maybe we want access to this id to remove the ingredient from the recipe? This will be a new command so we need to retain access to this id for later use.

Let's create a new file called /cypress/fixtures/testIds.json. The idea is to save this value to this file, and later, we can read the value from this file. Let's update this code like so:

cy.getComponent(Recipe) .contains(Okra) .invoke('attr', 'data-id') .as('ingredientId') cy.get('@ingredientId').then((createIngredientId) => { ingredientId = createIngredientId.toString() cy.writeFile('/cypress/fixtures/testIds.json', { ingredientId: ingredientId, }) cy.getComponentWithId(Ingredient, ingredientId).click() })

Now, we are taking the @ingredientId alias we created and saving it to the file for later use. Let's say we now want to use this value to issue a new command to delete the ingredient and confirm its deletion. We can grab this value from the file with just a few lines of code using cy.readFile().

cy.readFile("/cypress/fixtures/testIds.json").then((fixture: Fixture) => { cy.getComponentWithId(DeleteIngredientButton, fixture.ingredientId).click(); cy.getComponent(ConfirmDeleteModal).getComponent(DeleteButton).click(); cy.getComponentWithId(Ingredient, fixture.ingredientId).should("not.exist"); ... });

Keeping Tests Independent

One last thing to keep in mind is making sure the tests are truly independent. It is OK to have variables that depend on data fetched within the same .spec file, but requiring data created from another .spec is bad practice.

Let's say we have 2 test specs. createUser.spec.ts and deleteUser.spec.ts. You might be inclined to run the integration steps to create the sample user, then use that data inside of the other spec that deletes the sample user. But this would mean deleting the user depends on first creating this user, which is untrue. A better method for this structure would be to test both of these together as a single integration such as createAndDeleteUser.spec.ts so if this is run independently or without the other test, it functions as expected. You will see the importance of this when hooking up tests with GitHub actions or other CI/CD architecture builds where the test order is not always in control.

I hope this has provided some insight into some easy Cypress methods you could utilize in your next project. E2E testing can be hard to set up, but with these methods, it ensures it's kept clean for scalability and readability. Remember, a clean, well-structured Cypress integration testing setup not only ensures effective end-to-end testing but also results in happy, productive developers.

Thanks for reading!

Resources:

Seth Nelson photo
Seth Nelson
Software Engineer
company logo
ServicesTeam IntegrationStrategy and DesignCustom TeamsProof of Concept
TeamOur EmployeesCareersAbout UsOur Customers
TestimonialsAtomic ObjectSimple ThreadZEAL
Contact Us
176 W. Logan St. #335
Noblesville IN, 46060