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

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

Go で hello-world の減量

Go言語ってビルドサイズが大きいですよね。 バイナリサイズを削減する ldflagsupx について、どの程度の減量が見込めるのか確認してみます。 ついでに標準ライブラリへの理解を兼ねて hello-world の書き方もいくつかのパターンで比べてみます。

% go version
go version go1.15.5 darwin/amd64

fmt

一般的な hello-world の書き方です。

func main() {
    fmt.Print("Hello, world!")
}

いつも使っている表現ですが fmt パッケージで標準出力するのにちょっと違和感を持ってました。

os

func main() {
    os.Stdout.WriteString("Hello, world!")
}

fmt に比べて、こちらの方が標準出力している感がありますね。 でも fmt の方が色んな型を渡せて便利なので fmt を使っちゃいますね。

syscall

func main() {
    syscall.Write(syscall.Stdout, []byte("Hello, world!"))
}

使ったことはありませんでしたが、意外とシンプルに書けました。 syscall.Stdoutuintptr 型の変数で os.File 型の os.Stdout とは別物です。

バイナリサイズ比較

ビルドしてサイズを比べてみます。

  • ldflags を指定して go build -ldflags="-s -w" するパターン
  • ビルドしたバイナリに upx -9 を適用したパターン
  • その両方を適用したパターン

を比較したところ以下のようになりました。

default ldflags upx ldflags+upx
fmt 2.0M 1.6M 1.1M 620K
os 1.5M 1.1M 840K 448K
syscall 1.2M 915K 708K 376K

syscall が一番小さくなりましたが、それでも何も工夫しなければ1MBを超えています。 御膳立て部分のコードが大きそうですね。

ldflags と upx はそれぞれバイナリサイズを半分程度まで削減できるみたいです。 両方使うと元の大きさの 1/3 程度の小ささになりました。 まだ何をやっているツールか把握していませんが、upx すごいですね。

本当は各ライブラリの表現を部分的に引っ張ってきてより低級な表現に展開する、みたいなことをしたかったのですが、internal なパッケージをユーザ側で利用することができず諦めました。

おまけ

ディレクトリを跨いだファイルサイズの比較に tree -h が便利

tree -h でファイルサイズを表示しつつツリー表示してくれます。 --du オプションをつけると du 的にフォルダの合計サイズも表示してくれます。

% tree --du -h
.
├── [ 303]  build.sh
├── [5.3M]  fmt
│   ├── [2.0M]  hello-world
│   ├── [1.6M]  hello-world.ldflags
│   ├── [620K]  hello-world.ldflags.upx
│   ├── [1.1M]  hello-world.upx
│   └── [ 176]  main.go
├── [3.8M]  os
│   ├── [1.5M]  hello-world
│   ├── [1.1M]  hello-world.ldflags
│   ├── [448K]  hello-world.ldflags.upx
│   ├── [840K]  hello-world.upx
│   └── [  83]  main.go
└── [3.2M]  syscall
    ├── [1.2M]  hello-world
    ├── [915K]  hello-world.ldflags
    ├── [376K]  hello-world.ldflags.upx
    ├── [708K]  hello-world.upx
    └── [ 104]  main.go

  12M used in 3 directories, 16 files

続き: Karabiner-Elements と Swift で background のアプリに特定のキーストロークを送る

ターミナルやエディタでコーディングしつつ、参考書を表示しているKindleのページ送りをしたかったのが動機です。 表題で言っている background のアプリとは最前面(foremost)ではないウィンドウで動作しているアプリを指しています。

lightbulbcat.hatenablog.com

上の記事で試したのは、指定したアプリ向けにキーストロークを送る AppleScript を Karabiner-Elements で特定のキーにより実行する、というものでした。しかし AppleScript の限界なのか background のアプリにキーストロークを送ることはできず、実際には一瞬ターゲットのアプリを foremost に持ってきてキーを送り foremost を元のアプリに戻すという処理しか書けませんでした。

Swift を使えばターゲットのアプリを activate することなく特定のアプリにキーストロークを送れるようだったので、使用感向上のため実装してみました。

Swift スクリプトの用意

アプリにキーストロークを送る send-key-to-app.swift を用意します。 AppleScript よりこちらの方が好きです。

import Foundation
import AppKit

let args = CommandLine.arguments
if args.count != 3 {
  print("\(args[0]) [APP_NAME] [KEY_CODE]")
  print("APP_NAME: should be an executable file name(not a path)")
  print("KEY_CODE: a number such as 123(LEFT), 124(RIGHT), ...")
  exit(1)
}

let appName = args[1]
let keyCode = CGKeyCode(args[2]) ?? 0

let src = CGEventSource(stateID: CGEventSourceStateID.hidSystemState)    

func sendKeyStroke(pid: pid_t, keyCode: CGKeyCode) {
  let keyUp = CGEvent(keyboardEventSource: src, virtualKey: keyCode, keyDown: true)
  keyUp?.postToPid(pid)
  let keyDown = CGEvent(keyboardEventSource: src, virtualKey: keyCode, keyDown: false)
  keyDown?.postToPid(pid)
}

func getPidByName(executableFileName: String) -> Optional<pid_t> {
  let apps = NSWorkspace.shared.runningApplications
  for a in apps {
    print(a.bundleIdentifier)
    if a.executableURL?.lastPathComponent == executableFileName {
      return a.processIdentifier
    }
  }
  return nil
}

if let pid = getPidByName(executableFileName: appName) {
  sendKeyStroke(pid: pid, keyCode: keyCode)
} else {
  print("executable '\(appName)' not found in user processes")
}

