Так называется глава в книге Роберта Мартина, где описывается концепт:
Quote
Упрощай представление, которое сложно тестировать, а логику выноси в презентаторы
Сегодня я хочу рассказать о том, как это реализовать в типичном фронтенд приложении и покрыть unit-тестами.
К этой заметке я сделал небольшое приложение-стенд для демонстрации идеи. Вы можете запустить и потыкать даже с телефона.
Задача
Предположим, что нужно реализовать экран настроек по следующим требованиям:
- При загрузке настроек показывать скелетон;
- При внесении изменений в настройки показывать кнопки “Сохранить” и “Сбросить”;
- Одна секция “Email уведомления”;
Секция уведомлений состоит из:
- Включение и отключение маркетинговых уведомлений;
- Включение и отключение уведомлений безопасности;
Проблема
- Какая часть приложения отвечает за логику первых двух пунктов?
- Как это покрыть 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