きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

Go の struct と interface で Embedding

Effective Go の Embedding の内容を試してみます。

Go では embedding を利用して継承のようなことができますが、struct と interface の違いが今ひとつ理解できていなかったため、実際にコードを書いてコンパイラに怒られながら、どういう違いがあるのか試していきたいと思います。

確認方法

こんな感じで embedding を利用して BaseMethodSuperMethod を実装するパターンを色々見ていきます。

type baseInterface interface {
    BaseMethod() string
}

type superInterface interface {
    baseInterface
    SuperMethod() string
}

実装の確認用兼、interface を満たしているかの確認用の関数を用意します。

func printSuper(name string, v superInterface) {
    fmt.Println(name+".BaseMethod ->", v.BaseMethod())
    fmt.Println(name+".SuperMethod ->", v.SuperMethod())
}

interface を interface に埋め込む

type baseInterface interface {
    BaseMethod() string
}

// 一応 baseInterface の方の実装も作っておく
type implOfBaseInterface struct{}

func (implOfBaseInterface) BaseMethod() string {
    return "implOfBaseInterface.BaseMethod"
}

type superInterface interface {
    baseInterface
    SuperMethod() string
}

type implOfSuperInterface struct{}

// interface を interface に埋め込んだ場合 BaseMethod の定義は必要
func (i implOfSuperInterface) BaseMethod() string {
    return "implOfSuperInterface.BaseMethod"
}

func (implOfSuperInterface) SuperMethod() string {
    return "implOfSuperInterface.SuperMethod"
}

impleOfSuperInterface 側でも BaseMethod の実装が必要になります。 実装を参照する先も無いので当たり前と言えば当たり前ですね。

func main() {
    printSuper("implOfSuperInterface", implOfSuperInterface{})
}

してみると

implOfSuperInterface.BaseMethod -> implOfSuperInterface.BaseMethod
implOfSuperInterface.SuperMethod -> implOfSuperInterface.SuperMethod

これも impleOfSuperInterface に実装したものがそのまま呼ばれて、当たり前の結果ですね。 このパターンの埋め込みをするメリットとしては、interface 側のメソッドの定義を省略できることくらいでしょうか。

struct を struct に埋め込む

type baseStruct struct{}

func (b baseStruct) BaseMethod() string {
    return "baseStruct.BaseMethod"
}

type superStruct struct {
    baseStruct
}

// この場合 BaseMethod は省略できるが実装してもいい
// func (s superStruct) BaseMethod() string {
//     // 明示的に baseStruct.BaseMethod を呼ぶこともできるし(あまり意味なさそう)
//     return s.baseStruct.BaseMethod()
//     // 他の処理を定義してもいい
//     return "superStruct.BaseMethod"
// }

func (s superStruct) SuperMethod() string {
    return "superStruct.SuperMethod"
}

struct を struct に埋め込んだ場合は superStruct.BaseMethod の実装を省略できるみたいです。

func main() {
    printSuper("superStruct", superStruct{})
}

してみると

superStruct.BaseMethod -> baseStruct.BaseMethod
superStruct.SuperMethod -> superStruct.SuperMethod

superStruct.BaseMethodbaseStruct.BaseMethod が呼ばれていますね。

struct を interface に埋め込む(不可)

type structEmbeddedInterface interface {
    baseStruct
    SuperMethod()
}

type implOfStructEmbeddedInterface struct{}

func (implOfStructEmbeddedInterface) SuperMethod() {
    fmt.Println("structEmbeddedInterface.SuperMethod")
}

これは不可能なパターンで、コンパイル時に interface contains embedded non-interface baseStruct と怒られます。

interface を struct に埋め込む

type interfaceEmbeddedStruct struct {
    baseInterface
}

func (interfaceEmbeddedStruct) SuperMethod() string {
    return "interfaceEmbeddedStruct.SuperMethod"
}

この場合は interfaceEmbeddedStruct.BaseMethod の実装をしなくてもコンパイルは通ります。しかし、ランタイムで panic: runtime error: invalid memory address or nil pointer dereference というエラーになります。一番タチが悪いですね。

このパターンの活用事例は悩ましいですが、考察されているページがあったので時間のある時に読みたいと思います。 https://horizoon.jp/post/2019/03/16/go_embedded_interface/

今回触って改めて、このGo言語における embedding は手を動かさないと理解できないなと思いました。単純にパターンが多いので網羅しづらいのと、網羅されても今度は長大になるため読み解く時間がないので、手元で試した方が速いなと…

