Shared State
Add another scenario​
Now we’re going to add a new scenario:
#...
Scenario: Multiple shouts from one person
Given Lucy is at 0, 0
And Sean is at 0, 500
When Sean shouts
And Sean shouts
Then Lucy should hear 2 shouts from Sean
You will not need to change any production code to get this scenario to pass.
Isolation​
Tests should not depend on each other - they should run in isolation. You should be able to run tests in any order and get the same result.
We have been running several scenarios and Sean has been shouting in most of them.
How is it that in the scenario that you have just implemented Lucy has only heard 2 shouts from Sean?
Click for answer
SpecFlow helps maintain isolation by instantiating all step definition classes/structs before every scenario.
Growing your specification​
Right now we have a single feature file and a single step definition file. You’ve already learnt that a feature file acts as the specification of one capability of your application. Your application will have many capabilities and there will be many feature files. It is tempting to create a single step definition for each feature file, but this is not a good idea. Remember that SpecFlow loads all step definitions when it starts and they all exist in a single scope.
Instead create step definition files aligned to your domain entities (such as customer, account, fund). If you organise your step definitions like this, then it will be easier to find the step definition that you need, no matter which feature file you are working with.
Separating the step definitions​
Even though we have only just started with this application, we already have step definitions for 2 domains in a single step definition file:
- Location domain: the step(s) that say where a person is located
- Shout domain: the step(s) that control the shouting and hearing of messages
- C#
- JavaScript
- Go
Create a new step definition class, LocationStepDefinitions, and move the steps that deal with location into it. In order to make it compile, copy the shouty field declaration and initialization as well.
You will need to annotate the class with [Binding] attribute. SpecFlow uses this attribute to find classes which contain step definitions.
Even though we’ve only just started, our single step definition file already mixes two domains:
- Location: steps that say where a person is (
Lucy is at 0, 0) - Shout: steps that control shouting and hearing messages (
Sean shouts,Lucy should hear Sean)
Create a new step definition file: features/location_steps.js, and move the location-related steps into it, for example:
const { Given } = require('@cucumber/cucumber')
const Coordinate = require('../../lib/coordinate')
Given('{word} is at {int}, {int}', function (name, x, y) {
this.shouty.setLocation(name, new Coordinate(x, y))
})
Leave the shouting/hearing steps in features/step_definitions/shout_steps.js.
In Godog, create separate Go files for our different domains:
location_steps.gofor the location domain.shout_steps.gofor the shout domain.
Move the steps related to the location domain in the location_steps.go file and the steps related to the shout domain in the shout_steps.go file.
Remember, Godog doesn't need special annotations to find step definitions; it scans all methods of structs passed to ScenarioInitializer function.
What problem is this going to cause? What happens if you try to run SpecFlow now?
Sharing state​
- C#
- JavaScript
- Go
We need some way to share state between our step definition classes, while at the same time maintaining isolation between our scenarios. SpecFlow encourages you to do this by using a feature called context injection. Context injection is a simplified implementation of Dependency Injection (DI), using a built-in DI container of SpecFlow. (You can also configure SpecFlow to use an external DI container as well, but the built-in one is just enough for the majority of projects.)
The problem you encountered when splitting the existing step definitions between 2 classes was sharing the ShoutyNetwork instance. As the Shouty class is a simple class with a default constructor, we can use context injection to let SpecFlow create the ShoutyNetwork instance.
To be able to use this, the following changes have to be made in in both step definition classes:
- Remove the field initializer of the
shoutyfield, so that the declaration should beprivate readonly ShoutyNetwork shouty;. - Create a constructor for the step definition class with a parameter of
ShoutyNetworkand initialize theshoutyfield from the parameter in the constructor body.
The constructor parameter of the step definition class instructs SpecFlow to manage the
ShoutyNetwork instance and inject it to all step definition class instances that need it.
Run SpecFlow.
What happens? How many instances of the ShoutyNetwork are created?
ShoutyNetwork is production code​
You may have noticed that we are now leaving SpecFlow to initialize our ShoutyNetwork, which is in our production code. Production code often has complex initialization code, and various dependencies that change based on different configurations. So sometimes we need another solution.
There’s a famous computer science quote by David Wheeler, which says:
All problems in computer science can be solved by another level of indirection
And that’s just what we’re going to do here.
- Create a
ShoutyContextclass in theShouty.Specsproject - In
ShoutyContext, create a property calledShoutyof typeShoutyNetworkand initialize it usingnew - Replace direct references to
ShoutyNetworkin the step definition classes with references toShoutyContextand use itsShoutyproperty to access the shouty network.
Run SpecFlow again. All scenarios should still be passing.
Context injection got its name from the "context" classes we use to wrap the data we would like to share across multiple step definition classes.
The SpecFlow docs on Context-Injection provide a nice example for reference.
In JavaScript, Cucumber encourages you to share state between step definition files using the World object. The World is a per-scenario context object; Cucumber creates a new instance before each scenario so tests stay isolated.
Cucumber scans the features folder for glue code. Open features/support/world.js and change it to create a Shouty instance you can share across step files:
const { setWorldConstructor } = require('@cucumber/cucumber')
const Shouty = require('../../lib/shouty')
function CustomWorld() {
this.shouty = new Shouty()
}
setWorldConstructor(CustomWorld)
The setWorldConstructor call tells Cucumber to use CustomWorld when creating the World object. Any properties you set on this (like this.shouty) are then available in all your steps via this.
Now clean up your step definition files:
Remove the shared variable and hook from any step definition files that still have it.
let shouty
Before(function() {
shouty = new Shouty()
})
Replace calls to shouty with this.shouty, for example:
When('Sean shouts', function () {
this.shouty.shout('Sean', ARBITARY_MESSAGE)
})
If the World isn’t wired up correctly, Cucumber will throw undefined variable errors when it tries to use this.shouty. There can only be one World constructor — if you declare several, the last one wins.
The CustomWorld constructor is called once per scenario, ensuring each scenario gets a fresh set of shared variables and doesn’t leak state into the next scenario.
To share state between different step definition files in Godog, you can use a shared context struct. This is somewhat similar to context injection in other langauges but implemented manually in Go.
1. Define shared context​
type LocationSteps struct {
T *testing.T
Shouty *Shouty
}
func NewLocationSteps(t *testing.T, shouty *Shouty) *LocationSteps {
return &LocationSteps{T: t, Shouty: shouty}
}
type ShoutSteps struct {
T *testing.T
Shouty *Shouty
}
func NewShoutSteps(t *testing.T, shouty *Shouty) *ShoutSteps {
return &ShoutSteps{T: t, Shouty: shouty}
}
2. Modify step definition files to use the shared context​
func (ls *LocationSteps) lucyIsAt(x, y int) error {
//...
}
func InitializeLocationScenario(ctx *godog.ScenarioContext, ls *LocationSteps) {
//...
}
func (ss *ShoutSteps) seanShouts() error {
//...
}
func InitializeLocationScenario(ctx *godog.ScenarioContext, ss *ShoutSteps) {
//...
}
3. Initialize scenario context​
func InitializeScenario(t *testing.T) func(*godog.ScenarioContext) {
return func(sc *godog.ScenarioContext) {
//...
InitializeLocationScenario(sc, NewLocationSteps(t, shouty))
InitializeShoutScenario(sc, NewShoutSteps(t, shouty))
}
}
Run Godog. All scenarios should still be passing.