FFFT

主にwebプロダクト開発に関連する話をつらつらと

react-routerを使ったアプリ内遷移の方法 / historyはどう取得するのが良いか

f:id:keyama4:20200723123912p:plain

Reactで作ったWebアプリケーションではルーティングの管理や開発に、react-routerを使用することが一般的かと思います。
今回は、react-routerを使ったWebアプリケーションでのアプリ内遷移の方法をまとめます。

react-routerを使ったアプリ内遷移方法

まず前提として、Reactで作成するWebアプリケーションはSPAとなります。(少なくともReactで動的に変更される箇所は)
SPAでのアプリ内遷移はサーバへのアクセスが不要で、クライアントサイドですべて完結するのが特徴です。
爆速の遷移変更のUXを提供してくれます。
内部的には設定されているルーティングに応じて、表示させるビューを切り替えているイメージです。
SSR(サーバサイドレンダリング)のアプリケーションでは、画面の遷移にサーバとの通信が発生します。
ブラウザからURLを直叩きしたり、hrefで遷移させたりするやつですね。
React製のアプリケーションでも、同様の遷移方法が可能ですが、ページを構成するすべてのコンテンツのレンダリングからとなるためSPAの初期化からやり直しになり、SPAの旨味がなくなります。

React(react-router)でのアプリ内遷移方法は大きく下記2つになります。

それぞれ見ていきましょう。

Linkコンポーネントを使った遷移

Linkコンポーネントを使った遷移は、リンククリックのイベントを使って遷移を実現します。
aタグを使った遷移の利用シーンと同様です。aタグでの遷移のアプリ内遷移用の用途として認識しておくと良いと思います。(前述しましたが、アプリ内遷移にaタグを使うと遷移のパフォーマンスが悪い且つSPAの旨味なし)
Linkコンポーネントはtoのpropsに遷移先のパスを指定するのが、最もベーシックな使い方です。

例えば、下記のようなルーティングの設定があった場合。

    <BrowserRouter>
        <Switch>
            <Route path="/home" component={HomeComponent}/>
            <Route path="/page1" component={Page1Component}/>
            <Route path="/page2" component={Page2Component}/>
            <Route path="/page3/hoge" component={Page3HogeComponent}/>
        </Switch>
    </BrowserRouter>

HomeComponentからPage1ComponentにLinkで遷移させようとするとHomeComponentに下記を追加するイメージです。

...
<Link to="/page1">ページ1へ</Link>
...

Linkコンポーネントは上記のようにaタグのhrefと類似のベース機能を提供していますが、他にもパス以外にクエリストリングやハッシュ、locationに紐づけた状態を管理できたりします。

<Link to={{pathname: "/page1", search: "?param1=123", hash: "#sample", state: { fromHome: true }}}>ページ1へ</Link>

個人的にはsearchもhashもtoにそのまま含めた方がわかりやすかったりするシーンがほとんどなのでほとんど使わないです。
stateも実際に公開しているアプリケーションでは、まだ使ってないです。contextで状態管理させる方がわかりやすい。

詳細は公式ページをご覧ください。 reactrouter.com

historyを使った遷移

前述のLinkコンポーネントはユーザーのリンククリックのアクションのタイミングでしたが、APIのレスポンスの結果で画面を切り替えたいなどの動的な遷移を実現させたいシーンがあるかと思います。
そういったシーンでは、historyを使用します。
historyは、URLと状態を含むlocationを使用してアプリケーションの履歴を追跡するためのAPIを提供します。 名前のとおり、履歴まわりに主な役割がありそうな感じですが、パスの変更もこのライブラリを使用します。ちなみにreact-routerはhistoryへの依存性がめちゃくちゃ高い。
遷移方法は、主に5つありますがまずはpushを使えれば良いかと思います。
詳細は下記のAPIリファレンスをご覧ください。
github.com

コンポーネントからhistoryをどう取得すれば良いのか

historyオブジェクトを使えば、動的なアプリ内遷移の実現が可能ということはわかりましたが各コンポーネントからどう取得するのが良いのでしょうか。
いくつかやり方がありますが、最もカンタンなのはreact-routerのhooksを利用するやり方です。
useHistoryを使うと、Routerコンポーネントで管理されているhistoryオブジェクトを渡してくれます。