macOS のキーボード入力が重複するので Wacom 系のソフトウェアをアンインストールした

Mac を使いながら数年間悩まされ続けてきたキーボード入力系の不具合がある。

mac-ra.com

症状としては上の記事と同じで「application 」と打つと「appapplication」のように入力される。物理的なチャタリングとは異なりワンテンポ遅れてまとまったキーストロークが繰り返されるという、どうにもソフトウェア領域らしい不具合が発生し続けていた。記事で紹介されていた各種リセットを行ってみて、改善したかに思えた瞬間もあったが程なくして再発する。ただ記事を見つけて、同じ問題を抱えている人がいるのだと少し安心できた。

そんなこともあり日本語の記事も他に見つからず、英語で調べるの面倒だなと思いながら、たまにイライラする程度だしと数年間放置し続けていたのだが、いざ調べてみるとあっさり解決方法が見つかった。

Wacom 系のソフトウェアが原因らしい。アンインストールすれば症状が改善されたとの報告がされている。

Kudos to rakdavid. 本当にありがとう。

私もタブレットユーザなので、いくつかインストールしていた Wacom 系のドライバやついでにユーティリティを全てアンインストールしたところ、今のところ本当に症状が解消されている。どうやって気づいたんだこんなもの。もう iMac 買い換えるかとも思い始めていた。そして同様に Wacom をインストールして問題が再発し絶望する未来もありえた。ここに私は正しく、救われたのだった。

早く調べておけばよかった。過去数年間の矢印戻りバックスペース連打行末戻りのルーチンと、いつ重複が発生するかという不信感は確実に私の処理能力に対するオーバーヘッドになっていた。数十行のコードを完成させるまでに10回は発生していたように思う。そして変数名が異なりメソッド名が異なりコンパイラに怒られたりランタイムエラーになったりするのだ。それで数十秒のロスだ。トライアンドエラーを繰り返すコーディング作業の中でこのロスは大きすぎるし邪魔すぎる。

人間が1日に行える判断の回数には上限があるという。この不具合が奪っていったものについて、私はもうあまり考えたくはない。

Material UI の AppBar と Drawer を自分好みに組み合わせる

半年ほどかけてようやく Material UI にも慣れてきました。AppBar と Drawer の組み合わせは昨今のアプリでは定番となっていますが、レスポンシブに Drawer の表示を切り替えようとすると、AppBar との色使いの兼ね合いで迷います。

  • モバイルなどの狭いスクリーン
    • Drawer は AppBar のボタンをクリックした時に表示
  • デスクトップなどの広いスクリーン
    • Drawer は常にページ横などに表示

というのが割とよくあるパターンですが、この時両方にテーマカラーなどの濃い色をつけてしまうと、広いスクリーンの場合に上と横の2辺に濃い色が来てしまいます。

f:id:lightbulbcat:20190820041626p:plain

個人的な好みですが、この 2辺以上の濃い色のバーに囲まれたコンテンツ からは窮屈さを感じてしまい、あまり好きではありません。

今回作成するのはスクリーン幅によって AppBar の色が変わるようにして Drawer と組み合わせたものです。

f:id:lightbulbcat:20190820041754g:plain

ついでに公式に useScrollTrigger のデモがあったので、それも利用してなんかいい感じに表示されてくれる AppBar をカスタムしていきます。

動作するコードは github に置いてあります。

ドロワー用のダークテーマを用意する

ドロワー用のテーマをサクッと定義しておきます。今回は単純なコンテンツなので部分的に palette を定義するにと止めています。手抜きです。

import { createMuiTheme } from '@material-ui/core'
import { cyan } from '@material-ui/core/colors'
import { darken, lighten } from '@material-ui/core/styles/colorManipulator'

export default createMuiTheme({ palette: {
  primary: {
    light: lighten(cyan[700], 0.5),
    main: darken(cyan[700], 0.3),
    dark: darken(cyan[700], 0.5),
    contrastText: '#fff',
  },
  background: {
    default: darken(cyan[700], 0.5),
    paper: darken(cyan[700], 0.5),
  },
  type: 'dark',
} })

重要なのは type: 'dark' です。これが指定されていると文字色が白くなったりするなど、各コンポーネントがダークテーマを前提とした色使いになります。

