Часть 5. Композиция приложения

Продолжаем серию постов и экспериментов о разработке и проектировании приложений. В прошлый раз мы реализовали инфраструктуру для приложения и привязали её к выходным портам ядра. В этом посте мы поговорим о композиции, соберём конвертер из составных частей и немножко затронем разные виды тестирования.

Строим снизу вверх

Приложение мы будем собирать из составных частей «снизу вверх». У нас уже есть юзкейсы, инфраструктурные сервисы и некоторое количество компонентов для UI. Из этого мы можем построить фичу.

Чёткого определения фичи я дать не смогу, но если объяснить приблизительно, то фича — это набор функциональности, которая делит один ограниченный контекст. Можно сказать, что фича — такой «микросервис», который отвечает за работу какой-либо части домена.

У Скотта Влашина есть хорошая схема, которая мне кажется наиболее близкой к тому, как я понимаю фичи. В его представлении композиция приложения выглядит так:

Functions we can combine in services:
[low-level operation] >> [low-level operation] => [service]

Services into processes:
[service] >> [service] >> [service] => [use case]

Combining processes in parallel, we get an application:
[use case]
[use case] => [application]
[use case]

В моём представлении перед последним шагом не хватает ещё одного:

Functions we can combine in services:
[low-level operation] >> [low-level operation] => [service]

Services into processes:
[service] >> [service] >> [service] => [use case]

Combining processes from 1 bounded context, we get a feature (part of an app):
[use case]
[use case] => [feature]
[use case]

Combining features, we get a full application:
[feature]
[feature] => [application]
[feature]

Собственно, конвертер — это и есть «фича». В нём находится набор из 3 связанных по смыслу (то есть через предметную область) юзкейсов, объединённых одним ограниченным контекстом (правилами конвертации).

Компонуем юзкейс обновления базовой валюты

За юзкейс обновления значения базовой валюты у нас сейчас отвечает функция updateBaseValue, которая реализует тип входного порта UpdateBaseValue. Чтобы прикрутить к нему инфраструктуру и UI, мы воспользуемся хуком.

Первым делом создадим хук useUpdateBaseValue, который будет предоставлять функциональность юзкейса компонентам (инжектить юзкейс):

// core/updateBaseValue.composition

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	// ...
};

Затем вспомним, какие этому юзкейсу необходимы зависимости:

type Dependencies = {
	readConverter: ReadConverter;
	saveConverter: SaveConverter;
};

…И соберём настоящие инстансы всех нужных сервисов:

// core/updateBaseValue.composition

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	const readConverter = useStoreReader();
	const saveConverter = useStoreWriter();

	// ...
};

Эти сервисы мы теперь можем передать в функцию юзкейса:

// core/updateBaseValue.composition

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	const readConverter = useStoreReader();
	const saveConverter = useStoreWriter();

	return (value) => updateBaseValue(value, { readConverter, saveConverter });
};

Наконец, чтобы избежать лишних перерисовок, используем useCallback:

// core/updateBaseValue.composition.ts

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	const readConverter = useStoreReader();
	const saveConverter = useStoreWriter();

	return useCallback(
		(value) => updateBaseValue(value, { readConverter, saveConverter }),
		[readConverter, saveConverter]
	);
};

И создадим публичное API для этого юзкейса:

// core/updateBaseValue/index

export * from './updateBaseValue.composition';

Хук как способ композиции

Можно заметить, что мы используем хук — только как средство для композиции юзкейса. То есть мы не держим в нём логики приложения, а лишь логику взаимодействия между модулями.

В хуке мы описываем, какие необходимо создать зависимости, куда их передать и какой интерфейс функция должна предоставлять в результате. С натяжкой можно сказать, что мы используем хуки, как такой «DI-контейнер курильщика».

«DI-контейнер» — потому что он избавляет код приложения от необходимости заботиться о своей композиции с другими модулями. «Курильщика» — потому что большую часть работы нам приходится делать руками: вызывать хуки нужных сервисов, подставлять зависимости в юзкейс и т.д.

Проблема этого «контейнера» в том, что код композиции будет исполняться в рантайме. Эта проверка будет выполняться на каждый перерендер хука, а этого хотелось бы избежать:

useCallback(
	(value) => updateBaseValue(value, { readConverter, saveConverter }),
	[readConverter, saveConverter]
);

Да и в целом код не выглядит как код стандартного Реакт-приложения. Но если мы посмотрим на его аналог в более конвенциональном виде, то поймём, что в «стандартных» хуках мы бы сделали то же самое, просто менее явно.