始めて Swift を書きましたが、Optional 型と if let のコードが気持ちいいですね。 値を wrap した Optional の扱い方はちょっと Rust っぽいです。

Karabiner-Elements に設定する前に以下のように動作確認をしておきます。

swift send-key-to-app.swift Kindle 124

これで background で動いている Kindle のページが進めば成功です。 AppleScript のものと比べるとウィンドウ切り替えのチカチカ感が減ってとても快適です。

Karabiner-Elements の設定

冒頭で紹介した以前の記事と shell_command 以外は同じです。 適当に Command + 1 でページ送り、Command + 2 でページバックするようにしています。

{
  "profiles": [
    {
      "complex_modifications": {
        "rules": [
          {
            "description": "Kindleのページ移動",
            "manipulators": [
              {
                "from": {
                  "key_code": "1",
                  "modifiers": {
                    "mandatory": ["command"],
                    "optional": ["any"]
                  }
                },
                "to": [{"shell_command": "swift /path/to/send-key-to-app.swift Kindle 123"}],
                "type": "basic"
              },
              {
                "from": {
                  "key_code": "2",
                  "modifiers": {
                    "mandatory": ["command"],
                    "optional": ["any"]
                  }
                },
                "to": [{"shell_command": "swift /path/to/send-key-to-app.swift Kindle 124"}],
                "type": "basic"
              }
            ]
          },

更新したら「Restart Karabiner-Elements」をすれば早速反映されます。

この使い方でもいいのですが、現状ページ送りごとに毎回 swift インタプリターが実行されているので、気になる場合はビルドすると良さそうですね。

余裕があったので Swift をビルドする

swift package build --type executable でコマンド用のプロジェクトが簡単に作成できたので作っておきました。

https://github.com/asa-taka/send-key-to-app

心なしかページ遷移を繰り返してもiMacのファンが鳴らなくなった気がします。

Karabiner-Elements と AppleScript で background のアプリに特定のキーストロークを送る

ターミナルやエディタでコーディングしつつ、参考書を表示しているKindleのページ送りをしたかったのが動機です。 表題で言っている background のアプリとは最前面(foremost)ではないウィンドウで動作しているアプリを指しています。

指定したアプリ向けにキーストロークを送る AppleScript を Karabiner-Elements で特定のキーにより実行することで実現します。 「background のアプリに」と言いつつ実際にやっていることは、一瞬 foremost に持ってきてキーを送って元のアプリに戻す、という処理です。

AppleScript の用意

アプリにキーストロークを送る send-key.applescript を用意します。 AppleScript を書くのは初めてですが自然言語志向すぎて胸焼けしそうですね。

on run argv
  if (count of argv) < 2 then
    log "send-key [TARGET_APP] [KEY_CODE]"
    log "KEY_CODEs: 123(LEFT), 124(RIGHT), ..."
    error number -1721
  end if

  set targetApp to (item 1 of argv as text)
  set keyCode to (item 2 of argv as number)

  set currentApp to (path to frontmost application as text)

  activate application targetApp
  tell application "System Events" to key code keyCode
  activate application currentApp
end run

シンプルに書きたい人は変数を埋め込んで以下のようにしてもOKです。

set currentApp to (path to frontmost application as text)
activate application "Kindle"
tell application "System Events" to key code 123
activate application currentApp

事前に以下のように動作確認をしておきます。osascriptAppleScript をシェルから起動するためのものらしいです。

osascript send-key.applescript Kindle 124

これで Kindle のページが進んで元のアプリが frontmost になっていれば成功です。

background のアプリに直接キーストロークを送る仕組みが見つからなかったので、 一度ターゲットのアプリを activate してからキーストロークを送り元のアプリを再び activate しています。 なので一瞬ウィンドウがチカチカします。ホームメイド感がありますね。

Karabiner-Elements の設定

Karabiner-Elements に前述のスクリプトを起動するキーの設定をします。 デフォルトだと ~/.config/karabiner みたいです。

f:id:lightbulbcat:20201121220129p:plain

以下のような感じで profiles[].complex_modifications.rules に設定を追加します。 全体は長いので、今回関係のないフィールドは省略しています。

設定の肝は from のキーに対して shell_command で任意のコマンドを実行可能な点です。 これができるだけでも Karabiner の用途はかなり広がりますね。

ここでは適当に Command + 1 でページ送り、Command + 2 でページバックするようにしています。

{
  "profiles": [
    {
      "complex_modifications": {
        "rules": [
          {
            "description": "Kindleのページ移動",
            "manipulators": [
              {
                "from": {
                  "key_code": "1",
                  "modifiers": {
                    "mandatory": ["command"],
                    "optional": ["any"]
                  }
                },
                "to": [{"shell_command": "osascript /path/to/send-key.applescript Kindle 123"}],
                "type": "basic"
              },
              {
                "from": {
                  "key_code": "2",
                  "modifiers": {
                    "mandatory": ["command"],
                    "optional": ["any"]
                  }
                },
                "to": [{"shell_command": "osascript /path/to/send-key.applescript Kindle 124"}],
                "type": "basic"
              }
            ]
          },

設定ファイルの構造については Karabiner-Element のドキュメント をご覧ください。

更新したら「Restart Karabiner-Elements」をすれば早速反映されます。

サボったところ

Karabiner-Element の設定をいい感じにしたらいけるかもしれませんが、調べるのが面倒で放置している箇所です。

  • Kindle を起動していない時に設定したキーを打つと問答無用で Kindle が立ち上がってくる
  • 他にもあるかも…

快適にコードを書きたいというのが当初の目的なので、時間はコーディングに割くべきと、多少の不具合には目をつぶっています(正当化)。

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()
  })
})

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