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

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

С чего началось

Когда в последний раз открывал FeatureProvider.ts, он был 586 строк. Один файл, один экспортируемый метод createFeatureProvider, и внутри него всё: три вспомогательные функции, хеш FNV-1a для роллаута, чтение и запись localStorage, подключение SSE и WebSocket с авто-переподключением.

Технически это работало. Но добавить новую логику — значит лезть в этот файл и разбираться что к чему. Написать тест на изолированную часть — нельзя, всё слито в одном потоке.

Тогда же выяснилось, что тестов нет вообще. Есть testing.ts — утилита для тех, кто использует библиотеку в своих тестах. Но сам плагин не покрыт ничем. А логика там нетривиальная: цепочка приоритетов флагов, расписание с датами включения/выключения, зависимости флагов друг от друга, роллаут по хешу userId. Всё это живёт без единого теста.


Разбивка файла

Сначала выделил что вообще есть в этом монолите. Оказалось — четыре независимые вещи.

src/core/helpers.ts — три чистые функции без состояния:

ts Copy
isFlagTruthy(val)   // boolean | string → boolean. 'false' и 'variantA' ведут себя по-разному
parseUrlValue(raw)  // URL-параметр → FlagValue
parseVarValue(raw)  // URL-параметр переменной → number | boolean | string

Эти функции нигде не хранили состояние и ни от чего не зависели — идеальные кандидаты для выноса. Тестировать их стало просто.

src/core/rollout.ts — FNV-1a хеш и резолвер флага по роллауту:

ts Copy
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 Copy
loadPersistedOverrides()
savePersistedOverrides(data)
removePersistedOverrides()
loadProfiles() / saveProfiles()

До этого localStorage.removeItem(PERSIST_KEY) вызывался прямо внутри фабрики. И ключ 'vue-feature-toggles:overrides' был строкой, вписанной в трёх местах. Теперь ключи — экспортируемые константы, весь I/O — в одном файле.

src/core/live-updates.ts — SSE и WebSocket с авто-переподключением:

ts Copy
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/.

Copy
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.vuefeature.vue, DtButton.vuedt-button.vue. Переименование сделал через git mv — история файлов сохранилась.


Тесты: vitest + @vue/test-utils

Стек выбрал стандартный для Vue-проектов:

sh Copy
npm install --save-dev vitest @vue/test-utils happy-dom

vitest.config.ts:

ts Copy
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 Copy
// 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 Copy
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 Copy
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 Copy
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 UtilsuseFeature тестируется в реальном компоненте, не в изоляции:

ts Copy
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 Copy
# .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 Copy
// было
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

Читать далее

14.05.2026

Маршруты с пересадками, дуги большого круга и лейблы прямо на линиях

На карте авиамаршрутов появился поиск маршрутов с пересадками: можно найти перелёт из города в город, выбрать авиакомпанию для каждого сегмента — и вся цепочка отобразится на карте. Заодно маршрутные линии теперь рисуются дугами по большому кругу, а на каждом сегменте висит лейбл с названием АК и кодами аэропортов.

Метки
авиацияThree.jsVueWebGLкартография