今回はこのドロワー用のダークテーマと、コンテンツ領域用の Material UI デフォルトのテーマを利用します。ルートの App コンポーネントはこんな感じです。MyAppBar が今回作成するメインのコンポーネントです。

const theme = createMuiTheme()
const App: React.FC = () => {
  return (
    <div className="App">
      <CssBaseline />
      <MuiThemeProvider theme={theme}>
        <MyAppBar />
      </MuiThemeProvider>
    </div>
  )
}

MyAppBar

実際に組んだ AppBar が以下のようなものです。 MyContentMyDrawerContent は適当な内容を定義しているだけなので適当に読み飛ばしてください。

HideOnScrolluseScrollTrigger を利用した例として 公式のサンプル として書かれているものです。ありがたいですね。同じ公式のサンプルからスクロール時に AppBarbox-shadow を適用する効果も付け加えてみました。

import React from 'react'
import {
  Drawer,
  AppBar,
  Toolbar,
  IconButton,
  Typography,
  makeStyles,
  useMediaQuery,
  useScrollTrigger,
  Theme,
} from '@material-ui/core'
import { ThemeProvider } from '@material-ui/styles'
import * as Icons from '@material-ui/icons'

import drawerTheme from '../themes/drawerTheme'
import { useBoolean } from '../utils'
import MyContents from './MyContents'
import MyDrawerContent from './MyDrawerContent'
import HideOnScroll from './HideOnScroll'

const useStyles = makeStyles(
  ({ palette, spacing: sp, breakpoints: bp, mixins }) => ({
    root: {
      backgroundColor: palette.background.default,
      minHeight: '100vh',
    },
    appBar: {
      [bp.up('md')]: {
        width: `calc(100vw - 240px)`,
      },
      transition: 'background .2s, color .2s',
    },
    drawerPaper: {
      width: 240,
    },
    menuButton: {
      marginRight: sp(2),
    },
    contentWrapper: {
      [bp.up('md')]: {
        paddingLeft: 240,
      },
    },
    appBarSpacer: mixins.toolbar,
    content: {
      padding: sp(3),
    },
  }),
)

export default function MyAppBar() {
  const cls = useStyles()
  const [mobileOpen, openDrawer, closeDrawer] = useBoolean() // useState の boolean 版

  // `useMediaQuery` でモバイルかどうか判定 (*˘꒳˘*) らくちん
  const isMobile = useMediaQuery<Theme>(theme => theme.breakpoints.down('sm'))

  // おまけ: `useScrollTrigger` でスクロール時に `AppBar` に `box-shadow` を適用する
  // おしゃれ (*˘꒳˘*) 
  const elevationTrigger = useScrollTrigger({
    disableHysteresis: true,
    threshold: 0,
  })

  // ドロワーの中身を適当に
  const drawerContent = <MyDrawerContent sets={3} length={12} />

  return (
    <div className={cls.root}>
      <ThemeProvider theme={drawerTheme}>
        <nav>
          {isMobile ? (
            <Drawer
              open={mobileOpen}
              color="primary"
              onClose={closeDrawer}
              classes={{ paper: cls.drawerPaper }}
              variant="temporary"
              ModalProps={{ keepMounted: true }}
              children={drawerContent}
            />
          ) : (
            <Drawer
              open
              color="primary"
              classes={{ paper: cls.drawerPaper }}
              variant="permanent"
              children={drawerContent}
            />
          )}
        </nav>
      </ThemeProvider>

      <ThemeProvider
        theme={originalTheme => (isMobile ? drawerTheme : originalTheme)}
      >
        <HideOnScroll>
          <AppBar
            className={cls.appBar}
            color={isMobile ? 'primary' : 'default'}
            elevation={elevationTrigger ? 4 : 0}
          >
            <Toolbar>
              {isMobile && (
                <IconButton
                  className={cls.menuButton}
                  edge="start"
                  color="inherit"
                  onClick={openDrawer}
                >
                  <Icons.Menu />
                </IconButton>
              )}
              <Typography variant="h6">AppBar</Typography>
            </Toolbar>
          </AppBar>
        </HideOnScroll>
      </ThemeProvider>

      <div className={cls.contentWrapper}>
        <div className={cls.appBarSpacer} />
        <div className={cls.content}>
          <MyContents length={12} />
        </div>
      </div>
    </div>
  )
}

今回の肝としては isMobiletheme をスイッチしている部分です。

<ThemeProvider theme={originalTheme => (isMobile ? drawerTheme : originalTheme)}>

