Часть 2. Проектируем пользовательские сценарии

Продолжаем серию постов и экспериментов о разработке и проектировании приложений.

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

Сценарии и данные

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

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

Мы можем составить такой сценарий, например, для обновления котировок и получим нечто вроде:

Пользовательский сценарий: “Обновить курсы валют”
Когда пользователь нажимает кнопку обновления:

1. Приложение получает обновленные курсы валют из API.
2. Находит текущее введенное значение базовой валюты.
3. Пересчитывает значение квот-валюты на основе входных данных.
4. Показывает обновленные значения на экране.

Заметим, что пока мы описываем детали взаимодействия с API получения котировок и не указываем конкретных функций обновления UI. Сейчас мы лишь указываем порядок действий, описываем операции «на верхнем уровне».

Уровни абстракции

При упоминании «уровней проектирования» мне нравится ссылаться на пост Марка Симанна о «фрактальной архитектуре».

В этой концепции Марк представляет программы, как набор вложенных друг в друга шестиугольников. Каждый шестиугольник вмещает в себя ограниченное количество составных частей, но их можно приближать, чтобы рассмотреть детали. Так мы на каждом уровне держим ограниченное количество информации, не перегружая голову:

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

Эту же метафору мне нравится применять и при проектировании. Каждый уровень проработки сценария — это такой шестиугольник. В первом приближении мы видим только «общее описание» операций, которые будет выполнять приложение.

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

Юзкейс в типах

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

Пользовательский сценарий: “Обновить курсы валют”
Когда пользователь нажимает кнопку обновления:

1. Приложение получает обновленные [ExchangeRates] из API.
2. Находит текущее введенное [BaseValue].
3. Пересчитывает [QuoteValue] на основе входных данных.
4. Показывает обновленные значения на экране.

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

ButtonClickEvent ->

  ExchangeRates ->
  [BaseValue, QuoteCode] ->
  [QuoteValue, ExchangeRate] ->

DataRenderedEvent

Эту последовательность мы и постараемся выразить в коде функции далее.

Impureim Sandwich

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

Такая организация кода называется «Функционально ядро в императивной оболочке», или как её называют ещё — “Impureim sandwich”. Её суть в том, чтобы сконцентрировать весь «нечистый» код по краям, а внутри держать только преобразования данных, построенные на чистых функциях:

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

Главная польза сендвича в том, что он убирает влияние непредсказуемых эффектов на данные, с которыми мы работаем. Но применительно к нашему сценарию есть ещё несколько плюсов:

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

Проектируем юзкейс

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

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

// Get fresh exchange rates from the API or a runtime data storage:

type FetchRates = () => Promise<ExchangeRates>;
type ReadConverter = () => [BaseValue, QuoteCurrencyCode];

Затем имеющиеся данные мы прогоняем через уже имеющиеся доменные преобразования:

// Transform data using the domain model:

type LookupRate = (rates: ExchangeRates, code: QuoteCode) => ExchangeRate;
type CalculateQuote = (base: BaseValue, rate: ExchangeRate) => QuoteValue;

В конце выводим результат на экран:

// Update the data in the UI:

type UpdateConverter = (rates: ExchangeRates, quote: QuoteValue) => void;

Сила абстракции

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

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

На каждом уровне у нас только необходимое количество деталей, причём — только тех, что важны на текущем уровне погружения в систему
На каждом уровне у нас только необходимое количество деталей, причём — только тех, что важны на текущем уровне погружения в систему

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

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

Порты приложения

Типы, которыми мы «закрылись» от библиотек и сторонних сервисов, — это так называемые порты приложения.

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

Порты, через которые внешний мир обращается к приложению, будем называть входными (input ports). А те, через которые приложение обращается во внешний мир — выходными (output ports).

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

Например, в случае с обновлением котировок, входным портом в приложение была бы некоторая функция-обработчик клика по кнопке:

// Этот тип говорит: «Когда пользователь нажмёт на кнопку,
// приложение запустит некий асинхронный процесс».

type RefreshRates = () => Promise<void>;

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

// Network & API:
type FetchRates = () => Promise<ExchangeRates>;

// Runtime storage:
type ReadConverter = () => ConverterModel;
type UpdateConverter = (patch: Partial<ConverterModel>) => void;

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

Внешний мир получает «рычажки» для работы с приложением и объяснения, как приложение хочет взаимодействовать с ним
Внешний мир получает «рычажки» для работы с приложением и объяснения, как приложение хочет взаимодействовать с ним

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

Реализация с опорой на абстракцию

Как в прошлый раз, описав функциональность в типах, мы можем приступить к реализации сценария в виде функции:

