#blog #FSharp I hopefully add some sort of descison-making logic code to Rolbots Arena, under the guidance of #BDD #TDD
As I have a scenario for a new user on the title screen, I ought to have one for a returning user. The new user goes straight to the arena loading screen, so the returning user should instead be taken to a menu screen.
Scenario: Main menu for a returning user
Given a returning user who is at the title screen
When the user leaves the title screen
Then the main menu screen is shown to the user
let [<Given>]``a returning user who is at the title screen`` () = ()
let [<Then>]``the main menu screen is shown to the user`` () =
Assert.Equal(controller.CurrentScreen, "main menu screen")
The 'When' defined earlier is reused.
Naturally this fails right away. That's what I want, of course. Now I need to add a unit test so that can fail too.
let [<Fact>]``for a new user, continuing from the title screen goes directly to the arena loading screen`` () =
let controller = Controller.create()
controller.DoAction "continue"
Assert.Equal(controller.CurrentScreen, "arena loading screen")
let [<Fact>]``for a returning user, continuing from the title screen goes to the main menu screen`` () =
let controller = Controller.create()
controller.DoAction "continue"
Assert.Equal(controller.CurrentScreen, "main menu screen")
However there's currently nothing to distinguish these two test cases. The outcome cannot be different because the initial conditions are the same. There's no representation of the difference between a new or returning user. So the test is currently wrong, as it doesn't actually include all of what is being tested.
How about this:
let [<Fact>]``for a returning user, continuing from the title screen goes to the main menu screen`` () =
let controller = Controller.create true
///...
module Controller =
let create returningUser = Controller()
In the future I figure the controller will access/determine this information from the environment. This will be handled by something external to the controller which is passed in (like a dependency which is being injected). For testing, I won't be altering the external environment, but rather passing in something with the same interface which gives the answers that I want it to. Currently there's only one value so I might as well just pass the right answer in, for the sake of simplicity. For TDD, mimimum implementation is the watchword! Well, two words.
Now the controller has to do something different based on the value of returningUser. But that's currently only known to the create function. So I need to pass that in to the constructor, and match against it to determine how to mutate the currentScene value.
type Controller(returningUser) =
let mutable currentScreen = "title screen"
//member __.reset () = ()
member __.CurrentScreen = currentScreen
member __.CurrentActions = ["continue"]
member __.DoAction _ =
match returningUser with
| false -> currentScreen <- "arena loading screen"
| true -> currentScreen <- "main menu screen"
module Controller =
let create returningUser = Controller returningUser
But now I have a compile error, because returningUser is now inferred to be a bool. So I need to add an overload with a default value. But I can't overload plain functions. Actually Controller.create is looking fairly redundant. I can just use a constructor directly.
It's a simple matter to change all cases of Controller.create() in tests to Controller(). And then I can add a new constructor (pun intended) for the case of no parameters.
type Controller(returningUser) =
let mutable currentScreen = "title screen"
new () =
Controller(false)
//...
This works, but the scenario test still fails because it hasn't been updated to supply the parameter. That's because all the scenarios and their steps are using the same controller. I can't simply have them use two different controllers, because of this:
let [<When>]``the user leaves the title screen`` () =
controller.DoAction "continue"
That step is used both in the case of a new user and a returning user. It seems that I can't reuse the controller between these scenarios after all. That's fine though. I'm not doing much with the controller in these tests, and later tests won't have this issue since the new vs. returning user divide shouldn't apply to other features. I surmise.
So the upshot of that is that I need to make the controller value mutable.
let mutable controller = Controller()
let [<Given>] ``a user who is not using Rolbots Arena`` () =
controller <- Controller()
let [<When>] ``the user starts using Rolbots Arena`` () =
//controller.reset()
()
let [<Then>] ``the title screen is shown`` () =
Assert.Equal(controller.CurrentScreen, "title screen")
let [<Then>] ``the user is shown the option to continue`` () =
Assert.Equal<IEnumerable<string>>(controller.CurrentActions, ["continue"])
let [<Given>]``a new user who is at the title screen`` () =
controller <- Controller false
let [<When>]``the user leaves the title screen`` () =
controller.DoAction "continue"
let [<Then>]``the arena begins to load without showing the main menu`` () =
Assert.Equal(controller.CurrentScreen, "arena loading screen")
let [<Given>]``a returning user who is at the title screen`` () =
controller <- Controller true
let [<Then>]``the main menu screen is shown to the user`` () =
Assert.Equal(controller.CurrentScreen, "main menu screen")
Now all the tests pass. It's time for some refactoring.
There's no redundancy in the sense of duplication, but there are some unnecessary degrees of freedom. The values of currentScreen and currentAction are strings, which means they could be any string. But there's no current need for them to be strings. They're not used as strings, just identifiers. So I might as well replace them with discriminated union cases. That limits their possible values to the specific necessary allowable values.
let [<Fact>]``create Controller and ensure it's something`` () =
let controller = Controller()
Assert.NotNull controller
let [<Fact>]``the Controller must start on the title screen with the option to continue`` () =
let controller = Controller()
Assert.Equal(controller.CurrentScreen, Screen.Title)
Assert.Equal<IEnumerable<UserAction>>(controller.CurrentActions, [UserAction.Continue])
let [<Fact>]``for a new user, continuing from the title screen goes directly to the arena loading screen`` () =
let controller = Controller UserFamiliarity.New
controller.DoAction UserAction.Continue
Assert.Equal(controller.CurrentScreen, Screen.ArenaLoading)
let [<Fact>]``for a returning user, continuing from the title screen goes to the main menu screen`` () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
Assert.Equal(controller.CurrentScreen, Screen.MainMenu)
The discriminated union names aren't actually necessary here (I could drop UserFamiliarity. for instance) but they add clarity. But I don't use them everywhere in the controller code, as sometime's it's pretty obvious:
namespace Rolbots
type Screen =
| Title
| MainMenu
| ArenaLoading
type UserAction =
| Continue
type UserFamiliarity =
| New
| Returning
type Controller(userFamiliarity) =
let mutable currentScreen = Title
new () =
Controller UserFamiliarity.New
member __.CurrentScreen = currentScreen
member __.CurrentActions = [Continue]
member __.DoAction _ =
match userFamiliarity with
| New -> currentScreen <- ArenaLoading
| Returning -> currentScreen <- MainMenu
Note the first step definition:
let [<Given>] ``a user who is not using Rolbots Arena`` () =
controller <- Controller()
This scenario doesn't care which type of user is involved, so it currently uses the default. But really, it applies to both cases. I could copypast it but that would be bad. Fortunately TickSpec has that covered.
Scenario Outline: Title screen appears on startup
Given a <userkind> user who is not using Rolbots Arena
When the user starts using Rolbots Arena
Then the title screen is shown
And the user is shown the option to continue.
Examples:
|userkind|
|new|
|returning|
Now I need to change the steps a little. For consistency of definitions, the first step shouldn't create the controller, as the user isn't using Rolbots yet. The first step just determines what kind of user this is. The second step will use that information to create the controller. The two Then steps are unchanged. As a new mutable variable is being ontroduced, but the controller is being used as before, I've decided to spit the scenarios into two separate modules. This makes it clear which mutable variables are relevant to each.
module Appears =
let mutable familiarity = UserFamiliarity.New
let [<Given>] ``a (new|returning) user who is not using Rolbots Arena`` userkind =
familiarity <-
match userkind with
| "new" -> UserFamiliarity.New
| "returning" -> UserFamiliarity.Returning
| s -> invalidOp ("Unknown user familiarity: " + s)
let mutable controller = Controller()
let [<When>] ``the user starts using Rolbots Arena`` () =
controller <- Controller familiarity
let [<Then>] ``the title screen is shown`` () =
Assert.Equal(controller.CurrentScreen, Screen.Title)
let [<Then>] ``the user is shown the option to continue`` () =
Assert.Equal<IEnumerable<UserAction>>(controller.CurrentActions, [UserAction.Continue])
module Continue =
let mutable controller = Controller()
let [<Given>]``a new user who is at the title screen`` () =
controller <- Controller UserFamiliarity.New
let [<When>]``the user leaves the title screen`` () =
controller.DoAction UserAction.Continue
let [<Then>]``the arena begins to load without showing the main menu`` () =
Assert.Equal(controller.CurrentScreen, Screen.ArenaLoading)
let [<Given>]``a returning user who is at the title screen`` () =
controller <- Controller UserFamiliarity.Returning
let [<Then>]``the main menu screen is shown to the user`` () =
Assert.Equal(controller.CurrentScreen, Screen.MainMenu)
This ought to pass all tests without implementation changes, and does. (Indicentally, I initially had the user is shown the option to continue
in the wrong module. Because it's currently hard coded and immutable, it didn't actually break the tests! But I can't really add any tests that would fail, as there's no behaviour specified to test that would be different in any case currently known.)
Now both kinds of users can enter and exit the title screen, so it's time to move on to a new feature!
But first, a little more refactoring.
With the previous change as an example, I can combine the 'new user continues from the title' and 'returning user continues from the title' into one parameterised scenario which reuses a lot of test code.
Scenario Outline: Specific starts for particular groups of users
Given a <userkind> user who is at the title screen
When the user leaves the title screen
Then the <nextscreen> screen is shown next
Examples:
|userkind |nextscreen |
|new |arena loading |
|returning |main menu |
First I factor out the matching:
let strToUserkind = function
| "new" -> UserFamiliarity.New
| "returning" -> UserFamiliarity.Returning
| s -> invalidOp ("Unknown user familiarity: " + s)
(The final case is there for the compiler.)
And then I can combine the steps into one:
let [<Given>]``a (new|returning) user who is at the title screen`` userkind =
controller <- Controller (strToUserkind userkind)
The other scenario outline uses strToUserkind too.
Of course I have to combine the 'check the scene is correct' steps too!
let [<Then>]``the (arena loading|main menu) screen is shown next`` nextscreen =
Assert.Equal(controller.CurrentScreen,
match nextscreen with
| "arena loading" -> ArenaLoading
| "main menu" -> MainMenu
| s -> invalidOp ("Unknown scene: " + s)
)
Easy.
On to part 4!