といっても特に特別なことをやっているわけではないですね。 こんな感じかな...で実装して実際に動いてよく動くもんだな、と感心したというそんな感じの実験的コードでした。

余談ですが地味に 100vh がとても便利でした。今までスクリーンの高さを覆う要素を定義するには height: 100% を親子間でリレーしていかなければならなかったので、入れ込んだ要素でそれを行うのが面倒だったのですが、いいプロパティが増えたものですね。create-react-app の初期状態のコードを見ていて気づきました。いいコードを入れてくれているものですね。ありがたいです。

Material-UI と Downshift のスナップショットテストで属性値の差分が出るのを解消する

最近 React のテストを書き始めて、Jest の toMatchSnapshot 便利だなと感じながら使っています。 テスト結果をいちいちテストコード内に頑張って組み立てる必要がなくて楽ですし、 出力結果の差分ってコードの変更の結果としてもとても理解しやすいんですよね。 ロジックを追うより出力を見た方が何をしてるか一目瞭然な場合も多いです。

さてこのスナップショットテスト、便利なんですが、時々変な差分が出たり、一つのテストの失敗が別のテストにも波及して見えることがあったりします。class とか aria-xxxx とかその辺の属性値が微妙にブレたりするんですよね。JavaScript のライブラリによくあるパターンだと思います。 原因としては、モジュール内にIDを裁判するためのカウンタ変数など、モジュール内 State とも言えるような変数が存在して、テストが一箇所失敗するとカウンタがずれて後続のテストのスナップショットに波及する、と言った感じだと思います。

Issue として見かける問題なので、著名なライブラリであれば、モジュール内変数を操作するためのテスト用ユーティリティが提供されている場合があります。されていない場合はちょっと辛いですね。諦めて差分を甘受するかIssue/PRを送るかの二択になりそうです。

今回は Material UI と Downshift で出くわした属性値のブレと、そのブレを解消した記録です。動作するコードは github に置いておきました。

TL;DL

コードが長くなるので先に要所だけ抜き出しておきます。

Material UI の makeStyles の採番をなくす

クラス名の生成関数をインデックス番号が振られないように新しく定義して Provide してやります。

import { StylesProvider } from '@material-ui/styles'
import { GenerateId } from 'jss'

const generateTestClassName: GenerateId = (rule, sheet) => {
  return `${sheet!.options.classNamePrefix}-${rule.key}`
}

const provideTestStyles = (el: React.ReactElement) => (
  <StylesProvider generateClassName={generateTestClassName} children={el} />
)

// `createElement` 時に以下のように使ったり
provideTestStyles(
  <>
    <MySelectInput items={items} itemToString={itemToString} />
    <MyOtherComponent />
  </>
)

テスト用と割り切って !. を使っています。 テスト用の Provider をメソッドにするかコンポーネントにするかはお好みでどうぞ。

Downshift の採番をテストごとにリセットする

こちらはテストごとに採番をリセットする方法をとります。

import { resetIdCounter } from 'downshift'

beforeEach(resetIdCounter)

以上です。

参考

順を追って

採番される対象

今回スナップショットで差分が出る場所は以下の通りです。

  • Material UI: makeStyles で生成したクラス名
    • MySelectInput-root-2323
  • Downshift:
    • aria-labelledby="downshift-0-label"0

テスト対象のコンポーネント

テスト対象となるコンポーネントです。

f:id:lightbulbcat:20190815031020p:plain

よくあるリストから選択するタイプの Material-UI + Downshift な入力コンポーネント MySelectInput です。

import React from 'react'
import Downshift from 'downshift'
import { Paper, MenuItem, TextField, makeStyles } from '@material-ui/core'

interface Props<Item> {
  items: Item[]
  itemToString: (item: Item | null) => string
  onChange?: (item: Item | null) => void
}

const useStyles = makeStyles(
  {
    root: {},
    menu: {},
  },
  { name: 'MySelectInput' },
)

