Skip to content

Gherkin DSL for Swift

Tyler Thompson edited this page Jul 26, 2020 · 20 revisions

Sometimes there's a preference to keep everything in Swift instead of trying to point to .feature files. This has some great advantages in terms of flexibility and friendliness for developers. It also has some tradeoffs. It's important to remember that the value of Gherkin is largely its readability. To that end CucumberSwift's DSL is designed to help drive that readability.

Where do I describe features?

Ostensibly you should put your feature declarations inside the setupSteps method of Cucumber. Example:

extension Cucumber: StepImplementation {
    func setupSteps() {
        //define features here
    }
}

NOTE: these features also obey the optional shouldRunWith method. Including the ability to specify lines you want run. You can treat this largely the same as using .feature files.

If you are using Swift 5.3 (Xcode 12):

I suggest you make use of the new [self] closure semantics to avoid having to either sprinkle self. everywhere or having to come up with ways to avoid having to put self. everywhere.

Example:

Scenario("") { [self] in
    Given(some: precondition()) //no self.precondition()
}

Defining Steps

Steps in the DSL are defined using an @autoclosure. It's important to understand this means you can provide a single void function, but not a closure. This is on purpose because idiomatic Gherkin is 1 line per step.

Example:

func userExists(withName: String) { 
    //logic 
}
//Step definition:
Given(a: userExists(withName: "John Doe"))

Like other forms of Gherkin a Step must be embedded in a Scenario which in turn is embedded in a Feature. If you define a step and do not add it to a Scenario that is part of a Feature it will not execute. This is once again to help drive towards idiomatic Gherkin.

Defining Scenarios

Scenarios have a title, optional tags, and a series of steps. They use a function builder (much like SwiftUI) to give a friendly API. That comes with 2 important caveats. The first is you must supply some steps in the closure, you cannot define an empty scenario. The second is that function builders are relatively new. This means that error handling around them isn't very friendly yet. If you find yourself seeing an abort trap 6 or segmentation fault error try extracting that code out of the scenario closure, and seeing if you can get it working in isolation, then add it back.

Example:

Scenario("title") {
    Given(I: print("Hello World!"))
}

//with tags
Scenario("title", tags: ["tag1", "tag2"]) {
    Given(I: print("Hello World!"))
}

NOTE: Once again be aware that a Scenario must be defined as part of a Feature if it is going to execute.

Defining Scenario Outlines

Scenario Outlines are special type that generate multiple Scenarios. They always have an Examples array attached to them that is used to create 1 Scenario per example.

Example:

ScenarioOutline("SomeTitle", 
                headers: (first:String, last:String, balance:Double).self,
                steps: { (first, last, balance) in
    Given(a: personNamed(first, last))
    When(I: searchFor(last))
    Then(I: seeABalanceOf(balance))
}, examples: {
    [
        (first: "John", last: "Doe", balance: 0),
        (first: "Jane", last: "Doe", balance: 10.50),
    ]
})

The headers property is where you describe a type. I recommend a tuple, like the example. This reads somewhat similarly to Gherkin you'd find in a .feature file. The examples closure must return an array of that type. Finally the steps closure is called once per example, with the data in that example.

Some ScenarioOutlines should have a title that changes per example too. To make that happen there is a title closure you can use.

NOTE: As you're writing a ScenarioOutline with a title closure the compiler won't be able to determine the type right away. Finish defining the ScenarioOutline before trying to fix any errors it throws.

Example:

ScenarioOutline("\($0.first)'s balance is accurate", 
                headers: (first:String, last:String, balance:Double).self,
                steps: { (first, last, balance) in
    Given(a: personNamed(first, last))
    When(I: searchFor(last))
    Then(I: seeABalanceOf(balance))
}, examples: {
    [
        (first: "John", last: "Doe", balance: 0),
        (first: "Jane", last: "Doe", balance: 10.50),
    ]
})

Defining Features

Features are the top-level object for all Gherkin. Features have a title, optional tags, and a series of Scenarios. They use a function builder (much like SwiftUI) to give a friendly API. That comes with 2 important caveats. The first is you must supply some Scenarios in the closure, you cannot define an empty Feature. The second is that function builders are relatively new. This means that error handling around them isn't very friendly yet. If you find yourself seeing an abort trap 6 or segmentation fault error try extracting that code out of the Feature closure, and seeing if you can get it working in isolation, then add it back.

Example:

Feature("title") {
    Scenario("scnTitle") {
        Given(I: print("Hello World!"))
    }
}

