React Select を使ったコンポーネントのテストをする

react-select という複数選択やoptionにサムネイルを追加したりできる高機能なselectを提供するライブラリがある。

OptionSelector.tsx:

import { useCallback } from 'react';
import Select from 'react-select';

export type Option = {
  value: string;
  label: string;
};

export const options: Option[] = [
  { value: '1', label: 'Option 1' },
  { value: '2', label: 'Option 2' },
  { value: '3', label: 'Option 3' },
];

type Props = {
  value: Option | undefined;
  onChange: (option: Option | undefined) => void;
};

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback((option: Option | null) => {
    onChange(option || undefined);
  }, [onChange]);

  return (
    <div data-testid="OptionSelector">
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
      />
    </div>
  );
};

使う分にはいいが、このライブラリを使っているコンポーネントのテストをする時は少々やっかいで、たとえば、特定のオプションを選択してから何かをするようなテストを書く場合などである。

__tests__/OptionSelector.test.tsx:

import { render, screen } from '@testing-library/react';
import OptionSelector, { Option, options } from '../OptionSelector';

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    let option: Option | undefined = undefined;
    const onChange = jest.fn((newOption: Option | undefined) => {
      option = newOption;
    }));

    render(<OptionSelector value={option} onChange={onChange} />);

    // なんらかの値を選択したい

    expect(onChange).toHaveBeenCalledTimes(1);
    expect(option).toBe(options[1]);
  });
});

このような場合は、モックに置き換えるのが一般的だが、react-select-event というライブラリを使うとモックに置き換えることなく任意の値を選択することができる。

import React from 'react'
import Select from 'react-select'
import {render} from '@testing-library/react'
import selectEvent from 'react-select-event'

const {getByTestId, getByLabelText} = render(
  <form data-testid="form">
    <label htmlFor="food">Food</label>
    <Select options={OPTIONS} name="food" inputId="food" isMulti />
  </form>,
)
expect(getByTestId('form')).toHaveFormValues({food: ''}) // empty select

// select two values...
await selectEvent.select(getByLabelText('Food'), ['Strawberry', 'Mango'])
expect(getByTestId('form')).toHaveFormValues({food: ['strawberry', 'mango']})

// ...and add a third one
await selectEvent.select(getByLabelText('Food'), 'Chocolate')
expect(getByTestId('form')).toHaveFormValues({
  food: ['strawberry', 'mango', 'chocolate'],
})

react-select-event | Testing Library

上記のサンプルコードによると selectEvent.select(<react-select内部のinput要素>, <選択するオプション>) というAPIを使用することで選択できる模様。

ここで、react-select内部のinput要素 は、react-selectinputId を指定すると任意のIDを付与できるので、label要素のfor属性と同じ値を指定することで紐づけられる。そのため、getByLabelText(<labelに指定している文字列>) で取得できるようになる。

これにより、以下のようにテストが書けるようになる。

OptionSelector.tsx:

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback<OnChangeSelect>((option) => {
    onChange(option || undefined);
  }, [onChange]);

  return (
    <div data-testid="OptionSelector">
      <label htmlFor="option_selector">option:</label>
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
        inputId="option_selector"
      />
    </div>
  );
};

__tests__/OptionSelector.test.tsx:

import { render, screen } from '@testing-library/react';
import selectEvent from 'react-select-event';
import OptionSelector, { Option, options } from '../OptionSelector';

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    let option: Option | undefined = undefined;
    const onChange = jest.fn((newOption: Option | undefined) => {
      option = newOption;
    }));

    render(<OptionSelector value={option} onChange={onChange} />);

    await selectEvent.select(screen.getByLabelText('option:'), [options[1].label]);

    expect(onChange).toHaveBeenCalledTimes(1);
    expect(option).toBe(options[1]);
  });
});

ここで、以下にあげるように、いくつかの問題がある。

  1. IDを指定しているのでこのコンポーネントを一つの画面で複数使用している場合うまくいかない
  2. label要素がない場合は自分でコンポーネント内部のIDをもとにdocument.getElementById()などで取得する必要がある

まず 1. の問題についてだが、ジェネレーターなどを使ってコンポーネント生成毎に異なるIDを生成することで解決できる。

OptionSelector.tsx:

function* inputIdGenerator(): Generator<string, string, string> {
  let id = 1;
  while (true) {
    yield `option_selector_${id++}`;
  }
}
const inputIdIterator = inputIdGenerator();

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback<OnChangeSelect>((option) => {
    onChange(option || undefined);
  }, [onChange]);

  const inputId = useMemo(() => inputIdIterator.next().value, []);

  return (
    <div data-testid="OptionSelector">
      <label htmlFor={inputId}>option:</label>
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
        inputId={inputId}
      />
    </div>
  );
};

そして次に 2. の問題についてだが、コンポーネントdata-inputid 属性を追加してここにIDを指定して、テスト時に参照することで解決できる。

OptionSelector.tsx:

export default function OptionSelector({ value, onChange } : Props) {
  const onChangeSelect = useCallback<OnChangeSelect>((option) => {
    onChange(option || undefined);
  }, [onChange]);

  const inputId = useMemo(() => inputIdIterator.next().value, []);

  return (
    <div data-testid="OptionSelector" data-inputid={inputId}>
      <label htmlFor={inputId}>option:</label>
      <Select
        value={value}
        options={options}
        onChange={onChangeSelect}
        inputId={inputId}
      />
    </div>
  );
};

__tests__/OptionSelector.test.tsx:

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    ...
    await selectEvent.select(
      document.getElementById(screen.getByTestId('OptionSelector').dataset.inputid!)!,
      [options[1].label]
    );
    ...
  });
});

さらにヘルパー関数を作成しておくとよい。

OptionSelector.tsx:

export function getReactSelectInputElement(el: HTMLElement | undefined | null): HTMLInputElement {
  if (!el) throw new Error('Element is null');
  if (el.dataset.testid !== 'OptionSelector') throw new Error('Element is not OptionSelector');
  if (typeof el.dataset.inputid === 'undefined') throw new Error('InputId is empty');
  const input = el.querySelector<HTMLInputElement>(`#${el.dataset.inputid}`);
  if (!input) throw new Error('input element not found');
  return input;
}

__tests__/OptionSelector.test.tsx:

describe('<OptionSelector />', () => {
  it('calls onChange() prop with selected value', async () => {
    ...
    await selectEvent.select(
      getReactSelectInputElement(screen.getByTestId('OptionSelector')),
      [options[1].label]
    );
    ...
  });
});

以上で、react-select を使ったコンポーネントもテストできるようになった。また、上記の方針で実装したサンプルコードは sandbox/ts-react-select にある。

環境:

  • React: 18.2.0
  • react-select: 5.8.0
  • react-select-event: 5.5.1

Docker 25.0 でヘルスチェックに start-interval オプションが追加された

Docker にはヘルスチェック機能があるが、25.0start-interval オプションが追加された。

The options that can appear before CMD are:

  • --interval=DURATION (default: 30s)
  • --timeout=DURATION (default: 30s)
  • --start-period=DURATION (default: 0s)
  • --start-interval=DURATION (default: 5s)
  • --retries=N (default: 3)

...

start interval is the time between health checks during the start period. This option requires Docker Engine version 25.0 or later.

HEALTHCHECK - Dockerfile reference | Docker Docs

いままでは、以下のように interval 間隔でヘルスチェックがされていた。 このとき start-period を指定していると、コンテナ開始から指定した時間が経過するまでは失敗しても無視されるという動作をする。

 |<------- start-period(10s) --------->|
 |<- interval(5s) ->|<- interval(5s) ->|<- interval(5s) ->|<- interval(5s) ->|
 |
 +------------------+------------------+------------------+------------------+
 ^                  ^                  ^                                           
 |                  |                  |                                            
失敗               失敗               成功                                            

start-interval を指定すると、start-period の時のヘルスチェック間隔を設定できる。使い道としては、Docker Compose で depends_onservice_healthy を指定する時に起動が完了するまで待ち、立ち上がったら素早くhealthy状態に移行できるように高頻度でヘルスチェックしつつ、通常のヘルスチェックを頻繁にしなくするということができる。

なお、start-period 期間内でヘルスチェックを通ると指定した時間が経過していなくても、通常のヘルスチェックに移行する。

- SI: start-interval

 |<-------- start-period(3s) ---------->|
 |<- SI(1s) ->|<- SI(1s) ->|<- SI(1s) ->|<---------------- interval(10s) ---------------->|
 |
 +------------+------------+------------+-------------------------------------------------+
 ^            ^            ^            ^                                           
 |            |            |            |                                            
失敗         失敗         失敗         成功                                            

Docker Compose では compose.yaml で以下のように指定できる。

compose.yaml:

services:
  app:
    ...
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql:8
    volumes:
      - type: volume
        source: mysql_data
        target: /var/lib/mysql
    ports:
      - target: 3306
        published: 3306
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 1
    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
      start_period: 10s
      start_interval: 1s
      interval: 600s
      timeout: 30s
      retries: 0
volumes:
  mysql_data:

RubyのENV.fetch()で空文字列の時もデフォルト値を使うようにする

Rubyには、指定された環境変数の値を読み込み、存在しなければデフォルト値を返すENV.fetch()というメソッドがある。Railsなどでは設定ファイルで環境変数から値を読み込むのによく使う。

config/database.yml:

default: &default
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

しかし、たとえば指定された環境変数が空文字列だったりすると、デフォルト値ではなく空文字列が返されてしまう。

HOGE='' ruby -e 'puts ENV.fetch("HOGE"){1}' #=> 

これは direnv の .envrc や Docker Compose の compose.yaml環境変数を指定していたりすると起きがちである。

.envrc:

export HOGE=

compose.yaml:

services:
  app:
    environment:
      HOGE: ${HOGE}

なので、空文字列の時もデフォルト値が参照されるようにENVをモンキーパッチして、新たにENV.fetch2()というENV.fetch()とほぼ同様の動作をするメソッドを実装する。

lib/env.rb:

class << ENV
  # 基本的な動作は`ENV.fetch()`と同様だが、空文字列のときもデフォルト値を返すようにする
  def fetch2(*args, &block)
    key, default = args
    value = fetch(*args, &block)
    return value if value != ''

    raise KeyError, format('key not found: "%s"', key) if args.size == 1 && !block
    warn('block supersedes default value argument', uplevel: 1) if args.size == 2 && block
    block ? yield(key) : default
  end
end
HOGE='' ruby -r ./lib/env.rb -e 'puts ENV.fetch2("HOGE"){1}' #=> 1

これで問題を解決できた。なお、空文字列の時はデフォルト値ではなく空文字列が返ってきてほしい時があるので、ENV.fetch() をオーバーライドするのはやめた方がよい。

Gist: https://gist.github.com/mrk21/3c6f29aa91659dcaf45eefba11f866a3

環境

年齢の算出

年齢の算出は簡単にできるかと思いきや、少々面倒臭い。これは、閏年というものが存在するためである。そのため以下のような単純な時間のdiffで算出しようとすると年を重ねる日時が1日ほどズレる場合がある。

const today = new Date(2010, 2, 10); // 2010-03-10
const birthday = new Date(2000, 2, 10); // 2000-03-10
const diff = today.getTime() - birthday.getTime();
const age = Math.floor(diff / (1000*3600*24*365.25));
console.log(age); // 9

また、誕生日が閏日(02-29)の人は、閏年以外は うるう年をめぐる法令|参議院法制局 によると、03-01 が誕生日となるのでこれも考慮すると以下のようになる。だいたいの日付オブジェクトでは閏年以外で 02-29 を指定すると 03-01 と解釈されるので、閏日の時に条件分けする必要はない。

// const today = new Date(2010, 1, 29); // 2010-03-01, 閏年以外で 02-29 を指定すると 03-01 と解釈される
const today = new Date(2010, 2, 10); // 2010-03-10
const birthday = new Date(2000, 2, 10); // 2000-03-10

let age = today.getFullYear() - birthday.getFullYear();
if (today.getMonth() < birthday.getMonth()) {
  age--;
}
else if (today.getMonth() === birthday.getMonth() && today.getDate() < birthday.getDate()) {
  age--;
}
console.log(age); // 10

ここで、上記で参照した うるう年をめぐる法令|参議院法制局 によると道路交通法では閏日の人は 02-28 が誕生日と解釈されるので道路交通法関連で年齢を算出するときは注意が必要となる。また日本国外では別のルールに基づく可能性があるので合わせて注意が必要となる。

Minecraft Java Edition 1.20 + PaperMC + Multiverse-Core 環境での砂無限増殖装置(Sand Duper)

エンドポータルとチャンクローダーを利用した砂無限増殖装置というのがある(通称: Sand Duper)。以下の動画を参考に作ったが、PaperMC + Multiverse-Core という環境だと動作しなかった。

問題と解決方法

問題点は以下のとおりでこれらを解決する必要がある。

  1. Sand Duper が動作しない
  2. チャンクローダーが動作しない
  3. エンドポータルを通った砂が別の箇所にスポーンする

Sand Duper が動作しない