export default function MySelectInput<Item>(props: Props<Item>) {
  const { items, ...downshiftProps } = props
  const classes = useStyles()
  return (
    <Downshift {...downshiftProps}>
      {({
        isOpen,
        getInputProps,
        getMenuProps,
        getItemProps,
        itemToString,
        inputValue,
        highlightedIndex,
      }) => {
        const itemsToUse = inputValue
          ? items.filter(e => itemToString(e).includes(inputValue))
          : items
        return (
          <div className={classes.root}>
            <TextField {...getInputProps()} fullWidth />
            {isOpen && (
              <div className={classes.menu} {...getMenuProps()}>
                <Paper open={isOpen} {...getMenuProps()}>
                  {itemsToUse.map((item, index) => (
                    <MenuItem
                      key={index}
                      children={itemToString(item)}
                      selected={index === highlightedIndex}
                      {...getItemProps({ item, index })}
                    />
                  ))}
                </Paper>
              </div>
            )}
          </div>
        )
      }}
    </Downshift>
  )
}

そして makeStyles の採番をずらすために、もう一つコンポーネントが必要だったので、 適当に MyOtherComponent を定義します。

import React from 'react'
import { makeStyles } from '@material-ui/core'

const useStyles = makeStyles(
  { root: {} },
  { name: 'MyOtherComponent' },
)

export default function MyOtherComponent() {
  const classes = useStyles({})
  return <div className={classes.root} />
}

テスト

採番に対して何も対策していない元々のテストコードです。 ReactDOM をそのまま利用した素朴なテストです。

import React from 'react'
import ReactDOM from 'react-dom'

import MySelectInput from './MySelectInput'
import MyOtherComponent from './MyOtherComponent'

// Common Props
// ------------

interface SimpleItem {
  id: number
  name: string
}

const items: SimpleItem[] = [
  { id: 1, name: 'item-1' },
  { id: 2, name: 'item-2' },
]

const itemToString = (item: SimpleItem | null) => (item ? item.name : '')

// Test Container
// --------------

let container: HTMLDivElement

beforeEach(() => {
  document.body.innerHTML = ''
  container = document.createElement('div')
  document.body.appendChild(container)
})

// Tests
// -----

describe('MySelectInput with MyOtherComponent', () => {
  it('renders without crush', async () => {
    // throw new Error('Something occurred!')
    ReactDOM.render(
      <>
        <MySelectInput items={items} itemToString={itemToString} />
        <MyOtherComponent />
      </>
    , container)
    expect(container).toMatchSnapshot()
  })

  it('renders without crush in reverse order', async () => {
    ReactDOM.render(
      <>
        <MyOtherComponent />
        <MySelectInput items={items} itemToString={itemToString} />
      </>
    , container)
    expect(container).toMatchSnapshot()
  })
})

ポイントとしては MySelectInputMyOtherComponent の順序を逆にして二回テストを行っているところです。普段はこんなテストはしませんが、採番ズレを再現するために手軽な方法をとっています。

一度このテストを実行してスナップショットを保存してから、 上記のコードの throw new Error('Something occurred!')コメントアウトを外すと初めのテストがレンダリング前に失敗になり、二番目のテストのスナップショットに

-     aria-labelledby="downshift-1-label"
-     class="MySelectInput-root-1"
+     aria-labelledby="downshift-0-label"
+     class="MySelectInput-root-2"

のような差分がちらほらと見られるはずです。 コンポーネントの初回の評価順が逆になったことによる class のインデックス番号のズレと、Downshift の評価回数が減ったことによる aria の属性値のズレが発生しています。

冒頭に書いた変更を行えばこのズレが解消されるはずです。最後に修正後のテストコード全体を書いておきます。

import React from 'react'
import ReactDOM from 'react-dom'
import { resetIdCounter } from 'downshift'
import { StylesProvider } from '@material-ui/styles'
import { GenerateId } from 'jss'

import MySelectInput from './MySelectInput'
import MyOtherComponent from './MyOtherComponent'

// Common Props
// ------------

interface SimpleItem {
  id: number
  name: string
}

const items: SimpleItem[] = [
  { id: 1, name: 'item-1' },
  { id: 2, name: 'item-2' },
]

const itemToString = (item: SimpleItem | null) => (item ? item.name : '')

// Test Container
// --------------

let container: HTMLDivElement

beforeEach(() => {
  document.body.innerHTML = ''
  container = document.createElement('div')
  document.body.appendChild(container)

  // Reset Downshift module-internal state
  resetIdCounter()
})

// Test Styles
// -----------

// Generate class name without rule index
const generateTestClassName: GenerateId = (rule, sheet) => {
  return `${sheet!.options.classNamePrefix}-${rule.key}`
}

const provideTestStyles = (el: React.ReactElement) => (
  <StylesProvider generateClassName={generateTestClassName} children={el} />
)

// Tests
// -----

