reactjsnext.jsreduxjestjsredux-toolkit

How to disable store persistence between Jest tests?


Has anyone used tests with Jest and run into issues where a Redux store persists between tests? Any ideas on how to reset it or prevent this?

await act(async () => {
  render(
    <ProviderRootLayout>
      <ProductCard product={mockProduct} />
    </ProviderRootLayout>
  );
});

In my test, I'm testing the actual integration between the provider and the component, but it's causing the problem I mentioned — the first test runs fine, but subsequent ones fail because they start using data from the previous test, which is not correct.

I've tried using afterEach, but it hasn't worked (and it does run, since the console.log fires):

afterEach(async () => {
  console.log("going to clear the stores");
  jest.clearAllMocks(); // Clear mocks after each test
  jest.resetModules();
  jest.clearAllTimers();
  localStorage.clear();
  sessionStorage.clear();
  await persistor.purge();
});

I've searched online and asked some AIs, but no luck so far.

Now I leave you the rest of the code that I think is relevant:

test code

import { act, render, screen, fireEvent } from "@testing-library/react";

import ProductCard from "./ProductCard";

import ProviderRootLayout from "../layouts/ProviderRootLayout";
import { persistor } from "@/store";

jest.mock("axios", () => {
  // ... more mock code here

  mockedCreateAxios.mockReturnValue({
    get: restponseGet,
  });

  return {
    create: mockedCreateAxios,
  };
}); // Mock axios

const mockProduct = {
  id: 1,
  name: "Cerveza Premium",
  description: "Cerveza artesanal de alta calidad",
  priceBaseCurrency: 5.99,
  priceBaseDiscount: null,
  stock: 2,
  ignoreStock: false,
  published: true,
  image: "/assets/products/img/cerveza.png",
  discountPercentage: 20,
  freeShipping: true,
  categoryId: 1,
  companyId: 1,
  itsNew: true,
  brand: "Marca1",
};

describe("ProductCard Component", () => {
  afterEach(async () => {
    console.log("va a limpiar los store");
    jest.clearAllMocks(); 
    
    jest.resetModules();
    jest.clearAllTimers();
    localStorage.clear();
    sessionStorage.clear();
    await persistor.purge();
  });

  const setup = async () => {
    /* eslint-disable testing-library/no-unnecessary-act */
    await act(async () => {
      render(
        <ProviderRootLayout>
          <ProductCard product={mockProduct} />
        </ProviderRootLayout>
      );
    });
    /* eslint-enable testing-library/no-unnecessary-act */
  };

  it("disables add to cart button when stock is insufficient", async () => {
    await setup();
    const incrementButton = screen.getByTestId("idtest-button-add");
    expect(incrementButton).not.toHaveClass("cursor-not-allowed");
    fireEvent.click(incrementButton);

    const addToCartButton = screen.getByTestId("idtest-button-cart-add");
    expect(addToCartButton).not.toHaveClass("cursor-not-allowed");
    fireEvent.click(addToCartButton);
    expect(addToCartButton).toHaveClass("cursor-not-allowed");
  });

  it("(repeat) disables add to cart button when stock is insufficient", async () => {
    await setup();
    const incrementButton = screen.getByTestId("idtest-button-add");
    expect(incrementButton).not.toHaveClass("cursor-not-allowed");
    fireEvent.click(incrementButton);

    const addToCartButton = screen.getByTestId("idtest-button-cart-add");
    expect(addToCartButton).not.toHaveClass("cursor-not-allowed");
    fireEvent.click(addToCartButton);
    expect(addToCartButton).toHaveClass("cursor-not-allowed");
  });
});

fixstore.ts

import createWebStorage from "redux-persist/lib/storage/createWebStorage";
import { WebStorage } from "redux-persist/lib/types";

