Вопрос основан на отличном посте, связанном с F# / DI: https://fsharpforfunandprofit.com/posts/dependency-injection-1/
Пытался задать вопрос там. Однако похоже, что из-за некоторых сбоев на сайте сообщения больше не могут быть зарегистрированы. Итак, вот оно:
Интересно, как сценарий, описанный в этом посте, будет работать / трансформироваться в более реальный пример. Цифры ниже взяты немного с неба, поэтому, пожалуйста, скорректируйте их по своему усмотрению.
Рассмотрим некоторый достаточно небольшой проект на основе C#, основанный на DI / TDD / EF Code First:
Состав корня: 20 интерфейсов по 10 методов (в среднем) на каждый интерфейс. Хорошо, вероятно, это слишком много методов для каждого интерфейса, но, к сожалению, они часто раздуваются по мере развития кода. Я видел гораздо больше. Из них 10 являются внутренними службами без какого-либо ввода-вывода (без базы данных / "чистых" функций в мире func), 5 - это внутренние службы ввода-вывода (локальные базы данных и аналогичные), а последние 5 - внешние службы (например, внешняя база данных ( s) или что-либо еще, что вызывает некоторые удаленные сторонние службы).
Каждый интерфейс имеет реализацию производственного уровня с 4 внедренными интерфейсами (в среднем) и использует 5 членов каждого интерфейса, всего 20 методов (в среднем), используемых для каждой реализации.
Существует несколько уровней тестов: модульные тесты, интеграционные тесты (два уровня), приемочные тесты.
Модульные тесты: все вызовы имитируются с помощью соответствующей имитации настройки (например, с использованием какого-либо стандартного инструмента, такого как Moq). Итак, есть как минимум 20 * 10 = 200 юнит-тестов. Обычно их больше, потому что проверяется несколько разных сценариев.
Интеграционные тесты (уровень 1): все внутренние службы без ввода-вывода реальны, все внутренние службы, связанные с вводом-выводом, являются подделками (обычно БД в памяти), а все внешние службы проксируются на некоторые подделки / имитаторы. В основном это означает, что все внутренние службы ввода-вывода, такие как SomeInternalIOService: ISomeInternalIOService, заменяются на FakeSomeInternalIOService: ISomeInternalIOService, а все внешние службы ввода-вывода, такие как SomeExternalIOService: ISomeExternalIOService, заменяются на FakeSomeExternalIOService: ISomeExternalIOService. Итак, существует 5 поддельных внутренних служб ввода-вывода и 5 поддельных внешних служб ввода-вывода и примерно такое же количество тестов, как указано выше.
Интеграционные тесты (уровень 2): все внешние сервисы (включая теперь связанные с локальной базой данных) реальны, и все внешние сервисы проксируются для некоторых других фейков / моков, которые позволяют тестировать сбои внешних сервисов. В основном это означает, что все внешние службы ввода-вывода, такие как SomeExternalIOService: ISomeExternalIOService, заменяются BreakableFakeSomeExternalIOService: ISomeExternalIOService. Существует 5 различных (взламываемых) внешних поддельных служб ввода-вывода. Допустим, у нас около 100 таких тестов.
Приемочный тест: Все реально, но файлы конфигурации указывают на какие-то «тестовые» версии внешних сервисов. Допустим, таких тестов около 50.
Интересно, как это отразится на мире F#. Очевидно, много чего будет отличаться очень и некоторые вещи могут даже не существовать в мире F#!
Большое спасибо!
PS Я не ищу точного ответа. Достаточно «направления» с некоторыми идеями.
@ s952163 К сожалению, это не сработает. Поэтому я поставил несколько цифр в вопросе: существует (в среднем) 20 «инъекционных» методов. Передача всех их как частично применяемых функций станет кошмаром. Исходя из моего опыта работы с F#, я обнаружил, что на самом деле вам не нужно большинство тестов, которые требует C#. Правильно написанный код F# просто работает :), а для тестирования самых сложных фрагментов кода требуется лишь несколько тестов.





