Accessing Redux state in Cypress

Accessing Redux state in Cypress

Automated testing also known as End to End testing(E2E) have became a fundamental part on web testing universe due to its capacity to emulate user interaction but faster than a person can do it. Also the use of global state management libraries in front-end development like Redux for React, Vuex for Vue.js or NGRX for Angular have became a standard.

A common scenario in web development is to execute multiples api calls , and later gather all responses to create a final data structure to be presented to the user. Usually a common path to this is to wait until the last of calls is completed and then unify and format all of them to then save the whole new data structure on memory using a global state management library like previously mentioned.

When we are testing an app with this workflow will be really tricky to know when the last call is completed, so trying to intercept all of them can be a headache, also we could not know if the data coming from the apis have been modified after being received, this could be a problem at the time to verify the info being presented to the user in the UI.

Because these reasons above explained a common solution is to access the global state of the app and then use the stored values to check against the UI. Normally the UI what presents the data is only fully rendered when the data is completely ready to being presented to the user after being structured, formatted and stored in the global state. So a good technique used in automated testing to know if we are ready to access the global state, is to wait until some html element totally dependent of the global state status has been created or is visible. Then we access the state and check its values on the UI.

In this article we'll:

-Create and configure a React JS app from scratch

-Configured Redux into the app

-Configured End to End testing in our app using Cypress

-Access Redux state from Cypress when running automated testing

Our app will call an api to get a random recipe for cooking. After that we will verify using Cypress the info is being shown correctly to the user.

We'll use two api endpoints:

- One to get the recipe(a pasta recipe, I'm a fan of Italian cuisine)

- One to get the ingredients caloric info for each ingredient in the recipe

Once the app loads, the first endpoint(recipe) is call containing a field in the response object with all the ingredients. The thing is the caloric information for every ingredient is not present in this response. So We’ll have to call the second endpoint(ingredient) for each of the ingredients present in the array, also once all this endpoint calls are completed we’ll unify the response data from both endpoints and save it into the redux state. Our UI will consult this state to show the info to the user.

We'll be using a popular api for recipes named spoonacular , this is an amazing service to get info about food in general, so in order to use the api we have to register (link below):

https://spoonacular.com/food-api/console        

After register being completed we have to go to profile section and get the apiKey:

No alt text provided for this image

Now let's create the app, we use create-react-app tool:

npx create-react-app recipe        

In order to use the apiKey previously obtained we have to create an environment file in the root of our project named ".env" and put an entry containing the key:

REACT_APP_API_KEY=704fed1173864b2dbc39bbdae2h8fa91 //replace this value with your apiKey        

Lets install Redux to our app using Redux-Toolkit, axios library for make api calls easy, html-react-parser to render some content including html tags and bootstrap to style our UI, inside of generated recipe folder run the below command to install associated dependencies:

npm install @reduxjs/toolkit axios html-react-parser react-redux bootstrap        

Lets setup our Redux Store, this file represents our only reducer in our app, here is where we will manage our redux goblal state:

Path(please create the path for all code snippets): src/redux/reducer/recipe.js

import { createSlice } from '@reduxjs/toolkit'


const initialState = {
  value: null,
}


export const recipeSlice = createSlice({
  name: 'recipe',
  initialState,
  reducers: {
    fillRecipe: (state, action) => {
      state.value = action.payload;
    },
  },
})


// Action creators are generated for each case reducer function
export const { fillRecipe } = recipeSlice.actions;


export default recipeSlice.reducer        

As you see we have only one action in this reducer, the only functionality is to set the value of our reducer state, it means: save the object which represents the recipe.

Let's create our Redux Store and assign a reference of the redux Store to the window object of the browser when we are executing testing with Cypres:

Path: src/redux/store.js

import { configureStore } from '@reduxjs/toolkit'
import recipeReducer from './reducer/recipe'


export const store = configureStore({
  reducer: {
    recipe: recipeReducer,
  },
})


if (window.Cypress) {
  window['store'] = store
}        

Now let's import our Bootstrap css file and wrap our app inside of the Redux store using Provider High Order Component(HOC), change the file src/index.js to:

import 'bootstrap/dist/css/bootstrap.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { store } from './redux/store';
import { Provider } from 'react-redux';


const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
      <App />
    </Provider>
);


// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();        



Let's now create our Ingredient component, this component renders Ingredient info including an image of it. As you see we have included data attributes in order to access the html elements from our cypress code, I like to use the "data-cy" convention for naming data attributes to be accessed from Cypress:

Path: src/component/Ingredient/index.js

const Ingredient = ({ingredient, index}) => {
    return (<div className="col-3" key={index}>
                <div className='rounded'>
                <h5 className='text-uppercase' data-cy={`ingredient-${index}-name`}>{ingredient?.name}</h5>
                <div className='ingredient-image d-flex align-items-center justify-content-center'>
                    <img src={`${ingredient?.image}`}/>
                </div>
                <ul>
                    <li className='text-start' data-cy={`ingredient-${index}-protein`}>Percent Protein: {ingredient?.caloricInfo?.percentProtein}</li>
                    <li className='text-start' data-cy={`ingredient-${index}-fat`}>Percent Fat: {ingredient?.caloricInfo?.percentFat}</li>
                    <li className='text-start' data-cy={`ingredient-${index}-carbs`}>Percent Carbs: {ingredient?.caloricInfo?.percentCarbs}</li>
                </ul>
                </div>
            </div>);
};

export default Ingredient;        

As we see this component takes two properties:

-ingredient: object containing all ingredient info

-index: a unique identifier for each component in the UI

Now in the file App.js created by create-react-app we change the code to:

Path: src/App.js

import './App.css'
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import parse from 'html-react-parser';
import {generateRecipe} from './util';
import Ingredient from './component/Ingredient';


function App() {
  const dispatch = useDispatch();
  const recipe = useSelector((state) => state.recipe.value);


  useEffect(()=> {
    generateRecipe(dispatch)
  }, []);


  if(!!recipe)
    return (
      <div className='app' data-cy="recipe">
        <h1 className='text-center text-secondary'>Hi enjoy this healthy recipe today!!!</h1>
        <div className='recipe text-center'>
          <h3 data-cy="recipe-title">{recipe?.title}</h3>
          <img src={recipe?.image} className='recipe-image rounded'/>
          <p className='summary text-start'>
            <code>
              {parse(recipe.summary)}
            </code>
          </p>
          <h4>Ingredients</h4>
          <div className='container'>
            <div className='row'>
              {recipe?.missedIngredients.map((ingredient, index) => <Ingredient key={index} index={index} ingredient={ingredient}/>)}
            </div>
          </div>
        </div>
      </div>
    );
  else 
    return '';
}

export default App;        

Let's split this file content in order to explain it better, first we have the imports:

import './App.css'
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import parse from 'html-react-parser';
import {generateRecipe} from './util';
import Ingredient from './component/Ingredient';        

Then we get the App component declaration, along with the declaration of references for interact with our Redux Store: "dispatch" will allows to dispatch actions in order to modify the state and "recipe" is a live reference to the state value in our recipe reducer. Then we have useEffect hook declaration which callback function will be called only once when the component is created. In this function we'll call out utility function "generateRecipe" to obtain all the info from the api and provide the right format before being saved in the Redux state. Also we pass "dispatch" function as parameter because inside of this generateRecipe function we want to dispatch the final value to our Redux Store:

function App() 
  const dispatch = useDispatch();
  const recipe = useSelector((state) => state.recipe.value);
  
  useEffect(()=> {
     generateRecipe(dispatch)
  }, [])         

Then it's the final section of our component: html section. As we can see we only will render UI content if we are able to obtain a recipe and all related ingredients info, if not we get a blank page(just for simplicity):

  if(!!recipe)
    return (
      <div className='app' data-cy="recipe">
        <h1 className='text-center text-secondary'>Hi enjoy this healthy recipe today!!!</h1>
        <div className='recipe text-center'>
          <h3 data-cy="recipe-title">{recipe?.title}</h3>
          <img src={recipe?.image} className='recipe-image rounded'/>
          <p className='summary text-start'>
            <code>
              {parse(recipe.summary)}
            </code>
          </p>
          <h4>Ingredients</h4>
          <div className='container'>
            <div className='row'>
              {recipe?.missedIngredients.map((ingredient, index) => <Ingredient key={index} index={index} ingredient={ingredient}/>)}
            </div>
          </div>
        </div>
      </div>
    );
  else 
    return '';        

And this is the css associated to this component:

Path: src/App.css

.app {
 padding: 1em 3em;
 background: url("./assets/background.jpg") no-repeat center center fixed; 
 -webkit-background-size: cover;
 -moz-background-size: cover;
 -o-background-size: cover;
 background-size: cover;
}

