🫠

CypressでReduxストアが更新しない問題

2023/05/14に公開

redux store へアクセス

とあるプロジェクトの E2E テストで Cypress を導入していて、とあるテストケースでは、一つのリソースに対して CRUD 操作をテストしています。

この CRUD 操作によって、react のステートが更新され、さらに UI に反映されていく流れです。

ただ、通常 react のステートの更新による、画面の変化というのは、HTML 要素を Cypress の API で簡単に確認はできますが、今回のケースは若干特殊で、HTML 要素の変化がされず、WebGL のレイヤーに描画されるものになります。

これをテストするために、直接 HTML 要素の変化が確認できないので、迂回策として Redux のストアの変化を見るようにしました。

アプリコード側

Cypress 上で、redux store へのアクセスするには、まずアプリコード内にstorewindowにくっつけることです。

import React from "react";
import { Provider as StoreProvider } from "react-redux";

const store = createStore(reducer);

const App = () => {
  return <StoreProvider store={store}>{/* <...> */}</StoreProvider>;
};

if (window.Cypress) {
  window.store = store;
}

export default App;

ts で書く場合、WindowオブジェクトにCypressstoreが存在しないので、@ts-ignoreするか、別途タイプ宣言しておく必要があります。

// global.d.ts
declare global {
  interface Window {
    Cypress?: Record<string, any>;
    store?: Record<string, any>;
  }
}

export {};

Cypressコード側

次に Cypress 側で、ストアアクセスするためのコマンドを作ります。

// support/commands.ts
import "@testing-library/cypress/add-commands";

Cypress.Commands.add("getReduxStore", (key: string) => {
  return cy
    .window()
    .its("store")
    .invoke("getState")
    .then((state) => cy.wrap(state[key]));
});

// global.d.ts

/// <reference types="cypress"/>

declare namespace Cypress {
  interface Chainable {
    getReduxStore(key: string): Chainable<Record<string, any>>;
  }
}

これで、テストコード内では、cy.getReduxStore('key')の形で、ストアのデータへアクセスすることができるようになります。

actions 上でステートが更新されない問題

この問題にかなり困っていました。ステートの前後の変化を観測するので、2 回getReduxStoreを呼び出しますが、2 回目の呼び出しにステートが更新されない問題があります。

cy.intercept("DELETE", url).as("deleteResource");
// get state before update
cy.getReduxStore("data").then(({ list }) => {
  const lengthBefore = list.length;

  // CRUD operation ...

  cy.wait("@deleteResource").then((interception) => {
    const { request, response } = interception;
    // request succeeded
    expect(response.statusCode).eq(200);

    // check state updated
    cy.getReduxStore("data").then(({ list }) => {
      expect(list.length).eq(lengthBefore - 1); // -> failed here
      // other assertions...
    });
  });
});

この問題は、github actions 上実行する時にほぼ 100%の確率で出会いました。ローカルから再現できず、actions 上のデバッグもかなりハードなので、解決するまでかなり時間がかかりました。

という時に こちらの記事 と出会いました。

A problem の節で述べているように、仮にアプリケーションの処理で delay が生じた場合、2 回目にステートをアクセスするときに必ずしも更新しているわけではないと。記事の例のように、実際に timeout をつけて強引に遅延させると再現可能な問題です。

Cypress のタイムアウト

Cypress のほとんどのコマンドは、呼び出された時に待ち時間が設けられています( こちら )。

例えば、cy.get()の API には、取得操作 → 要素が存在するアサーションというロジックになっています。アサーションが失敗すると、即時にテスト失敗となるわけではなく、タイムアウトの時間までリトライし続けます。

Cypress を使うとこの機能が当たり前のように思ってしまいがちですが、タイムアウトのない、もしくはリトライできない API も存在するのです。

例えば、上記のgetReduxStoreで使われているinvokeがそれにあたります。というのは、invokeで呼び出された処理でアプリケーションを変えてしまう可能性があるので、 安全にリトライ することができないのです。

