Рефакторинг vue-feature-toggles: разбил монолит, написал 97 тестов и настроил CI

С чего началось
Когда в последний раз открывал FeatureProvider.ts, он был 586 строк. Один файл, один экспортируемый метод createFeatureProvider, и внутри него всё: три вспомогательные функции, хеш FNV-1a для роллаута, чтение и запись localStorage, подключение SSE и WebSocket с авто-переподключением.
Технически это работало. Но добавить новую логику — значит лезть в этот файл и разбираться что к чему. Написать тест на изолированную часть — нельзя, всё слито в одном потоке.
Тогда же выяснилось, что тестов нет вообще. Есть testing.ts — утилита для тех, кто использует библиотеку в своих тестах. Но сам плагин не покрыт ничем. А логика там нетривиальная: цепочка приоритетов флагов, расписание с датами включения/выключения, зависимости флагов друг от друга, роллаут по хешу userId. Всё это живёт без единого теста.
Разбивка файла
Сначала выделил что вообще есть в этом монолите. Оказалось — четыре независимые вещи.
src/core/helpers.ts — три чистые функции без состояния:
ts
isFlagTruthy(val) // boolean | string → boolean. 'false' и 'variantA' ведут себя по-разному
parseUrlValue(raw) // URL-параметр → FlagValue
parseVarValue(raw) // URL-параметр переменной → number | boolean | string
Эти функции нигде не хранили состояние и ни от чего не зависели — идеальные кандидаты для выноса. Тестировать их стало просто.
src/core/rollout.ts — FNV-1a хеш и резолвер флага по роллауту:
ts
hashToFloat('alice:myFlag') // → число [0, 1], детерминировано: одни входные → один результат
resolveFlagDef('myFlag', { value: true, rollout: 0.2 }, 'alice')
Главное свойство роллаута — детерминированность: один и тот же пользователь всегда видит один и тот же вариант. hashToFloat даёт это гарантированно. Вынес в отдельный файл и сразу написал тесты — проверил что rollout: 1.0 всегда включает флаг, rollout: 0.0 — всегда выключает, и что хеш воспроизводим.
src/core/persistence.ts — весь localStorage в одном месте:
ts
loadPersistedOverrides()
savePersistedOverrides(data)
removePersistedOverrides()
loadProfiles() / saveProfiles()
До этого localStorage.removeItem(PERSIST_KEY) вызывался прямо внутри фабрики. И ключ 'vue-feature-toggles:overrides' был строкой, вписанной в трёх местах. Теперь ключи — экспортируемые константы, весь I/O — в одном файле.
src/core/live-updates.ts — SSE и WebSocket с авто-переподключением:
ts
setupLiveUpdates({ type: 'sse', url: '/api/flags/stream' }, applyUpdate)
После этого FeatureProvider.ts сократился вдвое. Он импортирует всё вышеперечисленное и содержит только фабрику createFeatureProvider и checkExpiry. Публичный API — без изменений.
Компоненты: порядок в папках и kebab-case
В src/components/ была мешанина. Публичные компоненты (Feature.vue, FeatureDevTools.vue) лежали рядом с внутренними UI-кирпичами DevTools-панели (DtButton.vue, DtIcon.vue, DtToggle.vue и другие). Никакой логики в расположении — просто всё в кучу.
Разделил: компоненты которые экспортируются наружу — остались в src/components/. Всё, что является внутренним UI для DevTools-панели — переехало в src/ui/.
src/
├── components/
│ ├── feature.vue
│ ├── feature-variant.vue
│ └── feature-dev-tools.vue
└── ui/
├── dt-badge.vue
├── dt-button.vue
├── dt-icon.vue
├── dt-toggle.vue
├── dt-flag-row.vue
├── dt-group-row.vue
├── dt-history-row.vue
└── shared.ts
Заодно переименовал все файлы из PascalCase в kebab-case. Vue сам использует такое именование в документации, большинство проектов его придерживаются. Feature.vue → feature.vue, DtButton.vue → dt-button.vue. Переименование сделал через git mv — история файлов сохранилась.
Тесты: vitest + @vue/test-utils
Стек выбрал стандартный для Vue-проектов:
sh
npm install --save-dev vitest @vue/test-utils happy-dom
vitest.config.ts:
ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
},
})
Неожиданная проблема с localStorage
Запустил первые тесты — 59 упали сразу. Ошибка везде одна: localStorage.clear is not a function.
Happy-dom предоставляет localStorage, но в версии которую я получил — без метода .clear(). А beforeEach(() => { localStorage.clear() }) — стандартный способ чистить хранилище между тестами. Без этого тесты начинают влиять друг на друга: данные из одного просачиваются в следующий.
Решил через vi.stubGlobal — заменил localStorage собственным классом ещё до запуска тестов:
ts
// src/__tests__/setup.ts
import { vi, beforeEach } from 'vitest'
class LocalStorageMock {
private store = new Map<string, string>()
getItem(key: string) { return this.store.get(key) ?? null }
setItem(key: string, val: string) { this.store.set(key, val) }
removeItem(key: string) { this.store.delete(key) }
clear() { this.store.clear() }
key(index: number) { return [...this.store.keys()][index] ?? null }
get length() { return this.store.size }
}
vi.stubGlobal('localStorage', new LocalStorageMock())
beforeEach(() => { localStorage.clear() })
setupFiles в vitest.config.ts указывает на этот файл — мок ставится до первого теста и живёт весь прогон. beforeEach чистит его между тестами. После этого все 59 тестов прошли.
Что покрыто
Хелперы и роллаут — чистые функции, тестируются напрямую:
ts
expect(isFlagTruthy('false')).toBe(false)
expect(isFlagTruthy('variantA')).toBe(true)
// Роллаут детерминирован
expect(resolveFlagDef('flag', { value: true, rollout: 1.0 }, 'user')).toBe(true)
expect(resolveFlagDef('flag', { value: true, rollout: 0.0 }, 'user')).toBe(false)
Цепочка приоритетов — главная логика плагина. Каждый уровень должен перекрывать предыдущий:
ts
it('runtime setFlag overrides loader', async () => {
const p = createFeatureProvider({
flags: { feat: false },
loader: async () => ({ feat: true }),
})
await nextTick()
expect(p.isEnabled('feat')).toBe(true) // loader отработал
p.setFlag('feat', false)
expect(p.isEnabled('feat')).toBe(false) // runtime перекрыл
})
Расписание — флаг принудительно выключен до даты включения:
ts
it('forces flag off before "from" date', () => {
const future = new Date(Date.now() + 86400000 * 30).toISOString().slice(0, 10)
const p = createFeatureProvider({
flags: { feat: true },
schedule: { feat: { from: future } },
})
expect(p.isEnabled('feat')).toBe(false)
})
Composables через Vue Test Utils — useFeature тестируется в реальном компоненте, не в изоляции:
ts
it('is reactive — updates when flag changes', async () => {
const { install, provider } = makeWrapper({ feat: false })
const wrapper = mount(
defineComponent({
setup() { return { flag: useFeature('feat') } },
template: '<div>{{ flag }}</div>',
}),
{ global: { plugins: [install] } },
)
provider.setFlag('feat', true)
await nextTick()
expect(wrapper.text()).toBe('true')
})
Итого: 5 файлов, 97 тестов, все проходят.
CI
Добавил GitHub Actions — три шага в строгом порядке:
yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
Порядок важен: сначала typecheck — если типы сломаны, нет смысла собирать. Потом build — убеждаемся что vite собирает без ошибок. Потом тесты — самый полный прогон. Запускается на каждый push в master и на каждый PR.
README: нашёл баг в документации
Пока обновлял README — нашёл неверный путь импорта в секции SSR. Там был написан субпакет vue-feature-toggles/ssr, которого нет в package.json:
ts
// было
import { serializeFlags } from 'vue-feature-toggles/ssr'
// стало
import { serializeFlags } from 'vue-feature-toggles'
Плюс обнаружил две секции которых не было совсем — Adapter loaders (LaunchDarkly, Unleash, Flagsmith) и Vite plugin. Оба есть как точки входа в package.json и используются, но в документации про них ни слова. Добавил.
GitHub: https://github.com/macrulezru/vue-feature-toggles
NPM: https://www.npmjs.com/package/vue-feature-toggles