まず、そもそも砂無限増殖装置の要である Sand Duper が動作しない(粘着ピストンでガチャガチャするとブロックが増殖する現象)。これは PaperMC で修正されてしまっているためである。これを解決するためには GravityControl というModを導入する。設定は特に必要ない。

GravityControl - Minecraft Plugin

チャンクローダーが動作しない

次にチャンクローダーが動作しないのでエンドに行ったり遠くに行くとSand Duperの装置が停止してしまう。どうもPaperMC環境だと動作しないチャンクローダーがある模様。そのため、PaperMCでも動作するチャンクローダーとして以下があるのでこれに置き換える。

エンドポータルを通った砂が別の箇所にスポーンする

エンドポータルを通った砂が別の箇所にスポーンする。これは Multiverse-Core が原因で、転送された砂が worlds.yml に記述されているエンドの spawnLocation で指定された座標にスポーンしてしまうためである(なぜかプレイヤーは影響を受けない。リスポーンするときはここになる)。

これを解決するためには、spawnLocation をエンドポータルで転送される座標に指定する必要がある。なお、このとき横方向の座標は+0.5しないと微妙にズレてうまくいかない。これはおそらく座標はブロックの端を表すが、エンドポータルで転送される座標はエンティティの中心をもとにしているせいかもしれない。また縦方向に+1するといいかもしれない。

plugins/Multiverse-Core/worlds.yml:

worlds:
  world_the_end:
    spawnLocation:
      ==: MVSpawnLocation
      x: 100.5
      y: 50.0
      z: 0.5
      pitch: 0.0
      yaw: 0.0

なお、別件だが Multiverse-Core ではデフォルトではリスポーン位置はそのディメンジョンになるので、worlds.yml のエンドの respawnWorldworld に変更しておかないとベッドを使っていない場合エンドから出れなくなるので注意が必要。

plugins/Multiverse-Core/worlds.yml:

worlds:
  world_the_end:
    respawnWorld: world

最後に

これでようやく砂無限装置が動作するようになる。なお、砂無限増殖装置を作成するときにエンドポータルフレームを破壊するが、PaperMC では unsupported-settings.allow-permanent-block-break-exploits の設定を true にしないと破壊できないので注意が必要(デフォルト false)。ほかにもこういった怪しい動作が修正されていたりするので、unsupported-settings 以下の設定は有効にしておくとよい。

config/paper-global.yml:

unsupported-settings:
  allow-grindstone-overstacking: true
  allow-headless-pistons: true
  allow-permanent-block-break-exploits: true
  allow-piston-duplication: true

動作環境

  • Minecraft:
  • Server:
    • PaperMC: 1.20.1
  • Mod:
    • Multiverse-Core: 4.3.1
    • GravityControl: 2.0.0

overscroll-behavior を使ってダイアログを開いている時はページのスクロールをしないようにする

まず以下のダイアログを考える。このダイアログはダイアログ内のコンテンツが一定の高さに到達するとスクロールバーが出るようになっている。

<div class="content">
  <button class="open-button" type="button">open modal</button>

  <p>メッセージ1</p>
  <p>メッセージ2</p>
  ...

  <div class="modal">
    <div class="modal-content">
      <div class="modal-header">
        <h2>Modal Header</h2>
      </div>
      <div class="modal-body">
        <p>メッセージ1</p>
        <p>メッセージ2</p>
        ...
      </div>
      <div class="modal-footer">
        <button>OK</button>
        <button>Cancel</button>
      </div>
    </div>
  </div>
</div>
* {
  box-sizing: border-box;
}

.modal {
  display: block;
  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  opacity: 0;
}
.modal.open {
  z-index: 1000;
  opacity: 1;
}
.modal::after {
  display: block;
  position: absolute;
  z-index: 1;
  content: ' ';
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
  display: flex;
  flex-direction: column;
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 80%;
  border: 1px solid #888;
  background-color: #fff;
}
.modal-header {
  width: 100%;
}
.modal-body {
  width: 100%;
  height: 200px;
  overflow-y: auto;
}
.modal-footer {
  width: 100%;
  margin-top: auto;
}

しかし、ダイアログの中でスクロールしているときにダイアログのスクロールバーが端に到達するとページのスクロールバーが動いてしまう。また、ホイールしたときにポインターがダイアログ外にあるときもページのスクロールバーが動いてしまう。

この動作を防ぐためには、overscroll-behavior プロパティを使って、デフォルトの動作を変える必要がある。overscroll-behavior にはいくつか指定できるが、none を指定することで、親要素のスクロールバーにスクロール操作が伝搬することを無効にすることができる。