...
export const SampleComponent: FC<Props> = () => {
    const history = useHistory();
    history.push('/page1');
...

hooksが提供される以前は、RouteChildrenPropsから取得するやり方をよく記事で見ていました。
しかし、このやり方はRouteコンポーネントのpropsとして渡されていないコンポーネントからは取得できなかったり、使い勝手が悪かった。
そこで、アプリケーションの初期化タイミングでhistoryオブジェクトを生成してRouterコンポーネントに渡し、グローバルで管理してどこからでも利用できるように実装することをやってたんですが、useHistoryはそれを全部やってくれる。ありがたい。
また、他のhooks(useParams, useLocation)を使えば、RouteChildrenPropsから引っ張ってこなくてはいけなかった値をカンタンに取得・利用することができます。
hooks便利。
useHistoryや他のhooksの詳細情報は下記をご覧ください。

reactrouter.com

create-react-appでTypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined

しばらくいじっていないcreate-react-appで作ったフロントエンドのアプリケーションをローカル起動させたらこんなエラーが。

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
    at validateString (internal/validators.js:121:11)
    at Object.join (path.js:1039:7)
    at noopServiceWorkerMiddleware (/Users/me/myapp/frontend/node_modules/react-dev-utils/noopServiceWorkerMiddleware.js:14:26)
    at Layer.handle [as handle_request] (/Users/me/myapp/frontend/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/Users/me/myapp/frontend/node_modules/express/lib/router/index.js:317:13)
    at /Users/me/myapp/frontend/node_modules/express/lib/router/index.js:284:7
    at Function.process_params (/Users/me/myapp/frontend/node_modules/express/lib/router/index.js:335:12)
    at next (/Users/me/myapp/frontend/node_modules/express/lib/router/index.js:275:10)
    at launchEditorMiddleware (/Users/me/myapp/frontend/node_modules/react-dev-utils/errorOverlayMiddleware.js:20:7)
    at Layer.handle [as handle_request] (/Users/me/myapp/frontend/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/Users/me/myapp/frontend/node_modules/express/lib/router/index.js:317:13)
    at /Users/me/myapp/frontend/node_modules/express/lib/router/index.js:284:7
    at Function.process_params (/Users/me/myapp/frontend/node_modules/express/lib/router/index.js:335:12)
    at next (/Users/me/myapp/frontend/node_modules/express/lib/router/index.js:275:10)
    at handleWebpackInternalMiddleware (/Users/me/myapp/frontend/node_modules/react-dev-utils/evalSourceMapMiddleware.js:42:7)
    at Layer.handle [as handle_request] (/Users/me/myapp/frontend/node_modules/express/lib/router/layer.js:95:5)

nodeのバージョンアップで何かしらのライブラリが動かなくなってるやつかなーと思いつつ。 通常の起動時のエラーだとターミナルでエラー出てアプリ起動せずのケースがほとんどだと思うんですが、今回は起動して画面にもこのままスタックトレースが表示されたので「いつもとちげぇ....!!!」と少し焦りました。

react-dev-utilsが悪さをしているよう。

対処法は、react-scriptsのバージョンを3.4.0以上に上げれば解消。 問題が起きているアプリケーションのreact-scriptsのバージョンはおそらく3.3.*のはずです。

Yes upgrading to 3.4.0 fixes it, as does pinning react-dev-utils to previous version. However all projects running react-scripts 3.3.* without dependencies lock (or after npm update) will be broken unless react-dev-utils is fixed to handle the undefined argument. It isn't a hassle for me, really appreciate all the good work in CRA, just helping those that encounter the error.

下記、参考。
github.com github.com

同じエラーで焦った人がこの記事を見つけて焦る時間が減ってくれたら嬉しいです。

Spring Securityのデフォルト認証をWeb APIとして利用 / ステータスコードが302になるのを200, 401に変更

SpringやSpring Bootでサーバアプリケーションを作成すると認証を作る際にSpring Securityを利用することが多いかと思います。
自分も3, 4年前ぐらいにけっこう使っていて、0から設計・開発したアプリケーションにも使っていたのに完全に忘れていましたw
ライブラリがでかくて全容を理解するのが大変。(思い出すのも大変...)

ということで、今回はSpring Securityを使ってデフォルトの認証機能をフォーム入力からの認証ではなく、Web APIとして利用する際のミニマム対応まとめます。

直近触っているプロジェクトが、Kotlinを使っているのでKotlinでまとめます。まぁJavaでもやることは変わらないです。

spring-boot-starter-securityをdependencyに追加

build.gradle.ktsのdepencenciesにspring-boot-starter-securityを追加します。

...
dependencies {
...
  implementation("org.springframework.boot:spring-boot-starter-security")
...
}
...

コンフィグを設定

WebSecurityConfigurerAdapterを継承したコンフィグクラスを作成します。 EnableWebSecurityのアノテーションをつけることで、Spring Securityの設定として有効になります。

@Configuration
@EnableWebSecurity
class AuthTokenSecurityConfig() : WebSecurityConfigurerAdapter() {

  override fun configure(http: HttpSecurity) {
    http
        .csrf { c ->
          c.disable() // csrfは今回の話と関係ないのでdisableに
        }
        .authorizeRequests { r ->
          r.anyRequest().authenticated()  // すべてのエンドポイントで認証必須に
        }
        .formLogin { }
        .logout { }
  }


  @Bean
  fun users(): UserDetailsService {
    val user: UserDetails = User.builder()
        .username("user")
        .password(CustomPasswordEncoder().encode("SamplePassword123!"))
        .build()
    return InMemoryUserDetailsManager(user)
  }
}

認証するユーザー情報は今回はハードコードでインメモリで管理します。

username: user
password: SampleUser123!

ちゃんとやろうとするなら、org.springframework.security.core.userdetails.User を継承したUserクラスと、 org.springframework.security.core.userdetails.UserDetailsServiceを継承したクラスを実装します。 UserDetailsServiceを継承したクラスでloadUserByUsernameメソッドをオーバーライドして、DBからユーザー情報引っ張ってきて諸々検証する感じになるかと思います。

現状の挙動確認

上記の設定で認証は動きます。
Spring Securityではデフォルトで /login がログインのエンドポイント、 /logout がログアウトのエンドポイントになります。

curlで叩いてみます。

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=SampleUser123!' -v

正常に認証が通ったのかどうかわからんレスポンスが返ってきます。(ちゃんと通ってます。)

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 41
>
* upload completely sent off: 41 out of 41 bytes
< HTTP/1.1 302
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=DA9B54324442FD5FEF2B31D68E181900; Path=/; HttpOnly
< Location: http://localhost:8080/
< Content-Length: 0
< Date: Fri, 17 Jul 2020 05:50:40 GMT
<
* Connection #0 to host localhost left intact

内容を見ると、レスポンスのHTTPステータスが302でリダイレクト先が http://localhost:8080/ になっています。

認証失敗のレスポンスも確認してみましょう。

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=FAILED' -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 29
>
* upload completely sent off: 29 out of 29 bytes
< HTTP/1.1 302
< Set-Cookie: JSESSIONID=C83308084D39450F029A2A8EF5A4501D; Path=/; HttpOnly
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Location: http://localhost:8080/login?error
< Content-Length: 0
< Date: Fri, 17 Jul 2020 06:47:44 GMT
<
* Connection #0 to host localhost left intact

失敗のレスポンスもHTTPステータスは302です。
リダイレクト先が http://localhost:8080login?error になっていますが、Web APIとして認証を利用しようとすると、かなりきついですね。。。
リダイレクト先を見て、認証の可否を判定するのは辛過ぎます。

そこで、認証が成功した場合は200を、認証が失敗した場合は401を返すようにします。

Handlerの実装

まずはCustomAuthenticationSuccessHandler, CustomAuthenticationFailureHandlerの2つを実装します。
それぞれ import org.springframework.security.web.authentication のAuthenticationSuccessHandler, AuthenticationFailureHandlerを実装します。

@Component
class CustomAuthenticationSuccessHandler : AuthenticationSuccessHandler {
  override fun onAuthenticationSuccess(request: HttpServletRequest,
                                       response: HttpServletResponse,
                                       auth: Authentication) {
    if (response.isCommitted) {
      return
    }
    response.status = HttpStatus.OK.value()
  }
}
@Component
class CustomAuthenticationFailureHandler : AuthenticationFailureHandler {
  override fun onAuthenticationFailure(request: HttpServletRequest,
                                       response: HttpServletResponse,
                                       exception: AuthenticationException) {
    if (response.isCommitted) {
      return
    }
    response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.reasonPhrase)
  }
}

