この記事は HRBrain アドベントカレンダー18日目の記事です。
はじめに
先日、約1年にわたる社内の旧コンポーネントライブラリ置き換えを完了したので、その備忘録です。技術的な内容というより、ほとんどは進め方や工夫した部分の話です。本記事がなにかしらの参考になれば幸いです。
前提
本題へ入る前にいくつか前提を記載します:
- 弊社ではデザインシステムを表現するコンポーネントライブラリがあり、新旧の2種類が存在する
- 機能開発が急務であり、置き換え自体をロードマップに直接組み込むのは難しい
- 機能リリース前に、テストフェーズを挟む(通常は QA エンジニアによる確認)
- コンポーネントの記述が散らばっており、各ページ・コンポーネントごとに動作確認を要する
「コンポーネントの記述が散らばっている」とは以下の「各コンポーネントが直接コンポーネントライブラリを import している」状態を指します。
// UserProfile.tsx
import { DesignLibraryButton } from 'DesignLibrary'
export const UserProfile = ({ onEdit, onLogout }) => {
return (
<>
<DesignLibraryButton
onClick={onEdit}
variant="primary"
>
編集
</DesignLibraryButton>
<DesignLibraryButton
onClick={onLogout}
variant="secondary"
>
ログアウト
</DesignLibraryButton>
</>
)
}
// CommentForm.tsx
import { DesignLibraryButton } from 'DesignLibrary'
export const CommentForm = ({ onSubmit, isSubmitting }) => {
return (
<form>
<textarea />
<DesignLibraryButton
onClick={onSubmit}
disabled={isSubmitting}
variant="primary"
>
送信
</DesignLibraryButton>
</form>
)
}
やったこと
まず、進め方の全体像は以下です:
- 方針提案
- タスク分解
- 実装
- テスト
- リリース
- タスクがなくなるまで 3 - 5 の繰り返し
ごく一般的なフローですが、道筋は思ったよりもスムーズではなかったので意識した内容や工夫した点を中心にお伝えします。
1. 方針提案
まず置き換えに当たって次の2通りの方法を考えて提案しました:
- ページ単位での変更
- コンポーネント単位での変更(以下の画像に示すような「ボタン」のみの変更など)
それぞれのメリデメを整理するとこんな感じです。
| 開発の進め方 | メリット | デメリット |
|---|---|---|
| ページ単位 | ・影響範囲が限定的である ・コンポーネントに特化した個別確認が必要でなく、(機能追加のついでの確認ができるため)テスト負荷が低い |
・実装・テストともにコンポーネントごとのコンテキストスイッチが必要 ・影響範囲や実装時の漏れを把握しづらく、テスターへの共有負荷が高い |
| コンポーネント単位 | ・実装・テストともにコンポーネントごとのコンテキストスイッチが不要 ・影響範囲や実装時の漏れを把握しやすく、テスターへの共有負荷が低い |
・影響範囲が広範である ・コンポーネントに特化した個別確認が必要であるため、(何かのついででの確認が難しく)テスト負荷が高い |
現状、QA リソースより開発側のリソースが相対的にあることから、テスト実施負荷が低い 1 の「ページ単位」での置き換えを選択しました。
2. タスク分解
方針が定まったので、よりイメージを具体的なタスクに落とし込んでいきます。
ここで意識したのは 、「実装の進め方」 や 「置き換え後の対応」 です。
実装の進め方
プロダクトの開発ロードマップが出ているので、そちらと照らし合わせながら、実装の優先順位・逆に実装しないほうがよいものの当たりをつけます。
例えば、早期にリリースする画面は先んじて実装しておく・まるっとリファクタが入りそうな画面は置き換え自体が不要になることもあるので、全体像が見えるまで着手しないでおくなどです。
置き換え後の対応
アンインストールの他に、不要な記述を設定ファイルから消したり CI の依存から外すなどの後片付けもしておく必要があるので、それ自体もタスクとして切っておきます。関係各位への共有が必要であればそれも忘れないようにタスクにします。
3. 実装
ページ単位で実装していると、当然古い画面も出てきますが、ここで重要なのは 「リファクタをしない」 ということです。
その理由は、以下です:
- リリース時期によっては PR の生存期間が長くなるため、コンフリクト解消の手間を最小限にしたい
- 影響範囲が小さくなることで、テスト工数の肥大化と潜在的なバグリスクを最小限にしたい
4. テスト
QA エンジニアによる確認
QA エンジニアには影響範囲と確認してほしい内容を適切に伝え、リリース用の機能の導線で確認していただきます。
もちろん導線で触らない箇所・操作などもあり得るので、そこまでしっかり伝えて確認漏れがないようにすることが重要です。
このフローで進めても、ほとんど機能追加を行わない画面が最後には残ってしまいます。
あと少しの所まで来ているので、これまで「QA テストで確認いただいたテストケースが残っていること」と、「リリースでバグが出なかったこと」を担保に、開発者側の確認でリリースする方針を固めました。
開発者による確認
開発者に協力を依頼するため、テストフローとテスト基準を記載した資料を作成し、共有しました。
基本的に開発者確認はバイアスを避けるため、実装者以外の確認を挟むのがよいです。
これにて、ようやくすべて置き換えることができました!!!
今後の置き換えに備えて
私が、今回の置き換え時に特に辛いと感じたのは、「動作確認」と「影響範囲・置き換え漏れの確認」でした。
そこで、今後も同様の事案に対して備えるため、チームでは以下の方針にしました。
腐敗防止層を導入
腐敗防止層を導入することで以下のメリットを享受することを狙いとします:
- ライブラリ変更時の修正箇所が1ファイルに集約される
- プロジェクト固有のデフォルト値を一元管理できる
- テストの責務が明確になる
例えば、以下のようなコンポーネントをプロダクトの最下層に噛ませてこちらを使用するようにします。
// Button.tsx
import React from 'react'
import { DesignLibraryButton } from 'DesignLibrary'
type Props = React.ComponentPropsWithoutRef<typeof DesignLibraryButton>
export const Button = (props: Props) => {
// 必要であればロジックを追加
return <DesignLibraryButton {...props} />
}
プロダクト全体のドメインが必要であればこちらに追加したいですが、全体反映に適さない場合は、こちらをさらに使用する側で拡張します。
これにより、上記の辛さを次のように解決します。
具体的な解決案
1. 動作確認
基本的に前述の腐敗防止層に対して、理想的な動作を担保するコンポーネントテストを厚めに書きます。
プロダクト全体の汎用処理があればこちらに入れるのでそちらと統合したテストになります。これにより、ライブラリ側の変更に対する退行の保護と自プロダクトの基本ロジックとの確認責務をこちらに集中させます。
使用側ではレンダリングされていること、そこで新たに定義されたロジックのみのテストを追加するだけです。
// Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('非活性の場合、クリックできないこと', async () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick} disabled>Click me</Button>)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
...
})
// LoginForm.tsx
import { Button } from './Button'
type Props = {
isLoading: boolean
onSubmit: () => void
}
export const LoginForm = ({ isLoading, onSubmit }: Props) => {
return (
<form>
<Button
onClick={onSubmit}
disabled={isLoading}
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</Button>
</form>
)
}
// LoginForm.test.tsx
describe('LoginForm', () => {
// 使用側ではレンダリングされていることのみをテスト
// Button の振る舞いは腐敗防止層で保証済みのため
it('Button がレンダリングされること', () => {
render(<LoginForm isLoading={false} onSubmit={vi.fn()} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
// 追加のロジック(LoginForm 側の責務のもの)はこちらで検証
it('isLoading が true のとき、ボタンのラベルが「ログイン中...」であること', () => {
render(<LoginForm isLoading={true} onSubmit={vi.fn()} />)
expect(screen.getByRole('button', { name: 'ログイン中...'})).toBeInTheDocument()
})
...
})
2. 影響範囲・置き換え漏れの確認
コンポーネントライブラリを各所で呼び出していると grep が面倒で変更・確認漏れが発生する可能性があります。集約箇所から呼び出すルールにすることで影響範囲を正確に把握できるようになります。
おわりに
サードパーティのライブラリ置き換えなどに広げて考えると、よくある話だと思いますが、初めての場合、何から着手すべきかなど迷うこともあるかもしれません。
そんなときに本記事が一助となることを切に願っています。
