Подмена поведений
storyshots требует подмены компонентов: запросы и команды на функции заглушки,
для того чтобы сделать эталонное тестирование возможным.
Подмена через инверсию
Инверсия зависимостей является одним из способов подмены поведений.
Рассмотрим следующий пример:
async function placeAnOrder(order: OrderRepository) {
showLoading('Создание заказа');
await order.createOrder();
hideLoading();
showMessage('Заказ был успешно создан');
}
Функция placeAnOrder принимает в качестве аргумента любое значение, реализующее интерфейс OrderRepository.
В реальном коде в функцию placeAnOrder передается репозиторий выполняющий обращения к серверу:
placeAnOrder(orderRepository);
В окружении storyshots ту же функцию можно использовать с заглушками:
placeAnOrder(mockOrderRepository);
Поведение placeAnOrder изменится, однако исходный код самой функции будет идентичным. Это и называется расширением.
Инверсия зависимостей
С помощью интерфейса OrderRepository зависимость между placeAnOrder и orderRepository была инвертирована.
Репозиторий удалось подменить только потому, что placeAnOrder теперь не зависит от orderRepository напрямую.
В то же время, если реализация placeAnOrder изменится:
async function placeAnOrder(order: OrderRepository) {
showLoading('Создание заказа');
await order.createOrder();
// Добавился новый метод
await order.scheduleDelivery();
hideLoading();
showMessage('Заказ был успешно создан');
}
То orderRepository придётся измениться, чтобы удовлетворять новому интерфейсу.
Интерфейсы являются частью своих клиентов, так как именно пользователи (клиенты) диктуют требования системам (интерфейсам), которыми пользуются.
Инверсия в React
Инверсия зависимостей зачастую сопровождается механизмами внедрение зависимостей (dependency injection). DI можно разделить на две стадии:
- Создание зависимостей
- Внедрение зависимостей
React предоставляет свой аналог DI, так называемый контекст, где Provider является компонентом устанавливающим
зависимости, а Consumer позволяет их считывать, сквозь дерево компонентов.
С одной стороны это позволяет уменьшить связанность компонентов и повышает их гибкость. С другой - уменьшает возможности для статической типизации.
В таком случае можно создать корневой провайдер определяющий подменяемые зависимости:
type Externals = {
repositories: {
/* методы обращения к серверу */
};
env: {
/* методы работы с Web API */
};
/* и другие */
};
const Context = createContext<Externals | undefined>();
export const Externals: React.FC<
React.PropsWithChildren<{ externals: Externals }>
> = ({ externals, children }) => {
return <Context.Provider externals={externals}>{children}</Context.Provider>;
};
export const useExternals = () => {
const externals = useContext(Context);
if (externals === undefined) {
throw new Error('Externals dependency is missing');
}
return externals;
};
Далее объявить фабрики для реального и тестового окружений:
declare function createExternals(): Externals;
declare function createMockExternals(): Externals;
Тестовый код желательно отделять от реального окружения (см. Дислокация тестов).
Точка входа в реальное окружение может выглядеть так:
export const Main: React.FC = () => (
// В реальном окружении используются реальные externals.
<Externals externals={createExternals()}>
<App />
</Externals>
);
В тестах externals подменяются на тестовые данные:
export const { run, it } = createPreviewApp({
createExternals: createMockExternals,
createJournalExternals: createJournalExternals,
});
run(
map(stories, (story) => ({
render: (externals) => (
// В окружении storyshots внедряются тестовые зависимости
<Externals externals={externals}>
<App />
</Externals>
),
...story,
})),
);
Оценка
Достоинства данного метода:
- Строгость - инвертируемые зависимости нельзя использовать до того, как они будут созданы. Это очевидное свойство дополнительно контролируется компилятором.
- Безопасность - данный метод идеально совмещается с TypeScript и обеспечивает максимальную корректность на статическом уровне.
- Влиятельность - с помощью данного метода подмены, тесты оказывают дополнительное влияние на архитектуру приложения, делая её более расширяемой и адаптивной к изменениям.
Недостатки:
- Многословность - инверсия требует создание новой промежуточной сущности, интерфейса.
- Требовательность - код должен быть структурирован таким образом, чтобы поддерживать инверсию. Библиотеки, даже самые популярные, далеко не всегда позволяют расширять свои поведения данным образом.
- Зависимость - данный метод подмены сильнее связывает тесты с внутренним устройством кода проекта, усложняя рефакторинг.
Данный метод подмены рекомендуется для новых проектов с небольшой кодовой базой и пока ещё податливой внутренней структурой.
Подмена через сайд-эффекты
Помимо явной подмены через инверсию, также можно заменять зависимости путём их явного модифицирования.
Рассмотрим пример:
const orderRepository = {
// Обращение к серверу
createOrder: () => fetch('...'),
};
async function placeAnOrder() {
showLoading('Создание заказа');
await orderRepository.createOrder();
hideLoading();
showMessage('Заказ был успешно создан');
}
placeAnOrder использует orderRepository. Для того чтобы протестировать функцию, можно подменить поведение
репозитория напрямую:
// Подменяем метод `createOrder` на заглушку на прямую.
orderRepository.createOrder = () => {
/* ... */
};
placeAnOrder();
В storyshots, все истории существуют в изолированных друг от друга окружениях, поэтому влияние подобного рода подмены
на другие тесты исключается.
Monkey-patching в React
При данном типе подмены, рекомендуется использовать репозитории как глобальные singleton объекты:
export const orderRepository = {
/* ... */
};
export const userRepository = {
/* ... */
};
export const productRepository = {
/* ... */
};
/* И другие репозитории */
В конкретном компоненте, репозитории используются напрямую, по ссылке:
export const UserPage: React.FC = () => {
const response = useQuery(userRepository.getUser);
/* ... */
};
В тестах, следует объявить фабрику заглушек на основе глобальных репозиториев:
// Реестр используемых в приложении репозиторев
const registry = {
orderRepository,
userRepository,
productRepository,
};
// Фабрика по созданию заглушек для каждого из репозиториев
declare function createMockRepositories(): typeof registry;
Реестр репозиториев можно объявить сразу, на уровне реального кода, в таком случае в тестах его создавать не придется, что уменьшит связанность.
Далее объявить компонент, который будет осуществлять внедрение описанных зависимостей:
type Props = React.PropsWithChildren<{ repositories: typeof registry }>;
const RepositoryReplacer: React.FC<Props> = ({ repositories, children }) => {
useMemo(() => {
/**
* *Опционально* можно помечать не замоканные методы как не реализованные по умолчанию.
* Это упростит отладку и исключит нежелательные сайд-эффекты.
*/
markAllAsNotImplemented();
injectImplementations(repositories);
}, []);
return children;
};
function markAllAsNotImplemented() {
forEveryMethod(registry).forEach(
(repository, method) => (registry[repository][method] = notImplemented),
);
}
function injectImplementations(overrides: Props['repositories']) {
forEveryMethod(overrides).forEach(
(repository, method, impl) => (registry[repository][method] = impl),
);
}
Интеграция storyshots:
export const { run, it } = createPreviewApp({
createExternals: createMockExternals,
createJournalExternals: createJournalExternals,
});
run(
map(stories, (story) => ({
render: (repositories) => (
// В окружении storyshots внедряются тестовые зависимости
<RepositoryReplacer repositories={repositories}>
<App />
</RepositoryReplacer>
),
...story,
})),
);
Зависимости подменяются не сразу, а на этапе выполнение render функции RepositoryReplacer. Это означает, что если
подменяемые функции используются до этого, например на этапе загрузки модуля, то их реализация останется оригинальной:
// getVersion не будет подменён так выполнится раньше чем сработает подмена в RepositoryReplacer.
const version = manifestRepository.getVersion();
export const App = () => {
/* ... */
};
Зависимости можно подменять и раньше, но тогда для них не будет работать функция arrange.
Оценка
Достоинства:
- Компактность - метод не требует создания большого числа дополнительных сущностей.
- Независимость - за счёт своей не явности, такой способ подмены идеально подходит для использования в legacy стемах.
- Глобальность - с помощью данного вида подмены, можно заменять поведения даже там, где это не предусматривалось изначальное - например в сторонних библиотеках.
Недостатки:
- Не строгость - нет никакой гарантии что подменяются все зависимости, что используются в приложении.
- Не безопасность - корректность сайд-эффектов нельзя в полной мере проверить с помощью статических типов.
Подмены через инверсию и сайд-эффекты можно комбинировать:
- Репозитории можно подменять методом инверсии, так как они являются частью приложения и находятся под полным контролем разработчиков
- Web-API следует заменять через сайд-эффекты, так как он является глобальным и общедоступным. Библиотека
@storyshots/web-api-mocksкак раз это и выполняет.