ついでにログアウトも302がデフォルトでレスポンスされるので、CustomLogoutSuccessHandlerを実装します。

@Component
class CustomLogoutSuccessHandler : LogoutSuccessHandler {

  override fun onLogoutSuccess(request: HttpServletRequest,
                                       response: HttpServletResponse,
                                       auth: Authentication) {
    if (response.isCommitted) {
      return
    }
    response.status = HttpStatus.OK.value()
  }
}

コンフィグを修正

ログイン、ログアウト処理のハンドリングに先ほど実装したハンドラーを渡します。

@Configuration
@EnableWebSecurity
class AuthTokenSecurityConfig(
    private val loginSuccessHandler: CustomAuthenticationSuccessHandler,
    private val loginFailureHandler: CustomAuthenticationFailureHandler,
    private val logoutSuccessHandler: CustomLogoutSuccessHandler
) : WebSecurityConfigurerAdapter() {

  override fun configure(http: HttpSecurity) {
    http
        .csrf { c ->
          c.disable()
        }
        .authorizeRequests { r ->
          r.anyRequest().authenticated()
        }
        .formLogin { f ->
          f.successHandler(loginSuccessHandler)
          f.failureHandler(loginFailureHandler)
        }
        .logout { l ->
            l.logoutSuccessHandler(logoutSuccessHandler)
        }
  }


  @Bean
  fun users(): UserDetailsService {
    val user: UserDetails = User.builder()
        .username("user")
        .password(CustomPasswordEncoder().encode("SamplePassword123!"))
        .build()
    return InMemoryUserDetailsManager(user)
  }
}