.recipe-image {
  height: 250px;
  margin-bottom: 1em;
}


.summary {
  font-weight: 600;
}


.col-3 .rounded {
  border: 2px solid grey;
  padding-top: 0.5em;
  margin-bottom: 0.5em;
}


h4 {
  margin-bottom: 1.5em;
}


.ingredient-image {
  height: 120px;
}


ul {
  list-style-type: none;
  font-weight: 600;
}        

Now let's explain the file containing all logic for obtain the data from the api(utils.js) and provide the final format, first we have the "axios" library import, this is the library used to execute the api calls, next we have our reducer action to modify the state, then we have a const name "baseUrl" with the base url of our api:

Path: src/util/index.js

import axios from 'axios';
import { fillRecipe } from '../redux/reducer/recipe';
const baseUrl = 'https://api.spoonacular.com';        

Next there is our master function in charge to call the first endpoint obtaining the recipe info(step 1), then it will create an array of javascript promises, one for each ingredient and store them into an array(step 2), then it will call a function named "getAllCaloricInfo" who takes the previous array as a parameter and return the data of all ingredients in an array(step 3), then it calls "assignNutritionalInfo" function who is in charge to assign the caloric info obtained in step 3 to the correspondent ingredient in the "recipe" object obtained in step 1, once this is completed it dispatch the recipe object with all info already incorporated(step 4). As you see we have wrapped all this logic inside a try-catch block just in case an error occurs:

export const generateRecipe = async (dispatch) => {
    try {
      //step 1: get a radom recipe
      const response  = await axios.get(`${baseUrl}/recipes/complexSearch?query=pasta&addRecipeInformation=true&number=1&fillIngredients=true&apiKey=${process.env.REACT_APP_API_KEY}`);
      const {data: {results}} = response;
      
      if (Array.isArray(results) && results.length > 0) {

        const ingredientPromises = [];


       // step 2: create a list of promises, one for each ingredient 
       results[0]?.missedIngredients?.forEach(ingredient => {
            ingredientPromises.push(axios.get(`${baseUrl}/food/ingredients/${ingredient?.id}/information?amount=1&apiKey=${process.env.REACT_APP_API_KEY}`));        
        });


        //step 3: execute all promises
        const caloricInfo = await getAllCaloricInfo(ingredientPromises);


        //step 4: assign all nutritional info to the recipe
        const fullRecipe =  assignNutritionalInfo(results[0], caloricInfo);
        dispatch(fillRecipe(fullRecipe));
      }
    } catch (e){
      alert('Ooops, our Master Chef is out of kitchen');
    }
}        

This is how it looks the url for recipe endpoint request, the query parameters mean:

query=pasta&addRecipeInformation=true&number=1&fillIngredients=true&apiKey=${process.env.REACT_APP_API_KEY}        

-query = pasta (obtain a pasta recipe)

-addRecipeInformation = true (add instructions about how to cook this recipe)

-number = 1 (only one recipe)

-fillIngredients = true (response will incorporate the ingredients list)

-apiKey = 704fed1173864b2dbc39bbdae268fa9a

https://api.spoonacular.com/recipes/complexSearch?query=pasta&number=1&fillIngredients=true&apiKey=704fed1173864b2dbc39bbdae268fa9a        
No alt text provided for this image

In the picture above we see the response is an array with one recipe, including among other fields an array(missedIngredients) with all the ingredients, but as you see in the expanded ingredient object we don't have the caloric info, that's reason we have to call the ingredient endpoint.

Now let's see "getAllcaloricInfo" function in detail, as we can see all promises are executed in parallel, "Promise.All" waits until all promises are completed to then return a final array object containing an object response for each promise. After this is completed we return only the "data" object of each response:

const getAllCaloricInfo = async (ingredientPromises) => {
    //call all promises in parallel
    const response = await Promise.all(ingredientPromises);
    //return only the data
    return response.map(res => res.data);
}        

This is how it looks the url for ingredient endpoint request, the query parameters mean:

-amount = 1 (one ingredient)

-apiKey = 704fed1173864b2dbc39bbdae268fa91

As we can observe in the url there is a number(18372), this is the id of one of the ingredients obtained in the previous endpoint(recipe) call:

https://api.spoonacular.com/food/ingredients/18372/information?amount=1&apiKey=704fed1173864b2dbc39bbdae268fa91        
No alt text provided for this image