describe('MySelectInput with MyOtherComponent', () => {
  it('renders without crush', async () => {
    // throw new Error('Something occurred!')
    ReactDOM.render(provideTestStyles(
      <>
        <MySelectInput items={items} itemToString={itemToString} />
        <MyOtherComponent />
      </>,
    ), container)
    expect(container).toMatchSnapshot()
  })

  it('renders without crush in reverse order', async () => {
    ReactDOM.render(provideTestStyles(
      <>
        <MyOtherComponent />
        <MySelectInput items={items} itemToString={itemToString} />
      </>,
    ), container)
    expect(container).toMatchSnapshot()
  })
})

こういう風にテストコードはは太っていくんですね。

React のテストは react-dom/test-utils から @testing-library/react に進むと理解しやすそう

lightbulbcat.hatenablog.com

の続きです。React のテスト用ライブラリを色々試した結果 react-dom/test-utils をちょっと触った後で、 @testing-library/react を導入するといいんじゃないかなと思いました。学習順序的な話です。

react-dom/test-utils は React のテストを素朴で馴染みの深いDOM・イベント操作で行えることを確認できます。 ただし記述量が多くボイラープレート的なコードも必要にります。 そこで @testing-library/react を導入するとテストコードが簡潔になります。

@testing-library は高機能ですがその分どのAPIをどう使うべきかで初めは迷います。 react-dom/test-utils を利用した手法を知っていると「とりあえず element だけ捕まえてあとは低レベルな操作でなんとかする」という選択肢がとれるので、初めに react-dom/test-utils を触っておくのは有用だと思いました。

今回は Jest 上で試しました。

比較してみる

スナップショットテストとイベントの発火を伴う簡単なテストを書いて両者のコードを比較していきます。

テスト対象のコンポーネント

コンポーネント内の要素を取得する方法も確認したかったので div の子要素にカウントアップボタンを持つコンポーネントを用意しました。

import React, { useState } from 'react'

function CountButton() {
  const [count, setCount] = useState(0)
  const increment = () => { setCount(s => s + 1) }
  return <button onClick={increment}>{count}</button>
}

export default function MyComponent() {
  return (
    <div>
      <CountButton />
    </div>
  )
}

react-dom/test-utils

初めは react-dom/test-utils です。

import React from 'react'
import { render } from 'react-dom'
import { act } from 'react-dom/test-utils'
import MyComponent from './MyComponent'

// 使い回すコンテナ要素
let container: HTMLDivElement

beforeEach(() => {
  container = document.createElement('div');
  document.body.appendChild(container);
});

afterEach(() => {
  document.body.removeChild(container);
});

test('MyComponent renders fine :)', () => {
  render(<MyComponent />, container)
  expect(container).toMatchSnapshot()
})

test('MyComponent works fine :)', () => {
  render(<MyComponent />, container)

  const button = container.querySelector('button')
  if (!button) throw new Error('button not found')

  expect(button.textContent).toBe('0')
  act(() => {
    button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
  })
  expect(button.textContent).toBe('1')
})

DOMのAPIと同様 container.querySelector で子要素であるボタンを取得できます。 act 内での dispatchEvent でイベントをディスパッチすることでクリックイベントを発火します。 普段こういうAPIは使わないので、イベントの発火ってこういう風にするんだなとかちょっと新鮮でした。 基本的に document.body 以下のDOMを操作しているだけのコードに見えるので、 React のテストと言えど素朴な感じで書けることがわかりますね。

Node.js 上で動いているはずなので document へのアクセスはどうやっているのか若干お膳立てされている感じがありますが、多分 Jest が jsdom なんかをいい感じにセットアップしてくれているのでしょう。

スナップショットも普通に動きました。

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

exports[`MyComponent renders fine :) 1`] = `
<div>
  <div>
    <button>
      0
    </button>
  </div>
</div>
`;

react-dom だけでこれが得られるなら react-test-renderer は特に無くてもいい気がしましたが、shallow レンダリングしたい場合は必要になるとか、そんな感じでしょうか。

@testing-library/react

次に @testing-library/react で同様の処理を書いていきます。

import React from 'react'
import { render, cleanup, fireEvent } from '@testing-library/react'
import MyComponent from './MyComponent'

beforeEach(cleanup)

test('MyComponent renders fine :)', () => {
  const { container } = render(<MyComponent />)
  expect(container).toMatchSnapshot()
})

