Cypress の wait について考える

　Cypressとはwebサイト等の自動テストツールであり、なんかとてもいい感じに動いてくれる(らしい)ものである。これがどのようになんかとてもいい感じなのかは以下に詳しい。

https://docs.cypress.io/guides/overview/key-differences

　この記事はCypressでのテスト作成において何度も立ちはだかった「通ったり通らなかったりする」という状態(主にレンダリングとのズレ)に対してどう向かえばよいのか考えるものである。
　まったくベストプラクティスでないことを断っておく。また、一般化された解決策ではなく、場当たり的多数回試行的なものである。

https://docs.cypress.io/guides/references/best-practices

<dl>
<dt>テスト種別</dt>
<dd>E2Eテスト</dd>
<dt>ブラウザ</dt>
<dd>Chrome</dd>
<dt>Cypressバージョン</dt>
<dd>v10.11.0</dd>
</dl>

# wait をどう使うか

<dl>
<dt>結論(体感)</dt>
<dd>・レンダリング関係での失敗はwait(n)でほぼ確実に避けることができるがその分時間がかかる</dd>
<dd>・タイムアウトの時間を延ばしてもレンダリング関係のものはあまり解決しない</dd>
<dd>・API(ネットワークリクエスト/レスポンス)の待ちで解決できることがある</dd>
<dd>・待つ代わりにshould()での確認を挟むと解決しやすい</dd>
</dl>

## wait(n)を挟みたい

　ベストプラクティス等々いろいろなところ[^bestpractice][^explicit]では「wait(n)を挟むな!」のような調子である。そこまででは無いと思う。
　waitを挟みたいときは大抵、ページのロードが遅いだとかサーバーとのやり取りで少し時間がかかるだとかで次のテストコマンドが早まって失敗しないようにしたいときであろう。
　そもそもCypressでは以下のように様々なタイムアウトが設定できるようになっており[^cyconf]、wait(n)による待ちは挟まなくても良いように思える。
| 項目 | 対象 |
|:-:|:-:|
| defaultCommandTimeout | Cypressコマンド[^cytimeout] |
| execTimeout | cy.exec()によるシステムコマンド[^cytimeout] |
| taskTimeout | cy.task()によるtaskプラグインコマンド[^cytimeout][^cytask] |
| pageLoadTimeout | ページ遷移やcy.visit/go/reload()コマンド[^cytimeout] |
| requestTimeout | cy.as()でエイリアスにされたリクエスト[^cywaittimeout] |
| responseTimeout | cy.as()でエイリアスにされたリクエストのレスポンス[^cywaittimeout] |

　しかし、私がテストを組んだときはこのタイムアウトにあまり効を感じることはなかった。たとえばページロードだとかそういうものに対しては目に見える意味があったが、URLが変わりロード単体が終わったところで次に流れてしまうため、結局レンダリングには間に合わずにテストが失敗してしまうことが多かった。waitを挟まないことで結果的に**Flaky**[^cyflake]なテストが量産されていた。

```javascript: before
cy.get('hoge').contains('fuga').click();
cy.get('piyo').contains('fugafuga').click();
// -> タイムアウト!
cy.get('piyopiyo >').should('have.length', 1);
// -> タイムアウト!
```

　確実に画面がレンダリングされ、操作が可能になるまで必ず待つためにwait(n)を挟んだ。

```javascript: after
cy.get('hoge').contains('fuga').click();
cy.wait(100);
// 通る
cy.get('piyo').contains('fugafuga').click();
cy.wait(500);
// 通る
cy.get('piyopiyo >').should('have.length', 1);
```

　そうすると嘘のようにするするとテストが進み本質的でないテストの失敗が無くなった。

　こういったようにwait(n)を挟まないと正常に進まないときは大抵、

* コマンド成功とページ遷移が同期しなかった
* レンダリングの途中で次のコマンドの条件が揃い不完全なまま実行された
* should('not.exist')が次のコマンドだった等(= shouldがロード前に反応してしまう)
* (confirm)に引っかかる、(alert)が予期せぬタイミングに入ってくる

などがあり、

```javascript:
cy.get('hoge').contains('fuga').click();
// 画面遷移開始
cy.get('piyo')
// レンダリング完了 -> しかしget(piyo)はレンダリング前
.type('hogehoge');
// タイムアウト!
```

のようなことに起因する失敗には手を焼いた。これはタイムアウトでは対処できず、getが通過してしまった勢いでtypeが走り失敗となってしまう。

## wait(n)から解き放たれたい

　このページが参考になった。

