-
Notifications
You must be signed in to change notification settings - Fork 6
BDD Testing
Behavior testing simply means that we should test how an application behaves in certain situations.
Before starting, make sure you have the following installed:
- Python 3.x
- Behave
This tutorial will walk you through writing tests for and coding a feature of a Twenty-One (or "Blackjack") game. Specifically, we'll be testing the logic for the dealer. To get started, create a root directory where your code will go, and then create the following directories and blank files:
- ├── features
- │ ├── dealer.feature
- │ └── steps
- │ └── steps.py
- └── twentyone.py
Here's a brief explanation of the files: dealer.feature: The written out tests for the dealer feature. steps.py: The code that runs the tests in dealer.feature. twentyone.py: The implementation code for the dealer feature.
Although behavioral tests do not require test-driven development, the two methodologies go hand-in-hand. We'll approach this problem from a test-driven perspective, so instead of jumping to code, we'll start with the tests.
Open dealer.feature and add the following first line:
Feature: The dealer for the game of 21
The first test will be simple — when the round starts, the dealer should deal itself two cards. The word Behave uses to define a test is "Scenario", so go ahead and add the following line:
Scenario: Deal initial cards
Before we write more, we need to understand the three phases of a basic Behave test: "Given", "When", and "Then". "Given" initializes a state, "When" describes an action, and "Then" states the expected outcome.
Scenario: Deal initial cards
Given a dealer
When the round starts
Then the dealer gives itself two cards
Now to see how Behave works, simply open a terminal in the root directory of your code and run the following command: behave You should see this output:
Feature: The dealer for the game of 21 # features/dealer.feature:1
Scenario: Deal initial cards # features/dealer.feature:3
Given a dealer # None
When the round starts # None
Then the dealer gives itself two cards # None
Failing scenarios:
features/dealer.feature:3 Deal initial cards
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 3 undefined
Took 0m0.000s
Behave suggests how to implement steps. You can think of a step as a task for Behave to execute. Each phase ("given", "when", and "then") are all implemented as steps.
The steps that Behave runs are written in Python and they are the link between the descriptive tests in .feature files and the actual application code. Go ahead and open steps.py and add the following imports:
from behave import *
from twentyone import *
Behave steps use annotations that match the names of the phases. This is the first step as described in the scenario:
@given('a dealer')
def step_impl(context):
context.dealer = Dealer()
It's important to notice that the text inside of the annotation matches the scenario text exactly. If it doesn't match, the test cannot run.If you run behave again, you'll see the test fails, but now for a different reason: We haven't defined the Dealer class yet! Now we will open twentyone.py and create a Dealer class:
class Dealer():
pass
Here are the next steps to add to steps.py:
@when('the round starts')
def step_impl(context):
context.dealer.new_round()
@then('the dealer gives itself two cards')
def step_impl(context):
assert (len(context.dealer.hand) == 2)
Again, the annotation text matches the text in the scenario exactly. In the "when" step, we have access to the dealer created in "given" and we can now call a method on that object. Finally, in the "then" step, we still have access to the dealer, and we assert that the dealer has two cards in its hand. We defined two new pieces of code that need to be implemented: new_round() and hand. Switch back to twentyone.py and add the following to the Dealer class:
class Dealer():
def __init__(self):
self.hand = []
def new_round(self):
self.hand = [_next_card(), _next_card()]
The _next_card() function will be defined as a top-level function of the module, along with a definition of the cards. At the top of the file, add the following:
import random
_cards = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
def _next_card():
return random.choice(_cards)
If you run behave now, you should see that the test passes:
Feature: The dealer for the game of 21 # features/dealer.feature:1
Scenario: Deal initial cards # features/dealer.feature:3
Given a dealer # features/steps/steps.py:5 0.000s
When the round starts # features/steps/steps.py:9 0.000s
Then the dealer gives itself two cards # features/steps/steps.py:14 0.000s
1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.000s
Behave makes this easier to do by providing tools to create a tableized test instead of writing out each test separately. Here is a test that checks several scenarios:
Scenario Outline: Get hand total
Given a <hand>
When the dealer sums the cards
Then the <total> is correct
Examples: Hands
| hand | total |
| 5,7 | 12 |
| 5,Q | 15 |
| Q,Q,A | 21 |
| Q,A | 21 |
| A,A,A | 13 |
You should recognize the familiar "given, when, then" pattern, but there's a lot of differences in this test. First, it is called a "Scenario Outline". Next, it uses parameters in angle brackets that correspond to the headers of the table. Finally, there's a table of inputs ("hand") and outputs ("total"). Here's how to implement the new "given" step:
@given('a {hand}')
def step_impl(context, hand):
context.dealer = Dealer()
context.dealer.hand = hand.split(',')
The angle brackets in the dealer.feature file are replaced with braces, and the hand parameter becomes an object that is passed to the step, along with the context. Next, add the remaining steps:
@when('the dealer sums the cards')
def step_impl(context):
context.dealer_total = context.dealer.get_hand_total()
@then('the {total:d} is correct')
def step_impl(context, total):
assert (context.dealer_total == total)
":d" after the total parameter is a shortcut to tell Behave to treat the parameter as an integer. It saves us from manually casting with the int() function. Create this as a top-level function in the twentyone.py module:
def _hand_total(hand):
values = [None, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10, 10]
value_map = {k: v for k, v in zip(_cards, values)}
total = sum([value_map[card] for card in hand if card != 'A'])
ace_count = hand.count('A')
for i in range(ace_count, -1, -1):
if i == 0:
total = total + ace_count
elif total + (i * 11) + (ace_count - i) <= 21:
total = total + (i * 11) + ace_count - i
break
return total
def get_hand_total(self):
return _hand_total(self.hand)
if you run behave now, you'll see that each example in the table runs as its own scenario. This saves a lot of space in the features file, but still gives us rigorous tests that pass or fail individually. it's important to understand that when using parameters, the order matters. Parameterized steps should be ordered from most restrictive to least restrictive. If you do not do this, the correct step may not be matched by Behave Here is the new given step, ordered properly:
@given('a dealer')
def step_impl(context):
context.dealer = Dealer()
## NEW STEP
@given('a hand {total:d}')
def step_impl(context, total):
context.dealer = Dealer()
context.total = total
@given('a {hand}')
def step_impl(context, hand):
context.dealer = Dealer()
context.dealer.hand = hand.split(',')
The typed parameter {total:d} is more restrictive than the untyped {hand}, so it must come earlier in the file.