Go の struct と interface で Embedding
Effective Go の Embedding の内容を試してみます。
Go では embedding を利用して継承のようなことができますが、struct と interface の違いが今ひとつ理解できていなかったため、実際にコードを書いてコンパイラに怒られながら、どういう違いがあるのか試していきたいと思います。
- 確認方法
- interface を interface に埋め込む
- struct を struct に埋め込む
- struct を interface に埋め込む(不可)
- interface を struct に埋め込む
確認方法
こんな感じで embedding を利用して BaseMethod
と SuperMethod
を実装するパターンを色々見ていきます。
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.BaseMethod
で baseStruct.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 を使いながら数年間悩まされ続けてきたキーボード入力系の不具合がある。
症状としては上の記事と同じで「application 」と打つと「appapplication」のように入力される。物理的なチャタリングとは異なりワンテンポ遅れてまとまったキーストロークが繰り返されるという、どうにもソフトウェア領域らしい不具合が発生し続けていた。記事で紹介されていた各種リセットを行ってみて、改善したかに思えた瞬間もあったが程なくして再発する。ただ記事を見つけて、同じ問題を抱えている人がいるのだと少し安心できた。
そんなこともあり日本語の記事も他に見つからず、英語で調べるの面倒だなと思いながら、たまにイライラする程度だしと数年間放置し続けていたのだが、いざ調べてみるとあっさり解決方法が見つかった。
- homebrew - Duplicate keyboard input after upgrading to macOS Catalina - Ask Different
- https://discussions.apple.com/thread/250733138?answerId=251408993022
Wacom 系のソフトウェアが原因らしい。アンインストールすれば症状が改善されたとの報告がされている。
Kudos to rakdavid. 本当にありがとう。
私もタブレットユーザなので、いくつかインストールしていた Wacom 系のドライバやついでにユーティリティを全てアンインストールしたところ、今のところ本当に症状が解消されている。どうやって気づいたんだこんなもの。もう iMac 買い換えるかとも思い始めていた。そして同様に Wacom をインストールして問題が再発し絶望する未来もありえた。ここに私は正しく、救われたのだった。
早く調べておけばよかった。過去数年間の矢印戻りバックスペース連打行末戻りのルーチンと、いつ重複が発生するかという不信感は確実に私の処理能力に対するオーバーヘッドになっていた。数十行のコードを完成させるまでに10回は発生していたように思う。そして変数名が異なりメソッド名が異なりコンパイラに怒られたりランタイムエラーになったりするのだ。それで数十秒のロスだ。トライアンドエラーを繰り返すコーディング作業の中でこのロスは大きすぎるし邪魔すぎる。
人間が1日に行える判断の回数には上限があるという。この不具合が奪っていったものについて、私はもうあまり考えたくはない。
Material UI の AppBar と Drawer を自分好みに組み合わせる
半年ほどかけてようやく Material UI にも慣れてきました。AppBar と Drawer の組み合わせは昨今のアプリでは定番となっていますが、レスポンシブに Drawer の表示を切り替えようとすると、AppBar との色使いの兼ね合いで迷います。
- モバイルなどの狭いスクリーン
- Drawer は AppBar のボタンをクリックした時に表示
- デスクトップなどの広いスクリーン
- Drawer は常にページ横などに表示
というのが割とよくあるパターンですが、この時両方にテーマカラーなどの濃い色をつけてしまうと、広いスクリーンの場合に上と横の2辺に濃い色が来てしまいます。
個人的な好みですが、この 2辺以上の濃い色のバーに囲まれたコンテンツ からは窮屈さを感じてしまい、あまり好きではありません。
今回作成するのはスクリーン幅によって AppBar の色が変わるようにして Drawer と組み合わせたものです。
ついでに公式に 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 が以下のようなものです。
MyContent
と MyDrawerContent
は適当な内容を定義しているだけなので適当に読み飛ばしてください。
HideOnScroll
は useScrollTrigger
を利用した例として 公式のサンプル として書かれているものです。ありがたいですね。同じ公式のサンプルからスクロール時に AppBar
に box-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> ) }
今回の肝としては isMobile
で theme
をスイッチしている部分です。
<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)
以上です。
参考
- https://github.com/downshift-js/downshift#resetidcounter
- SSR 用って書いてあるので、あんまりテストで使ったりはしないのかもしれません...
- https://github.com/mui-org/material-ui/issues/9492#issuecomment-368205258
- そのまま使わせてもらいました :)
順を追って
採番される対象
今回スナップショットで差分が出る場所は以下の通りです。
- Material UI:
makeStyles
で生成したクラス名MySelectInput-root-23
の23
- Downshift:
aria-labelledby="downshift-0-label"
の0
テスト対象のコンポーネント
テスト対象となるコンポーネントです。
よくあるリストから選択するタイプの 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() }) })
ポイントとしては MySelectInput
と MyOtherComponent
の順序を逆にして二回テストを行っているところです。普段はこんなテストはしませんが、採番ズレを再現するために手軽な方法をとっています。
一度このテストを実行してスナップショットを保存してから、
上記のコードの 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 に進むと理解しやすそう
の続きです。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つのライブラリです。現時点の理解を書き添えてみました。
- enzyme
- react-test-renderer
- スナップショットを取ることができる
- @testing-library/react
fireEvent
でイベントを発火することができる
セットアップ
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 によるとモダンブラウザなら普通に使えるようです。
使い方は AbortController
を new
してメンバの signal
を fetch
に仕込むと 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... ) }
もう少し簡潔に書けると思ったのですが、とりあえずこんな感じです。
controller
で fetch
が終了したかどうかも取得できれば 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 年現在では普通に使えているようで何よりです。