#blog #FSharp #TDD #BDD I figure out what is the next feature to start developing, and (I presume) I start developing it.
They can be refactored too.
Currently I'm only testing the default case, not all cases. I can ensure that the required properties hold for all possible kinds of users (even if I add some in the future) with Hedgehog (and Hedgehog.Experimental, which has the Auto generator.)
let [<Fact>]``for any user, the Controller must start on the title screen with the option to continue`` () =
property{
let! kind = GenX.auto<UserFamiliarity>
let controller = Controller kind
return controller.CurrentScreen = Screen.Title
return controller.CurrentActions = [UserAction.Continue]
} |> Property.check
It will randomly generate various values for kind, and will fail if any return is false. Future proofing!
I can also combine the two continue checks into one, supplying specific test cases. xUnit has an InlineData attribute which I'd like to use but unfortunately it only works on things like literals, not discriminated union values. So I need to use MemberData instead, which requires passing the data through an object array. That's unfortunate, but it's test data.
let continueData =
seq{
let testPair tuple =
[|fst tuple :>Object;snd tuple :>Object|]
yield testPair (UserFamiliarity.New,Screen.ArenaLoading)
yield testPair (UserFamiliarity.Returning,Screen.MainMenu)
}
let [<Theory>][<MemberData("continueData")>]``for a [kind] user, continuing from the title screen goes directly to the [next] screen`` kind next =
let controller = Controller kind
controller.DoAction UserAction.Continue
Assert.Equal(controller.CurrentScreen, next)
(I actually started the next bit before I did the previous bit, but I'm doing the new feature work on a feature branch, so the changes aren't getting mixed together.)
At the moment, users start out on the Title screen, and are next sent to either the main menu screen or the arena loading screen. Returning users will want to get to the arena loading screen too, and the main menu is an obvious place to put that behaviour. In addition, I think I'll want a way of getting to the settings screen from the main menu.
Feature: MainMenu Screen
In order to choose how to battle robots
Users of Rolbots Arena should be able to
See and select from a list of options, including entering the Arena, and adjusting settings
Scenario: Default menu options
Given a user with no extra features unlocked
When the user enters the main menu
Then the user can see the following options:
* Arena
* Settings
Scenario Outline: Enter the Arena
Given a user who is at the main menu screen
When the user chooses the <option> option
Then the <nextscreen> screen is shown next
Examples:
|option |nextscreen |
|arena |arena loading |
|settings |settings |
This file (MainMenu.feature) when built as an embedded resource, will... do absolutely nothing on its own. But I've got my nifty feature code (which I had to base on the examples in the xUnit repo) to wire up a new test case. I don't remember whether I showed it before, but in any case here's XunitFeature.fs :
module Rolbot.XunitFeature
open TickSpec
open Xunit
let source = AssemblyStepDefinitionsSource(System.Reflection.Assembly.GetExecutingAssembly())
let scenarios resourceName = source.ScenariosFromEmbeddedResource resourceName |> MemberData.ofScenarios
[<Theory; MemberData("scenarios", "Behaviour.Title.feature")>]
let Title (scenario : Scenario) = scenario.Action.Invoke()
[<Theory; MemberData("scenarios", "Behaviour.MainMenu.feature")>]
let MainMenu (scenario : Scenario) = scenario.Action.Invoke()
The MemberData object receives a test string that is the location of the embedded resource file containing the scenario data for the unit test function. It shows up as one test in the Test Explorer tree but upon examination it will have multiple sub-cases (one per scenario).
Currently the tests fail because of missing step functions. I don't have 'a user with no extra features unlocked', but that will be easy because that's the default case. In the future I expect to add some additional menu options of unknown description, which will require additional scenarios to be added here.
'the user enters the main menu' is a little tricky. That states that an action takes place. Currently the only user action which causes entering the main menu is 'continue' from the splash screen, in the case of a returning user. I'll have to add other cases in the near future, but I'll keep it simple and direct for now.
'the user can see the following options:' will be given a list as a parameter (consisting of the bullet-pointed items) to be compared to the actual actions list. Simple, especially since xUnit handles converting from strings into discriminated union cases for me.
'a user who is at the main menu screen' is simple enough to generate but I'd rather be able to create a new controller already at that state rather than having to do an action as setup. Not worth worrying about now though.
'the user chooses the
'the screen is shown next' has already been written, but needs a new case added to it.
module MainMenuSteps
open TickSpec
open Rolbots
open Xunit
let mutable controller = Controller()
let [<Given>]``a user with no extra features unlocked`` () =
controller <- Controller UserFamiliarity.Returning
//because I don't have a case where New users can enter the main menu yet
let [<When>]``the user enters the main menu`` () =
controller.DoAction UserAction.Continue
let [<Then>]``the user can see the following options:`` (options: UserAction[]) =
Assert.Equal(controller.CurrentActions, options)
let [<Given>]``a user who is at the main menu screen`` () =
controller <- Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
//simple way to get to the menu but I'd prefer something more direct
let [<When>]``the user chooses the (arena|settings) option`` option =
controller.DoAction (
match option with
| "arena" -> UserAction.Arena
| "settings" -> UserAction.Settings
| s -> invalidOp ("Unknown option: " + s)
)
And a few new DU cases need to be added for it to compile.
type Screen =
| Title
| MainMenu
| ArenaLoading
| Settings
type UserAction =
| Continue
| Arena
| Settings
The three tests fail due to the values being wrong (rather than because the test can't be set up or performed), which is great.
There's actually a significant problem. See if you can figure it out before I get to it.
The starting point is pretty obvious.
module Rolbot.Tests.MainMenu
open Xunit
open Rolbots
open System.Collections.Generic
let [<Fact>]``basic menu actions are present`` () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
let options = [UserAction.Arena;UserAction.Settings]
Assert.Equal<IEnumerable<UserAction>>(controller.CurrentActions, options)
Of course this fails because it's locked on [Continue]. Also I finally noticed that all my Assert.Equals are backwards: expected value should go first. Oops. Good thing I caught that before it went any further. I hope it doesn't create merge problems for me.
Time to do some coding to make the test pass! Also I'm renaming CurrentActions to AvailableActions, which is more meaningful and correct.
type Controller(userFamiliarity) =
let mutable currentScreen = Title
let mutable availableActions = [Continue]
new () =
Controller UserFamiliarity.New
member __.CurrentScreen = currentScreen
member __.AvailableActions = availableActions
member __.DoAction _ =
match userFamiliarity with
| New -> currentScreen <- ArenaLoading
| Returning -> currentScreen <- MainMenu
availableActions <- [Arena;Settings]
Yeah, because there's no way to get back to the title screen, changing the available actions to the menu options whenever any action is performed is enough to make all the tests pass. Now to make the actions actually do something...
I'll implement this similiarly to what I did for the title-continue variations, so to facilitate that I'll merge in those changes from master. (There were a couple of merge conflicts due to my out-of-order Equals argument fixing, but that was easily resolved.)
Here's the test for navigation away from the main menu:
let navData =
seq{
yield testPair (UserAction.Arena,Screen.ArenaLoading)
yield testPair (UserAction.Settings,Screen.Settings)
}
let [<Theory>][<MemberData("navData")>]``basic menu actions work: [action] goes to screen [next]`` action next =
let controller = Controller UserFamiliarity.Returning
controller.DoAction action
Assert.Equal(next, controller.CurrentScreen)
I moved testPair into a separate file so it can be reused by whatever tests need to do something like this. (I may need a triple etc version later.)
[<AutoOpen>]
module Rolbot.Tests.Common
open System
let testPair tuple =
[|fst tuple :>Object;snd tuple :>Object|]
And the new test cases both fail due to a lack of any navigation code.
That's not difficult to add:
member __.DoAction action =
match userFamiliarity with
| New -> currentScreen <- ArenaLoading
| Returning -> currentScreen <- MainMenu
availableActions <- [Arena;Settings]
match action with
| UserAction.Settings -> currentScreen <- Screen.Settings
| UserAction.Arena -> currentScreen <- Screen.ArenaLoading
|_ -> ()
This makes the unit test cases both pass but the corresponding behaviour tests fail. Why is that?
I'll give you a moment in case you haven't spotted the problem yet.
...
...
It's a case of mutable values and namespaces (and tests which find the function to call via reflection) sneaking up to bite us. (Well, bite me.) The last step of the new scenario outline:
```Gherkin
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
Is the same as one for the Title feature, so it was automatically reused (the names match) which seemed like a good thing. But no! Because the step implementation for the Title feature is using a controller variable that's defined in a different module! Therefore, the changes in the earlier parts of the test are not effecting the result in the later part. Whoops!
But fortunately, upon examination I can see how TickSpec can actually pass state between steps. For example:
let [<Given>] ``a (new|returning) user who is not using Rolbots Arena`` userkind =
strToUserkind userkind
let [<When>] ``the user starts using Rolbots Arena`` familiarity =
Controller familiarity
let [<Then>] ``the title screen is shown`` (controller:Controller) =
Assert.Equal(Screen.Title, controller.CurrentScreen)
let [<Then>] ``the user is shown the option to continue`` (controller:Controller) =
Assert.Equal<IEnumerable<UserAction>>([UserAction.Continue], controller.AvailableActions)
Now the offending reused step function becomes:
let [<Then>]``the (arena loading|main menu|settings) screen is shown next`` nextscreen (controller:Controller) =
Assert.Equal(
match nextscreen with
| "arena loading" -> ArenaLoading
| "main menu" -> MainMenu
| "settings" -> Screen.Settings
| s -> invalidOp ("Unknown scene: " + s)
, controller.CurrentScreen)
I'll put that in a common module too, as it doesn't belong to one or the other in particular. This new Common module in CommonSteps.fs doesn't actually need to be referenced directly, since TickSpec finds step functions by name by reflection.
And the steps for the main menu are easily transformed:
let [<Given>]``a user with no extra features unlocked`` () =
Controller UserFamiliarity.Returning
//because I don't have a case where New users can enter the main menu yet
let [<When>]``the user enters the main menu`` (controller:Controller) =
controller.DoAction UserAction.Continue
let [<Then>]``the user can see the following options:``
(options: UserAction[]) (controller:Controller) =
Assert.Equal(options, controller.AvailableActions)
controller
let [<Given>]``a user who is at the main menu screen`` () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
//simple way to get to the menu but I'd prefer something more direct
controller
let [<When>]``the user chooses the (arena|settings) option`` option (controller:Controller) =
controller.DoAction (
match option with
| "arena" -> UserAction.Arena
| "settings" -> UserAction.Settings
| s -> invalidOp ("Unknown option: " + s)
)
Happily everything passes now. Time for a little refactor. The Controller.DoAction method is sometimes setting currentScreen twice. This is unnecessary.
member __.DoAction action =
availableActions <- [Arena;Settings]
match action with
| UserAction.Settings -> currentScreen <- Screen.Settings
| UserAction.Arena -> currentScreen <- Screen.ArenaLoading
| UserAction.Continue ->
match userFamiliarity with
| New -> currentScreen <- ArenaLoading
| Returning -> currentScreen <- MainMenu
Also there's no need to repeat the 'currentScreen <-' over and over again.
member __.DoAction action =
availableActions <- [Arena;Settings]
currentScreen <-
match action with
| UserAction.Settings -> Screen.Settings
| UserAction.Arena -> Screen.ArenaLoading
| UserAction.Continue ->
match userFamiliarity with
| New -> ArenaLoading
| Returning -> MainMenu
I don't currently know what the settings will be, but I'm sure there will be a few. What I do know is that users will need to be able to get back to the Main Menu from the settings screen. That's all pretty obvious:
Feature: Settings Screen
In order to configure their robot-battling experience to their needs and preferences
Users of Rolbots Arena should be able to
See and adjust the values of application settings, and return to the main menu when they are done.
Scenario: Option to return from settings to main menu
Given a user who is at the main menu screen
When the user enters the settings screen
Then the user can see the following options:
* MainMenu
Scenario: Use the Main Menu option to return to the main menu
Given a user who is at the settings screen
When the user chooses the main menu option
Then the main menu screen is shown next
There's lots of opportunity for reuse here. I only need a couple of new steps:
let [<When>]``the user enters the settings screen`` (controller:Controller) =
controller.DoAction UserAction.Settings
let [<Given>]``a user who is at the settings screen`` () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
controller.DoAction UserAction.Settings
controller
And one old one gets extended a little and then moved over to the CommonSteps package:
let [<When>]``the user chooses the (arena|settings|main menu) option`` option (controller:Controller) =
controller.DoAction (
match option with
| "arena" -> UserAction.Arena
| "settings" -> UserAction.Settings
| "main menu" -> UserAction.MainMenu
| s -> invalidOp ("Unknown option: " + s)
)
Unit tests for Settings are pretty boilerplate at the moment too. I could simplify and just test the single 'go back to Main Menu' case explicitly, but I forsee that Settings is likely to lead to other screens which go to other screens. So I'll need that scaffolding later and it's less work to leave it in.
let [<Fact>]``option to return from settings to main menu is present`` () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
controller.DoAction UserAction.Settings
let options = [UserAction.MainMenu]
Assert.Equal<IEnumerable<UserAction>>(options, controller.AvailableActions)
let navData =
seq{
yield testPair (UserAction.MainMenu,Screen.MainMenu)
}
let [<Theory>][<MemberData("navData")>]
``basic settings navigation works: [action] goes to screen [next]`` action next =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
controller.DoAction UserAction.Settings
controller.DoAction action
Assert.Equal(next, controller.CurrentScreen)
In creating that I noticed a problem with the MainMenu unit tess which I'll address shortly. In the meantime, I have a spot of implementation to do. The 'return to main menu' test already passes (just making the project compile was enough to implement it unless I went out of my way to do it wrong) so that just leaves the action list. (That hints at the aformentioned problem.)
It turns out that the available options depend upon the current screen, which means no availableOptions field is required.
member __.AvailableActions =
match currentScreen with
| Screen.Title -> [Continue]
| Screen.MainMenu -> [Arena;Settings]
| Screen.Settings -> [MainMenu]
| _ -> []
That works nicely. Now for that problem.
There's an error in one of my unit tests.
let [<Theory>][<MemberData("navData")>]
``basic menu actions work: [action] goes to screen [next]`` action next =
let controller = Controller UserFamiliarity.Returning
controller.DoAction action
Assert.Equal(next, controller.CurrentScreen)
Do you see it?
...
It claims to check menu actions but it actuallly never continues from the title screen to the menu!
This would be more apparent if I'd thought to test that the actions only worked when available. I should have thought of this when I first came up with the name 'AvailableActions'. An example of a necesary test is:
let [<Fact>]``for any user, other actions have no effect on the title screen`` () =
property{
let! kind = GenX.auto<UserFamiliarity>
let! action = GenX.auto<UserAction>
let controller = Controller kind
controller.DoAction action
return action = UserAction.Continue || controller.CurrentScreen = Screen.Title
} |> Property.check
That checks that there's no way to leave the Title screen other than the Continue action, regardless of user kind. Main menu and settings need similar tests. But first, this test fails because Hedgehog quickly found that the Arena action changes scene away from Title. Oops!
The simplest fix is adding an initial case with a guard condition to DoAction:
member __.DoAction action =
currentScreen <-
match action with
| _ when not (List.contains action __.AvailableActions) -> currentScreen
//...
Fixing only the Title case would actually require more specific conditions. This should have fixed all cases. Let's see, by writing the other tests. (Yes I'm meant to write tests first but...)
Oh, and of course now the broken-all-along MainMenu action tests fail, cos they're not actually testing on the main menu. Slapping in a Continue action fixes those.
I made another common helper test because the unavailable actions tests for Main Menu and Settings are so similar:
let checkUnavailableActions (controller:Controller) =
let options = controller.AvailableActions
let screen = controller.CurrentScreen
property{
let! action = GenX.auto<UserAction>
controller.DoAction action
return (List.contains action options) || controller.CurrentScreen = screen
} |> Property.check
Unfortuantely this doesn't work right, because controller is mutable, and the test iterations aren't resetting it back to the provided value. I need to rebuild the controller each time. Fortunately, functions are first class values in F# so I can do this:
let checkUnavailableActions controllerSource =
property{
let controller:Controller = controllerSource()
let options = controller.AvailableActions
let screen = controller.CurrentScreen
let! action = GenX.auto<UserAction>
controller.DoAction action
return (List.contains action options) || controller.CurrentScreen = screen
} |> Property.check
And that can be used as follows:
let [<Fact>]``other actions should do nothing on Main Menu`` () =
let controller () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
controller
checkUnavailableActions controller
That works great, so now I can do a little more refactoring.
let controller () =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
controller
That looks familiar. There's multiple places where I made a controller and set it to a particular screen. I should just reuse a function like this one.
let controllerMM() =
let controller = Controller UserFamiliarity.Returning
controller.DoAction UserAction.Continue
controller
let [<Fact>]``basic menu actions are present`` () =
let controller = controllerMM()
let options = [UserAction.Arena;UserAction.Settings]
Assert.Equal<IEnumerable<UserAction>>(options, controller.AvailableActions)
//etc
let [<Fact>]``other actions should do nothing on Main Menu`` () =
checkUnavailableActions controllerMM
And similar for the settings screen. I can also easily test that the same properties hold if the user goes back and forth between Settings and Main Menu.
Okay, it's high time I moved on to the Arena... loading screen. In chapter 5!