CypressでReduxストアが更新しない問題
redux store へアクセス
とあるプロジェクトの E2E テストで Cypress を導入していて、とあるテストケースでは、一つのリソースに対して CRUD 操作をテストしています。
この CRUD 操作によって、react のステートが更新され、さらに UI に反映されていく流れです。
ただ、通常 react のステートの更新による、画面の変化というのは、HTML 要素を Cypress の API で簡単に確認はできますが、今回のケースは若干特殊で、HTML 要素の変化がされず、WebGL のレイヤーに描画されるものになります。
これをテストするために、直接 HTML 要素の変化が確認できないので、迂回策として Redux のストアの変化を見るようにしました。
アプリコード側
Cypress 上で、redux store へのアクセスするには、まずアプリコード内にstore
をwindow
にくっつけることです。
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
オブジェクトにCypress
やstore
が存在しないので、@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 つの要素から構成されています。
- アプリケーションの遅延でステートが更新されない
-
invoke
API にリトラーはないため、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