export function createPersistStorage(): WebStorage {
  const isServer = typeof window === "undefined";

  // Returns noop (dummy) storage.
  if (isServer) {
    return {
      getItem() {
        return Promise.resolve(null);
      },
      setItem() {
        return Promise.resolve();
      },
      removeItem() {
        return Promise.resolve();
      },
    };
  }

  return createWebStorage("session"); // return createWebStorage("local");
}

const storage = createPersistStorage();

export default storage;

cartSlice.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Product } from "@/types";

export interface CartItem {
  product: Product;
  quantity: number;
}

export interface CartState {
  items: CartItem[];
}

function loadInitialCart(): CartItem[] {
  return [];
}

const initialState: CartState = {
  items: loadInitialCart(),
};

const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addToCart: (state, action: PayloadAction<CartItem>) => {
      const existingItem = state.items.find(
        (item) => item.product.id === action.payload.product.id
      );
      if (existingItem) {
        existingItem.quantity += action.payload.quantity;
      } else {
        state.items.push(action.payload);
      }
    },
    removeFromCart: (state, action: PayloadAction<number>) => {
      state.items = state.items.filter(
        (item) => item.product.id !== action.payload
      );
    },
    clearCart: (state) => {
      state.items = [];
    },
  },
});

export const { addToCart, removeFromCart, clearCart } = cartSlice.actions;

export default cartSlice.reducer;

store/index.ts

import { configureStore } from "@reduxjs/toolkit";
import storage from "./fixstore";

import { persistReducer, persistStore } from "redux-persist";
import { combineReducers } from "@reduxjs/toolkit";
import {
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
} from "redux-persist";

import currencyReducer from "./slices/currencySlice";
import locationSlice from "./slices/locationSlice";
import notificationSlice from "./slices/notificationSlice";
import cartSlice from "./slices/cartSlice";

const persistConfig = {
  key: "root",
  storage,
  whitelist: ["currency", "location", "cart"], // Solo persiste el reducer de autenticación
};

const rootReducer = combineReducers({
  currency: currencyReducer,
  location: locationSlice,
  notification: notificationSlice,
  cart: cartSlice,
});

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const persistor = persistStore(store);

ProviderRootLayout.tsx

import React from "react";
import Providers from "@/providers/Providers";
import RootLayoutContent from "@/components/layouts/RootLayoutContent";

const ProviderRootLayout = ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) => {
  return (
    <Providers>
      <RootLayoutContent>{children}</RootLayoutContent>
    </Providers>
  );
};

export default ProviderRootLayout;

Providers.tsx

"use client";

import { Provider } from "react-redux";
import { store, persistor } from "@/store";
import { PersistGate } from "redux-persist/integration/react";
import MainLoader from "@/components/common/loaders/MainLoader";

export default function Providers({ children }: { children: React.ReactNode }) {
  return (
    <Provider store={store}>
      <PersistGate loading={<MainLoader />} persistor={persistor}>
        {children}
      </PersistGate>
    </Provider>
  );
}

What I need is for the store data to start from the beginning between tests, resetting between each one, so that the test data is isolated. (For example, testing the same test twice that uses the store produces the same result.) But it's important that I try to test with the real components, meaning using the same Provider, to avoid false positives.