// core/refreshRates

export const refreshRates = () => {};

Вызов этой функции — входная точка в ядро приложения из UI. Это значит, что эта функция — реализует входной порт. Убедимся, что она реализует тип RefreshRates:

// core/ports.input

export type RefreshRates = () => Promise<void>;

// core/refreshRates

export const refreshRates: RefreshRates = async () => {};

// Функция должна быть асинхронной,
// потому что `RefreshRates` возвращает промис.
// Чтобы быстро проверить, реализует ли функция какой-то тип,
// мы можем явно указать ей тип через `:`.
// Тогда если типы не совпадают, TypeScript ругнётся на нас.

Внутренностями функции refreshRates будет описание всего юзкейса:

// core/refreshRates

import type { RefreshRates } from '../ports.input';

import { calculateQuote } from '../domain/calculateQuote';
import { lookupRate } from '../domain/lookupRate';

export const refreshRates: RefreshRates = async () => {
	// 1. Fetch latest rates from the API:
	const rates = await fetchRates();

	// 2. Get the current model from runtime storage:
	const model = readConverter();

	// 3. Run all the domain data transformations:
	const rate = lookupRate(rates, model.quoteCode);
	const quote = calculateQuote(model.baseValue, rate);

	// 4. Update the runtime storage,
	//    consequently triggering the rerender.
	saveConverter({ rates, quoteValue: quote });

	// *. If we were working not with React but with a lib
	//    that doesn't automatically track rerenders,
	//    we could trigger the rerender from here manually.
};

Внутри сценария мы опираемся на гарантии от функций, реализующих выходные порты: fetchRates, readConverter и saveConverter. Благодаря типам, мы знаем, какие аргументы каждая из них ожидает, и какой результат мы получим после вызова:

// core/ports.output

type FetchRates = () => Promise<ExchangeRates>;
type ReadConverter = () => Converter;
type SaveConverter = (patch: Partial<Converter>) => void;

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

const fetchRates: FetchRates = async () => {};
const readConverter: ReadConverter = () => {};
const saveConverter: SaveConverter = () => {};

Зависимости сценария

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

Представим, что вместо отдельных функций у нас есть некий объект, в котором все эти функции собраны вместе:

// core/refreshRates

type AllOutputPorts = {
	fetchRates: FetchRates;
	readConverter: ReadConverter;
	saveConverter: SaveConverter;
};

const ports: AllOutputPorts = {}; /*...*/

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

// core/refreshRates

export const refreshRates: RefreshRates = async () => {
	const { fetchRates, readConverter, saveConverter } = ports;

	// ...Остальной код юзкейса.
};

Если пойти немножко дальше, то мы заметим, что держать объект зависимостей внутри функции не обязательно — мы можем передавать его, как аргумент для refreshRates:

// core/refreshRates

export const refreshRates: RefreshRates = async ({
	fetchRates,
	readConverter,
	saveConverter
}: AllOutputPorts) => {
	// ...
};

Порты теперь находятся в замыкании функции refreshRates, а не в каком-то конкретном объекте. Это значит, что для refreshRates они самые-что-не-есть настоящие, но вот создавать заглушки для портов нам больше не нужно — все зависимости функции refreshRates берутся прямо из её аргументов.

Правда мы поломали гарантии типа RefreshRates: согласно этому типу функция refreshRates не должна требовать аргументов, а сейчас ей нужен объект с зависимостями. Исправим это, добавив дефолтное значение аргумента, чтобы он перестал быть обязательным. (Ну и заодно обновим имя типа.)

// core/refreshRates

const stub = {} as Dependencies;

export const refreshRates: RefreshRates = async ({
	fetchRates,
	readConverter,
	saveConverter
}: Dependencies = stub) => {
	// ...
};

Если вы узнали в этом внедрение зависимостей (Dependency Injection, DI), то в целом это оно и есть. Точнее, его около-функциональный аналог.

Тестируем юзкейс

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

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

// core/refreshRates.test

describe('when called', () => {
	it.todo('should recalculate the quote value using the rates from the API');
	it.todo('should update the exchange rates data with the one from the API');
	it.todo('...');
});

Для проверки сценария мы будем стараться использовать output-based, то есть имитировать вход и следить за выходом функции. Иногда мы будем применять и state-based тестирование, хотя оно и менее устойчиво к изменениям.

Чтобы протестировать юзкейс, мы заменим зависимости на стабы и моки. Вместо функций, которые нам предоставляют какие-то данные, мы подставим стабы — простые заглушки, без особой сложной функциональности. Для проверки же результата воспользуемся шпионом — функцией, которая будет следить, как и с какими аргументами её вызовут:

// core/refreshRates.test

// Простые стабы, которые подсовывают некоторые данные:
const fetchRates = async () => ({ ...rates });
const readConverter = () => ({ ...converter });

// Шпион, который следит за вызовом функции сохранения:
const saveConverter = vi.fn();

// Все зависимости для юзкейса:
const dependencies = {
	fetchRates,
	readConverter,
	saveConverter
};

Затем опишем, что в результате работы должен быть вызван шпион с определёнными данными:

// core/refreshRates.test

describe('when called', () => {
	it('should recalculate the quote value using the rates from the API', async () => {
		// В результате вызова сценария:
		await refreshRates(dependencies);

		// ...Шпион должен быть вызван с такими данными:
		expect(saveConverter).toHaveBeenCalledWith({ quoteValue: 5, rates });
	});
});

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

Я стараюсь писать тесты и сами функции так, чтобы в коде было как можно меньше моков и как можно больше стабов. Это позволяет избежать test-induced damage, а также делает тесты независимыми от конкретной реализации зависимостей и расположения модулей в файловой системе. Но это лишь моё предпочтение, ваши взгляды на тестирование могут отличаться.

Композиция и дефолтные зависимости

Выше мы упоминали, что передавать зависимости последним аргументом — не лучшее решение:

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

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

Намерение и реализация

Если мы посмотрим на импорты в файле юзкейса, то увидим, что ядро приложения использует только доменные функции и типы входных и выходных портов:

// core/refreshRates.ts

// Из портов импортируем только типы:
import type { RefreshRates } from '../ports.input';
import type { FetchRates, ReadConverter, SaveConverter } from '../ports.output';

// Из домена — сами функции:
import { calculateQuote } from '../domain/calculateQuote';
import { lookupRate } from '../domain/lookupRate';

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

Intention:                      Composition:                   Implementation:

RefreshRates(                   RefreshRates(
  FetchRates,                     fetchRatesFn,         <-     const fetchRatesFn = async () => {...},
  ReadConverter,        ->        readConverterFn,      <-     const readConverterFn = () => {...},
  SaveConverter                   saveConverterFn       <-     const saveConverterFn = (data) => {...}
)                               )

Declares dependencies,          Configure work of the function
that can be configurable.       by passing specific dependencies.

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

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

Направление зависимостей

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

Внешний мир зависит от портов, ядро приложения зависит от домена
Внешний мир зависит от портов, ядро приложения зависит от домена

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

// core/refreshRates.test

const saveConverter = vi.fn();
const dependencies = {
	// ...
	saveConverter
};

describe('when called', () => {
	it('should recalculate the quote value using the rates from the API', async () => {
		await refreshRates(dependencies);
		expect(saveConverter).toHaveBeenCalledWith(/*...*/);
	});
});

Это довольно субъективный аргумент, потому что сервисы всегда можно замокать на уровне модулей. (Хотя стоит также учитывать, что моки модулей бывает сложнее обновлять, и они привязывает нас к файловой структуре. Что выбрать — зависит от задачи.)

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

Другие юзкейсы

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

Сперва объявим входной порт:

// core/ports.input

export type UpdateBaseValue = (value: string | number) => void;

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

Затем спроектируем сам сценарий:

// core/updateBaseValue

export const updateBaseValue: UpdateBaseValue = (rawValue) => {
	// 1. Нечистая секция:
	// 1.1. Получить текущие `quoteCode` и `rates`.
	//
	// 2. Чистая секция:
	// 2.1. Нормализовать значение, полученное как аргумент.
	// 2.2. Определить текущий курс выбранной пары.
	// 2.3. Пересчитать значение квот-валюты.
	//
	// 3. Нечистая секция:
	// 3.1. Сохранить обновлённое значение базовой валюты.
	// 3.2. Сохранить пересчитанное значение квот-валюты.
};

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

// core/updateBaseValue

export const updateBaseValue: UpdateBaseValue = (rawValue) => {
	// 1. Нечистая секция:
	// 1.1. Получить текущие `quoteCode` и `rates`.

	// 2.
	const baseValue = createBaseValue(rawValue);
	const currentRate = lookupRate(model.rates, model.quoteCode);
	const quoteValue = calculateQuote(baseValue, currentRate);

	// 3. Нечистая секция:
	// 3.1. Сохранить обновлённое значение базовой валюты.
	// 3.2. Сохранить пересчитанное значение квот-валюты.
};

А для того, чтобы достать и сохранить данные, мы привяжемся к выходным портам:

// core/updateBaseValue

