Так называется глава в книге Роберта Мартина, где описывается концепт:

Цитата

Упрощай представление, которое сложно тестировать, а логику выноси в презентаторы

Сегодня я хочу рассказать о том, как это реализовать в типичном фронтенд приложении и покрыть unit-тестами.

К этой заметке я сделал небольшое приложение-стенд для демонстрации идеи. Вы можете запустить и потыкать даже с телефона.

Задача

Предположим, что нужно реализовать экран настроек по следующим требованиям:

  1. При загрузке настроек показывать скелетон;
  2. При внесении изменений в настройки показывать кнопки “Сохранить” и “Сбросить”;
  3. Одна секция “Email уведомления”;

Секция уведомлений состоит из:

  1. Включение и отключение маркетинговых уведомлений;
  2. Включение и отключение уведомлений безопасности;

Проблема

  • Какая часть приложения отвечает за логику первых двух пунктов?
  • Как это покрыть unit-тестами?

Позавчера я бы ответил, что в контейнере, вчера, что в хуке, но сегодня наиболее подходящий для меня вариант это Presentation (FCIS).

Presentation

(state: DomainState) ViewState

Определение

Функция, которая принимает на вход бизнес стейт приложения, перерабатывает данные по определённым правилам и возвращает ViewState. Это всегда чистая и полная функция

Она отвечает на вопрос: “Как будет выглядеть viewState при таком-то бизнес стейте?”

  • При загрузке показывать скелетон;
  • При внесении изменений в настройки показывать кнопки “Сохранить” и “Сбросить”.

Пример

export const settingsPresentation = (state: SettingsState) => {
  const { original, draft, status } = state
 
  return new SettingsPageViewState({
    heading: "settings",
 
    sections: Object.keys(draft).map((section) => {
      switch (status) {
        case "loading":
          return new SkeletonViewState()
 
        case "idle":
          return sectionPresentation(state, section)
 
        case "pending":
          return sectionPresentation(state, section)
      }
    }),
 
    actions: !_.isEqual(draft, original) ? saveOrDiscardPresenatation(state) : undefined,
  })
}

Тест кейсы

Настройки
  Если система в состоянии загрузки
    √ Пользовать видит скелетон
  Если система в состоянии простоя
    √ Пользователь видит разделы настроек
  Кнопки сохранения и сброса изменений
    √ Скрыты по умолчанию
    √ Появляются, если в черновик внесли изменения

Пример теста

describe("Если система в состоянии загрузки", () => {
  const viewState = settingsPagePresentation(
    settingsState({
      status: "loading",
    }),
  )
 
  test("Пользовать видит скелетон", () => {
    viewState.sections.forEach((section) => expect(section).toBeInstanceOf(SkeletonViewState))
  })
})

ViewState

Определение

Сущность, которая описывает, все возможные состояния пользовательского интерфейса. Presentation создаёт и возвращает экземпляр viewStat’а при трансформации бизнес стейта.

Отвечает на вопрос: “Как может выглядеть компонент?”

В React проектах делают интерфейс для определения параметров, которые может принять компонент. ViewState это такое же описание компонента, только он:

  • Не содержит коллбеков;
  • Используется в Presentation и тестах на него.

Вообще может быть и interface’ом, это выглядит даже чище, но в тестах нельзя проверить присутствие интерфейса. А class можно через instanceof.

Пример

class SettingsViewState {
  heading: string
  sections: SectionViewState[] | SkeletonViewState
  actions: SaveOrDiscardViewState | undefined
 
  constructor(arg: SettingsViewState) {
    this.heading = arg.heading
    this.sections = arg.sections
    this.actions = arg.actions
  }
}

View

Компонент или страница React/Vue/Svelte/… фреймворка

View в данном случае реализован в виде скромного объекта. Компонент содержит только связующий код, тут нет логики, юнитами покрывать нечего.

interface SettingsProps {
  viewState: SettingsViewState
}
 
export const Settings: React.FC<SettingsProps> = ({ viewState }) => {
  const render = (section) => {
    if (section instanceof SectionViewState) return <Section viewState={section} />
 
    if (section instanceof SkeletonViewState) return <Skeleton viewState={section} />
  }
 
  return (
    <div className="...">
      <h1 className="...">{viewState.heading}</h1>
      {viewState.sections.map((section) => render(section))}
      {viewState.actions && <SaveOrDiscard viewState={viewState.actions} />}
    </div>
  )
}

Мысли

  • В Presentation на вход можно передать просто AppState целиком, тогда можно будет формировать вьюстейт на основе feature-флагов проекта, текущей темы, варианта окружения и всё это будет под тестами;
  • Внедрение такой сущности ещё дальше отодвигает ваш “чистый код” на TypeScript от фреймворк зависимостей, что должно способствовать упрощению поддержки новых версий библиотек представления, увеличению покрытия юнит-тестами, скорости доставки новых фич;
  • Функция settingsState в тесте это одна из фабрик значений по умолчанию;
  • Каждому компоненту соответствует свой viewState;
  • ViewState может быть и интерфейсом TypeScript, но их неудобно проверять на тип. Придётся для каждого вью стейта класть дескриптор, по которому мы сможем проверить, что это именно он;
  • Вместо if (section instanceof SectionViewState) можно использовать switch(section.constructor) case SectionViewState, но TypeScript на начало 2024 не понимает, что в кейсе нужный тип и заставляет дополнительно писать: <Section viewState={section as SectionViewState} />
  • ViewState не содержит onClick’ов и onChang’ей. Он должен отвечать только за представление. Такие коллбэки стоит положить рядом в параметры компонента;
  • ViewState это не App/DomainState. Это просто моделька того, что может видеть пользователь;
  • В книге Роберта Мартина “Чистая архитектура” в главе “Презентаторы и представления” описаны по своей сути такие же сущности, но немного по-другому названы. После разговора с другом android-разработчиком и после трансляции у Соера мне объяснили, что viewModel жёстко ассоциируется с MVVM, хотя вью модели у Дяди Боба это по сути дата классы, в то время, как в MVVM это вообще другая сущность. Короче, я не хотел бы вносить большей путаницы. И советую всё своё внимание направить именно на концепт;
  • И в тэгах проскакивает FCIS (Functional Core Imperative Shell). Я специально их оставил, чтобы позже, можно было бы легко находить статьи по этой концепции. Пока скажу, что для фронтов это по сути Redux. Но сама концепция намного шире и интересней.

Тэги

#фронтенды,#юнитТестирование,#fcis,#presenter,#viewModel,#viewState