-
-
Notifications
You must be signed in to change notification settings - Fork 122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Testable Cmds - Proposal 2 - Changed Sub<'msg> to return an optional message asynchronously #320
Conversation
I think this is great - at least conceptually. I've always found the "loss of the async modality" in the inner "dispatch" of Cmd implementation a little odd - but couldn't identify where/how that should be addressed or even why it was really a problem. At one point I suggested making My only question is about cases where a |
@TimLariviere I like the idea of changing Aside from solving the unit testing problem this approach is attractive because:
|
Hmm, yes, haven't thought of that. Maybe we should have 2 differents concepts? If I take the example of progress report, the Cmd would kick off to background process and return a message to acknowledge that. |
Is taking a dependency on |
Actually it's not really different than today. let cmd = [ (fun dispatch -> async { let! msg = p in dispatch msg } |> Async.StartImmediate) ]
// Fabulous main loop
for sub in cmd do
sub dispatch It's only moved to another place. |
I may misunderstand something, but is |
@cmeeren No, I don't think we should unit test every Cmds, only those that are meaningful for your app. Maybe there's no need to execute the |
Just for the record: What I am missing right now is the opportunity to test that a specific command has been fired, not necessarily the command itself (that I can achieve otherwise, e.g. by testing another function that is then being called by the command). I think this is quite important for use cases like
tl;dr I want to be able to make sure that a certain type of command with specific parameters has been issued. Or am I missing something and that should be possible already, even for |
If it's important to unit test that The actual production runtime Did that make sense? Sorry if not; I'm just having a hard time imagining how to unit test commands in a sane way, since as far as I have used Elmish, the update function calls eg. Maybe it's a lack of experience or knowledge, but to me, the |
Example: module Http =
let signIn (username, password) : Async<Result<Token, SignInError>> =
async {
use client = HttpClient ()
...
}
type Msg =
| SignIn of Username * Password
| SignInComplete of Result<Token, SignInError>
[<RequireQualifiedAccess>]
type CmdMsg =
| SignIn of Username * Password
let cmdMsgToCmd : CmdMsg -> Cmd<Msg> = function
| CmdMsg.SignIn (u, p) -> Cmd.OfAsync.perform Http.signIn (u, p) SignInComplete
let update (toCmd: CmdMsg -> Cmd<Msg>) msg model =
match msg with
| SignIn (u, p) -> { model with IsBusy = true }, toCmd (CmdMsg.SignIn (u, p))
| SignedIn res -> ... |
@aspnetde Yes, that's what I initially wanted to do. Being able to check if the non-executed Cmd is the same as the one I expect it to be. @cmeeren's example could be a good way to solve this issue gracefully (but is necessarily more verbose). let cmdMsgToCmd : CmdMsg -> Cmd<Msg> = function
| CmdMsg.SignIn (u, p) -> Cmd.OfAsync.perform Http.signIn (u, p) SignInComplete
let updateTestable msg model =
match msg with
| SignIn (u, p) -> { model with IsBusy = true }, CmdMsg.SignIn (u,p)
| SignedIn res -> ...
let update msg model =
let newModel, cmdMsg = updateTestable msg model
let cmd = cmdMsgToCmd cmdMsg
newModel, cmd Because otherwise you wouldn't be able to test the CmdMsg in a unit test, as the CmdMsg would be instantly converted to Cmd. Going further, we could follow the same logic and apply the same pattern as Msg. Without changing Fabulous, this would theoretically be possible // Page A
type PageAMsg = | SignedIn | ...
type PageACmd = | SignedInCmd of data | ...
let convertCmdMsg cmd =
match cmd with
| SignedInCmd data -> Cmd.ofAsync ...
let update msg model =
match msg with
| SignedIn = { model with IsBusy = true }, SignedInCmd someData
// App
type AppMsg = | PageA of PageAMsg | PageB of PageBMsg
type AppCmdMsg = | PageA of PageACmd | PageB of PageBCmd
let convertCmdMsg cmd =
match cmd with
| PageACmd cmdMsg-> PageA.convertCmdMsg cmdMsg |> Cmd.map AppMsg
| ...
let update msg model =
match msg with
| PageA msg ->
let pageAModel, pageACmd = PageA.update msg model.PageAModel
{ model with PageA = pageAModel }, (PageA pageACmd)
| ...
type App() =
let convertedUpdate msg model =
let newModel, newCmdMsg = App.update msg model
let cmd = App.convertCmdMsg newCmdMsg
(newModel, cmd)
Program.mkSimple App.init convertedUpdate App.view
|> Program.run... |
It seems this is a common issue. I tend to like your propsal @TimLariviere:
|
I was thinking that you passed in a function that, when receiving the expected
It'll either have to be
Yes, and I think this it's a good idea to keep the existing abstractions (the architecture is well-known and well-documented with lots of resources available on the net), and not force this on users. The The (This also allows the users to choose whether to use |
since @aspnetde asked me on twitter. Here is a ugly version that works with elmish: |
@forki I don't know what the twitter conversation was specifically about, but that only seems to allow you to verify that a resulting message was returned (and only after the function has run, i.e. integration testing |
@cmeeren sure but if that's soo super important to test, then you can introduce a new message. and in the pattern match of then update function you make that a oneliner to redispatch |
Haha, yes, I was just writing about that when your comment popped up, though I see it from another angle: I can't help but feeling like this whole However, the By the way, here's another reason to not force a particular design on users: The general case would have to be |
Heck, you could even have a |
the actual point I'm trying to make: simplify the update function to pattern match that basically only has oneliners for each case. at that point I would not test that level anymore |
If I am not mistaken right now, I think it's not much of a difference, is it? Having a However, I would prefer to have a dedicated |
If you don't have a Behaviour as data - part of what I love about FP. 😊 |
I've got this: View.Button(
text = "Sign in",
isVisible = (not model.IsBusy),
command = (fun () -> dispatch LoginStarted),
canExecute = model.Login.IsCredentialsProvided) | LoginStarted ->
let credentials = { UserName = model.Login.UserName; Password = model.Login.Password }
{ model with IsBusy = true }, logInCmd credentials Now I want to make sure that not only If I understand @forki he proposes something like: type Msg =
| LoginStarted
// Commands
| CmdLoginStarted of Credentials
match msg with
| LoginStarted ->
let credentials = { UserName = "Foo"; Password = "Bar" }
// model, CmdLoginStarted(credentials) <- Edit: That's plain nonsense
model, Cmd.ofMsg (CmdLoginStarted(credentials))
| CmdLoginStarted credentials -> model, logInCmd credentials So I could make sure Right? |
match msg with
| LoginStarted ->
let credentials = { UserName = "Foo"; Password = "Bar" }
model, CmdLoginStarted(credentials)
| CmdLoginStarted credentials -> model, logInCmd credentials I don't get this. The first case is returning This is what you avoid with |
Sorry, that was too quick and dirty: match msg with
| LoginStarted ->
let credentials = { UserName = "Foo"; Password = "Bar" }
model, Cmd.ofMsg (CmdLoginStarted(credentials))
| CmdLoginStarted credentials -> model, logInCmd credentials |
Yes, and my point is that when testing |
I agree. But you will end up needing some kind of agreement on how to do it in a particular application within a team (or with your future self) anyway, as no one will stop you to do it the "wrong" way. So developer discipline is needed anyway. That being said I can only repeat myself at this point that I would be happy to see the |
For now this works for me: type ``When a login is being started``() =
[<Fact>]
let ``The Log in Command is being issued and the Credentials are being passed``() =
let userName = Guid.NewGuid().ToString()
let password = Guid.NewGuid().ToString()
let model = initialModel()
let model = { model with Login = { model.Login with UserName = userName; Password = password } }
let _, cmd = App.update App.Msg.LoginStarted model
let credentials = { UserName = userName; Password = password }
cmd
|> dispatchesMessage (App.Msg.CmdLoginStarted(credentials))
|> should be True Key is let dispatchesMessage (msg:App.Msg) (cmd:Cmd<App.Msg>) =
let messages = ResizeArray<_>()
cmd |> Seq.iter (fun f -> f(fun x -> messages.Add x))
messages |> Seq.exists (fun m -> m = msg) |
I'm not sure I understand. As long as one agrees to have the In any case, it seems we agree. :) |
Assuming you have only one single program (remember the scaling discussion ;)), you are absolutely right. And the longer I think about it, the more I like it (the approach discussed here). 👍 |
I'm quite satisfied with @cmeeren's The cons are fairly acceptable (it's the sames as Msg, which is widely accepted).
So I'm inclined to finally close this PR and open a new one with only docs and samples (no need to change Fabulous) If you want to continue your discussion, feel free to open an issue. :) |
I'm fine with it :) |
Great! But the general case is returning |
I like this one better than #309
The need stays the same. We want to be able to unit test
Cmd
.While
Cmd.none
andCmd.ofMsg
are easy to test, it becomes more complex forCmd.ofAsyncMsg
(Fabulous hides the fact that it's asynchronous).Cmd.ofMsgOption
andCmd.ofAsyncMsgOption
are plain untestable because they may never dispatch anything, and we have no way to know when or if they will dispatch.My proposal here is to change the way we handle
Cmd
.Instead of Cmd being a list of functions
Dispatch<'msg> -> unit
(doesn't return anything right now, but may dispatch a message at a later time)I changed it to a list of functions
unit -> Async<'msg voption>
(return a promise that at some point an optional message will need to be dispatched)That way, it makes testing a lot more easier.