//with tags
Feature("title", tags: ["t1", "t2"]) {
    Scenario("scnTitle") {
        Given(I: print("Hello World!"))
    }
}

Backgrounds are a special kind of Scenario without a title, that add all of their steps to the beginning of all other scenarios in a Feature Example:

Feature("F1") {
    Background {
        Given(I: print("B1"))
    }
    Scenario("SC1") {
        //will execute Given(I: print("B1")) first
        When(I: print("S1"))
    }
}

Descriptions are a special kind of Scenario that does not require steps. It's purely there to add context.

Example:

Feature("Some terse yet descriptive text of what is desired") {
    Description("""
        Textual description of the business value of this feature
        Business rules that govern the scope of the feature
        Any additional information that will make the feature easier to understand
    """)
    Scenario("scnTitle") {
        Given(I: print("Hello World!"))
    }
}

Defining Rules

Rules are a Gherkin v6 feature find out more. This document won't posit on their value or their readability, instead it's going to focus on their functionality. They allow you group together Scenarios and have multiple levels of Background steps.

Example:

Feature("F1") {
    Background {
        Given(I: print("B1"))
    }
    Rule("R1") {
        Background {
            Given(I: print("B2"))
        }
        Scenario("SC1") {
            //First it executes: Given(I: print("B1"))
            //Then it executes: Given(I: print("B2"))
            Given(I: print("S1"))
        }
    }
    Scenario("SC2") {
        //First it executes: Given(I: print("B1"))
        Given(I: print("S2"))
    }
}

Putting it all together

Here's an example of how this works altogether.

extension Cucumber: StepImplementation {
    public var bundle: Bundle {
        class This { }
        return Bundle(for: This.self)
    }

    public func shouldRunWith(scenario: Scenario?, tags: [String]) -> Bool {
        return tags.contains("t1")
    }

    func setupHooks() { //NOTE: These hooks also work with the DSL
        BeforeFeature { feature in }
        BeforeScenario { scenario in }
        BeforeStep { step in }
        AfterStep { step in }
        AfterScenario { scenario in }
        AfterFeature { feature in }
    }
    
    public func setupSteps() {
        setupHooks()
        Feature("Some terse yet descriptive text of what is desired") {
            Description("""
               Textual description of the business value of this feature
               Business rules that govern the scope of the feature
               Any additional information that will make the feature easier to understand
             """)

            Background {
                Given(I: print("This runs before the steps in all scenarios"))
            }
            
            Scenario("Some determinable business situation", tags:["t1"]) {
                Given(some: precondition())
                When(some: actionByTheActor())
                Then(some: testableOutcomeIs(.achieved))
            }
            
            ScenarioOutline({ "Before \($0) hook works correctly" }, headers: String.self, steps: { scn in
                Given(I: haveABeforeScenarioHook())
                When(I: runTheTests())
                Then(beforeScenarioGetsCalledOnScenario(withTitle: "Before \(scn) hook works correctly"))
            }, examples: {
                [
                    "scenario",
                    "scenario outline"
                ]
            })
        }
    }
}

Localization

Localization is not commonly supported when creating APIs. However because Cucumber ships with a JSON file that describes different supported languages and their mappings to specific keywords this became a possibility. Non-English Gherkin is prefixed by its language code BG_Пример won't read quite as well as Пример but it was necessary to avoid a truly shocking amount of conflicts.

Current Limitations

Running a specific example in a Scenario Outline by line

While running by line is supported trying to use that feature to run a specific example in a ScenarioOutline isn't particularly reasonable. Examples could be defined inline, or maybe they're all defined on the same line, or maybe they're extracted into a different file altogether. Because this extreme flexibility is possible in the DSL running a specific example by line is only supported when using .feature files.

Step DataTables

After a lot of thought if a DataTable is desired on a step the implementor can handle that. While we could take an approach similar to ScenarioOutline it'd require giving up on things like the @autoclosure in steps. These things are in place specifically to help guide people towards writing idiomatic Gherkin in Swift. DataTables do not seem like they're a useful enough feature to warrant that.

DocStrings

Swift supports multi-line strings which are very similar to DocStrings. The difference is that DocStrings in Gherkin can have a type,

Example:

"""<xml>
<node>thing</node>
"""

Similar to the discussion on DataTables, this just doesn't seem like it's really worth a whole customized part of the DSL. If you'd like to use multi-line strings that is already supported, if those has a specific type associated with them, that seems like something the implementor can handle easily.