https://filiphric.com/waiting-in-cypress-and-how-to-avoid-it

　wait(n)は確実にn秒コマンド実行を停止するため、テストの高速化という面では劣っており確かに非効率ではある。
　wait(n)を使わずにこれまでの問題を解決してみよう。

### タイムアウトの時間を延ばす

　タイムアウトに関する公式のページは主に以下である。

https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#Timeouts

　まずは **cypress.config.js** からタイムアウトの時間を調整してみよう。
　cypress.config.jsはCypressのダッシュボードの*Settings>Project settings>Resolved configuration* から設定項目を確認することができる。シアンの掛けがついている部分がスクリプトで変えられる部分だ。
　以下のように設定を変更したい部分だけを {} の中に記していく。

```javascript: cypress.config.js
const { defineConfig } = require("cypress");

module.exports = defineConfig({
    e2e: {
        setupNodeEvents(on, config) {},
        defaultCommandTimeout: 20000, //初期値: 4000
        pageLoadTimeout: 100000, //初期値: 60000
    },
});
```

　おそらく**defaultCommandTimeout**と**pageLoadTimeout**の値をいろいろ試しながら変えると良いだろう。
　レンダリングが間に合わないだとか、ページ遷移が間に合わないだとか、そういうことに起因する失敗はこれで回避できる可能性がある。タイムアウトの時間を延ばす副作用として、失敗判定されるまでの時間が長くなるというものがある。テストの流れの最初の方で失敗すれば、その後のit()の始まりは多くの場合失敗となり (これに関してはテストの独立化である程度回避すべきことではある[^cyiso]がその程度が難しい) 、その分だけタイムアウトが増えるということだ。
　これを避けるアイデアとして、コマンドのオプションでタイムアウトを個別に付与することがある。

```javascript:
// describe内でdefaultCommandTimeoutが使えるコマンド全てに適用
describe('describe timeout', {defaultCommandTimeout: 15000})
// it内でdefaultCommandTimeoutが使えるコマンド全てに適用
it('it timeout', {defaultCommandTimeout: 15000})
// 特定のコマンドのみに適用
cy.get('hoge', {defaultCommandTimeout: 15000})
```

　この用法での注意点は「設定上書き」という点である。指定したタイムアウトはこれまでの設定に上乗せされるわけではなく、そこだけその時間に設定される。設定値のオフセットを考慮しよう。

　しかし、これで完全に解決できるわけではない。タイムアウトを延ばしたからといってレンダリングを上手く待ってくれるわけではないのだ。ただテストが失敗になるまでの時間が延びただけというパターンも経験上多い。テストやサイトの構造上の問題なのかもしれないが。

### エイリアスを使う

　wait()は単にある秒数だけ停止させるだけではなく、エイリアス[^cyaliase]を用いて特定のリクエストやレスポンスを待つこともできる[^cynet]。
　エイリアスは *as()* で定義する。click()等の動作が起こるものはエイリアス化しない方がいい[^cyas]。

```javascript:
cy.get('hoge').contains('fuga').as('fuga');
cy.get('@fuga').click();

// 適切でない例
cy.get('hoge').contains('fuga').click().as('fuga');
cy.get('@fuga');
```

　wait()で使えるエイリアスは *intercept* によるリクエスト/レスポンスの待ちである。これはテストコマンドの直後のリクエスト/レスポンスに対する解決を待つ事ができる。

```javascript:
// HTTPメソッドをエイリアス化
cy.intercept('POST', '/hoge/fuga').as('getfuga');
// POST が出されるコマンド
cy.get('[data-cy="submit"]').click();
// intercept に登録したAPIが実行されるのを待機
cy.wait('@getfuga');
```

　APIがちゃんと満たされるまで待たないと次のテストが失敗するような場合はwait(n)よりもwait(intercept)のほうが完全に効率的である。
　今回求めていたものではないが、これ自体はとても有用である。ベストプラクティス等でもこれが多用されている。以下がinterceptについてわかりやすい。

https://future-architect.github.io/articles/20210810b/


## どうしよう -wait()を使わない選択肢-

　wait()には時間による待ちとエイリアスによる待ちがあることがわかった。そして時間による待ちは効率がほぼ最悪ということも理解できた。しかしながら、上までの手法が直接的な解決にならない場合はどうすれば良いのだろうか。

### url()
　url()[^cyurl]を用いてurlが変化するのを検知するのも手ではある。

```javascript: 
cy.get('hoge').click();
cy.url().should('eq', '/hoge');
// この時点でURLは適当なものに変化している
cy.get('fuga').should('include.text', 'piyo');
```