Solution

  • You should use a new Redux store for each test instead of using a single globally defined store reference and attempting to "clean up" between tests.

    The unit tests might also be doing a tad bit too much. You very likely don't need the Redix-Persist state persistence and UI layout components just to unit test the ProductCard component. Provide and mock only the bare minimum the component under test needs.

    Using a Wrapper to Render Providers

    export const setupStore = (preloadedState?: Partial<RootState>) => {
      return configureStore({
        reducer: rootReducer,
        preloadedState
      });
    }
    
    export type RootState = ReturnType<typeof rootReducer>;
    export type AppStore = ReturnType<typeof setupStore>;
    
    const AllProviders = ({ children }: { children: React.ReactNode }) => (
      <Provider store={setupStore()}>
        {children}
      </Provider>
    );
    
    render(<SomeComponent />, { wrapper: AllProviders });
    

    Example:

    import { act, render, screen, fireEvent } from "@testing-library/react";
    import { AllProviders } from "../path/to/AllProviders";
    
    // Component under test
    import ProductCard from "./ProductCard";
    
    jest.mock("axios", () => { .... }); // Mock axios
    
    const mockProduct = { .... };
    
    describe("ProductCard Component", () => {
      ...
    
      it("disables add to cart button when stock is insufficient", async () => {
        render(
          <ProductCard product={mockProduct} />,
          { wrapper: AllProviders }
        );
    
        const incrementButton = screen.getByTestId("idtest-button-add");
        expect(incrementButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(incrementButton);
    
        const addToCartButton = screen.getByTestId("idtest-button-cart-add");
        expect(addToCartButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(addToCartButton);
        expect(addToCartButton).toHaveClass("cursor-not-allowed");
      });
    
      it("(repeat) disables add to cart button when stock is insufficient", async () => {
        render(
          <ProductCard product={mockProduct} />,
          { wrapper: AllProviders }
        );
    
        const incrementButton = screen.getByTestId("idtest-button-add");
        expect(incrementButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(incrementButton);
    
        const addToCartButton = screen.getByTestId("idtest-button-cart-add");
        expect(addToCartButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(addToCartButton);
        expect(addToCartButton).toHaveClass("cursor-not-allowed");
      });
    });
    

    Using a Custom Render Function

    This is such a comment pattern that it is recommended to create a custom render function to wrap creating the providers and render the component-under-test.

    import { render, RenderOptions } from "@testing-library/react";
    import type { AppStore, RootState } from "@/store";
    import { setupStore } from "@/store";
    
    interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
      preloadedState?: Partial<RootState>;
      store?: AppStore;
    }
    
    export const renderWithProviders = (
      ui: React.ReactElement,
      extendedRenderOptions: ExtendedRenderOptions = {}
    ) => {
      const {
        preloadedState = {},
        store = setupStore(preloadedState),
        ...renderOptions
      } = extendedRenderOptions
    
      const Wrapper = ({ children }: React.PropsWithChildren) => (
        <Provider store={store}>{children}</Provider>
      );
    
      return render(ui, { wrapper: Wrapper, ...renderOptions });
    };
    
    renderWithProviders(<SomeComponent />);
    renderWithProviders(<SomeComponent />, { preloadedState });
    

    Example:

    import { act, screen, fireEvent } from "@testing-library/react";
    import { renderWithProviders } from "../path/to/AllProviders";
    
    // Component under test
    import ProductCard from "./ProductCard";
    
    jest.mock("axios", () => { .... }); // Mock axios
    
    const mockProduct = { .... };
    
    describe("ProductCard Component", () => {
      ...
    
      it("disables add to cart button when stock is insufficient", async () => {
        renderWithProviders(<ProductCard product={mockProduct} />);
    
        const incrementButton = screen.getByTestId("idtest-button-add");
        expect(incrementButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(incrementButton);
    
        const addToCartButton = screen.getByTestId("idtest-button-cart-add");
        expect(addToCartButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(addToCartButton);
        expect(addToCartButton).toHaveClass("cursor-not-allowed");
      });
    
      it("(repeat) disables add to cart button when stock is insufficient", async () => {
        renderWithProviders(<ProductCard product={mockProduct} />);
    
        const incrementButton = screen.getByTestId("idtest-button-add");
        expect(incrementButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(incrementButton);
    
        const addToCartButton = screen.getByTestId("idtest-button-cart-add");
        expect(addToCartButton).not.toHaveClass("cursor-not-allowed");
        fireEvent.click(addToCartButton);
        expect(addToCartButton).toHaveClass("cursor-not-allowed");
      });
    });
    

    This is such a comment pattern that it is recommended to create a custom render method to wrap creating the providers.