しかし、overscroll-behavior プロパティは以下の状態でしか指定が有効にならない。

  1. 要素のスクロールバーが表示されている
  2. 要素のスクロールバーのバーが表示されている

1. については要素に overflow-y: scroll を指定することで解決できる。しかし、これが指定されただけだと要素のコンテンツが要素内に収まる場合はスクロールバーのバーが表示されないため 2. の条件が有効にならない。

これを解決するためには、要素のコンテンツ、つまり子要素の高さの合計が、100% を超える必要がある。これを実現する手っ取り早い方法が height: calc(100% + 1px); で、常に親要素より 1px だけ高くなるので、スクロールバーのバーが表示されるようになる。

これを踏まえて上記のダイアログを改良する。方針としてはダイアログのレイヤー全体.modaloverscroll-behavior: none; を指定し、その子要素、ここではダイアログの背景.modal::afterheight: calc(100% + 1px); を指定する。また、ダイアログの本文.modal-bodyには overscroll-behavior: contain; を指定するとスマホで端に到達したときにスクロールバーがバウンスするデフォルトの動作をするようになるので、こちらも指定する。

...
.modal {
  display: block;
  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
+  overflow-y: scroll;
+  overscroll-behavior: none;
  opacity: 0;
}
.modal::after {
  display: block;
  position: absolute;
  z-index: 1;
  content: ' ';
  top: 0;
  left: 0;
  right: 0;
-  bottom: 0;
+  height: calc(100% + 1px);
  background-color: rgba(0, 0, 0, 0.4);
}
...
.modal-body {
  width: 100%;
  height: 200px;
  padding: 15px;
  overflow-y: auto;
+  overscroll-behavior: contain;
}
...

これで、ダイアログのスクロールバーが端に到達してもページのスクロールバーが動かなくなった。ここでこの方法のデメリットとして以下がある。

  1. ダイアログレイヤー.modalにスクロールバーが常に表示される
  2. ダイアログレイヤー.modalが1pxスクロールできてしまうので、場合によっては微妙にカクつく

この動作が気になる場合はこの方法を使うことはできないが、基本的に問題ないはず。

また、overscroll-behavior は以下の環境以外では使用できないが、この場合でも親要素のスクロールバーが動くだけなので致命的な問題ではないはず。

なお、この方針で実装したサンプルが以下にある。

参考

Rails + PostgreSQL でデータベースを作り直す

PostgreSQLでは、他のセッションが同じdatabaseに接続しているときは DROP DATABASE できないようになっている。

my_database=# DROP DATABASE my_database;
ERROR:  database "my_database" is being accessed by other users
DETAIL:  There is 1 other session using the database.

そのため、Rails で以下のようにデータベースを作り直すときにしばしば問題になる。

$ rails db:drop db:setup
PG::ObjectInUse: ERROR:  database "my_database" is being accessed by other users
DETAIL:  There is 1 other session using the database.
Couldn't drop database 'my_database'
rails aborted!
ActiveRecord::StatementInvalid: PG::ObjectInUse: ERROR:  database "my_database" is being accessed by other users (ActiveRecord::StatementInvalid)
DETAIL:  There is 1 other session using the database.


Caused by:
PG::ObjectInUse: ERROR:  database "my_database" is being accessed by other users (PG::ObjectInUse)
DETAIL:  There is 1 other session using the database.

Tasks: TOP => db:drop:_unsafe
(See full trace by running task with --trace)

これを解決するためには、他のセッションを切断してから再度 DROP DATABASE すればいいが、以下のSQLを実行することでも切断することができる。

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'my_database' AND pid <> pg_backend_pid();
my_database=# SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'my_database' AND pid <> pg_backend_pid();
 pg_terminate_backend
----------------------
 t
(1 row)

そのため、以下のように他セッションの接続を切断する Rake task を作成し実行すれば他のセッションを気にすることなく作成しなおすことができるようになる。

lib/tasks/db/connection.rake:

namespace :db do
  namespace :connection do
    desc 'Close all DB connections'
    task :close => :environment do
      db_name = ActiveRecord::Base.connection.current_database
      ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array([<<~SQL, db_name]))
        SELECT pg_terminate_backend(pid)
        FROM pg_stat_activity
        WHERE datname = '%s' AND pid <> pg_backend_pid();
      SQL
    end
  end
end
$ rails db:connection:close db:drop db:setup
Dropped database 'my_database'
Created database 'my_database'

環境

参考