You said:
In an object-oriented style, they will be passed through the interface through dependency injection.
And the same approach is used in FP, but instead of entering through the constructor of the object, you "enter" as the parameters of the function.
So, you are on the right track with your applyUpdateTestable , except that it will also be used as real code, and not just as validated code.
For example, here a function with three additional dependencies is passed to:
module Core = let applyUpdate getThisFn getThatFn createThatThingfn updateData currentState = if not (someConditionAbout updateData) then log (SomeError) let this = getThisFn updateData currentState.Thingy // etc { currentState with This = this // etc. }
Then, in the "production" code, you enter the real dependencies:
module Production = let applyUpdate updateData currentState = Core.applyUpdate Real.getThis Real.getThat Real.createThatThingfn updateData currentState
or more simply using a partial application:
module Production = let applyUpdate = Core.applyUpdate Real.getThis Real.getThat Real.createThatThing
and in the test version, you enter mocks or stub instead:
module Test = let applyUpdate = Core.applyUpdate Mock.getThis Mock.getThat Mock.createThatThing
In the "production" example above, I statically hardcoded the dependencies on Real functions, but just like in the case of OO style dependency injection, the production of applyUpdate can be created by some top-level coordinator and then passed to the functions that need it.
This answers your questions, I hope:
- The same core code is used for both production and testing.
- If you statically hard code dependencies, you can still use F12 to drill them.
More complex versions of this approach exist, such as the Reading Monad, but the code above is the easiest way to start with.
Mark Seemann has some good posts on this topic, such as Integration Testing and SOLID: the next step is Functional and Ports and Adapters .