As can see in the picture above multiples requests are executed, one for each ingredient, and in the responde inside "nutrition" object there is a field named "caloricBreakdown" with all info related to Carbs, Fat and Protein.

Finally let's see "assignNutritionalInfo" function who is in charge of assigning the associated caloric info to each ingredient in the "recipe" object:

const assignNutritionalInfo = (recipe, caloricInfo) => {

    //for each ingredient assign the associated caloric info
    recipe.missedIngredients.forEach((ingredient, index)=> {
      ingredient['caloricInfo'] = caloricInfo[index]?.nutrition?.caloricBreakdown;
    });

    //return recipe with full information
    return recipe;
}        

Then in the root of our project folder we execute "npm run start", this command launch the app, then we visit https://localhost:3000:

No alt text provided for this image

Now it's time to create an End to End testing, first let's install Cypress in our project, in the project root folder we run the next command:

npm install cypress -D        

After that we execute the next command to launch the Cypress dashboard:

npx cypress open        

Now the below window will appear, and we select the "E2E testing" option:

No alt text provided for this image

This lead to the next screen where we click "CONTINUE" button:

No alt text provided for this image

This lead to other screen where we select our favorite available browser and click the green button "Start E2E Testing in ..."

No alt text provided for this image

Now an instance of your browser will be launched where we will select "Create new spec" option:

No alt text provided for this image

A modal to introduce the name of your test will appear and we'll name the test redux.cy.js and click "Create spec" button:

No alt text provided for this image

This will lead to a new modal where we can either run the test created or create another one, but now we'll just close this modal:

No alt text provided for this image


Previous actions have created a file in our project root folder named "cypress.config.js", this is where Cypress configuration is defined, here we need to add a field name "baseUrl" pointing to the origin of the app we want to test, React apps created using create-react-app by default run on port 3000, so we we add our entry pointing to this port and running in localhost:

const { defineConfig } = require("cypress")

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
    baseUrl: "https://localhost:3000"
  },
});;        

Also a folder named "cypress" has been created in the root of our project with a folder named "e2e" where our automated test recipe.cy.js live, let's change it's content to:

Path: src/cypress/e2e/recipe.cy.js

describe('Recipes', () => {

    it('Recipe info is being shown', () => {

        //go to our page
        //(step 1)
        cy.visit('/');


        //if the entry point to our UI exist it means the data was already available in redux
        //(step 2)
        cy.get('[data-cy="recipe"]').should('exist');


        //we have to access the redux store from the window object
        //then we get the state (step 3)
        cy.window().its('store').invoke('getState').then((state) => {

            //access reducer value
            const {recipe: {value}} = state;

            //validate the UI presents the same info
            //(step 4)
            cy.get('[data-cy="recipe-title"]').contains(value.title);

            const {missedIngredients} = value;
            missedIngredients.forEach((ingredient, index) => {

                const {caloricInfo: {percentProtein, percentFat, percentCarbs}} = ingredient;
                cy.get(`[data-cy="ingredient-${index}-protein"]`).should('include.text', percentProtein);
                cy.get(`[data-cy="ingredient-${index}-fat"]`).should('include.text',percentFat);
                cy.get(`[data-cy="ingredient-${index}-carbs"]`).should('include.text',percentCarbs);

            });

        });

    })

})        

Below code shows how first we visit our website(step 1) to then proceed to check the html element containing the recipe info in our UI exist(step 2), if this is the case then it means our Redux State is ready to be accessed because a value(the full recipe object) have been assigned, then we proceed to it (step 3) and finally once this state value is obtained we validate the data in our Redux state match the info being shown in the UI(step 4).

Now in our Cypress instance we click on our test "redux.cy.js":

No alt text provided for this image

And we can see our test runs fine:

No alt text provided for this image

Conclusions:

We have seen how to create and configured a React JS project with end to end testing incorporated. I hope this helps all end to end tester using Cypress when they need to access data from a centralized state like Redux. Thanks.

Full Code: https://github.com/norberton86/recipe

Alexei Rojas Quiroga

Software Developer | C# | .Net | Backend

2 年

Excellent work ??

Hello Norberto... We post 100's of job opportunities for developers daily here. Candidates can talk to HRs directly. Feel free to share it with your network. Visit this link - https://jobs.hulkhire.com And start applying.. Will be happy to address your concerns, if any

回复

要查看或添加评论,请登录

社区洞察

其他会员也浏览了