Более конвенциональный хук

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

Если с точки зрения эксперимента это валидно, то в реальных проектах не всегда рентабельно. В конвенциональном коде, вероятно, код юзкейса сам был бы хуком:

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	const model = useConverter();
	const saveConverter = useStoreWriter();

	return () => {
		const baseValue = createBaseValue(rawValue);
		const currentRate = lookupRate(model.rates, model.quoteCode);
		const quoteValue = calculateQuote(baseValue, currentRate);
		saveConverter({ baseValue, quoteValue });
	};
};

Идея остаётся той же, мы лишь жертвуем независимостью ядра приложения от инструментов и смешиваем композицию с логикой ради удобства.

В целом, в таком компромиссе нет ничего страшного. До тех пор, пока мы учитываем при проектировании направление зависимостей, слабое зацепление, опору на абстракции и Impureim сендвич, код будет расширяемым и отлаживаемым.

В конце концов, даже в самом коде хука видно, где мы готовим зависимости, а где начинается юзкейс:

export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
	// Готовим зависимости и данные.
	// (Impure section.)
	const model = useConverter();
	const saveConverter = useStoreWriter();

	// Объявляем функцию юзкейса.
	// Реализуем интерфейс входного порта,
	// чтобы компоненты были отцеплены от ядра.
	return () => {
		//
		// Проводим преобразования данных.
		// (Pure section.)
		const baseValue = createBaseValue(rawValue);
		const currentRate = lookupRate(model.rates, model.quoteCode);
		const quoteValue = calculateQuote(baseValue, currentRate);

		// Дёргаем сервис, чтобы сохранить модель.
		// (Impure section.)
		saveConverter({ baseValue, quoteValue });
	};
};

При необходимости такой хук можно обратно распилить на «независимое ядро» и «композицию», потому что мы учли все ограничения при его реализации. Такое правило можно даже использовать, как мысленный линтер при написании конвенциональных хуков — если мы можем вытащить функцию с бизнес-логикой наружу, передать ей зависимости, и она будет работать, то хук написан хорошо.

При желании такой хук сам по себе тоже можно отцепить от конкретных реализаций useStoreWriter, useStoreReader и useConverter. Я не буду подробно останавливаться на этом в тексте, но я оставлю ссылку на пример, где я расписал разные варианты композиции юзкейса в хуках.

Далее по тексту мы договоримся использовать первый вариант (с явной передачей зависимостей в юзкейс), исключительно чтобы идеи композиции были чуть виднее и яснее.

Связываем юзкейс с UI

Мы соединили юзкейс и инфраструктуру, теперь соединим это всё с UI-слоем. Сперва слегка обновим зависимости компонента с полем базовой валюты и укажем, что доступ к юзкейсу мы будем получать через хук:

type BaseValueInputDeps = {
	useUpdateBaseValue: Provider<UpdateBaseValue>;
	useBaseValue: SelectBaseValue;
};

Так как само по себе юзкейс и его тип остались такими же, внутри компонента нам будет достаточно получить доступ к функции. Весь остальной код менять не потребуется:

export function BaseValueInput({ useUpdateBaseValue, useBaseValue }: BaseValueInputDeps) {
	const updateBaseValue = useUpdateBaseValue();
	// ...Остальной код остаётся нетронутым.
}

Логика работы компонента и его взаимодействие с входным портом приложения не меняется, обновилась лишь их композиция — то есть, как и с помощью каких инструментов мы их сочетаем. Ранее мы передавали функцию юзкейса непосредственно через пропс, теперь мы используем хук для «инъекции» этой функции.

Польза проектирования от домена как раз в том, что мы получаем возможность компоновать приложение разными способами в зависимости от задачи. Это делает взаимодействие между модулями более гибким.

После изменения публичного API компонента, нам останется лишь обновить тесты. Опять же, так как суть работы компонента не изменилась, в тестах нам достаточно будет подправить композицию хука:

const updateBaseValue = vi.fn();

// Новая строка:
const useUpdateBaseValue = () => updateBaseValue;
const useBaseValue = () => 42;

const dependencies = {
	// Обновляем зависимости:
	useUpdateBaseValue,
	useBaseValue
};

// ...Всё остальное остаётся нетронутым.

«Регистрация» компонента

Последнее, что нам осталось сделать, — создать обёртку с «публичным API» в модуле компонента. Эта обёртка возьмёт на себя обязанность подключить все необходимые зависимости в компонент, а наружу выдать его «продакшен-версию» с уже подключёнными зависимостями:

// ui/BaseValueInput.composition

// Импортируем так, чтобы настоящее имя
// можно было использовать в этом файле далее:
import { BaseValueInput as Component } from './BaseValueInput';

// Достаём подготовленные зависимости для этого компонента,
// а именно хуки с юзкейсом и с селектором данных.
import { useUpdateBaseValue } from '../../core/updateBaseValue';
import { useBaseValue } from '../../infrastructure/store';

// «Регистрируем» компонент с тем же именем,
// но не требующий пропсов с зависимостями:
export const BaseValueInput = () => Component({ useUpdateBaseValue, useBaseValue });

Здесь мы как бы строим фасад над внутренней кухней, которая для соседних модулей не важна. Все обязательные зависимости компонент получает прямо перед тем, как оказаться внутри публичного API. Сторонние модули об этих зависимостях не в курсе и поэтому зацепление между ними не увеличится.

Добавим ре-экспорт компонента и объявим, что именно в этом модуле является публичным интерфейсом:

// ui/BaseValueInput/index.ts

export * from './BaseValueInput.composition';

Более конвенциональный компонент

Как и в случае с «неконвенциональным» хуком, код компонента не похож на обычный Реакт-компонент, и дело тут также в явной композиции. На деле импорты хуков в компоненте опять же можно прописать прямо в коде реализации и использовать прямо так:

import { useUpdateBaseValue } from '../../core/updateBaseValue';
import { useBaseValue } from '../../infrastructure/store';

export function BaseValueInput() {
	const value = useBaseValue();
	const updateBaseValue = useUpdateBaseValue();
	// ...
}

Разница будет такой же, как и в случае с хуком: мы жертвуем слабым зацеплением в пользу удобства импортов и отсутствия дополнительного кода. В реальных проектах мы скорее увидим такой код.

Тесты для такого компонента скорее всего будут использовать моки хуков и сервисов. Зацепление между компонентом и ядром приложения и инфраструктурой будет несколько выше, но количество «склеивающего» кода будет заметно ниже.

DI, сайд-эффекты и функциональное ядро

Собранный только что юзкейс и процессы внутри него тяготеют в функциональному программированию и повторяют Impureim сендвич, о котором мы говорили ранее. Все сайд-эффекты собраны на его краях — в UI и инфраструктуре, а по центру находятся чистые доменные функции, которые занимаются лишь преобразованием данных.

Все сайд-эффекты мы сосредотачиваем в UI и инфраструктуре, а в ядро юзкейса мы пишем насколько возможно чисто
Все сайд-эффекты мы сосредотачиваем в UI и инфраструктуре, а в ядро юзкейса мы пишем насколько возможно чисто

При этом мы используем такие понятия как зависимости, DI, «регистрация» и т.д. Может показаться, что мы входим в противоречие, ведь в ФП зависимостей быть не может, но на деле эи понятия мы используем как раз на краях сендвича — там, где сайд-эффекты и живут.

Если детальнее посмотреть на компоновку юзкейса, то мы увидим, что его ядро собрано как набор последовательного вызова нескольких функций. Такая композиция — функциональна. В ядре юзкейса мы не используем понятия «зависимостей», только входные и выходные данные.

На краях же мы в любом случае вынуждены работать с сайд-эффектами: читать данные из поля, выводить информацию на экран, обновлять хранилище. Именно тут, снаружи мы используем «нечистые» и «нефункциональные» техники. Это хоть и компромисс, но мы допускаем просто для удобства компоновки.

Идейно же, если отбросить все «паттерны» и «так называемый DI», мы всё ещё соблюдаем заветы функциональщины — а именно оборачиваем чистые преобразования в нечистый контекст, который предоставляет все необходимые данные.

Компонуем юзкейс обновления котировок

Теперь обсудим асинхронные процессы. Чтобы скомпоновать юзкейс обновления котировок, мы также обернём его в хук:

// core/refreshRates.composition

export const useRefreshRates: Provider<RefreshRates> = () => {
	const readConverter = useStoreReader();
	const saveConverter = useStoreWriter();

	return useCallback(
		() =>
			refreshRates({
				fetchRates,
				readConverter,
				saveConverter
			}),

		// Следить за `fetchRates` не требуется,
		// потому что эта функция точно не изменится.
		[readConverter, saveConverter]
	);
};

В зависимостях кнопки, однако, мы видим несколько другое возвращаемое значение:

type RefreshRatesDeps = {
	useRefreshRates: () => {
		execute: RefreshRates;
		status: Status;
	};
};

Чтобы «подружить» типы обоих хуков, мы можем написать отдельный адаптер, который будет преобразовывать хук, возвращающий асинхронную функцию, в хук, возвращающий этот тип.

Пишем адаптер для хука

Для этого нам понадобится функция высшего порядка. ФВП принимают другие функции как аргумент или возвращают функции как результат. В нашем случае ФВП asCommand будет брать один хук и возвращать другой:

// shared/infrastructure/cqs

type Adapted = {
	execute: RefreshRates;
	status: Status;
};

export const asCommand =
	(useRefresh: Provider<RefreshRates>): Provider<Adapted> =>
	() => {
		const [status, setStatus] = useState<Status>({ is: 'idle' });
		const refresh = useRefresh();

		const execute = async () => {
			setStatus({ is: 'pending' });
			await refresh();
			setStatus({ is: 'idle' });
		};

		return { status, execute };
	};

Дальше мы можем использовать этот адаптер, чтобы прицепить к компоненту хук, предоставляющий юзкейс:

// ui/RefreshRates.composition

import { RefreshRates as Component } from './RefreshRates';
import { useRefreshRates } from '../../core/refreshRates';
import { asCommand } from '~/shared/infrastructure/cqs';

// Передаём как зависимость не сам хук юзкейса,
// а его «преобразованную» версию.

export const RefreshRates = () => Component({ useRefreshRates: asCommand(useRefreshRates) });

Выносим служебный код

Вообще, функциональность адаптера asCommand кажется несколько «служебной», потому что:

  • адаптировать таким образом, вероятно, придётся не один асинхронный процесс;
  • а сам адаптер не зависит от конкретного юзкейса.

Мы можем немного обобщить этот адаптер и получить утилитарную функцию, которую сможем переиспользовать. Сперва объявим вспомогательные типы, которые объяснят другим разработчикам, какую мы хотим выразить:

type AsyncFn = (...args: unknown[]) => Promise<unknown>;

type Command<F extends AsyncFn> = {
	execute: F;
	status: Status;
};

Затем, выделим функцию высшего порядка, которая будет его использовать:

// shared/infrastructure/cqs

export const asCommand =
	<F extends AsyncFn>(useHook: Provider<F>): Provider<Command<F>> =>
	() => {
		const [status, setStatus] = useState<Status>({ is: 'idle' });
		const command = useHook();

		const execute = async () => {
			setStatus({ is: 'pending' });
			await command();
			setStatus({ is: 'idle' });
		};

		return { status, execute } as Command<F>;
	};

Так как принцип работы не поменялся, обновлять места использования не потребуется.

Добавляем обработку ошибок

Асинхронные процессы ненадёжные. Во время их работы может произойти ошибка, которую нам хотелось бы отловить и (пока что только) рассказать о ней пользователю.

Расширим интерфейс Command<T> и добавим обработку разных случаев: успеха и ошибки.

type Command<F extends AsyncFn> = {
	execute: F;
	result: Result;
};

type Status = Result['is'];
type Result = { is: 'idle' } | { is: 'pending' } | { is: 'failure'; error: Error };

Теперь обновим адаптер:

export const asCommand =
	<F extends AsyncFn>(useHook: Provider<F>): Provider<Command<F>> =>
	() => {
		// Добавляем локальный стейт для ошибки:
		const [status, setStatus] = useState<Status>('idle');
		const [error, setError] = useState<Nullable<Error>>(null);
		const command = useHook();

		// Добавляем try-catch и наивную обработку ошибок:
		const execute = async () => {
			setStatus('pending');
			setError(null);

			try {
				await command();
				setStatus('idle');
			} catch (error) {
				setError(error as Error);
				setStatus('failure');
			}
		};

		// Меняем результат, чтобы он реализовывал интерфейс `Command<T>`:
		const result = status === 'failure' ? { is: status, error } : { is: status };

		return { result, execute };
	};

В этот раз нам надо будет обновить компонент и тесты, потому что мы поменяли интерфейс, от которого он зависит:

export function RefreshRates({ useRefreshRates }: RefreshRatesDeps) {
	// Деструктурируем результат,
	// чтобы достать соответствующие свойства:

	const { execute, result } = useRefreshRates();
	const pending = result.is === 'pending';

	// ...
}

В тестах:

// Меняем тип стабов:
const idle: Result = { is: 'idle' };
const pending: Result = { is: 'pending' };

