目次
概要
共通コンポーネントのテスト方針を検討する中で、Vitestを使用したVRT(Visual Regression Testing)とアクセシビリティテスト環境を構築しました。
この記事は、共通コンポーネントのテスト方針策定プロセスで最終的に選択されたVitestベースのテスト環境の構築過程をまとめたものです。全体的なテスト方針とツール選定プロセスについては [React] モノレポ環境での共通コンポーネントテスト方針 を参考にしてください。
なぜVitestなのか
テスト方針の検討でJest、Storybook、Vitestの3つを比較した結果、Vitestを選択しました。
- ネイティブVRTサポート:
toMatchScreenshotを標準提供しており、別途ライブラリなしでスクリーンショット比較が可能です。 - プロダクトコードと同じ技術スタック: Viteベースなのでプロダクトのビルド環境と同じモジュール解決、CSS処理を使用します。
- 実際のブラウザでテスト: Browser Modeを通じてPlaywrightブラウザでコンポーネントをレンダリングするため、jsdomベースのテストより実際の環境に近いです。
他のツールとの比較に興味がある場合は、[React] Jest + PuppeteerでVRT環境構築 と [React] Storybook Test RunnerでVRT + アクセシビリティテスト構築 も参考にしてください。
全体構成
VitestベースのVRTの動作フローは以下の通りです。
- Dockerコンテナで一貫した環境を提供(ローカル実行時)
- VitestがPlaywright WebKitブラウザを起動
- 各テストでコンポーネントをレンダリングしスクリーンショットをキャプチャ
- 既存のベースライン画像と比較し差異があればテスト失敗
- アクセシビリティテストは
axe-coreでWCAG違反事項を検査
Docker Container(一貫したフォント + ブラウザ環境)
→ Vitest(Browser Mode)
→ Playwright WebKit
→ コンポーネントレンダリング + スクリーンショットキャプチャ
→ toMatchScreenshotでベースライン比較
→ axe-coreでアクセシビリティ検査
環境設定
Vitest Browser ModeとPlaywrightを使用してVRT + アクセシビリティテスト環境を構築するために、以下の設定が必要です。
パッケージインストール
Vitest Browser ModeとVRT/アクセシビリティテストに必要なパッケージをインストールします。
yarn add -D vitest @vitest/browser @vitest/browser-playwright @vitest/ui @vitest/coverage-istanbul axe-core
各パッケージの役割は以下の通りです。
| パッケージ | 役割 |
|---|---|
vitest | テストランナー(VRTのtoMatchScreenshot標準提供) |
@vitest/browser | Vitest Browser Modeプラグイン |
@vitest/browser-playwright | Playwrightブラウザプロバイダー |
@vitest/ui | テスト結果をブラウザUIで確認 |
@vitest/coverage-istanbul | テストカバレッジレポート |
axe-core | アクセシビリティ(a11y)検査エンジン |
Vitest設定
以下はVitestの核心設定ファイルです。Browser Modeを有効化し、スクリーンショット保存パスを設定してVRTテストを構成します。
// vitest.config.ts
import react from '@vitejs/plugin-react';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
// axe-coreを事前バンドルしてテスト実行速度を向上
include: ['axe-core'],
},
test: {
browser: {
enabled: true,
provider: playwright({
launchOptions: {
timeout: 60000,
},
}),
// WebKitを使用する理由: CI(Linux)とローカル(macOS)の両方で
// 最も一貫したレンダリング結果を提供
instances: [{ browser: 'webkit' }],
viewport: { width: 1280, height: 720 },
headless: true,
screenshotFailures: false,
expect: {
toMatchScreenshot: {
// スクリーンショットファイルをテストファイルと同じディレクトリの
// __screenshots__ フォルダに保存
resolveScreenshotPath: ({ testFileDirectory, arg, ext }) => {
return `${testFileDirectory}/__screenshots__/${arg}${ext}`;
},
},
},
},
// *.vitest.tsx ファイルのみテスト対象に含める
include: ['**/*.vitest.tsx'],
setupFiles: ['./vitest-setup.ts'],
// CSS Modules等のスタイルファイルを実際に処理
css: true,
coverage: {
provider: 'istanbul',
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
},
},
css: {
modules: {
localsConvention: 'camelCase',
},
},
});
主要な設定ポイントは以下の通りです。
browser.instances: [{ browser: 'webkit' }]: WebKitブラウザを使用します。ChromiumとはOS間のレンダリング差異が少なく、CIとローカル環境で一貫したスクリーンショットを得られます。browser.expect.toMatchScreenshot.resolveScreenshotPath: スクリーンショットファイルをテストファイルと同じディレクトリの__screenshots__フォルダに保存します。コンポーネントごとにスクリーンショットを管理しやすくなります。include: ['**/*.vitest.tsx']: 既存のJestユニットテスト(*.test.tsx)とVitest VRTテスト(*.vitest.tsx)を分離します。2つのテストランナーが互いのファイルを実行しないようにします。css: true: CSSファイルを実際に処理し、スタイルが適用された状態でスクリーンショットをキャプチャします。
Setupファイル
テスト実行前の共通設定を担当するファイルです。
// vitest-setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// 共通スタイル(CSS変数等)をimportして
// プロダクトと同じスタイル環境でテスト
import './src/utils/styles/common.scss';
// 各テスト後にDOMをクリーンアップ
afterEach(() => {
cleanup();
});
@testing-library/jest-dom/vitestをimportするとtoBeInTheDocument()等のDOM matcherが使用できます。ここで共通スタイルファイルをimportする理由は、CSS変数等が定義されていないとコンポーネントがプロダクトと異なるレンダリングになる可能性があるためです。
.gitignore設定
テスト実行中に生成される一時ファイルはGitから除外する必要があります。ただし、VRTの比較基準となるスクリーンショット(__screenshots__/)はコミット対象です。
# Vitest添付ファイル(テスト失敗時に生成されるdiff画像等)
.vitest-attachments/
Dockerで一貫したレンダリング環境を構築
なぜDockerが必要なのか
VRTはピクセル単位でスクリーンショットを比較するため、レンダリング環境が少しでも異なるとテストが失敗します。特に以下の要因がスクリーンショットの差異を引き起こします:
- フォント: macOSとLinuxでデフォルトフォントが異なり、同じフォントでもレンダリングエンジンによって微細な差異があります。
- OS別テキストレンダリング: アンチエイリアシング、サブピクセルレンダリング方式がOSごとに異なります。
Dockerを使用すれば、ローカル開発環境(macOSまたはWindows)とCI環境(Linux)の両方で同じフォントとブラウザでテストを実行でき、環境差異による偽の失敗(false positive)を防止できます。
Dockerfile
以下のようにDockerfileを作成して、Vitest VRTテストのための一貫した環境を構築します。
# Dockerfile
FROM node:24.13.0-bookworm
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# タイムゾーン設定(スクリーンショットの日時表示を統一)
ENV TZ=Asia/Tokyo
# yarn使用のためにcorepackを有効化
RUN corepack enable
# Playwright WebKitのシステム依存パッケージをインストール
RUN npx playwright install-deps webkit
# 決定論的なレンダリングのためにフォントをインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-core \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ルート設定ファイル
COPY package.json yarn.lock .yarnrc.yml .nvmrc ./
# yarnプラグイン
COPY .yarn/plugins/ .yarn/plugins/
# workspace依存関係のみコピー
COPY packages/lib/components/package.json packages/lib/components/
COPY packages/config/typescript/ packages/config/typescript/
COPY packages/config/stylelint/package.json packages/config/stylelint/
COPY packages/config/eslint/package.json packages/config/eslint/
# 依存関係インストール(Dockerレイヤーキャッシング)
# --mode=skip-build: ライフサイクルスクリプトをスキップ(husky install等の失敗を防止)
RUN yarn install --mode=skip-build
# Playwright WebKitブラウザをインストール
RUN npx playwright install webkit
ここでの主要ポイントは以下の通りです。
fonts-noto-core,fonts-noto-cjk: Notoフォントをインストールして全環境で同じフォントでレンダリングします。CJK(中日韓)フォントも含まれているため、多言語テキストを含むコンポーネントも一貫してテストできます。yarn install --mode=skip-build:husky install等のビルドスクリプトをスキップしてDockerビルドの失敗を防止します。- レイヤーキャッシング:
package.jsonとyarn.lockを先にコピーしてインストールした後、ソースコードをマウントすることで、依存関係が変更されなければキャッシュを再利用します。
docker-compose.yml
以下のようにdocker-compose.ymlを作成して、Dockerコンテナ内でVitest VRTテストを実行できるようにします。
# docker-compose.yml
services:
vrt:
build:
# モノレポルートをビルドコンテキストに指定(全workspaceのpackage.jsonを含めるため)
context: ../../..
# ビルドに使用するDockerfileのパス(ビルドコンテキストからの相対パス)
dockerfile: packages/lib/components/Dockerfile
image: vitest-vrt
volumes:
# ソースコード(テストファイル、スクリーンショット等)をマウント
- ./:/app/packages/lib/components
# ホストOS用のnode_modulesを隠し、イメージのLinux用バイナリを使用(匿名ボリューム)
- /app/packages/lib/components/node_modules
environment:
# ファイル変更監視にポーリングを使用(Dockerマウント環境での検知用)
- CHOKIDAR_USEPOLLING=true
working_dir: /app/packages/lib/components
ここでの主要ポイントは以下の通りです。
- 匿名ボリューム(
/app/.../node_modules): ホスト(macOS、Windows)のnode_modulesには該当OS用のネイティブバイナリが含まれているため、Linuxコンテナでは使用できません。匿名ボリュームでホストのnode_modulesを隠し、Dockerイメージビルド時にインストールされたLinux用バイナリを使用します。 - ソースコードマウント: ソースコードのみマウントしてコード変更がリアルタイムに反映されます。watchモードでファイルを修正すると自動的にテストが再実行されます。
テストコードの作成
テスト環境が構築されたので、実際のテストコードを作成していきます。
ファイルコンベンション
VRTテストファイルは*.vitest.tsxファイル名コンベンションを使用します。
ComponentName/
├── index.tsx # コンポーネント実装
├── index.test.tsx # ユニットテスト(Jest)
├── index.vitest.tsx # VRT + イベント + アクセシビリティテスト(Vitest)
└── __screenshots__/ # ベースラインスクリーンショット(自動生成)
├── button-children-text.png
├── button-variants.png
└── ...
既存のJestで作成されたユニットテスト(*.test.tsx)を維持しながら、VRTとイベント/アクセシビリティテストは*.vitest.tsxファイルに分離して作成しました。将来的にはすべてのテストをVitestに統合することも可能ですが、現時点ではVRTテストとユニットテストを明確に区分して管理するためにこのように構成しました。
レンダリングテスト(Props変更に伴うView確認)
各Props組み合わせごとにスクリーンショットをキャプチャしてViewを検証します。
// src/components/Button/Button/index.vitest.tsx
import { render } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { Button } from '.';
describe('Button 表示確認', () => {
test('renders with text content', async () => {
render(<Button>Click me</Button>);
await expect
.element(page.getByRole('button'))
.toMatchScreenshot('button-children-text.png');
});
test('renders all variants', async () => {
render(
<div
data-testid="button-variants"
style={{ display: 'inline-flex', gap: '8px' }}
>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="destructive">Destructive</Button>
</div>
);
await expect
.element(page.getByTestId('button-variants'))
.toMatchScreenshot('button-variants.png');
});
test('disabled', async () => {
render(
<div
data-testid="button-disabled"
style={{ display: 'inline-flex', gap: '8px' }}
>
<Button disabled>Disabled</Button>
<Button variant="primary" disabled>
Primary Disabled
</Button>
<Button variant="secondary" disabled>
Secondary Disabled
</Button>
</div>
);
await expect
.element(page.getByTestId('button-disabled'))
.toMatchScreenshot('button-disabled.png');
});
});
複数のvariantを1つのスクリーンショットにまとめて比較すると、テスト数を減らしながらすべての状態を検証できます。
よくあるテストケース
Props変更以外にも、実際の使用環境で頻繁に発生するエッジケースをテストします。
// 長いテキスト: text-overflow等が正しく動作するか確認
test('長いテキストが含まれる場合', async () => {
render(
<div
data-testid="button-long-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
width: '300px',
}}
>
<Button>これは非常に長いテキストが含まれているボタンです</Button>
<Button widthType="small">
これは非常に長いテキストが含まれているボタンです
</Button>
</div>
);
await expect
.element(page.getByTestId('button-long-text'))
.toMatchScreenshot('button-long-text.png');
});
// 狭い親要素: コンテナが狭い時にレイアウトが崩れないか確認
test('親要素の幅が狭い場合', async () => {
render(
<div
data-testid="button-narrow-parent"
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
width: '80px',
}}
>
<Button>ボタンラベル</Button>
<Button widthType="full">ボタンラベル</Button>
</div>
);
await expect
.element(page.getByTestId('button-narrow-parent'))
.toMatchScreenshot('button-narrow-parent.png');
});
イベントテスト
ユーザーインタラクションをシミュレートして、イベントハンドラーが正しく動作するか検証します。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
describe('Event', () => {
test('calls onClick handler when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', async () => {
const onClick = vi.fn();
render(
<Button onClick={onClick} disabled>
Click me
</Button>
);
await userEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
アクセシビリティテスト
axe-coreを使用してWCAGアクセシビリティ違反事項を自動で検査します。
import { runAxe } from '../../../test-utils/axe';
describe('Accessibility', () => {
test('should have no accessibility violations', async () => {
const { container } = render(<Button>Accessible Button</Button>);
const results = await runAxe(container);
expect(results.violations).toHaveLength(0);
});
test('should have no accessibility violations when disabled', async () => {
const { container } = render(<Button disabled>Disabled Button</Button>);
const results = await runAxe(container);
expect(results.violations).toHaveLength(0);
});
test('should have no accessibility violations for all variants', async () => {
const { container } = render(
<div>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="destructive">Destructive</Button>
</div>
);
const results = await runAxe(container);
expect(results.violations).toHaveLength(0);
});
});
アクセシビリティテストユーティリティ
すべてのテストで共通して使用するaxe-coreラッパーユーティリティを作成し、デフォルト設定を共有してテストコードの重複を減らしました。
// src/test-utils/axe.ts
import axe from 'axe-core';
/**
* デフォルトaxe-core設定
*
* 全テストで共通して無効化するルール:
* - color-contrast: テスト環境の背景色との組み合わせで誤検知が発生
* - button-name: アイコン専用ボタンは別途aria-labelでテスト
*/
export const defaultAxeConfig: axe.RunOptions = {
rules: {
'color-contrast': { enabled: false },
'button-name': { enabled: false },
},
};
/**
* axe-coreを実行してアクセシビリティ違反事項を検査
*/
export async function runAxe(
container: Element,
config?: axe.RunOptions
): Promise<axe.AxeResults> {
return axe.run(container, {
...defaultAxeConfig,
...config,
rules: {
...defaultAxeConfig.rules,
...config?.rules,
},
});
}
スクリーンショットクリーンアップスクリプト
コンポーネントを削除したりテストを修正すると、使用されなくなったスクリーンショットファイルが残ることがあります。このような不要なファイルを自動的に整理するスクリプトを作成しました。
// tool/cleanup-vitest-screenshots.mjs
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SRC_DIR = path.resolve(__dirname, '../src')
// *.vitest.tsx ファイルから toMatchScreenshot('ファイル名') で参照されている
// スクリーンショット名を収集
function findUsedScreenshots() {
const testFiles = findFilesRecursively(SRC_DIR, /\.vitest\.tsx$/)
const usedScreenshots = new Set()
for (const testFile of testFiles) {
const content = fs.readFileSync(testFile, 'utf-8')
const matches = content.matchAll(/toMatchScreenshot\(['"]([^'"]*)['"]\)/g)
for (const match of matches) {
const name = match[1].replace(/\.png$/, '')
usedScreenshots.add(name)
}
}
return usedScreenshots
}
// __screenshots__ フォルダの全PNGファイルを収集
function findScreenshotFiles() { /* ... */ }
function main() {
const usedScreenshots = findUsedScreenshots()
const screenshotFiles = findScreenshotFiles()
let deletedCount = 0
for (const file of screenshotFiles) {
const basename = extractBasename(file)
if (!usedScreenshots.has(basename)) {
fs.unlinkSync(file)
deletedCount++
console.log(`Deleted: ${file}`)
}
}
console.log(`Used screenshots: ${usedScreenshots.size}`)
console.log(`Deleted files: ${deletedCount}`)
}
main()
このスクリプトの動作原理は以下の通りです。
- すべての
*.vitest.tsxファイルからtoMatchScreenshot('ファイル名')パターンで参照されているスクリーンショット名を収集 __screenshots__/フォルダの全PNGファイルを探索- 参照されていないファイルを削除
実行スクリプト
このように作成されたテストを実行するために、以下のスクリプトをpackage.jsonに追加します。
// package.json
{
"scripts": {
"test:view": "yarn test:view:docker:build && docker compose run --rm -it vrt npx vitest",
"test:view:ci": "TZ=Asia/Tokyo vitest run",
"test:view:update": "yarn test:view:docker:build && docker compose run --rm vrt npx vitest run --update",
"test:view:docker:build": "docker compose build vrt",
"test:view:cleanup": "node ./tool/cleanup-vitest-screenshots.mjs",
"test:view:coverage": "yarn test:view:docker:build && docker compose run --rm vrt npx vitest run --coverage"
}
}
各スクリプトの役割は以下の通りです。
| スクリプト | 用途 |
|---|---|
test:view | ローカルでDockerを使用してVRT実行(watchモード) |
test:view:ci | CI環境でVRT実行(Docker無しで直接実行) |
test:view:update | ベースラインスクリーンショット更新(コンポーネントが意図的に変更された場合) |
test:view:docker:build | Dockerイメージビルド |
test:view:cleanup | 使用されていないスクリーンショットファイル削除 |
test:view:coverage | テストカバレッジレポート生成 |
ローカルではDockerを通じて一貫した環境でテストし、CIではコンテナ内で直接実行します。test:view:ciでTZ=Asia/Tokyoを設定する理由は、日時を表示するコンポーネントのスクリーンショットがタイムゾーンによって異なるのを防止するためです。
CI連携(GitHub Actions)
GitHub ActionsでVRTを自動実行するワークフロー設定です。
# .github/workflows/check_code_components.yml(一部抜粋)
test-components-vrt:
# workflow_dispatchで手動実行するか、'vrt'ラベルが付いたPRで自動実行
if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'vrt')
name: Test components VRT
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: node:24.13.0-bookworm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enable corepack
run: corepack enable
- name: Install fonts for deterministic rendering
run: |
apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-core \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
- name: Install dependencies
run: yarn install --immutable
- name: Get Playwright version
id: playwright-version
run: echo "version=$(yarn info @vitest/browser-playwright --json | jq -r '.children.Version')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-webkit-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install webkit --with-deps
- name: Install Playwright dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: yarn playwright install-deps webkit
- name: Test view
run: yarn test:view:ci:components
このCIワークフローの核心ポイントは以下の通りです。
- 条件付き実行: すべてのPRでVRTを実行すると時間がかかるため、
vrtラベルが付いたPRでのみ自動実行されるよう設定します。必要時にActionsタブから手動実行も可能です。 - 同じフォントのインストール: Dockerfileと同様に
fonts-noto-core、fonts-noto-cjkをインストールしてローカルDocker環境と同じレンダリングを保証します。 - Playwrightブラウザキャッシング: Playwrightブラウザのインストールに時間がかかるため、バージョンごとにキャッシングしてCI実行時間を短縮します。
ディレクトリ構成
この記事で作成または修正するファイルの全体ディレクトリ構成は以下の通りです。
プロジェクトルート/
├── .github/
│ └── workflows/
│ └── check_code_components.yml # CIワークフロー(修正)
├── .gitignore # .vitest-attachments/ 追加(修正)
├── packages/
│ └── lib/
│ └── components/
│ ├── Dockerfile # Dockerイメージ設定(新規)
│ ├── docker-compose.yml # Docker Compose設定(新規)
│ ├── package.json # 実行スクリプト追加(修正)
│ ├── vitest.config.ts # Vitest設定(新規)
│ ├── vitest-setup.ts # テストセットアップ(新規)
│ ├── tool/
│ │ └── cleanup-vitest-screenshots.mjs # クリーンアップスクリプト(新規)
│ └── src/
│ ├── test-utils/
│ │ └── axe.ts # アクセシビリティテストユーティリティ(新規)
│ └── components/
│ └── Button/
│ └── Button/
│ ├── index.tsx
│ ├── index.test.tsx
│ ├── index.vitest.tsx # VRTテスト(新規)
│ └── __screenshots__/ # ベースラインスクリーンショット(自動生成)
│ ├── button-children-text.png
│ ├── button-variants.png
│ └── ...
まとめ
この記事では、Vitestを使用したVRT + アクセシビリティテスト環境の構築方法を紹介しました。
| 項目 | 内容 |
|---|---|
| レンダリング/VRT | Vitest Browser Mode + toMatchScreenshot |
| イベントテスト | @testing-library/user-eventでインタラクションシミュレーション |
| アクセシビリティ | axe-coreでWCAG違反事項検査 |
| 一貫したレンダリング | Docker + Notoフォントで環境差異を解消 |
| テスト分離 | *.vitest.tsxファイルコンベンションで既存Jestテストと分離 |
| スクリーンショット管理 | コンポーネントディレクトリの__screenshots__/に保存、クリーンアップスクリプト提供 |
| CI連携 | GitHub Actionsでvrtラベルによる条件付き実行 |
Vitestベースのアプローチの利点は、プロダクトコードと同じViteビルドパイプラインを使用するため、CSS Modules、SCSS等の処理が自然であり、toMatchScreenshot APIが簡潔でテストコードの可読性が高い点です。
チーム内でテスト方針を検討するプロセスと他のツールとの比較については[React] モノレポ環境での共通コンポーネントテスト方針を、既存のJestプロジェクトにVitestを段階的に導入する方法については[React] JestプロジェクトにVitestを導入する - Viewテスト分離戦略も参考にしてみてください。
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。