Let's develop a Salt shaker using BDD and Python!
This repository contains a humble introduction to Behaviour-Driven development with Python. The source code including tests within this repository will help you understanding the basics for BDD development by developing a simple program.
Behaviour Driven Development (BDD) is a process where software is developed starting from the functional definition of the product. This means that no only technical staff is involved in the process from the beginning, but also QA and Business participants may join this process in order to provide a better approach to our product.
To describe how the product should work, it may be difficult for non-technical staff to write things like tests for describing the expected behaviour. Instead, there are some DSLs like Gherkin that help formalise the scenarios that describe our product scope.
In practice, BDD is an extension of Test-Driven Development as both processes start by defining tests firsts. From the tests, developers shall implement the product so the tests are executed successfully without errors. TDD also differs from BDD in their target scope. TDD may include tests at any level (unit, integration, acceptance), and BDD is mainly scoped at Acceptance Testing, due to its behavioural approach.
This repository contains a simple project aimed at better understanding how BDD works... a Salt Shaker!
Your favourite restaurant asks you to develop a salt shaker that contains by default an amount of salt doses. This shaker shall help customers to perform a single action:
- Serve salt: Drop a salt dose on their favourite meal.
Just like there are customer that don't put salt on their plates, there are others that love salty plates, so think of scenarios where users take more than one dose from their plates.
... just one more thing. Don't think on features like "Refilling the shaker" or stuff like that. Only think in Serving
Along this article, a source code repository can be cloned or downloaded. Check it out! And remember that contributions are kindly accepted.
This article asumes readers have a minimum knowledge on software development using Python language. Topics such as modules installation and virtual environments management are not covered within this article. In case you are not familiar with them, check this article about Python set-up for developers I wrote in Datacamp site.
Behavioural tests using Gherkin starts by defining feature files. These files group a single feature from our product, enclosing all the possible related scenarios. For our Shaker project, we could think on the simplest scenario:
- Shaking once the shaker will result in a salt dose on my plate.
The simplest case, usually called Happy Path usually occurs without errors nor abnormal results. Other cases shall be considered as well, describing scenarios not covered by the happy path:
- Shaking the shaker more than once will result in multiple doses on my plate.
- Shaking an empty shaker will result in no dose on my plate.
Using Gherkin language, our Happy Path could be expressed as follows:
Scenario: Single Service
Given A Salt Shaker
When I shake it once
Then A salt dose falls on my plate
Don't worry about the other sections. We'll cover them along the following sections ;)
For Python developers, there a some options that allow writing tests specified in Gherkin language. Among these options, the most famous are listed below:
For this article we will use pytest-bdd as our choice for writing the tests.
Using [pytest-bdd] we will write a module test_serving.py
where we will write the test functions. Note that features
and tests
directories are separated. This means that the *.feature
and test files are not necessarily within the same directory.
The first thing a test file shall specify is declare the feature file and the covered scenarios. We can do that through the use of the scenario
or scenarios
functions in pytest-bdd:
from pytest_bdd import scenarios
scenarios('../features/serving.feature')
The code shown above declared that this module will cover all the scenarios within the serving.feature
file. For covering specific scenarios, make use of the scenario
method. Note that both methods can be used as function calls or decorators!
Once the test module is linked to the feature file, test steps are associated to code using the given
, when
and then
decorators. For our example, the single serving scenario definitions are written as follows:
from pytest_bdd import given, when, then
from salty import Shaker
@given("A Salt Shaker")
def salt_shaker(doses):
yield Shaker(doses)
@pytest.fixture
@when("I shake it once")
def served(salt_shaker):
yield salt_shaker.shake()
@then("A salt dose falls on my plate")
def doses_serve(served):
assert served == 1
Note that we have written three functions, each one mapping to a step definition using the same text that describes the step in the feature file. Note that we have added another pytest.fixture
decorator the the when function, as we will use the returned value of the when function in the final then
step definition.
Et voilá. We have coded the tests for our first scenario!
As pytest-bdd is a plugin for pytest module, the way we run our test is as follows:
python -m pytest step_1/tests
Even though pytest execution can be launched using the pytest command, we will launch them using python -m pytest
for adding the salty
module to the sys.path
entries. You can read more about pytest python path settings in pytest official documentation.
When executing the previous command, pytest will discover test modules within the tests directory and perform the checks as declared in our step definition functions. The command output is the following:
==================== test session starts =====================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/japizarro/Personal/Talks/bdd-salt-shacker-definitive
plugins: bdd-3.2.1
collected 1 item
step_1/tests/test_serving.py . [100%]
===================== 1 passed in 0.02s ======================
As mentioned above, testing the happy path only checks part of your code. It is usually required to test edge scenarios where nominal behaviour changes.
For our salt shaker module, an edge scenario could be shaking an empty shaker. The result shall be serving no dose on a food plate. We could express that feature as follows:
Scenario: Empty shaker
Given An empty salt shaker
When I shake it once
Then no dose falls on my plate
And It's empty!
Based on the given scenario definition, we could write our tests functions as follows...
@given("An empty salt shaker")
def empty_salt_shaker():
yield Shaker(doses=0)
@then("no dose falls on my plate")
def no_dose(shake):
assert shake == 0
@then("It's empty!")
def its_empty(empty_salt_shaker):
assert empty_salt_shaker.remaining == 0
As you can see, we have not implemented the "I Shake it once" step defined, as it was previously defined for the first scenario. Furthermore, we will go over a set of improvements we can apply to our step definitions in order get a simpler version of our tests.
Taking into account the defined test scenarios (Single Service and Empty Shaker), there are some steps that are strictly related:
- An empty salt shaker is just a salt shaker with 0 doses of salt. For our scenarios, we could express them as follows:
Scenario: Single Service
Given A Salt Shaker with 100 doses
When I shake it once
Then A salt dose falls on my plate
Scenario: Empty shaker
Given A Salt shaker with 0 doses
When I shake it once
Then no dose falls on my plate
And It's empty!
Now, instead of two different given
step definitions, we have only one with a step argument as follows:
@given(parsers.cfparse("A Salt Shaker with {doses:d} doses"))
def salt_shaker(doses):
yield Shaker(doses)
In a similar manner, the then
step where the doses served can be check can be merged into a step with arguments. This could be the gherkin code for both scenarios:
Scenario: Single Service
Given A Salt Shaker with 100 doses
When I shake it once
Then 1 salt dose falls on my plate
Scenario: Empty shaker
Given A Salt shaker with 0 doses
When I shake it once
Then 0 salt dose falls on my plate
And It's empty!
And so the step definitions can be reimplemented as follows:
@then(parsers.cfparse("{expected_served:d} salt dose falls on my plate"))
def served_doses(served, expected_served):
assert served == expected_served
Pytest-BDD provides a parser object for processing step arguments and injecting them into the functions that require them. By default these arguments are processed as String objects, but some formatting can be specified based on the parse module. Furthermore, step arguments can consists on regular expressions or custom objects!
Check the step_2
in the code repository to see how the tests and feature file result after these changes. As an assignment to test your knowledge of this technique, I suggest you to try adding a step to the Single Service Scenario that checks the salt shaker has 99 remaining doses (hint: And The shaker has 99 units), and merge it with the "It's empty!" step defined in the Empty shaker scenario.
Maybe not you, but when I put some salt in a plate, I shake the salt shaker more than once. We will write another scenario to describe this use case:
Scenario: Serve multiple times
Given A Salt Shaker with 100 doses
When I shake the shaker 10 times
Then 10 salt dose falls on my plate
And The shaker contains 90 doses
Starting with the given scenario, we could write our step definitions for the given scenario with the following code:
@pytest.fixture
@when(parsers.parse("I shake {shakes:d} times"))
def multi_shake(salt_shaker, shakes):
doses = 0
for i in range(0, shakes):
doses += salt_shaker.shake()
yield doses
@then(parsers.parse("The shaker contains {expected_remaining:d} doses"))
def check_remaining(salt_shaker, expected_remaining):
assert salt_shaker.remaining == expected_remaining
Plus, all the serving scenarios could be merged into one single step!
Scenario: Single Service
Given A Salt Shaker with 100 doses
When I shake the shaker 1 times
Then 1 salt dose falls on my plate
Scenario: Empty shaker
Given A Salt shaker with 0 doses
When I shake the shaker 1 times
Then 0 salt dose falls on my plate
And The shaker contains 00 doses
Scenario: Serve multiple times
Given A Salt Shaker with 100 doses
When I shake the shaker 10 times
Then 10 salt dose falls on my plate
And The shaker contains 90 doses
The new scenario seems good, but not enough. The choice of specifying 10 shakes instead of another amount seems arbitrary. Furthermore, In case we want to add similar scenarios with different shakes, is it a good idea to just copy and paste the scenario? This task becomes tedious and repetitive, and it the long term leads to an unmantainable state.
Gherkin allows to define templates for running the same scenario multiple times with different combinations of values. These templates are usually called Scenario Outlines.
On a given scenario, the template parameters are written between brackets < >
. The parameter values for each tests are written inside and Examples table
Scenario: Serve multiple times
Given A Salt Shaker with <doses> doses
When I shake the shaker <serve> times
Then <served> salt doses fall on my plate
And The shaker contains <remain> doses
Examples:
| doses | serve | remain | served |
| 20 | 10 | 10 | 10 |
| 50 | 10 | 40 | 10 |
| 20 | 20 | 0 | 20 |
| 3 | 2 | 1 | 2 |
| 3 | 5 | 0 | 3 |
Did you noticed that? We are testing a new edge case where more shakes than remaining doses are performed! Think about the last example ;)
Upon the Scenario definition shown above, a total of 5 tests will be executed, performing all the described checks.
Similarly to the template arguments seen in previous sections, Scenario templates require parsing the input values into proper types. Unfortunately, this case is not as simple as reusing the parsers object seen previously. Instead, converters shall be defined within the test module for a given scenario (or for all of them).
CONVERTERS = dict(doses=int, serve=int, remain=int, served=int)
scenarios('../features/serving.feature', example_converters=CONVERTERS)
The final code for our feature testing will be as follows:
import pytest
from pytest_bdd import parsers, scenarios, given, when, then
from salty import Shaker
CONVERTERS = dict(doses=int, shakes=int, expected_remaining=int, expected_served=int)
scenarios('../features/serving.feature', example_converters=CONVERTERS)
@given("A Salt Shaker with <doses> doses")
@given(parsers.cfparse("A Salt Shaker with {doses:d} doses"))
def salt_shaker(doses):
yield Shaker(doses)
@pytest.fixture
@when(parsers.parse("I shake the shaker {shakes:d} times"))
@when("I shake the shaker <shakes> times")
def served(salt_shaker, shakes):
doses = 0
for i in range(0, shakes):
doses += salt_shaker.shake()
yield doses
@then(parsers.cfparse("{expected_served:d} salt doses falls on my plate"))
@then("<expected_served> salt doses fall on my plate")
def served_doses(served, expected_served):
assert served == expected_served
@then(parsers.parse("The shaker contains {expected_remaining:d} doses"))
@then("The shaker contains <expected_remaining> doses")
def check_remaining(salt_shaker, expected_remaining):
assert salt_shaker.remaining == expected_remaining
Notice that Step definition decorators had to be written twice. This is due to the regular and template Scenarios that share the same syntax. But in the real world, this is not a common thing.
When executing the tests suite the following output shall be shown:
===================== test session starts =====================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/japizarro/Personal/Talks/bdd-salt-shacker-definitive
plugins: bdd-3.2.1
collected 7 items
step_3/tests/test_serving.py ....... [100%]
===================== 7 passed in 0.04s ======================
As mentioned above, we defined 3 Scenarios, but a total of 7 test items have been executed. This is due to the Scenario outline conversion to five tests items, one per entry in the Examples table.