あけましておめでとうございました。当の昔に年は明けてますが、改めまして。

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 を利用することでできます。まぁシンプルですね。

さて、これだけならとてもシンプルな話で終わるのですが、例えば次のようなシナリオを考えると、これはそのままでは動作しません。

という感じです。擬似コード的なものとしては、次のようにしたいよ、という感じですね。

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を利用していました。特にそれで問題も無かったんですが、作っていく内にいくつかつらみ?的なものが出てきました。

というあたりでした。特にwire upは、全てがstreamである以上回避策を取ることがとても難しく、main関数があっというまに肥大化するという課題を解決するのが難しかった、というのがあります。他は、私が選択してみたライブラリを利用するのが思ったよりしんどいとかそういう感じですが。

リアーキしたアーキテクチャというかライブラリ

私は個人プロジェクトだとそのときの気分でリアーキしたりするので、今回もサックリとReact.jsにするかー、ということでやることにしました

しかし、Cycle.jsのパラダイムはとても有用だと感じている(特にdriver)ので、そこは活かしたいな・・・と考えた結果、以下のライブラリを選定しました。

という感じです。個人的にrecoilとかも触ってるんですが、stateという観点だとreduxがまぁバランスいいよな、という感覚です。今回、redux-observableを利用した理由は簡単で、Cycle.jsのときに作成したDriverをそのまま再利用するためです。

リアーキの進めかた

ざっくりこんな感じでやりました。所要期間は約一週間くらい(仕事の後も含めて)。

  1. まずhygenでボイラプレートを生成できるようにちょいちょい作成する
  2. 簡単なコンポーネントからReact化していく
    • with テスト
  3. stateが必要なコンポーネントになったら、actions/slice/selectorを作成していく
    • これもwith テスト
  4. 3.で作ったものを使ってコンポーネントを作る
    • できるだけwith テスト
  5. 非同期が必要なactionsに対してepicを作る
    • これもテスト込み
    • epicとは、 redux-observable で作成された非同期処理を表します。saga的なやつ?
  6. driverをxstreamからRxJSに置き換え
  7. ルートの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という形で、全体から副作用をどう追い出すのか?といったものも含め、大きな学びになりました。

時間がある人なら・・・というのはありますが、普段触っているものと異なるパラダイムのライブラリとかに触れるということは、大きな学びになると思うので、チャレンジしてみるのもいいんでないかなーと思ったりします。