// Объявляем зависимости, которые нам требуются:
type Dependencies = {
	readConverter: ReadConverter;
	saveConverter: SaveConverter;
};

export const updateBaseValue: UpdateBaseValue = (
	rawValue,
	// Ссылаемся на эти типы внутри функции:
	{ readConverter, saveConverter }: Dependencies
) => {
	// 1.
	const model = readConverter();

	// 2. ...

	// 3.
	saveConverter({ baseValue, quoteValue });
};

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

// core/updateBaseValue

const stub = {} as Dependencies;

export const updateBaseValue: UpdateBaseValue = (
	rawValue,
	{ readConverter, saveConverter }: Dependencies = stub
) => {
	// ...
};

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

// core/updateBaseValue.test

const readConverter = () => ({ ...converter });
const saveConverter = vi.fn();
const dependencies = {
	readConverter,
	saveConverter
};

describe('when given a valid base value update', () => {
	it('recalculates the model according to the new value and current rates', () => {
		updateBaseValue(42, dependencies);
		expect(saveConverter).toHaveBeenCalledWith({
			baseValue: 42,
			quoteValue: 21
		});
	});
});

describe('when given an invalid base value update', () => {
	it('recalculates the quote using 0 as the base value', () => {
		updateBaseValue('invalid', dependencies);
		expect(saveConverter).toHaveBeenCalledWith({
			baseValue: 0,
			quoteValue: 0
		});
	});
});

Разработка через тестирование

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

Напишем последний оставшийся юзкейс с изменением квот-валюты, используя TDD. Распланируем желаемое поведение:

// core/changeQuoteCode.test

describe('when given a new quote code', () => {
	it.todo('changes the quote code in the model');
	it.todo('recalculates quote according to the new code and current rates');
});

Напишем вызов первой функции и объявим, в каком виде хотим получить результат:

// core/changeQuoteCode.test

const saveConverter = vi.fn();
const dependencies = {
	saveConverter
};

describe('when given a new quote code', () => {
	it('changes the quote code in the model', () => {
		// После вызова функции:
		changeQuoteCode('DRG', dependencies);

		// ...Мы ожидаем увидеть изменение кода квот-валюты в модели.
		expect(saveConverter).toHaveBeenCalledOnce();
		expect(saveConverter.mock.lastCall?.at(-1).quoteCode).toBe('DRG');
	});
});

Создадим пустую реализацию и проверим, что тест падает по ожидаемой причине:

AssertionError: expected "spy" to be called once
  20|   it("changes the quote code in the model", () => {
  21|     changeQuoteCode("DRG", dependencies);
  22|     expect(saveConverter).toHaveBeenCalledOnce();
    |                           ^
  23|     expect(saveConverter.mock.lastCall?.at(-1).quoteCode).toBe("DRG");
  24|   });

- Expected   "1"
+ Received   "0"

После этого напишем реализацию:

// core/changeQuoteCode

type Dependencies = {
	saveConverter: SaveConverter;
};

export const changeQuoteCode: ChangeQuoteCode = (quoteCode, { saveConverter }: Dependencies) => {
	saveConverter({ quoteCode });
};

Затем напишем второй тест. В этот раз нам понадобится подмешать некоторое состояние и данные, поэтому создадим ещё один стаб в зависимостях:

// core/changeQuoteCode.test

const readConverter = () => ({ ...converter });
const saveConverter = vi.fn();
const dependencies = {
	readConverter,
	saveConverter
};

// ...

it('recalculates quote according to the new code and current rates', () => {
	changeQuoteCode('DRG', dependencies);
	expect(saveConverter).toHaveBeenCalledOnce();
	expect(saveConverter.mock.lastCall?.at(-1).quoteValue).toBe(2.5);
});

После этого проверим, что тест падает, потому что значения нет. И напишем реализацию:

// core/changeQuoteCode

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

export const changeQuoteCode: ChangeQuoteCode = (
	quoteCode,
	{ readConverter, saveConverter }: Dependencies
) => {
	const model = readConverter();

	const currentRate = lookupRate(model.rates, quoteCode);
	const quoteValue = calculateQuote(model.baseValue, currentRate);

	saveConverter({ quoteCode, quoteValue });
};

Починим поломанный интерфейс входного порта ChangeQuoteCode и добавим заглушку для зависимостей:

const stub = {} as Dependencies;

export const changeQuoteCode: ChangeQuoteCode = (
	quoteCode,
	{ readConverter, saveConverter }: Dependencies = stub
) => {
	// ...
};

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

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

Ссылки

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

Книги

Работа с зависимостями

Тестирование, моки и стабы

Абстракция, композиция и эффекты

Прочие понятия

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