これで認証が成功したときは200, 失敗したときは401が返るようになりました。

成功

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=SampleUser123!' -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 41
>
* upload completely sent off: 41 out of 41 bytes
< HTTP/1.1 200
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=67BB2178B3E54F30EC142BBD432812F1; Path=/; HttpOnly
< Content-Length: 0
< Date: Fri, 17 Jul 2020 07:03:50 GMT
<
* Connection #0 to host localhost left intact

失敗

$ curl -X POST 'http://localhost:8080/login' -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'username=user' --data-urlencode 'password=FAILED' -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 31
>
* upload completely sent off: 31 out of 31 bytes
< HTTP/1.1 401
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=9B5B698AC4339FF19A4E6F122CCC9428; Path=/; HttpOnly
< Content-Length: 0
< Date: Fri, 17 Jul 2020 07:01:55 GMT
<
* Connection #0 to host localhost left intact

chromeの「サイトまたはアプリでのデータ侵害により、パスワード情報が漏洩しました。」ダイアログ。初見ビビるけど焦ることなかれ

f:id:keyama4:20200717123340p:plain

localhostでフロントエンドアプリケーションの認証の動作確認をしている際に、上のキャプチャのダイアログが出てきた。

パスワードを変更してください

サイトまたはアプリでのデータ侵害により、パスワード情報が漏洩しました。
localhost:3000 のパスワードを今すぐ変更することをおすすめします。

パッと見、「え?え?え?なんかやばい実装自分がしちゃったか、やばいライブラリ読み込んだから認証情報が漏洩したのか!?」と思ったんですが、全くそんなことはなかった。
この機能は、chromeが2019年の12月にリリースした「Password Checkup」というもの。

security.googleblog.com

カンタンに言うと「Password Checkup」はgoogleが独自に漏洩リスクのあるパスワードを管理しているリストを使って、ユーザーのパスワードを入力時にチェックしてリストに含まれている場合ワーニングを出してくれる機能です。
データ侵害にあった危険なパスワードでないことを毎回チェックしてくれるんですね。
googleが出している記事によると、侵害されたとされる40億を超える認証情報と入力されたパスワードを比較しているらしいです。

今回のケースでは、自分が開発者でありテスト用のIDとパスワードを使っていたので気にする必要はなかったです。
しかし、実際に何かの認証が必要なアプリケーションに、パスワードを入力してログインしようとした際にこのダイアログが出た場合はすぐにパスワードを変更しましょう。

最近では、1Passwordなどのパスワードマネージャーツールが有料ですがあるので、こちらを利用するのがセキュリティ的には最も安全な気がします。

1password.com

パスワード忘れのリスクもなくなる。

最後に、「Password Checkup」はとても良い機能だなと思いつつ、localhostではダイアログ出さないでも良いんじゃないかなと思った。(ぼそっ)
ストアカウントのアイパスを適当に設定しなきゃ良いだけなんだけども。

CircleCIでの環境変数の設定方法と優先順位について

f:id:keyama4:20200706094330p:plain

CircleCIでの環境変数の設定方法は複数あります。
今回は、複数ある環境変数の設定のやり方を確認し、環境変数名が被った際にどのやり方で設定した環境変数が優先されるかをまとめます。
(記事作成中に公式で良さげなまとまったページを見つけたので、基本的にそっちに誘導してまとめる感じにしてます。)