test('MyComponent works fine :)', () => {
  const { container } = render(<MyComponent />)
  
  const button = container.querySelector('button')
  if (!button) throw new Error('button not found')

  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

だいぶ簡潔に書けていますね。

セットアップの

let container: HTMLElement

beforeEach(() => {
  container = document.createElement('div');
  document.body.appendChild(container);
});

afterEach(() => {
  document.body.removeChild(container);
});

が省けているのが楽です。テストファイルが多くなるとこの差は大きくなると思います。 クリーンナップ処理は beforeEach(cleanup) で行います。

container 要素を作ってそこにテスト対象のコンポーネントレンダリングするのは同様なのですが render 関数にその処理が含まれていて返り値で container 要素が帰ってくるのが楽ですね。 型も react-dom/test-utils の例と同様 HTMLElement なのでスナップショットを撮ったり querySelector を生やせたりと同じ使い方ができます。

他にイベント発火も

  act(() => {
    button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
  })

から

fireEvent.click(button)

と楽になりました。テストは簡潔に書けるに越したことはないですね。

react-dom/test-utils のコードを書いた後だと、だいたい裏でどんな処理がなされているかが想像できて良いです。

enzyme と react-test-renderer と @testing-library/react を素朴に試す

React のテスト用ライブラリっていくつかある上に、それぞれサポートしている機能が違うんですよね。 ついでに提供されているメソッドや、メソッドチェーンの様式も様々なので、出来るだけ速やかに繰り返し実行できる環境で触りながら把握できると便利です。 Jest でコンソール出力しながらだとまどろっこしいので、手元でサクサク試せる環境をセットアップした時のメモです。

React のテスティングライブラリ

今回対象にするのは以下の3つのライブラリです。現時点の理解を書き添えてみました。

セットアップ

package.json の抜粋です。エラーを消すために色々追加していったら、こんな感じで落ち着きました。

{
  "dependencies": {
    "@testing-library/react": "^8.0.7",
    "enzyme": "^3.10.0",
    "enzyme-adapter-react-16": "^1.14.0",
    "jsdom": "^15.1.1",
    "jsdom-global": "^3.0.2",
    "nodemon": "^1.19.1",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-test-renderer": "^16.8.6"
  }
}

nodemon はファイルを監視させて再実行させるために利用しています。お試し環境の良き友ですね。

今回は省けるだけ環境構築を省く、という方針でセットアップしたので、JSX は利用できなくなっています。 小規模なら React.createElement を直接利用してもそこまで大変ではありませんでした。

それぞれ試していく

テスト対象のコンポーネント

イベントや多少のネストなんかを確認できるようにしてみました。

const { createElement: e, useState } = require('react')

// Sample Components
// -----------------

const CountButton = () => {
  const [count, setCount] = useState(0)
  return e(
    'button',
    { onClick: () => setCount(c => c + 1) },
    `count: ${count}`
  )
}

const C1 = props => e('div', {}, 'C1: ', props.content)

const C2 = () => e('div', {},
  e(C1),
  e(C1, { content: 'content-from-C2' }),
  e('div', {},
    e(CountButton),
    e(CountButton),
    e('div', {},
      e(C1)
    )
  )
)

module.exports = { CountButton, C1, C2 }

enzyme

const { createElement: e } = require('react')
const enzyme = require('enzyme')
const Adapter = require('enzyme-adapter-react-16')
const { shallow } = enzyme
const { C1, C2 } = require('./components')

enzyme.configure({ adapter: new Adapter() })

console.log(shallow(e(C2)).debug())
console.log(shallow(e(C2)).find(C1).at(1).dive().debug())

これを実行すると

<div>
  <C1 />
  <C1 content="content-from-C2" />
  <div>
    <CountButton />
    <CountButton />
    <div>
      <C1 />
    </div>
  </div>
</div>

<div>
  C1: 
  content-from-C2
</div>

と出力されます。enzyme の shallow ってこういうことなんだなとか、 この段階ではこういうオブジェクトが帰ってきているんだなとかを眺めるための環境です。

試しに C1 内でエラーを投げるようにしてみたのですが、問題なく同じ出力がされました。

react-test-renderer

const { createElement: e, useState } = require('react')
const { create } = require('react-test-renderer')
const { C1, C2 } = require('./components')

console.log(create(e(C2)).toTree())
console.log(create(e(C2)).toJSON())

これを実行すると

{ nodeType: 'component',
  type: [Function: C2],
  props: {},
  instance: null,
  rendered:
   { nodeType: 'host',
     type: 'div',
     props: { children: [Array] },
     instance: null,
     rendered: [ [Object], [Object], [Object] ] } }

{ type: 'div',
  props: {},
  children:
   [ { type: 'div', props: {}, children: [Array] },
     { type: 'div', props: {}, children: [Array] },
     { type: 'div', props: {}, children: [Array] } ] }

@testing-library/react

動作させるには jsdom-global を実行しておくことが必要です。 Jest と組み合わせて使う場合には不要です。

const { createElement: e } = require('react')
const { render, fireEvent } = require('@testing-library/react')
require('jsdom-global')()
const { C2 } = require('./components')

const { getAllByText, debug } = render(e(C2))
debug()
fireEvent.click(getAllByText(/count/)[0])
debug()

以下のように出力されます。shallow ではないので enzyme の表示に比べて長いですね。

<body>
  <div>
    <div>
      <div>
        C1: 
      </div>
      <div>
        C1: 
        content-from-C2
      </div>
      <div>
        <button>
          count: 0
        </button>
        <button>
          count: 0
        </button>
        <div>
          <div>
            C1: 
          </div>
        </div>
      </div>
    </div>
  </div>
</body>
<body>
  <div>
    <div>
      <div>
        C1: 
      </div>
      <div>
        C1: 
        content-from-C2
      </div>
      <div>
        <button>
          count: 1
        </button>
        <button>
          count: 0
        </button>
        <div>
          <div>
            C1: 
          </div>
        </div>
      </div>
    </div>
  </div>
</body>

テスティングライブラリは大量のメソッド・ユーティリティが存在するので、TypeScript で構築すれば良かったです。

Reactコンポーネントのアンマウント後の更新を避けるために AbortController を使ってみる

これです。

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
    in UserList (at App.tsx:53)

この warning を消すために useEffect のクリーンアップ処理をきちんと実装してみようと思います。

動作するコードは github に置いてあります。

AbortController

fetch を中断するために AbortController というものが利用できるみたいです。Can I use によるとモダンブラウザなら普通に使えるようです。

使い方は AbortControllernew してメンバの signalfetch に仕込むと AbortController インスタンス経由でその fetch を中断できる、というものです。

const controller = new AbortController()
const { signal } = controller
fetch('http://localhost:3080/', { signal })

useEffect 内で利用してみる

以上のサイトを参考に fetch が完了する前にアンマウントされた場合にクリーンアップ処理で fetch を中断するようなコードを書きました。useEffect で返される関数がアンマウント時(もしくは同じ useEffect が再実行された時)に呼ばれクリーンアップ処理として作用します。

interface Data {
  // data structure...
}

const UserList: React.FC = () => {
  const [data, setData] = useState<Data>()
  const [error, setError] = useState<Error>()

  useEffect(() => {
    const controller = new AbortController()
    const { signal } = controller
    let finished = false
    
    fetch('http://localhost:3080/', { signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        // `abort` した後に `setError` してしまうと結局冒頭のエラーが出るので弾く
        if (err.name !== 'AbortError') setError(err)
      })
      .finally(() => {
        finished = true
      })

    return () => {
      if (!finished) {
        controller.abort()
        console.log('UserList: Fetch aborted')
      }
    }
  }, [setData, setError])

  if (error) return <div>{error.toString()}</div>
  if (!data) return <div>Loading...</div>

  return (
    // Render data...
  )
}

もう少し簡潔に書けると思ったのですが、とりあえずこんな感じです。 controllerfetch が終了したかどうかも取得できれば finished なんて変数を利用しなくてもいいんですけどね。 型定義を見る限り aborted しか取れなさそうです。

// lib.dom.d.ts
interface AbortController {
    readonly signal: AbortSignal;
    abort(): void;
}

interface AbortSignal extends EventTarget {
    readonly aborted: boolean;
    onabort: ((this: AbortSignal, ev: Event) => any) | null;
    addEventListener<K extends keyof AbortSignalEventMap>(type: K, listener: (this: AbortSignal, ev: AbortSignalEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof AbortSignalEventMap>(type: K, listener: (this: AbortSignal, ev: AbortSignalEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

https://blog.jxck.io/entries/2017-07-19/aborting-fetch.html の記事を読みながら使えるようになるまでに色々あったんだなと思いつつ、2019 年現在では普通に使えているようで何よりです。