あけましておめでとうございました。当の昔に年は明けてますが、改めまして。
Cypressで色々やろうとしてみたところ、以外と動かなくてハマってしまったので、メモっておこうかと思います
やりたいこと
Cypressには、 fixture という機能があります。簡単に言えば、指定したファイルを読み込んでその中身を取得できる・・・という機能です。Playwrightとかだと手書きしないといけないですが、Cypressはディレクトリ構造とかまで決められている代わりに、組み込みで提供されてます。
cy.fixture('users').as('usersJson') // load data from users.json
cy.fixture('logo.png').then((logo) => {
// load data from logo.png
})
上記のように、 cy.fixture
を利用することでできます。まぁシンプルですね。
さて、これだけならとてもシンプルな話で終わるのですが、例えば次のようなシナリオを考えると、これはそのままでは動作しません。
- msw.jsでresponseのモックをしたい
- 条件によって利用するfixtureを変更したい
- 条件はrequestの中身だったりするので、msw.jsの仕組みの中で判定とかしたい
という感じです。擬似コード的なものとしては、次のようにしたいよ、という感じですね。
cy.mockAPI({
"http://localhost:3000/hoge": post(["simple", "changed"], {
"simple": async (req) => {
const json = await req.clone().json();
return json.input_value === "test";
},
"changed": async (req) => {
const json = await req.clone().json();
return json.input_value === "changed";
},
}),
})
mockAPIというのは自作したAPIで、まぁ対象のURLにアクセスされたときにどのfixtureでモッキングするのか?というのを定義するためのDSLです。正直洗練できてはいないのですが、個人で利用するだけなのでまぁいいかな、というところ。
Cypressでやってみると起こること
Cypressは、Playwrightなどのasync/awaitを大前提にしたFrameworkよりかは、webdriverと同様に、 async/awaitを意識せずに同期的に書ける ことが重視されていると感じます。
過去、async/awaitが存在していない時代、コールバック地獄になっていたときは、このように同期的に処理できるAPIは画期的でした。今はもうasync/awaitが一般的になってしまいましたが。
さて、しかしこれはこれで困ったことになることに気付きました。前述した cy.fixture
ですが、こいつはPromiseを返すようで返していません。型としては Chainable
という型が返されます。 then
とかが使えるのでPromiseLikeなのかな〜と思ったりしますが、 そうではありません 。
例えば以下の例はどうなるでしょうか?
Promise.all(["a", "b"].map((fixture) => cy.fixture(fixture))).then(console.log);
// => ???
現在の一般的?な知識を前提にすると、fixtureの中身の配列・・・が返却されそうですが、答えは undefined
(だったか空配列)が表示されます。 cy.fixture
にthenで繋いだとしても、結果は同じになります。
これがなんで発生するのか、を考察すると、CypressはPromiseを露出させずに同期的に実行するため、Promiseを別途解決する仕組みを持っていると推察されます。cy.xxxを実行する度に、そのpromiseをqueueに入れるなどして、順番に解決されるようにしている感じでしょうか。そうなると、Promise.allのようなPromise APIを直接実行すると、Cypressが用意している機構ではなく、ランタイム側で直接処理されるため(Cypressの方も最終的にはランタイムで処理されるんですが)、順序がズレる、という事態が発生していると考えられます。
Promise.all的なことをやりたい
さて、実際Promise.all的なことをやりたい場合はどうしたらよいでしょうか、となったので色々試したり調べたりしましたが、結果としては以下のようにすると動くことが確認できました。
// fixtureを保存するためのmapを用意しておく。別にobjectでも配列でもなんでもいい
const fixtures = new Map<string, unknown>();
// cy.fixtureの結果をそれぞれ保存する
['path-a', 'path-b'].forEach((fixture) => {
cy.fixture(fixture).then((body) => {
fixtures.set(fixture, body);
});
});
// cy.wrapでfixturesをwrapして、実行順序を担保する
cy.wrap(fixtures).then((fixtures) => {
const handler = rest('http://localhost:3000/hoge', async (req, res, ctx) => {
let fixture: unknown;
const body = await req.clone().json();
if (body.foo == 'a') {
fixture = fixtures.get('path-a');
} else {
fixture = fixtures.get('path-b')
}
return res(
ctx.status(200),
ctx.set("content-type", "application/json"),
ctx.body(JSON.stringify(fixture))),
);
});
msw.worker.use(handler);
});
キモは cy.wrap
になります。cy.wrapしないでやると、空のfixturesにアクセスするだけになるので、悲しいことになりかねません。(実際には同一参照を見ているので、最終的には動くかもしれませんが、タイミング問題が発生する可能性も高くなります)
cy.wrapをすることで、Cypressの枠組みの中で実施されている順序制御の中に組み込むことができます。これをしないと、非同期処理をせずに進んでしまいます。
標準のasync/awaitを使うことの善し悪し
地味にハマりました。以前のCypressでは、PromiseLikeだったらしく、Promise.allにそのまま渡せばできたらしいのですが、今は仕組みが変わっており、動かなくなった、という経緯らしいです。
仕事の方では、Playwrightを選定していて、そっちではasync/awaitを利用することが大前提になっています。現代のJavaScriptでは、async/awaitが一級市民になっているため、下手に内部でラップして同期的に書くことができる、というAPIとの相性が相対的に悪くなっているかな・・・という感覚があります。
無論、毎回awaitしまくらないといけない、というめんどくささはあるんですが、awaitって打つことを省略する必要性はどこまであるんだ・・・?という疑問も湧くようになってきたのは確かです。とはいえ、CypressのDeveloper Experienceが圧倒的に良い、というのは否定できず、どこに力点を置くのか、という問題でしか無いな、とも思ってます。
ともあれ、CypressでPromiseを利用したいとか待ちあわせを実施したい、とかでは、 cy.wrap
のご利用を検討してみて下さい。
実際どうか?
とりあえずマウントはできるようになったのですが、正直コンポーネントベースでのテストは、UIライブラリでもない限りはそこまで必要ないかも・・・と思ってきた次第です。
Cycle.js的には、設定が面倒なのと、結果として Sinksから流れるのが確認できない というのが結構痛いです。流れていることを確認するためには、結局一段階ラップしたコンポーネントを都度作成しないといけないので、その手間よりだったら全体をテストした方が早くない?と思いました。
また、Cypressの設定側としても、component test用とE2E用とで複数用意する必要があります。正直そのコストは今の規模だと賄えない感じがしてます。Angular/React/Vueとかの、標準でサポートが入っているフレームワークを利用しているのならば、かなり楽なのかもしれませんけども。
とはいえ、久々にこういうツールを触っているのは楽しくもあったので、いい経験でした。数少ないCycle.jsユーザーの参考になれば。
TODO Cycle.jsのアプリケーションをReact.jsにリアーキしてみた :TypeScript:ReactJS:
:EXPORT_FILE_NAME: re-architecting_from_cyclejs
気付けば一年の十二分の一が終わろうとしています。時の流れは速すぎるぜ・・・。
タイトルにあるとおりなんですが、ちょっと思うところがあったため、どんなもんかとやってみました。
リアーキ対象
こいつです。
https://github.com/derui/jira-dependency-tree
なぜリアーキしたのか?
このアプリケーションは、元々cycle.jsを利用していました。特にそれで問題も無かったんですが、作っていく内にいくつかつらみ?的なものが出てきました。
- streamが複雑になるにつれ、wire up時の認知負荷が増してきた
- これはAngular.jsとかでもそうですが、すべてのデータをStreamでやりとりしようとすると、とてつもなく複雑になっていきます
- JSXの書き心地がちょっとよろしくない
- snabbdomの影響がモロに出ているからですが
- state管理を考えると、コンポーネント自体が膨らみがち
- この辺、恐らく一定のパターンを見出して、ライブラリを実装したりしたら負荷は大分減るとは思いましたが
というあたりでした。特にwire upは、全てがstreamである以上回避策を取ることがとても難しく、main関数があっというまに肥大化するという課題を解決するのが難しかった、というのがあります。他は、私が選択してみたライブラリを利用するのが思ったよりしんどいとかそういう感じですが。
リアーキしたアーキテクチャというかライブラリ
私は個人プロジェクトだとそのときの気分でリアーキしたりするので、今回もサックリとReact.jsにするかー、ということでやることにしました
しかし、Cycle.jsのパラダイムはとても有用だと感じている(特にdriver)ので、そこは活かしたいな・・・と考えた結果、以下のライブラリを選定しました。
- React.js
- まぁこれは。
- redux-toolkit
- 実は初めて利用しました。普通に書くより楽でいいですね。
- recoilは?という話もあるかもしれませんが、あっちはあっちで結局streamと同じ話が発生しがちだと感じています。どこで何が再レンダリングされるのか?を把握しづらいという点で。
- react-redux
- redux使う以上は。
- hygen
- ボイラープレートがいっぱいできるので、自動生成するために。
- redux-observable
- 今回の目玉。RxJSをreduxに持ち込みます
- vitest
- 元々viteを利用していたのと、jestはviteとの相性が最悪だったので。
- testing-library
- コンポーネントテストで使います
という感じです。個人的にrecoilとかも触ってるんですが、stateという観点だとreduxがまぁバランスいいよな、という感覚です。今回、redux-observableを利用した理由は簡単で、Cycle.jsのときに作成したDriverをそのまま再利用するためです。
リアーキの進めかた
ざっくりこんな感じでやりました。所要期間は約一週間くらい(仕事の後も含めて)。
- まずhygenでボイラプレートを生成できるようにちょいちょい作成する
- 簡単なコンポーネントからReact化していく
- with テスト
- stateが必要なコンポーネントになったら、actions/slice/selectorを作成していく
- これもwith テスト
- 3.で作ったものを使ってコンポーネントを作る
- できるだけwith テスト
- 非同期が必要なactionsに対してepicを作る
- これもテスト込み
- epicとは、
redux-observable
で作成された非同期処理を表します。saga的なやつ?
- driverをxstreamからRxJSに置き換え
- ルートのmoduleで色々wiring
今回はdriverでかなり色々やっていた(d3.jsのレンダリングやイベントハンドリング)ので、それをReactに持ち込むと地獄がまた見えるので、driverという概念はそのまま活かしました。xstreamからRxJSへの書き換えですが、xstreamがミニマルなライブラリで、RxJSが全部入りのやつなので置き換えそのものは特に問題なく進みました。ストリームの概念が若干異なるところはありましたが、そこについても今回は大きな問題になることもなかったです。
リアーキした結果
コンポーネントの見通しがよくなった
Cycle.jsだと、ある程度の規模になってくると、コンポーネントを取り込む処理自体がかなりの重量になってくるので、直感的な書き方ができるReactの方が、見通しが立てやすくなりました。
stateに対するテストが書きやすい
stateに対する更新は、やはりreduxだと書きやすいです。完全に純粋な関数のみで記述していけるので、なんかあったときにサクっと修正できるのは強みです。
redux-observableは思ったより使い勝手がよかった
今回初めて使ってみたんですが、これは思ったよりやりやすかったです(仕事でng-effects触っていたからかもしれない)。
例えば、↓のような感じで書けるんですが、これは他のepicとは切り離され、かつ最終的な結果としてActionを流さなければならない、という制約があるので、1actionにつき1epic、みたいなことをしておけば、複雑性がepicの中だけでなんとかなります。
const synchronizeIssues: (action$, state$) =>
action$.pipe(
filter(synchronizeIssues.match),
switchMap(() => {
const credential = state$.value.apiCredential.credential;
const condition = state$.value.project.searchCondition;
if (!credential) {
return of(synchronizeIssuesFulfilled([]));
}
return registrar
.resolve("postJSON")({
url: `${credential.apiBaseUrl}/load-issues`,
headers: {
"x-api-key": credential.apiKey,
},
body: {
authorization: {
jira_token: credential.token,
email: credential.email,
user_domain: credential.userDomain,
},
project: condition.projectKey,
condition: {
sprint: condition.sprint?.value,
epic: condition.epic,
},
},
})
.pipe(
map((response) => mapResponse(response as { [k: string]: unknown }[])),
map((issues) => synchronizeIssuesFulfilled(issues)),
);
}),
catchError((e) => {
console.error(e);
return of(synchronizeIssuesFulfilled([]));
}),
)
フロントで必要なものや待ちあわせは、できるだけselectorで集約して管理してあげることで、 combileLatest
の嵐などになったりせず、コンポーネントの中がそれなりに健全な作りになります。
とはいえ、やっぱり一定インタラクションが入ってくると、かなり複雑になってきてしまうんですが・・・。
エコシステムの強さを改めて感じた
Cycle.jsは、そのミニマリストな概念やstreamを前提に置いた処理など、streamを利用するというパラダイムにおいては尖りきっているな、と感じました。が、やっぱりエコシステムの影響は強く、開発ツールや周辺ライブラリの充実具合は、どうしても規模の経済が影響しやすいです。
とはいえ、使ってみたことで、streamに対する観点や勘所といったものがわかってきたり、driverという形で、全体から副作用をどう追い出すのか?といったものも含め、大きな学びになりました。
時間がある人なら・・・というのはありますが、普段触っているものと異なるパラダイムのライブラリとかに触れるということは、大きな学びになると思うので、チャレンジしてみるのもいいんでないかなーと思ったりします。