CircleCIとは

CI/CDサービス(ビルド・テスト・デプロイの自動化)の一つ。
GitHubとの連携がめちゃくちゃ簡単。
最近になって、GitHubが自社でCI/CDサービス(GitHub Actions)の提供を開始したので今後シェアがどうなるかわからないですが、まだまだCircleCIの方が高機能な印象。

環境変数の設定方法

シェルコマンドによる設定

こちらを参照

ステップ内での設定

こちらを参照

ジョブ内での設定

こちらを参照

コンテナ内での設定

こちらを参照

Context内での設定

Contextは、環境変数をプロジェクト間で共有するための仕組みです。
Organizationの下に複数、作成ができ、プロジェクトのconfig.ymlのworkflowsセクションで使うContextを指定することができます。
設定方法はこちらを参照

プロジェクト内での設定

こちらを参照

環境変数の優先順位

環境変数名が重複している場合、下記リストの上から順に優先されます。

  • シェルコマンドによる設定
  • ステップ内での設定
  • ジョブ内での設定
  • Context内での設定
  • プロジェクト内での設定
  • 組み込み環境変数

下記のように環境変数が設定されている場合を考えます。

環境変数名 / 設定方法 シェルコマンド ステップ ジョブ Context プロジェクト 組み込み
ENV_SAMPLE_1 1 3 5
ENV_SAMPLE_2 C D
CI false true

ENV_SAMPLE_1は 1 、ENV_SAMPLE_2は C 、CIは false となります。

気になっていること

現在のContextの仕様では、1ワークフローに1つのContextしか指定することができないようです。
「Organization直下のプロジェクトには、すべてこの環境変数を提供したい」というシーンでは、複数作成されているContextすべてにその環境変数を設定する必要があります。
ワークフローに複数のContextを指定できるようにするか、Organizationにそのまま環境変数を設定できる仕組みを提供して欲しいなと思ったりしました。

ここらへんは、GitHub Actionsの方が使い勝手良い。
2020年5月に、Organization単位でSecrets(秘匿情報を含めた環境変数)を設定できるようになってます。

github.blog

【create-react-app】jestでuuidを利用するコンポーネントのテストをする

create-react-appではテストランナーにjestが利用されています。

create-react-app.dev

今回、uuidを利用するコンポーネントでスナップショットテストを書く際に少し調べたのでまとめます。

jestjs.io

こちらのissueでいろいろ話されていますが、jest.mockを使うやり方が良さそう。

github.com

サンプルのアプリケーションを作成してまとめます。
プロジェクトを作成して、uuidのパッケージを追加。App.jsx(App.js)を下記のように修正します。

import React from 'react';
import './App.css';
import { v4 as uuid } from 'uuid';

function App() {
  return (
    <div className="App">
      <p>{uuid()}</p>
    </div>
  );
}

export default App;

App.test.jsxを下記のように修正。

import React from 'react';
import { render } from '@testing-library/react';
import App from './App';

test('renders currently', () => {
  const {container} = render(<App />);
  expect(container).toMatchSnapshot();
});

一度、testを実行します。

$ yarn run test

こんな感じで通るかと思います。

 PASS  src/App.test.jsx
  ✓ renders currently (18ms)

 › 1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        1.409s
Ran all test suites related to changed files.

Watch Usage
 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press q to quit watch mode.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press Enter to trigger a test run.

ただ、uuidを利用したコンポーネントのため、都度値が変わります。そのため、再度testを実行すると当然ですが失敗します。

 FAIL  src/App.test.jsx
  ✕ renders currently (6ms)

  ● renders currently

    expect(received).toMatchSnapshot()

    Snapshot name: `renders currently 1`

    - Snapshot
    + Received

      <div>
        <div
          class="App"
        >
          <p>
    -       12fa6ae8-75d3-40b3-97af-172d0a304d37
    +       19c42b95-7fcc-4848-9616-4396f6c768e5
          </p>
        </div>
      </div>

      5 | test('renders currently', () => {
      6 |   const {container} = render(<App />);
    > 7 |   expect(container).toMatchSnapshot();
        |                     ^
      8 | });
      9 |

      at Object.<anonymous> (src/App.test.jsx:7:21)

 › 1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or press `u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        0.182s, estimated 1s
Ran all test suites.

toMatchSnapshot()を使ったtestがはじめて実行された場合、src配下の__snapshots__にスナップショットファイルが生成されます。
中身を見ると、初回実行時に対象のコンポーネントレンダリングされたDOMが書かれています。

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders currently 1`] = `
<div>
  <div
    class="App"
  >
    <p>
      12fa6ae8-75d3-40b3-97af-172d0a304d37
    </p>
  </div>
</div>
`;