describe('when in idle state', () => {
	it('returns an enabled button', () => {
		// Обновляем зависимости:
		const useRefreshRates = () => ({ result: idle, execute });

		// ...Остальной код внутри тест-кейса останется нетронутым.
	});
});

Теперь мы можем добавить вывод текста ошибки под кнопкой:

export function RefreshRates({ useRefreshRates }: RefreshRatesDeps) {
	const { execute, result } = useRefreshRates();
	const pending = result.is === 'pending';
	const failure = result.is === 'failure';

	return (
		<>
			<Button type="button" onClick={execute} disabled={pending}>
				Refresh Rates
			</Button>

			{failure && <span>{result.error.message}</span>}
		</>
	);
}

И покрыть это тестом:

const failure: Result = {
	is: 'failure',
	error: new Error('Test error.')
};

describe('when in failure state', () => {
	it('returns a message error', () => {
		const useRefreshRates = () => ({ result: failure, execute });
		render(<RefreshRates useRefreshRates={useRefreshRates} />);

		const button = screen.getByText(/Test error./);

		expect(button).toBeDefined();
	});
});

Компонуем фичу

После того, как мы подготовили все юзкейсы, мы можем собрать из них конвертер.

Компонент конвертера будет такой «входной точкой» в фичу и будет собирать внутри себя другие компоненты, которые являются входными точками в юзкейсы:

// ui/Converter

import { BaseValueInput } from '../BaseValueInput';
import { QuoteSelector } from '../QuoteSelector';
import { RefreshRates } from '../RefreshRates';

export function Converter() {
	return (
		<form>
			<BaseValueInput />
			<QuoteSelector />
			<RefreshRates />
		</form>
	);
}

Мы можем думать об этом компоненте, как о «публичном интерфейсе» фичи, который доступен для внешнего мира.

Обратим внимание, что используемым компонентам уже не требуется передавать никаких зависимостей через пропсы, потому что это было сделано на этапе их композиции.

Несмотря на то, что в около-функциональном подходе мы стараемся держать зависимости явными, при представлении какой-то функциональности «наружу» модуля, зависимости мы скрываем. Так, например, рекомендует делать Скотт Влашин в своей книге «Доменной моделирование в функциональном стиле»:

  • Для функций, выдаваемых как публичное API, мы скрываем зависимости от их потребителей;
  • Для внутренних функций модуля мы выражаем зависимости явно.

Далее навешиваем провайдер для стора и ErrorBoundary:

// ...

import { StoreProvider } from '../../infrastructure/store';
import { ErrorBoundary } from '~/shared/ui/ErrorBoundary';

export function Converter() {
	return (
		<ErrorBoundary>
			<StoreProvider>
				<form>
					<BaseValueInput />
					<QuoteSelector />
					<RefreshRates />
				</form>
			</StoreProvider>
		</ErrorBoundary>
	);
}

И «регистрируем» этот компонент как публичное API:

// ui/Converter/index
export * from './Converter';

// features/converter/index
export * from './ui/Converter';

Структура папок и архитектура

До сих пор мы не уделяли особого внимания структуре папок в проекте. Я намеренно не акцентировал на этом рассказ, потому что структура папок не так важна, как взаимодействие между частями системы.

Я бы даже сказал, что структура проекта — это продукт того, как модули друг с другом общаются, какие данные делят и как организованы зависимости.

Мне нравится думать, что «правильная» структура проекта — это та, которая появилась эволюционно в результате проектирования. То есть та, которая отражает настоящие отношения между модулями.

Такой подход может казаться излишне «фривольным», но я считаю это плюсом. Потому что если мы выбираем строгую структуру папок заранее, в самом начале проекта, когда мы почти ничего о нём не знаем, она может стать лишним ограничением, которое будет мешать принимать те или иные решения.

В нашем случае структура папок, которая получилась по итогу, чем-то напоминает Feature-Sliced Design:

src/

  Фичи мы храним в отдельных папках,
  чтобы предотвратить зацепление между ними.
  (Мы подробно об этом поговорим в следующих постах.)

  features/converter/

    Ядро приложения (домен, юзкейсы и порты)
    лежат в папке `core`. Файлам в ней при необходимости
    будет удобно запретить импортировать что-либо,
    кроме потов и домена, например, линтером.

    core/
      domain/
      refreshRates/
      updateBaseValue/
      changeQuoteCode/
      ports.input.ts
      ports.output.ts

    Компоненты приложения лежат в папке `ui`.
    Отметим, что здесь лежат т.н. контейнеры,
    которые связывают ядро с UI.

    ui/
      RefreshRates/
      UpdateValueInput/
      QuoteSelector/

    Адаптеры к сервисам лежат в `infrastructure`.
    Тут мы делаем несопоставимые интерфейсы
    сторонних инструментов сопоставимыми
    и держим знания, специфичные для этой фичи.

    infrastructure/
      api/
      store/

  Реализации сервисов находятся в `services`.
  Это утилитарные, переиспользуемые модули,
  которые не зависят от предметной области проекта.

  services/
    network/

  Расширения, «библиотечный» код, стабы и моки,
  UI-кит, переиспользуемые компоненты находятся в `shared`.
  Здесь же мы можем хранить и Shared Kernel.

  shared/
    kernel.ts
    extensions/
    infrastructure/
    testing/
    ui/

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

Основное отличие от FSD, пожалуй, в явном выделении портов приложения. Но опять же делать явным все концепции не обязательно, мы их используем лишь, чтобы более чётко показать идею компоновки.

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

Добавляем интеграционные тесты

После компоновки фичи мы можем написать для неё интеграционные тесты. В них мы проверим, как работает публичное API этого модуля — то есть компонент Converter.

Важно, чтобы интеграционные тесты были ближе к «реальному взаимодействию» с приложением. Так мы сможем снизить урон от тестов и сделаем их устойчивее к рефакторингу.

В тексте мы, пожалуй, пропустим реализацию интеграционных тестов, просто чтобы сократить количество текста и кода. Примеры использования React Testing Library у нас в коде уже были, поэтому, думаю, эту часть мы можем пропустить.

Компонуем приложение

На следующем уровне композиции — уже целое приложение. Вообще, будь у нас несколько фич, мы бы компоновали здесь «набора виджетов» этих фич и создавали бы «страницы» или «экраны».

В нашем случае приложение — это всего одна фича, обрамлённая лейаутом, поэтому и код будет довольно простой:

// pages/Dashboard

import { Converter } from '~/features/converter';

export function Dashboard() {
	return <Converter />;
}

// src/App.tsx

export function App() {
	return (
		<main>
			<Header />
			<Dashboard />
			<Footer />
		</main>
	);
}

Стоит следить, чтобы на этот уровень не «протекала» никакая логика или описание сложных процессов. Мы должны компоновать уже полностью готовые блоки, которые можно заменять, удалять и переставлять без переписывания большого количества кода.

E2E тесты

После компоновки приложения мы можем написать набор End-to-end тестов, чтобы проверить интеграцию разных фич вместе и работу всего приложения в целом.

Такие тесты — это проверка приложения таким образом, как с ним бы работали настоящие пользователи. Эти тесты особенно полезны, если в приложении есть сложные бизнес-процессы, которые затрагивают несколько фич последовательно или даже одновременно.

E2E тесты обычно тяжёлые, требуют развёртывания браузера, поэтому мы держим их отдельно от интеграционных и юнит-тестов. Мы будем держать такие тесты рядом со страницами, потому что именно страницы — это «входная точка» в приложение для пользователя.

Для примера E2E тестов мы можем использовать Playwright. Например, напишем тест на то, что после клика по кнопке в конвертере появляются значения, которые мы ожидаем:

import { test, expect } from '@playwright/test';

test('refresh rates use case', async ({ page }) => {
	const valueInitial = /1 RPC = 0.3 IMC/;
	const valueExpected = /1 RPC = 0.98 IMC/;

	await page.goto('/');
	expect(page.getByText(valueInitial)).toBeDefined();

	const button = page.getByRole('button');
	await button.click();
	await expect(button).toBeDisabled();

	await page.waitForResponse('**/rates');
	expect(page.getByText(valueExpected)).toBeDefined();
});

На реализации других E2E тестов мы подробно останавливаться не будем, потому что это тема для отдельного поста. Но поиграться с набором тестов для этой фичи можно в исходниках на Гитхабе.

В следующий раз

В этом посте мы поговорили о композиции приложения, собрали конвертер из его составных частей и затронули разные виды тестирования. В следующий раз мы поговорим, как проделать то же самое, но без хуков, как внедрять зависимости «заранее» до рантайма и в чём потенциальная польза этого подхода.

Ссылки

Все ссылки на книги, статьи и другие материалы, упомянутые в статье.

Книги о разработке

Архитектура и взаимодействие модулей

Управление зависимостями

Паттерны и шаблоны

Инструменты и методологии

Прочее

Другие части серии