結果的に、この場合だと、getReduxStoreで取得したデータは一回切りのもので、継続するアサーションがマッチするまで更新されることがありません。

アプローチ1ー待つ

これまでの問題を整理すると、問題は 2 つの要素から構成されています。

  • アプリケーションの遅延でステートが更新されない
  • invokeAPI にリトラーはないため、getReduxStoreの値は更新されない

要素 1 から考える最もシンプルな解決策として、更新するまでwaitコマンドを通して待てば良い、とのことです。

cy.intercept("DELETE", url).as("deleteResource");
// get state before update
cy.getReduxStore("data").then(({ list }) => {
  const lengthBefore = list.length;

  // CRUD operation ...

  cy.wait("@deleteResource").then((interception) => {
    const { request, response } = interception;
    // request succeeded
    expect(response.statusCode).eq(200);

    // wait 2secs before checking state updated
    cy.wait(2000)
      .getReduxStore("data")
      .then(({ list }) => {
        expect(list.length).eq(lengthBefore - 1);
        // other assertions...
      });
  });
});

これで実際にテストしてみると、確かに actions 上でも通るようになりました。

しかしこれは根本的な解決になっていません。仮に 2 秒で足りなかったら失敗するし、操作によって待ち時間が前後するし、無駄な待ち時間が増えるし、不安要素が多すぎます。さらに、公式的にも、任意の時間を待つことをアンチパターンとして薦められていません(こちら)。

アプローチ2ーリトライ

ここはやはり、問題の要素 2 から考えて、Cypress のリトライ機能を利用できるコマンドにすれば良い、との方向です。

幸い、このリトライ問題を解決するための プラグイン がありました。ドキュメントで示されているように、shouldのアサーションが続いている場合、アサーションがパスするもしくはタイムアウトとなるまでリトラーするよ、とのことです。

AND is followed by a cy.should, the function will be retried until the assertion passes or times out (most Cypress commands do this)

つまり、次のように変えておくと、cy.getReduxStore('data').should(...)の形で呼び出せば、リトライ可能になります。

Cypress.Commands.add("getReduxStore", (key: string) => {
  const getState = (win: Cypress.AUTWindow) => win.store.getState()?.[key];
  return cy.window().pipe(getState);
});

ただ、ストアオブジェクトは通常複数のキー、ネストされているキーを持つオブジェクトなので、毎回トップレベルのstoreを取得しても、shouldを書くのが大変です。それを対応するために、コマンドを少し改善する必要があります。

Cypress.Commands.add("getReduxStore", (key: string) => {
  const getNestedValue = (obj: Record<string, any>, key: string) =>
    key.split(".").reduce((acc, cur) => acc?.[cur], obj);
  const getState = (win: Cypress.AUTWindow) => {
    const state = win.store.getState();
    return getNestedValue(state, key);
  };
  return cy.window().pipe(getState);
});

それでテストコードでは、cy.getReduxStore('data.list').should('have.length', lengthBefore - 1)とかで使えます。

このリトライ機能はある種、保険をかけていることなので、仮にもっと詳細なチェックをしたい場合、その直後でやれば良いとのことです。

cy.intercept("DELETE", url).as("deleteResource");
// get state before update
cy.getReduxStore("data").then(({ list }) => {
  const lengthBefore = list.length;

  // CRUD operation ...

  cy.wait("@deleteResource").then((interception) => {
    const { request, response } = interception;
    // request succeeded
    expect(response.statusCode).eq(200);

    // make sure the state is updated
    cy.getReduxStore("data.list").should("have.length", lengthBefore - 1);
    // extra detailed assertions
    cy.getReduxStore("data.list").then((list) => {
      expect(list.length).eq(lengthBefore - 1);
      // other assertions...
    });
  });
});

終わりに

今回は Cypress で redux store へのアクセスとその問題の解決策についてまとめました。もし今回の問題のように、「ステートが更新されてない?!」とかと出会ったら、リトライ可能な形に変換してみても良いかもしれません。

ではでは。

Discussion