二度目以降は、こいつと差分がある場合は落ちるという感じ。
対応は、テスト実行時に利用されるuuid生成の関数をモックにして、スナップショットと同じ固定値を返すようにしてあげます。

App.test.jsxを下記のように修正します。

import React from 'react';
import { render } from '@testing-library/react';
import App from './App';

jest.mock('uuid',() => ({
  v4: () => '12fa6ae8-75d3-40b3-97af-172d0a304d37',
}));

test('renders currently', () => {
  const {container} = render(<App />);
  expect(container).toMatchSnapshot();
});

これでtestが通ります。 サンプルのプロジェクトは下記にあげました。

github.com

【1分設定】create-react-appで作ったアプリケーションにSassを入れる

数年ぶりにcreate-react-appでフロントエンドのアプリを開発したんですが、Sassの入れ方がカンタンになってたのでまとめます。
結論ですが、node-sassを入れて、scssファイルを作り、読み込ませたいコンポーネントに直接importしてあげるだけです。

執筆時のバージョンは下記。

$ npx create-react-app --version
3.4.1

create-react-appでサンプルのプロジェクトを作る。  今回は「sample-project」にします。

$ npx create-react-app sample-project

そのまま yarn start するとこんな感じ。

f:id:keyama4:20200624121947p:plain

ここからSassを入れていきたいと思います。   src配下に下記のように custom.scssを作成します。

.App-header {
  background-color: #fff;
}

そして、App.jsimport './custom.scss'; を追加します。

import React from 'react';
import logo from './logo.svg';
import './App.css';
import './custom.scss';

function App() {
.....

ここで yarn start すると下記のエラーが出ます。

./src/custom.scss (./node_modules/css-loader/dist/cjs.js??ref--6-oneOf-5-1!./node_modules/postcss-loader/src??postcss!./node_modules/resolve-url-loader??ref--6-oneOf-5-3!./node_modules/sass-loader/dist/cjs.js??ref--6-oneOf-5-4!./src/custom.scss)
To import Sass files, you first need to install node-sass.
Run `npm install node-sass` or `yarn add node-sass` inside your workspace.
Require stack:
- /Users/keyama4/Desktop/sample-project/node_modules/sass-loader/dist/getDefaultSassImplementation.js
- /Users/keyama4/Desktop/sample-project/node_modules/sass-loader/dist/getSassImplementation.js
- /Users/keyama4/Desktop/sample-project/node_modules/sass-loader/dist/index.js
- /Users/keyama4/Desktop/sample-project/node_modules/sass-loader/dist/cjs.js
- /Users/keyama4/Desktop/sample-project/node_modules/loader-runner/lib/loadLoader.js
- /Users/keyama4/Desktop/sample-project/node_modules/loader-runner/lib/LoaderRunner.js
- /Users/keyama4/Desktop/sample-project/node_modules/webpack/lib/NormalModule.js
- /Users/keyama4/Desktop/sample-project/node_modules/webpack/lib/NormalModuleFactory.js
- /Users/keyama4/Desktop/sample-project/node_modules/webpack/lib/Compiler.js
- /Users/keyama4/Desktop/sample-project/node_modules/webpack/lib/webpack.js
- /Users/keyama4/Desktop/sample-project/node_modules/react-scripts/scripts/start.js

指示のとおり、node-sassを入れます。

$ yarn add node-sass

インストールできたら yarn start
下記の画面が表示されるかと思います。scssあたってますね。

f:id:keyama4:20200624122744p:plain

おまけで、cssフレームワークを適用してみます。 今回は、Bulmaを使います。

bulma.io

$ yarn add bulma

node_modules配下のbulmaのディレクトリ構成はこうなってます。

f:id:keyama4:20200624123329p:plain

bulma.sassを読み込みます。
custom.scss を下記のように修正。

@import "~bulma/bulma";

.App-header {
  background-color: #fff;
}

yarn start するとbulmaのスタイルが適用されます。 カンタンですね。
数年前は、いろんなパッケージをインストールしてスクリプトを修正しなくちゃいけなかったので感動しました。

ついでに、言わずもがなですがcreate-react-app--typescript オプションを追加して作ったプロジェクトも同様の手順でSassを入れることができます。