
概要
学習アプリやSaaS で「達成」「初回」「連続記録」などのイベントを通知/トーストで返す設計を考えると、複数のイベントが同時に発火するケースが避けられません。
例えば「初めての記録 + 目標達成 + 連続日数更新」が同じ操作で同時に成立する瞬間もあります。
これを「全部出す」と画面が騒がしくなり、「最後のだけ出す」と重要なものが埋もれてしまいます。
優先度ソート + 排他ルール + 上限件数 の3つを最初に決めて、ユーティリティ関数 1 箇所に閉じ込めると、後から種類を増やしても破綻しません。
本記事では実装パターンと、Playwright での race condition を踏まないテスト方法を解説します。
設計の3つの判断軸
通知/マイルストーンを統合するロジックには以下を順に決めます:
- 優先度の comparator: 種類ごとに数値を振って
sortで並べる - 排他ルール: 「初回イベントは他と排他」など、特定種類が出たら他を消す
- 上限件数: 出して良い最大数(多くは 2 件程度)
TypeScript 実装
type MilestoneKind = 'first_log' | 'achievement' | 'streak_record' | 'best_record'
interface Milestone {
kind: MilestoneKind
label: string
}
const PRIORITY: Record<MilestoneKind, number> = {
achievement: 1,
streak_record: 2,
best_record: 3,
first_log: 100, // 排他扱いのため他より低優先(実際は単独で返るので並びは無関係)
}
export function mergeMilestones(milestones: Milestone[]): Milestone[] {
if (milestones.length === 0) return []
// 排他ルール: first_log があれば単独表示
const firstLog = milestones.find((m) => m.kind === 'first_log')
if (firstLog) return [firstLog]
// それ以外は優先度順に最大2件
return [...milestones]
.sort((a, b) => PRIORITY[a.kind] - PRIORITY[b.kind])
.slice(0, 2)
}
ポイント:
- 排他ルールを関数内に閉じ込めます。呼び出し側で気を遣う必要はありません
- 入力配列を直接 sort せず、
[...milestones]でコピーしてから(純関数) - テストは「空 / first_log のみ / first_log + 他種混在 / 上限超え」の4パターン
Playwright での race-free 検証
通知/マイルストーンが API レスポンスと UI 表示の両方に現れるケースは、UI の DOM だけ待つと race condition を踏みやすいです。
page.waitForResponse でサーバ応答を待ちながら同期的に assert します。
test('目標達成時にマイルストーン1件が返る', async ({ page }) => {
await loginAsDemo(page)
await openRecordDialog(page)
await page.locator('#quick-log-count').fill('20')
// ボタンクリックと API レスポンス待ちを Promise.all で同時開始
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().endsWith('/api/goal-logs') && r.request().method() === 'POST',
{ timeout: 10_000 },
),
page.getByRole('button', { name: '今日を保存する' }).click(),
])
const body = await response.json()
// サーバ応答を直接 assert
expect(body.milestones).toHaveLength(1)
expect(body.milestones[0].kind).toBe('achievement')
// UI 反映も別途確認
await expect(page.locator('.milestone-celebration')).toBeVisible({ timeout: 10_000 })
})
なぜ Promise.all 同時開始か:
- 先に
click()してからwaitForResponseを書くと、レスポンスが既に届いていた場合に永久に待ちます - 必ずレスポンスを「取り損ねない」ためには、await の前にイベントを開始します
prefers-reduced-motion を CSS の computedStyle で検証
アニメーション付きの祝福演出は @media (prefers-reduced-motion: reduce) でスパークル等を止める実装が必要です。
Playwright で確実に検証するには getComputedStyle で ::before を直接見ます。
test('reduced motion 環境ではアニメーションが停止する', async ({ browser }) => {
const context = await browser.newContext({ reducedMotion: 'reduce' })
const page = await context.newPage()
// ... マイルストーン発火操作 ...
const sparkle = await page.locator('.milestone-achievement').evaluate((el) => {
const styles = window.getComputedStyle(el, '::before')
return {
opacity: styles.opacity,
animationName: styles.animationName,
animationDuration: styles.animationDuration,
}
})
// 環境によって挙動が分かれるので OR で柔軟に
expect(sparkle.opacity).toBe('0')
const motionDisabled =
sparkle.animationName === 'none' ||
sparkle.animationDuration === '0.01ms' || // global guard 経由
sparkle.animationDuration === '0s'
expect(motionDisabled).toBe(true)
})
留意点:
getComputedStyle(el, '::before')で疑似要素のスタイルが取れます- グローバルなガード(
*, *::before { animation-duration: 0.01ms !important })が併用されている場合は'0.01ms'のチェックも必要です aria-live="polite"+role="status"をコンポーネントに設定して、スクリーンリーダー利用者にも届くようにします
E2E でノイズを潰すための seed パターン
「初回イベントは排他」のような排他ルールがあると、E2E で別シナリオを検証する際に「想定外に first_log が出てしまう」現象が起きます。
テスト前に「前日に1件 done 記録を入れる」だけのヘルパーを用意しておくと、後段のテストが安定します。
async function seedPriorLog(page: Page): Promise<void> {
const yesterday = addDays(getE2ETodayKey(), -1)
await apiPost(page, '/api/logs', {
logDate: yesterday, recordKind: 'done', /* ... */
})
}
// 各テストの先頭で
await seedPriorLog(page)
シナリオ独立性を「前提を作るヘルパー」で確保し、本検証のロジックを汚染しません。
まとめ
- 通知/マイルストーン系は 優先度 + 排他 + 上限の3軸で設計し、ユーティリティ1関数に閉じます
- Playwright は
waitForResponseをPromise.allでクリックと同時開始すると race-free になります - アニメーションのアクセシビリティは
getComputedStyle(el, '::before')で検証できます - 「特定イベントが他を排他する」ケースの E2E は、seed ヘルパーで前提を作っておきます
次にやりたいこと
- 通知種類を増やしたときの優先度衝突テストを property-based に(fast-check 等)
- マイルストーン発火率の analytics 連携で「祝福→継続率」の相関を可視化

