In this tutorial, we will create some tests in Behave, which is a Cucumber variant for Python.
The test (specification) is initially created in Jira as a Cucumber Test and afterward, it is exported using the UI or the REST API.
We'll show you how to use both the Behave JSON report format and also the Cucumber JSON report format, in case you need it.
Code is available on GitHub; the repo contains some additional tests beyond the scope of this tutorial and some auxiliary scripts. |
Behave (and Cucumber) can be used in diverse scenarios. Next, you may find some usage patterns, even though using Behave is mostly recommended only if you are adopting BDD.
You may be adopting, or aiming to, one of the previous patterns.
Before moving into the actual implementation, you need to decide which workflow you'll use: do you want to use Xray/Jira as the master for writing the declarative specification (i.e. the Gherkin based Scenarios), or do you want to manage those outside using some editor and store them in Git, for example?
Please see Testing in BDD with Gherkin-based frameworks (e.g. Cucumber) for an overview of the possible workflows. The place that you'll use to edit the Gherkin Scenarios will affect your workflow. There are teams that prefer to edit Scenarios in Jira using Xray, while there are others that prefer to edit them by writing the .feature files by hand using some IDE. |
We'll use some dummy examples from Behave's documentation.
The test (specification) is initially created in Jira as Cucumber Tests and afterward, it is exported using the UI or the REST API.
This tutorial has the following requirements:
In case you need to interact with Xray REST API at low-level using scripts (e.g. Bash/shell scripts), this tutorial uses an auxiliary file with the credentials (more info in Global Settings: API Keys).
{ "client_id": "215FFD69FE4644728C72180000000000","client_secret": "1c00f8f22f56a8684d7c18cd6147ce2787d95e4da9f3bfb0af8f020000000000" } |
This section assumes using Xray as master, i.e. the place that you'll be using to edit the specifications (e.g. the scenarios that are part of .feature files).
The overall flow would be something like this, assuming Git as the source code versioning system:
Note that steps (5-9) performed by the CI tool are all automated, obviously.
To generate .feature file(s) based on Scenarios defined in Jira (i.e. Cucumber Tests and Preconditions), we can do it directly from Jira, by the REST API, or using a CI tool; we'll see that ahead in more detail.
Everthing starts with a user story or some sort of “requirement” that you wish to validate. This is materialized as a Jira issue and identified by the corresponding issue key (e.g. CALC-1206).
We can promptly check that it is “UNCOVERED” (i.e. that it has no tests covering it, no matter their type/approach).
If you have this "requirement" as a Jira issue, then you can just use the "Create Test" on that issue to create the Scenario/Scenario Outline and have it automatically linked back to the Story/"requirement".
Otherwise, you can create the Test using the standard (issue) Create action from Jira's top menu.
We need to create the Test issue first and fill out the Gherkin statements later on in the Test issue screen.
After the Test is created, and since we have done it from the user story screen, it will impact the coverage of related "requirement"/story.
The coverage and the test results can be tracked on the "requirement" side (e.g. user story). In this case, you may see that coverage changed from being UNCOVERED to NOTRUN (i.e. covered and with at least one test not run).
We repeat the process for additional "requirements" and/or test Scenarios.
The related statement's code is managed outside of Jira and stored in Git, for example.
You can then export the specification of the test to a Cucumber .feature file via the REST API, or the Xray - Export to Cucumber UI action from within the Test/Test Execution issue or even based on an existing saved filter. As a source, you can identify Test, Test Set, Test Execution, Test Plan, or "requirement" issues. A plugin for your CI tool of choice can be used to ease this task.
So, you can either:
#!/bin/bash token=$(curl -H "Content-Type: application/json" -X POST --data @"cloud_auth.json" https://xray.cloud.getxray.app/api/v2/authenticate| tr -d '"') curl -H "Content-Type: application/json" -X GET -H "Authorization: Bearer $token" "https://xray.cloud.getxray.app/api/v2/export/cucumber?keys=CALC-1206;CALC-1207" -o features.zip rm -rf features/*.feature unzip -o features.zip -d features |
We will export the features to a new directory named features/.
After being exported, the created .feature(s) will contain references to the Test issue key, eventually prefixed (e.g. "TEST_") depending on an Xray global setting, and the covered "requirement" issue key, if that's the case. The naming of these files is detailed in Generate Cucumber Features.
@REQ_CALC-1206 Feature: Showing off behave @TEST_CALC-1208 Scenario: Run a simple test Given we have behave installed When we implement a test Then behave will test it for us! |
@REQ_CALC-1207 Feature: Scenario Outline (tutorial04) @TEST_CALC-1209 Scenario Outline: Use Blender with <thing> Given I put "<thing>" in a blender When I switch the blender on Then it should transform into "<other thing>" Examples: Amphibians | thing | other thing | | Red Tree Frog | mush | | apples | apple juice | |
The corresponding steps implementation code lives in the following files.
# file:features/steps/blender.py # ----------------------------------------------------------------------------- # DOMAIN-MODEL: # ----------------------------------------------------------------------------- class Blender(object): TRANSFORMATION_MAP = { "Red Tree Frog": "mush", "apples": "apple juice", "iPhone": "toxic waste", "Galaxy Nexus": "toxic waste", } def __init__(self): self.thing = None self.result = None @classmethod def select_result_for(cls, thing): return cls.TRANSFORMATION_MAP.get(thing, "DIRT") def add(self, thing): self.thing = thing def switch_on(self): self.result = self.select_result_for(self.thing) |
# file:features/steps/step_tutorial01.py # ---------------------------------------------------------------------------- # STEPS: # ---------------------------------------------------------------------------- from behave import given, when, then @given('we have behave installed') def step_impl(context): pass @when('we implement a test') def step_impl(context): assert True is not False @then('behave will test it for us!') def step_impl(context): assert context.failed is False |
# file:features/steps/step_tutorial03.py # ---------------------------------------------------------------------------- # STEPS: # ---------------------------------------------------------------------------- from behave import given, when, then from hamcrest import assert_that, equal_to from blender import Blender @given('I put "{thing}" in a blender') def step_given_put_thing_into_blender(context, thing): context.blender = Blender() context.blender.add(thing) @when('I switch the blender on') def step_when_switch_blender_on(context): context.blender.switch_on() @then('it should transform into "{other_thing}"') def step_then_should_transform_into(context, other_thing): assert_that(context.blender.result, equal_to(other_thing)) |
Running tests
In order to run the tests there 2 options available:
If you choose the latter, the following code is based on a sample code provided by an open-source contributor "fredizzimo" (see original code here), with small changes to make it handle correctly the JSON serialization of status results. You may create this cucumber_json126.py
at the root of your project.
# -*- coding: utf-8 -*- from __future__ import absolute_import from behave.model_core import Status from behave.formatter.base import Formatter import base64 import six import copy try: import json except ImportError: import simplejson as json # ----------------------------------------------------------------------------- # CLASS: JSONFormatter # ----------------------------------------------------------------------------- class CucumberJSONFormatter(Formatter): name = 'json' description = 'JSON dump of test run' dumps_kwargs = {} json_number_types = six.integer_types + (float,) json_scalar_types = json_number_types + (six.text_type, bool, type(None)) def __init__(self, stream_opener, config): super(CucumberJSONFormatter, self).__init__(stream_opener, config) # -- ENSURE: Output stream is open. self.stream = self.open() self.feature_count = 0 self.current_feature = None self.current_feature_data = None self._step_index = 0 self.current_background = None self.current_background_data = None def reset(self): self.current_feature = None self.current_feature_data = None self._step_index = 0 self.current_background = None # -- FORMATTER API: def uri(self, uri): pass def feature(self, feature): self.reset() self.current_feature = feature self.current_feature_data = { 'id': self.generate_id(feature), 'uri': feature.location.filename, 'line': feature.location.line, 'description': '', 'keyword': feature.keyword, 'name': feature.name, 'tags': self.write_tags(feature.tags), 'status': feature.status.name, } element = self.current_feature_data if feature.description: element['description'] = self.format_description(feature.description) def background(self, background): element = { 'type': 'background', 'keyword': background.keyword, 'name': background.name, 'location': six.text_type(background.location), 'steps': [] } self._step_index = 0 self.current_background = element def scenario(self, scenario): if self.current_background is not None: self.add_feature_element(copy.deepcopy(self.current_background)) element = self.add_feature_element({ 'type': 'scenario', 'id': self.generate_id(self.current_feature, scenario), 'line': scenario.location.line, 'description': '', 'keyword': scenario.keyword, 'name': scenario.name, 'tags': self.write_tags(scenario.tags), 'location': six.text_type(scenario.location), 'steps': [], }) if scenario.description: element['description'] = self.format_description(scenario.description) self._step_index = 0 @classmethod def make_table(cls, table): table_data = { 'headings': table.headings, 'rows': [ list(row) for row in table.rows ] } return table_data def step(self, step): s = { 'keyword': step.keyword, 'step_type': step.step_type, 'name': step.name, 'line': step.location.line, 'result': { 'status': 'skipped', 'duration': 0 } } if step.text: s['doc_string'] = { 'value': step.text, 'line': step.text.line } if step.table: s['rows'] = [{'cells': [heading for heading in step.table.headings]}] s['rows'] += [{'cells': [cell for cell in row.cells]} for row in step.table] if self.current_feature.background is not None: element = self.current_feature_data['elements'][-2] if len(element['steps']) >= len(self.current_feature.background.steps): element = self.current_feature_element else: element = self.current_feature_element element['steps'].append(s) def match(self, match): if match.location: # -- NOTE: match.location=None occurs for undefined steps. match_data = { 'location': six.text_type(match.location) or "", } self.current_step['match'] = match_data def result(self, result): self.current_step['result'] = { 'status': result.status.name, 'duration': int(round(result.duration * 1000.0 * 1000.0 * 1000.0)), } if result.error_message and result.status == Status.failed: # -- OPTIONAL: Provided for failed steps. error_message = result.error_message result_element = self.current_step['result'] result_element['error_message'] = error_message self._step_index += 1 def embedding(self, mime_type, data): step = self.current_feature_element['steps'][-1] step['embeddings'].append({ 'mime_type': mime_type, 'data': base64.b64encode(data).replace('\n', ''), }) def eof(self): """ End of feature """ if not self.current_feature_data: return # -- NORMAL CASE: Write collected data of current feature. self.update_status_data() if self.feature_count == 0: # -- FIRST FEATURE: self.write_json_header() else: # -- NEXT FEATURE: self.write_json_feature_separator() self.write_json_feature(self.current_feature_data) self.current_feature_data = None self.feature_count += 1 def close(self): self.write_json_footer() self.close_stream() # -- JSON-DATA COLLECTION: def add_feature_element(self, element): assert self.current_feature_data is not None if 'elements' not in self.current_feature_data: self.current_feature_data['elements'] = [] self.current_feature_data['elements'].append(element) return element @property def current_feature_element(self): assert self.current_feature_data is not None return self.current_feature_data['elements'][-1] @property def current_step(self): step_index = self._step_index if self.current_feature.background is not None: element = self.current_feature_data['elements'][-2] if step_index >= len(self.current_feature.background.steps): step_index -= len(self.current_feature.background.steps) element = self.current_feature_element else: element = self.current_feature_element return element['steps'][step_index] def update_status_data(self): assert self.current_feature assert self.current_feature_data self.current_feature_data['status'] = self.current_feature.status.name def write_tags(self, tags): return [{'name': f'@{tag}', 'line': tag.line if hasattr(tag, 'line') else 1} for tag in tags] def generate_id(self, feature, scenario=None): def convert(name): return name.lower().replace(' ', '-') id = convert(feature.name) if scenario is not None: id += ';' id += convert(scenario.name) return id def format_description(self, lines): description = '\n'.join(lines) description = '<pre>%s</pre>' % description return description # -- JSON-WRITER: def write_json_header(self): self.stream.write('[\n') def write_json_footer(self): self.stream.write('\n]\n') def write_json_feature(self, feature_data): self.stream.write(json.dumps(feature_data, **self.dumps_kwargs)) self.stream.flush() def write_json_feature_separator(self): self.stream.write(",\n\n") # ----------------------------------------------------------------------------- # CLASS: PrettyJSONFormatter # ----------------------------------------------------------------------------- class PrettyCucumberJSONFormatter(CucumberJSONFormatter): """ Provides readable/comparable textual JSON output. """ name = 'json.pretty' description = 'JSON dump of test run (human readable)' dumps_kwargs = { 'indent': 2, 'sort_keys': True } |
export PYTHONPATH=`pwd` behave --format=cucumber_json126:PrettyCucumberJSONFormatter -o results/cucumber.json --format=json -o results/behave.json features |
Import results
After running the tests and generating the Behave report, it can be imported to Xray via the REST API or the Xray - Import Execution Results action within the Test Execution.
BASE_URL=https://xray.cloud.getxray.app token=$(curl -H "Content-Type: application/json" -X POST --data @"cloud_auth.json" "$BASE_URL/api/v2/authenticate"| tr -d '"') curl -H "Content-Type: application/json" -X POST -H "Authorization: Bearer $token" --data @"results/cucumber.json" "$BASE_URL/api/v2/import/execution/behave" |
If we use the Cucumber JSON formatter instead, then the endpoint to be used needs to be changed accordingly.
BASE_URL=https://xray.cloud.getxray.app token=$(curl -H "Content-Type: application/json" -X POST --data @"cloud_auth.json" "$BASE_URL/api/v2/authenticate"| tr -d '"') curl -H "Content-Type: application/json" -X POST -H "Authorization: Bearer $token" --data @"results/cucumber.json" "$BASE_URL/api/v2/import/execution/cucumber" |
The execution page provides detailed information, which in this case includes the results for the different examples along with the respective step results.