Я думаю, что один ключевой вопрос, от которого зависит ответ, заключается в том, какова модель взаимодействия с внешним вводом-выводом, которому следует приложение, и насколько сложна логика, управляющая взаимодействиями.
В простом сценарии у вас есть что-то вроде этого:
+-----------+ +---------------+ +---------------+ +------------+
| Read data | ---> | Processing #1 | ---> | Processing #2 | ---> | Write data |
+-----------+ +---------------+ +---------------+ +------------+
В этом случае очень мало нужды в имитации хорошо спроектированной функциональной кодовой базы. Причина в том, что вы можете протестировать все функции обработки без какого-либо ввода-вывода (это просто функции, которые принимают некоторые данные и возвращают некоторые данные). Что касается чтения и записи, там очень мало того, что можно было бы протестировать - в основном они просто выполняют ту работу, которую вы выполняли бы в своей «фактической» реализации ваших макетированных интерфейсов. В общем, вы можете сделать функции чтения и записи настолько простыми, насколько это возможно, и иметь всю логику в функциях обработки. Это идеальное место для функционального стиля!
В более сложном сценарии у вас будет что-то вроде этого:
+----------+ +----------------+ +----------+ +------------+ +----------+
| Some I/O | ---> | A bit of logic | ---> | More I/O | ---> | More logic | ---> | More I/O |
+----------+ +----------------+ +----------+ +------------+ +----------+
В этом случае ввод-вывод слишком чередуется с логикой программы, и поэтому сложно проводить какое-либо тестирование более крупных логических компонентов без какой-либо формы имитации. В этом случае сериал Марка Земанна - хороший исчерпывающий ресурс. Думаю, у вас есть следующие варианты:
Передача функций (и использование частичного приложения) - это простой функциональный подход, который будет работать, если вам не нужно передавать слишком много параметров.
Используйте более объектно-ориентированную архитектуру с интерфейсами - F# - это смешанный язык FP и OO, поэтому он также имеет хорошую поддержку. В частности, использование анонимных реализаций интерфейса означает, что вам часто не нужны имитирующие библиотеки.
Используйте шаблон «интерпретатор», в котором вычисления записываются на (встроенном) предметно-ориентированном языке, который описывает, какие вычисления и какие операции ввода-вывода необходимо выполнить (без фактического выполнения этого). Тогда вы сможете по-разному интерпретировать DSL в реальном и тестовом режиме.
В некоторых функциональных языках (в основном Scala и Haskell) людям нравится делать это, используя технику, называемую «свободные монады», но типичное описание этого, на мой взгляд, имеет тенденцию быть слишком сложным. (т.е. если вы знаете, что такое бесплатная монада, это может быть полезным указателем, но в противном случае вам, вероятно, лучше не лезть в эту кроличью нору).
Просто чтобы добавить к отличному ответу Томаса, вот несколько других предложений.
Как упоминал Томас, при проектировании FP мы склонны использовать проекты, ориентированные на конвейер, с одним конвейером для каждого варианта использования / рабочего процесса / сценария.
Что хорошо в этом подходе, так это то, что каждый из этих конвейеров может быть настроен независимо со своим собственным корнем композиции.
Вы говорите, что у вас есть 20 интерфейсов по 10 методов в каждом. Требуется ли для рабочего процесса каждыйвсе эти интерфейсы и методы? По моему опыту, для отдельного рабочего процесса может потребоваться только несколько из них, и в этом случае логика в корне композиции становится намного проще.
Если рабочему процессу действительно требуется более 5 параметров, скажем, тогда, возможно, стоит создать структуру данных для хранения этих зависимостей и передать ее:
module BuyWorkflow =
type Dependencies = {
SaveSomething : Something -> AsyncResult<unit,DbError>
LoadSomething : Key -> AsyncResult<Something,DbError>
SendEmail : EmailMessage -> AsyncResult<unit,EmailError>
...
}
// define the workflow
let buySomething (deps:Dependencies) =
asyncResult {
...
do! deps.SaveSomething ...
let! something = deps.LoadSomething ...
}
Обратите внимание, что зависимости обычно представляют собой просто отдельные функции, а не целые интерфейсы. Вы должны просить только то, что вам нужно!
Вы можете подумать о том, чтобы иметь более одного «корня композиции» - один для внутренних служб и один для внешних.
Обычно я разбиваю свой код на сборку «Core» только с чистым кодом и сборкой «API» или «WebService». который считывает конфигурацию и настраивает внешние службы. «Внутренний» корень композиции находится в сборке «Core», а «внешний» корень композиции находится в сборке «API».
Например, в сборке «Core» у вас может быть модуль, который запекает внутренние чистые сервисы. Вот какой-то псевдокод:
module Workflows =
// set up pure services
let internalServiceA = ...
let internalServiceB = ...
let internalServiceC = ...
// set up workflows
let homeWorkflow = homeWorkflow internalServiceA.method1 internalServiceA.method2
let buyWorkflow = buyWorkflow internalServiceB.method2 internalServiceC.method1
let sellWorkflow = ...
Затем вы используете этот модуль для своих «Интеграционных тестов (уровень 1)». На данный момент рабочие процессы все еще не имеют своих внешних зависимостей, поэтому вам нужно будет предоставить макеты для тестирования.
Точно так же в сборке «API» у вас может быть корень композиции, в которой предоставляются внешние службы.
module Api =
// load from configuration
let dbConnectionA = ...
let dbConnectionB = ...
// set up impure services
let externalServiceA = externalServiceA(dbConnectionA)
let externalServiceB = externalServiceB(dbConnectionB)
let externalServiceC = ...
// set up workflows
let homeWorkflow = Workflows.homeWorkflow externalServiceA.method1 externalServiceA.method2
let buyWorkflow = Workflows.buyWorkflow externalServiceB.method2 externalServiceC.method1
let sellWorkflow = ...
Затем в ваших «Интеграционных тестах (уровень 2)» и другом коде верхнего уровня вы используете рабочие процессы Api:
// setup routes (using Suave/Giraffe style)
let routes : WebPart =
choose [
GET >=> choose [
path "/" >=> Api.homeWorkflow
path "/buy" >=> Api.buyWorkflow
path "/sell" >=> Api.sellWorkflow
]
]
Приемочные испытания (с разными файлами конфигурации) могут использовать один и тот же код.
Вы можете просто выполнить частичное приложение и передать эти функции. Или: blog.ploeh.dk/2017/01/27/…