　これによってURLが遷移してからget()等を行うことができる。レンダリングによる失敗と感じるものの中には単にURLの遷移のタイミングがずれているだけということがある。それこそ上で言及した、遷移前にget()が成功しチェーンされた関数が失敗するというパターンはこれによって解決できる可能性が高い。

　ちなみにurl()はlocation()[^cyloc]を使っても同じような効果を得られる。

```javascript:
cy.url().should('include', '/home');
cy.location('pathname').should('include', '/hoge');
```

### shouldを上手く利用する

　レンダリングによって失敗し、かつurl()での判定でも通らない場合、考え方を「レンダリングが終わったかどうか」という方向に変えてみるのも手である。
　つまり、レンダリング後にのみ存在する要素が画面にあるかどうかを確認してから動作を実行させる。

```javascript: before
cy.get('alpha').click();
cy.get('hoge')
// 遷移前にget()が通る
// レンダリング後だがyieldはレンダリング前で失敗
.contains('fuga')
.click();
```

```javascript: after
cy.get('alpha').click();
// レンダリング後にのみある要素の判定
cy.get('piyo').should('exist');
// shouldが通ればレンダリング後
cy.get('hoge')
// yieldはレンダリング後のget()による適切なもの
.contains('fuga')
.click();
```

　shouldはその性質上比較的待ってくれやすいため、他のものとは異なりレンダリングを待たせることができる。

## 結局どうやって待つのが良いのか

　個人的な結論は、とにかく試行的改良を行うことである。wait(@)やurl()を試してみて、だめならshould()で……のように、失敗となるたびに該当箇所の特性を考えながら様々に試行するのが最終的に最善となると感じる。その結果wait(n)しか無理だと思えばそうなのだろう。
　以下に同じテストコードにおいて「全てwait(n)で回避した例」と「wait(n)を使わず回避した例」を残しておく。ちなみに、wait(n)を単に除去した例では1度も最後まで走らなかった。

| 項目 | wait(n)有り | wait(n)無し |
|:-:|:-:|:-:|
| wait(n)の数/合計時間 | 80/22秒 | 1/0.1秒 |
| 10回平均時間 | 69.6秒 | 48.5秒 |
| 10回成功するまでの試行回数 | 11 | 25 |

(後者でどうしても解決できない失敗が存在したため一箇所のみwait(100)を挟んだ)

　wait(n)を除きかつ非本質的な失敗を避けるために様々な方式を試していく工程は、wait(n)を挟んでいくよりも時間がかかり、かつ「何故どこをどのように」がよくわからないままであることが多く非効率に感じた。例えばAPIだとか単純なレンダリングのズレだとか、そういうものは解決がシンプルであるが、そうでないとかなり沼である。一度回避できたとしても、それがたまたまなのかどうかも判断がし辛い。故に表のように個別ではクリアできても通しでは失敗するということが起きる。
　それに対し、非本質的な失敗が起こるたびに該当箇所にwait(n)を挟んで回避させていくのは確実でかつ楽である。
　気持ち的にはwait(n)で回避させてしまいたいが、Cypressの看板である「速さ」という点では明らかにwait(n)を回避すべきである。無駄な待ち時間が減り、テスト流上最速に終わらせることができる。

[^cyconf]: https://docs.cypress.io/guides/references/configuration

[^cytimeout]: https://docs.cypress.io/guides/references/configuration#Timeouts

[^cywaittimeout]: https://docs.cypress.io/api/commands/wait#Timeouts

[^cyflake]: https://docs.cypress.io/guides/overview/key-differences#Flake-resistant

[^whatise2e]: https://circleci.com/ja/blog/what-is-end-to-end-testing/

[^bestpractice]: https://docs.cypress.io/guides/references/best-practices#Unnecessary-Waiting

[^explicit]: https://filiphric.com/8-common-mistakes-in-cypress-and-how-to-avoid-them#1-using-explicit-waiting

[^cyiso]: https://docs.cypress.io/guides/core-concepts/test-isolation

[^cynet]: https://docs.cypress.io/guides/guides/network-requests#Waiting

[^cyaliase]: https://docs.cypress.io/guides/core-concepts/variables-and-aliases#Aliases

[^cyas]: https://docs.cypress.io/guides/core-concepts/variables-and-aliases#Elements

[^cytask]: https://docs.cypress.io/api/commands/task

[^cyurl]: https://docs.cypress.io/api/commands/url

[^cyloc]: https://docs.cypress.io/api/commands/location