Repository: Biometria-se/grizzly (language: python)

Issue:
grizzly: keystore task documentation does not link to correct step implementation
**Describe the bug**
https://biometria-se.github.io/grizzly/framework/usage/tasks/keystore/

Step implementation links does not link to correct anchor, also not all keystore steps are listed in https://biometria-se.github.io/grizzly/framework/usage/steps/scenario/tasks.

**To Reproduce**
N/A

**Expected behavior**
All keystore step implementations listed in the step documentation, and correctly linked from the keystore task.


grizzly: keystore task documentation does not link to correct step implementation
**Describe the bug**
https://biometria-se.github.io/grizzly/framework/usage/tasks/keystore/

Step implementation links does not link to correct anchor, also not all keystore steps are listed in https://biometria-se.github.io/grizzly/framework/usage/steps/scenario/tasks.

**To Reproduce**
N/A

**Expected behavior**
All keystore step implementations listed in the step documentation, and correctly linked from the keystore task.




Discussion / hints:

Fix patch:
diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml
index adbb88a0..24435a8d 100644
--- a/.github/workflows/code-quality.yaml
+++ b/.github/workflows/code-quality.yaml
@@ -26,7 +26,7 @@ jobs:
 
     - name: install python dependencies
       id: pip
-      run: python -m pip install -e .[dev]
+      run: python -m pip install -e .[dev,docs]
 
     - name: run pylint
       id: pylint
@@ -83,7 +83,7 @@ jobs:
 
     - name: install python dependencies
       id: pip
-      run: python -m pip install -e .[dev]
+      run: python -m pip install -e .[dev,docs]
 
     - name: pytest
       id: pytest
@@ -92,12 +92,12 @@ jobs:
     - name: coverage (not windows)
       if: runner.os != 'Windows'
       id: coverage-not-windows
-      run: python -m coverage report --omit=**/*messagequeue*,**/mq/* --fail-under=95
+      run: python -m coverage report --omit=**/*messagequeue*,**/mq/* --fail-under=90
 
     - name: coverage (windows)
       if: runner.os == 'Windows'
       id: coverage-windows
-      run: python -m coverage report --omit=**/*messagequeue*,**/mq/* --fail-under=90
+      run: python -m coverage report --omit=**/*messagequeue*,**/mq/* --fail-under=89
 
   test-e2e:
     name: 'e2e tests ${{ matrix.run_mode }}'
@@ -248,6 +248,14 @@ jobs:
       working-directory: ./docs/_build
       run: find . -type f -name '*.html' | xargs -- grep -nHE '@TODO' | awk 'BEGIN{rc=0} !/^$/ {rc=1; print} END{exit rc}'
 
+    - name: check for unclosed code blocks
+      working-directory: ./docs/_build
+      run: for file in $(find . -type f -name '*.md'); do awk 'BEGIN{count=0} /```/ {count+=1} END{if (count % 2 != 0) { print FILENAME ", contains uneven number of code-block markers"; exit 1;}}' $file || break; done
+
+    - name: check code block format
+      working-directory: ./docs/_build
+      run: for file in $(find . -type f -name '*.md'); do awk 'BEGIN{count=0} /``` / {print FILENAME " " $0; count+=1} END{if (count > 0) { print FILENAME ", there should not be a space between code-block and language specification"; exit 1;}}' $file || break; done
+
   release-quality:
     name: releasable
     runs-on: 'ubuntu-latest'
diff --git a/.gitignore b/.gitignore
index 4b79c499..52ecde75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,5 +26,5 @@ docs/licenses/*
 docs/changelog/*
 docs/cli.md
 docs/_site
-docs/_build
+docs/_build*
 grizzly/__version__.py
diff --git a/docs/build.novella b/docs/build.novella
index dbbfea91..124544c0 100644
--- a/docs/build.novella
+++ b/docs/build.novella
@@ -1,6 +1,7 @@
 from pathlib import Path
+from grizzly_extras.novella import mkdocs_update_config, preprocess_markdown_update_with_header_levels, generate_dynamic_pages
 
-template "mkdocs"
+template "grizzly-mkdocs"
 
 action "mkdocs-update-config" {
     site_name = "Grizzly"
@@ -9,113 +10,19 @@ action "mkdocs-update-config" {
     update '$.theme.favicon' set: 'assets/favicon.ico'
     update '$.theme.palette' set: {'scheme': 'slate', 'primary': 'brown', 'accent': 'deep orange'}
     update_with config -> {
-        def make_human_readable(input):
-            def words = []
-            for word in input.split('_'):
-                words.append(word.capitalize())
-
-            def output = ' '.join(words)
-
-            for word in ['http', 'sftp', 'api', 'csv']:
-                output = output.replace(word.capitalize(), word.upper())
-                output = output.replace(word, word.upper())
-
-            def to_replace = dict(Iot='IoT', hub='Hub')
-            for value, replace_value in to_replace.items():
-                output = output.replace(value, replace_value)
-
-            return output
-
-        def config_nav_tasks = config['nav'][3]['Framework'][0]['Usage'][0]['Tasks']
-        def config_nav_tasks_clients = config_nav_tasks.pop()
-        def tasks = Path.cwd() / '..' / 'grizzly' / 'tasks'
-
-        def nav_tasks = []
-        for task in tasks.iterdir():
-            if task.is_file() and (task.stem == '__init__' or not task.stem.startswith('_')):
-                if task.stem == '__init__':
-                    nav_tasks.insert(0, 'framework/usage/tasks/index.md')
-                else:
-                    nav_tasks.append({make_human_readable(task.stem): 'framework/usage/tasks/{}.md'.format(task.stem)})
-        config_nav_tasks.extend(nav_tasks)
-
-        def tasks_clients = tasks / 'clients'
-        def nav_tasks_clients = []
-        for task_client in tasks_clients.iterdir():
-            if task_client.is_file() and (task_client.stem == '__init__' or not task_client.stem.startswith('_')):
-                if task_client.stem == '__init__':
-                    nav_tasks_clients.insert(0, 'framework/usage/tasks/clients/index.md')
-                else:
-                    nav_tasks_clients.append({make_human_readable(task_client.stem): 'framework/usage/tasks/clients/{}.md'.format(task_client.stem)})
-        config_nav_tasks_clients['Clients'] = nav_tasks_clients
-        config_nav_tasks.append(config_nav_tasks_clients)
-
-        def config_nav_testdata = config['nav'][3]['Framework'][0]['Usage'][1]['Variables'][1]
-        def nav_testdata = []
-        def variables = Path.cwd() / '..' / 'grizzly' / 'testdata' / 'variables'
-        for variable in variables.iterdir():
-            if variable.is_file() and (variable.stem == '__init__' or not variable.stem.startswith('_')):
-                if variable.stem == '__init__':
-                    nav_testdata.insert(0, 'framework/usage/variables/testdata/index.md')
-                else:
-                    nav_testdata.append({make_human_readable(variable.stem): 'framework/usage/variables/testdata/{}.md'.format(variable.stem)})
-        config_nav_testdata['Testdata'] = nav_testdata
-
-        def config_nav_users = config['nav'][3]['Framework'][0]['Usage'][2]
-        def users = Path.cwd() / '..' / 'grizzly' / 'users'
-        def nav_users = []
-        for user in users.iterdir():
-            if user.is_file() and (user.stem == '__init__' or not user.stem.startswith('_')):
-                if user.stem == '__init__':
-                    nav_users.insert(0, 'framework/usage/load-users/index.md')
-                else:
-                    nav_users.append({make_human_readable(user.stem): 'framework/usage/load-users/{}.md'.format(user.stem)})
-
-        config_nav_users['Load Users'] = nav_users
-
-        def config_nav_steps = config['nav'][3]['Framework'][0]['Usage'][3]['Steps']
-        def steps = Path.cwd() / '..' / 'grizzly' / 'steps'
-        for step in steps.iterdir():
-            if step.is_file() and (step.stem == '__init__' or not step.stem.startswith('_')):
-                if step.stem == '__init__':
-                    config_nav_steps.insert(0, 'framework/usage/steps/index.md')
-                else:
-                    config_nav_steps.append({make_human_readable(step.stem): 'framework/usage/steps/{}.md'.format(step.stem)})
-
-        def config_nav_steps_background = config_nav_steps[1]
-        def steps_background = steps / 'background'
-        def nav_steps_background = []
-        for step in steps_background.iterdir():
-            if step.is_file() and (step.stem == '__init__' or not step.stem.startswith('_')):
-                if step.stem == '__init__':
-                    nav_steps_background.insert(0, 'framework/usage/steps/background/index.md')
-                else:
-                    nav_steps_background.append({make_human_readable(step.stem): 'framework/usage/steps/background/{}.md'.format(step.stem)})
-
-        config_nav_steps_background['Background'] = nav_steps_background
-
-        def config_nav_steps_scenario = config_nav_steps[2]
-        def steps_scenario = steps / 'scenario'
-        def nav_steps_scenario = []
-        for step in steps_scenario.iterdir():
-            if step.is_file() and (step.stem == '__init__' or not step.stem.startswith('_')):
-                if step.stem == '__init__':
-                    nav_steps_scenario.insert(0, 'framework/usage/steps/scenario/index.md')
-                else:
-                    nav_steps_scenario.append({make_human_readable(step.stem): 'framework/usage/steps/scenario/{}.md'.format(step.stem)})
-
-        config_nav_steps_scenario['Scenario'] = nav_steps_scenario
+        mkdocs_update_config(config)
     }
 }
 
 action "preprocess-markdown" {
     update_with processor -> {
-        if hasattr(processor, '_renderer') and hasattr(processor._renderer, 'header_level_by_type'):
-            processor._renderer.header_level_by_type.update({
+        preprocess_markdown_update_with_header_levels(
+            processor, {
                 'Class': 3,
                 'Method': 2,
                 'Function': 2,
-            })
+            }
+        )
     }
 
     use "pydoc"
@@ -127,115 +34,5 @@ do
         precedes "preprocess-markdown"
     }
     action: {
-        def make_human_readable(input):
-            def words = []
-            for word in input.split('_'):
-                words.append(word.capitalize())
-
-            def output = ' '.join(words)
-
-            for word in ['http', 'sftp', 'api', 'csv']:
-                output = output.replace(word.capitalize(), word.upper())
-                output = output.replace(word, word.upper())
-
-            return output
-
-        def tasks = Path.cwd() / '..' / 'grizzly' / 'tasks'
-        for task in tasks.iterdir():
-            if task.is_file() and (task.stem == '__init__' or not task.stem.startswith('_')):
-                def filename = task.stem
-                if task.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'tasks' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-
-                if task.stem == '__init__':
-                    file.write_text('---\ntitle: Tasks\n---\n@pydoc grizzly.tasks\n')
-                else:
-                    file.write_text('---\ntitle: {task_title}\n---\n@pydoc grizzly.tasks.{task}\n'.format(task=task.stem, task_title=make_human_readable(task.stem)))
-
-        def tasks_clients = tasks / 'clients'
-        for task_client in tasks_clients.iterdir():
-            if task_client.is_file() and (task_client.stem == '__init__' or not task_client.stem.startswith('_')):
-                def filename = task_client.stem
-                if task_client.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'tasks' / 'clients' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-                if task_client.stem == '__init__':
-                    file.write_text('---\ntitle: Clients\n---\n@pydoc grizzly.tasks.clients\n')
-                else:
-                    file.write_text('---\ntitle: {task_title}\n---\n@pydoc grizzly.tasks.clients.{task}\n'.format(task=task_client.stem, task_title=make_human_readable(task_client.stem)))
-
-        def variables = Path.cwd() / '..' / 'grizzly' / 'testdata' / 'variables'
-        for variable in variables.iterdir():
-            if variable.is_file() and (variable.stem == '__init__' or not variable.stem.startswith('_')):
-                def filename = variable.stem
-                if variable.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'variables' / 'testdata' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-                if variable.stem == '__init__':
-                    file.write_text('---\ntitle: Testdata\n---\n@pydoc grizzly.testdata.variables\n')
-                else:
-                    file.write_text('---\ntitle: {variable_title}\n---\n@pydoc grizzly.testdata.variables.{variable}\n'.format(variable=filename, variable_title=make_human_readable(filename)))
-
-        def steps = Path.cwd() / '..' / 'grizzly' / 'steps'
-        for step in steps.iterdir():
-            if step.is_file() and (step.stem == '__init__' or not step.stem.startswith('_')):
-                def filename = step.stem
-                if step.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'steps' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-                if step.stem == '__init__':
-                    file.write_text('---\ntitle: Steps\n---\n@pydoc grizzly.steps\n## Considerations\nWhen writing step expressions, the following should be taken into consideration.\n@pydoc grizzly_extras.text.PermutationEnum\n@pydoc grizzly_extras.text.permutation\n')
-                else:
-                    file.write_text('---\ntitle: {step_title}\n---\n@pydoc grizzly.steps.{step}\n'.format(step=filename, step_title=make_human_readable(filename)))
-
-        def steps_background = steps / 'background'
-        for step in steps_background.iterdir():
-            if step.is_file() and (step.stem == '__init__' or not step.stem.startswith('_')):
-                def filename = step.stem
-                if step.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'steps' / 'background' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-                if step.stem == '__init__':
-                    file.write_text('---\ntitle: Background Steps\n---\n@pydoc grizzly.steps.background\n')
-                else:
-                    file.write_text('---\ntitle: {step_title}\n---\n@pydoc grizzly.steps.background.{step}\n'.format(step=filename, step_title=make_human_readable(filename)))
-
-        def steps_scenario = steps / 'scenario'
-        for step in steps_scenario.iterdir():
-            if step.is_file() and (step.stem == '__init__' or not step.stem.startswith('_')):
-                def filename = step.stem
-                if step.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'steps' / 'scenario' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-                if step.stem == '__init__':
-                    file.write_text('---\ntitle: Scenario Steps\n---\n@pydoc grizzly.steps.scenario\n')
-                else:
-                    file.write_text('---\ntitle: {step_title}\n---\n@pydoc grizzly.steps.scenario.{step}\n'.format(step=filename, step_title=make_human_readable(filename)))
-
-        def users = Path.cwd() / '..' / 'grizzly' / 'users'
-        for user in users.iterdir():
-            if user.is_file() and (user.stem == '__init__' or not user.stem.startswith('_')):
-                def filename = user.stem
-                if user.stem == '__init__':
-                    filename = 'index'
-
-                def file = directory / 'content' / 'framework' / 'usage' / 'load-users' / (filename + '.md')
-                file.parent.mkdir(parents=True, exist_ok=True)
-                if user.stem == '__init__':
-                    file.write_text('---\ntitle: Load Users\n---\n@pydoc grizzly.users\n')
-                else:
-                    file.write_text('---\ntitle: {user_title}\n---\n@pydoc grizzly.users.{user}\n'.format(user=filename, user_title=make_human_readable(filename)))
+        generate_dynamic_pages(directory)
     }
diff --git a/docs/content/command-line-interface/usage/metadata.md b/docs/content/command-line-interface/usage/metadata.md
index 1017575e..780d072d 100644
--- a/docs/content/command-line-interface/usage/metadata.md
+++ b/docs/content/command-line-interface/usage/metadata.md
@@ -17,7 +17,7 @@ have to remember all combinations in memory.
 
 ### Format
 
-``` gherkin
+```gherkin
 # grizzly-cli <[sub]parser> <argument>
 ```
 
@@ -28,7 +28,7 @@ Which is solved by checking {@link command-line-interface.usage} and correct the
 
 E.g., the `run` subparser is used by both `dist` and `local`, so when specifying an metadata comment for `run` arguments it should be:
 
-``` gherkin title="example.feature"
+```gherkin title="example.feature"
 # grizzly-cli run --verbose
 Feature: Example Feature
   Scenario: Example Scenario
@@ -37,7 +37,7 @@ Feature: Example Feature
 
 This means that executing `example.feature` either in mode `local` or `dist`, the argument `--verbose` will be injected unless already manually specified.
 
-``` plain
+```plain
 grizzly-cli local run example.feature -> grizzly-cli local run example.feature --verbose
 grizzly-cli dist run example.feature -> grizzly-cli dist run example.feature --verbose
 ```
@@ -45,13 +45,13 @@ grizzly-cli dist run example.feature -> grizzly-cli dist run example.feature --v
 If metadata comments adds arguments for a subparser that is not used when executing the feature file the following message will be seen when executing
 `grizzly-cli`:
 
-``` plain
+```plain
 ?? ignoring <arguments>
 ```
 
 Given the following feature file:
 
-``` gherkin title="example-dist.feature"
+```gherkin title="example-dist.feature"
 # grizzly-cli dist --health-retries 999
 # grizzly-cli dist --workers 6
 # grizzly-cli run --verbose
@@ -62,7 +62,7 @@ Feature: Example Feature
 
 When executed with `grizzly-cli local run example-dist.feature`, the output will contain:
 
-``` plain
+```plain
 ?? ignoring dist --health-retries 999
 ?? ignoring dist --workers 6
 ```
@@ -77,7 +77,7 @@ This is useful to remind the user about manual steps och checks that should be d
 
 ### Format
 
-``` gherkin
+```gherkin
 # grizzly-cli:notice <message>
 ```
 
@@ -85,7 +85,7 @@ Everything after `# grizzly-cli:notice ` (notice the space) will be displayed in
 
 ### Examples
 
-``` gherkin title="example.feature"
+```gherkin title="example.feature"
 # grizzly-cli:notice have you piped the fork in a loop?
 Feature: Example Feature
   Scenario: Example Scenario
@@ -94,9 +94,8 @@ Feature: Example Feature
 
 Running `example.feature` will in additional to the normal `grizzly-cli` input/output also trigger the following prompt:
 
-``` plain
+```plain
 have you piped the fork in a loop? [y/n]
 ```
 
 If `run` argument `-y/--yes` is provided, it will only print the message and not ask for confirmation.
-
diff --git a/docs/content/framework/usage/steps/index.md b/docs/content/framework/usage/steps/index.md
new file mode 100644
index 00000000..73a3e9bf
--- /dev/null
+++ b/docs/content/framework/usage/steps/index.md
@@ -0,0 +1,37 @@
+---
+title: Steps
+---
+@anchor pydoc:grizzly.steps Steps
+This package contains all step implementations needed to write a feature file that describes a `locust` load test scenario for `grizzly`.
+
+A feature is described by using [Gherkin](https://cucumber.io/docs/gherkin/reference/). These expressions is then used by `grizzly` to configure and
+start `locust`, which takes care of generating the load.
+
+```gherkin
+Feature: description of the test
+    Background: steps that are common for all (if there is multiple) scenarios
+        Given ...
+        And ...
+    Scenario: steps for a specific flow through a component in the target environment
+        Given ...
+        And ...
+        Then ...
+        When ...
+```
+
+In this package there are modules with step implementations that can be used in both `Background` and `Scenario` sections in a feature file.
+
+@anchor pydoc:grizzly.steps.custom
+## Custom
+
+Custom steps are implemented in your `grizzly` project `features/steps/steps.py` file. This is also the file that imports all `grizzly`-defined step implementations.
+
+There are examples of this in the {@link framework.example}.
+
+## Considerations
+
+When writing step expressions, the following should be taken into consideration.
+
+@pydoc grizzly_extras.text.PermutationEnum
+
+@pydoc grizzly_extras.text.permutation
diff --git a/docs/content/framework/usage/variables/templating.md b/docs/content/framework/usage/variables/templating.md
index e0cc01de..7adcee95 100644
--- a/docs/content/framework/usage/variables/templating.md
+++ b/docs/content/framework/usage/variables/templating.md
@@ -150,7 +150,7 @@ Then put request with name "example-{{ initial_id }}" to "/api/v{{ initial_id }}
 
 It is possible to implement [custom jinja2 filters](https://ttl255.com/jinja2-tutorial-part-4-template-filters/#write-custom) by decorating them with `grizzly.testdata.utils.templatingfilter`.
 
-``` python
+```python
 from grizzly.testdata.utils import templatingfilter
 
 
@@ -161,7 +161,7 @@ def touppercase(value: str) -> str:
 
 The name of the filter will be the same as the function name. By using the decorator it will be added to the default filters and can be used in templating expressions, such as:
 
-``` gherkin
+```gherkin
 And value for variable "foo" is "bar"
 And value for variable "bar" is "{{ foo | touppercase }}"
 
diff --git a/docs/diff.py b/docs/diff.py
new file mode 100644
index 00000000..2b7d2048
--- /dev/null
+++ b/docs/diff.py
@@ -0,0 +1,8 @@
+from pathlib import Path
+
+for file_old in Path.cwd().rglob('_build_old/**/*.md'):
+    file_new = Path(str(file_old).replace('/_build_old/', '/_build_new/'))
+
+    command = ['vim -d', str(file_old), str(file_new)]
+    print(' '.join(command))
+
diff --git a/example/features/environment.py b/example/features/environment.py
index 44fd452f..1150c43a 100644
--- a/example/features/environment.py
+++ b/example/features/environment.py
@@ -26,14 +26,14 @@ def touppercase(value: str) -> str:
 def before_scenario(
     context: Context, scenario: Scenario, *args: Tuple[Any, ...], **kwargs: Dict[str, Any]
 ) -> None:
-    '''
+    """
     Overloads before_scenario from grizzly, to set "stop_on_failure" for all scenarios.
     This would be the same as having the following step for all Scenario in a Feature:
 
-    ``` gherkin
+    ```gherkin
     And stop on first on first failure
     ```
-    '''
+    """
     grizzly_before_scenario(context, scenario, *args, **kwargs)
 
     grizzly = cast(GrizzlyContext, context.grizzly)
diff --git a/example/features/example.feature b/example/features/example.feature
index 23b533d7..cb0b61bf 100644
--- a/example/features/example.feature
+++ b/example/features/example.feature
@@ -2,7 +2,7 @@ Feature: grizzly example
   Background: common configuration
     Given "3" users
     And spawn rate is "1" user per second
-    And add callback "steps.custom.callback_client_server" for message type "client_server" from client to server
+    And register callback "steps.custom.callback_client_server" for message type "client_server" from client to server
 
   Scenario: dog facts api
     Given a user of type "RestApi" load testing "$conf::facts.dog.host$"
diff --git a/example/features/steps/steps.py b/example/features/steps/steps.py
index bcd2c70b..45719ef5 100644
--- a/example/features/steps/steps.py
+++ b/example/features/steps/steps.py
@@ -13,18 +13,18 @@
 
 @given(u'also log successful requests')
 def step_log_all_requests(context: Context) -> None:
-    '''This step does the same as:
+    """This step does the same as:
 
-    ``` gherkin
+    ```gherkin
     And set context variable "log_all_requests" to "True"
     ```
 
     Usage:
 
-    ``` gherkin
+    ```gherkin
     And log all requests
     ```
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     context_variable = create_context_variable(grizzly, 'log_all_requests', 'True')
     grizzly.scenario.context = merge_dicts(grizzly.scenario.context, context_variable)
@@ -32,9 +32,9 @@ def step_log_all_requests(context: Context) -> None:
 
 @then(u'send message "{data}"')
 def step_send_message(context: Context, data: str) -> None:
-    '''This step adds task steps.custom.Task to the scenario, which sends a message of type
+    """This step adds task steps.custom.Task to the scenario, which sends a message of type
     "example_message" from the server to the client, which will trigger the callback registered
     in `before_feature` in the projects `environment.py`.
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.scenario.tasks.add(Task(data))
diff --git a/grizzly/auth/aad.py b/grizzly/auth/aad.py
index c53ad24c..d87aa161 100644
--- a/grizzly/auth/aad.py
+++ b/grizzly/auth/aad.py
@@ -19,7 +19,7 @@
 
 Using client secret for an app registration.
 
-``` gherkin
+```gherkin
 Given a user of type "RestApi" load testing "https://api.example.com"
 And set context variable "auth.provider" to "<provider>"
 And set context variable "auth.client.id" to "<client id>"
@@ -33,7 +33,7 @@
 
 `auth.user.redirect_uri` needs to correspond to the endpoint that the client secret is registrered for.
 
-``` gherkin
+```gherkin
 Given a user of type "RestApi" load testing "https://api.example.com"
 And set context variable "auth.provider" to "<provider>"
 And set context variable "auth.client.id" to "<client id>"
@@ -86,13 +86,11 @@
 
 The user now have software based TOTP tokens as MFA method.
 
-
-
 #### Example
 
 In addition to the "Username and password" example, the context variable `auth.user.otp_secret` must also be set.
 
-``` gherkin
+```gherkin
 Given a user of type "RestApi" load testing "https://api.example.com"
 And set context variable "auth.provider" to "<provider>"
 And set context variable "auth.client.id" to "<client id>"
diff --git a/grizzly/behave.py b/grizzly/behave.py
index e01b0595..57e7db7f 100644
--- a/grizzly/behave.py
+++ b/grizzly/behave.py
@@ -149,7 +149,7 @@ def before_scenario(context: Context, scenario: Scenario, *args: Tuple[Any, ...]
         if matched_step is None:
             continue
 
-        if not in_correct_section(matched_step.func, ['grizzly.steps.scenario', 'grizzly.steps']):
+        if not in_correct_section(matched_step.func, ['grizzly.steps.scenario', 'grizzly.steps.scenario.tasks', 'grizzly.steps']):
             # to get a nicer error message, the step should fail before it's executed, see before_step hook
             setattr(step, 'location_status', 'incorrect')
 
diff --git a/grizzly/listeners/appinsights.py b/grizzly/listeners/appinsights.py
index 25f6d157..ea5e43ae 100644
--- a/grizzly/listeners/appinsights.py
+++ b/grizzly/listeners/appinsights.py
@@ -1,4 +1,4 @@
-'''
+"""
 Borrowed from:
 https://github.com/SvenskaSpel/locust-plugins/blob/master/locust_plugins/appinsights_listener.py
 
@@ -6,7 +6,7 @@
 
 Example kusko question to get response time:
 
-``` kusko
+```kusko
 traces
 | extend response_time = todouble(customDimensions["response_time"]),
 response_length = toint(customDimensions["response_length"]),
@@ -17,7 +17,7 @@
 | project timestamp, endpoint, response_time, response_length, spawn_rate, thread_count
 | render timechart;
 ```
-'''
+"""
 import logging
 
 from typing import Dict, Any, Optional
diff --git a/grizzly/steps/__init__.py b/grizzly/steps/__init__.py
index 92cd10e5..448c651e 100644
--- a/grizzly/steps/__init__.py
+++ b/grizzly/steps/__init__.py
@@ -1,31 +1,3 @@
-'''
-@anchor pydoc:grizzly.steps Steps
-This package contains all step implementations needed to write a feature file that describes a `locust` load test scenario for `grizzly`.
-
-A feature is described by using [Gherkin](https://cucumber.io/docs/gherkin/reference/). These expressions is then used by `grizzly` to configure and
-start `locust`, which takes care of generating the load.
-
-``` gherkin
-Feature: description of the test
-    Background: steps that are common for all (if there is multiple) scenarios
-        Given ...
-        And ...
-    Scenario: steps for a specific flow through a component in the target environment
-        Given ...
-        And ...
-        Then ...
-        When ...
-```
-
-In this package there are modules with step implementations that can be used in both `Background` and `Scenario` sections in a feature file.
-
-@anchor pydoc:grizzly.steps.custom
-## Custom
-
-Custom steps are implemented in your `grizzly` project `features/steps/steps.py` file. This is also the file that imports all `grizzly`-defined step implementations.
-
-There are examples of this in the {@link framework.example}.
-'''
 # flake8: noqa: F401,F403
 from .utils import *
 from .setup import *
diff --git a/grizzly/steps/background/__init__.py b/grizzly/steps/background/__init__.py
index ce756a93..2c3fe124 100644
--- a/grizzly/steps/background/__init__.py
+++ b/grizzly/steps/background/__init__.py
@@ -1,9 +1,9 @@
-'''
+"""
 @anchor pydoc:grizzly.steps.background Background
 This package contains step implementations that only is allowed in the `Background` section in a `Feature`. The steps in `Background` is only executed _once_.
 The feature will fail if they are added into any other section.
 
-``` gherkin
+```gherkin
 Feature: Example
     Background:
         # Here
@@ -13,7 +13,7 @@
 
 They are only allowed in the `Background`-section since they modify parts of the context that are used for all scenarios, which can translate to basic `locust`
 configuration.
-'''
+"""
 
 # flake8: noqa: F401,F403
 from .shapes import *
diff --git a/grizzly/steps/background/setup.py b/grizzly/steps/background/setup.py
index fcc9599d..c03997bb 100644
--- a/grizzly/steps/background/setup.py
+++ b/grizzly/steps/background/setup.py
@@ -1,6 +1,6 @@
-'''
+"""
 @anchor pydoc:grizzly.steps.background.setup Setup
-This module contains step implementations that configures the load test scenario with parameters applicable for all scenarios.'''
+This module contains step implementations that configures the load test scenario with parameters applicable for all scenarios."""
 import parse
 
 from urllib.parse import urlparse
@@ -31,25 +31,25 @@ def parse_message_direction(text: str) -> str:
 
 @given(u'save statistics to "{url}"')
 def step_setup_save_statistics(context: Context, url: str) -> None:
-    '''Sets an URL where locust statistics should be sent.
+    """Sets an URL where locust statistics should be sent.
 
     It has support for InfluxDB and Azure Application Insights endpoints.
 
     For InfluxDB the following format **must** be used:
 
-    ``` plain
+    ```plain
     influxdb://[<username>:<password>@]<hostname>[:<port>]/<database>?TargetEnviroment=<target environment>[&Testplan=<test plan>]
     [&TargetEnvironment=<target environment>][&ProfileName=<profile name>][&Description=<description>]
     ```
 
     For Azure Application Insights the following format **must** be used:
 
-    ``` plain
+    ```plain
     insights://?InstrumentationKey=<instrumentation key>&IngestionEndpoint=<ingestion endpoint>[&Testplan=<test plan>]
     insights://<ingestion endpoint>/?InstrumentationKey=<instrumentation key>[&Testplan=<test plan>]
     ```
 
-    ``` gherkin
+    ```gherkin
     And save statistics to "influxdb://grizzly:secret-password@influx.example.com/grizzly-statistics"
     And save statistics to "insights://?IngestionEndpoint=https://insights.example.com&Testplan=grizzly-statistics&InstrumentationKey=asdfasdfasdf="
     And save statistics to "insights://insights.example.com/?Testplan=grizzly-statistics&InstrumentationKey=asdfasdfasdf="
@@ -58,7 +58,7 @@ def step_setup_save_statistics(context: Context, url: str) -> None:
 
     Args:
         url (str): URL for statistics endpoint
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     url = cast(str, resolve_variable(grizzly, url))
     parsed = urlparse(url)
@@ -70,17 +70,17 @@ def step_setup_save_statistics(context: Context, url: str) -> None:
 
 @given(u'log level is "{log_level}"')
 def step_setup_log_level(context: Context, log_level: str) -> None:
-    '''Configure log level for `grizzly`.
+    """Configure log level for `grizzly`.
 
     Default value is `INFO`, by changing to `DEBUG` there is more information what `grizzly` is doing behind the curtains.
 
-    ``` gherkin
+    ```gherkin
     And log level is "DEBUG"
     ```
 
     Args:
-        log_level (str): allowed values `INFO`, `DEBUG`, `WARNING` och `ERROR`
-    '''
+        log_level (str): one of `INFO`, `DEBUG`, `WARNING`, `ERROR`
+    """
     assert log_level in ['INFO', 'DEBUG', 'WARNING', 'ERROR'], f'log level {log_level} is not supported'
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.setup.log_level = log_level
@@ -88,23 +88,23 @@ def step_setup_log_level(context: Context, log_level: str) -> None:
 
 @given(u'run for maximum "{timespan}"')
 def step_setup_run_time(context: Context, timespan: str) -> None:
-    '''Configures the time period a headless test should run for.
+    """Configures the time period a headless test should run for.
     If available test data is infinite, the test will run forever if this step is not used.
 
-    ``` gherkin
+    ```gherkin
     And run for maximum "1h"
     ```
 
     Args:
         timespan (str): description of how long the test should run for, e.g. 10s, 1h, 40m etc.
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.setup.timespan = timespan
 
 
 @given(u'set global context variable "{variable}" to "{value}"')
 def step_setup_set_global_context_variable(context: Context, variable: str, value: str) -> None:
-    '''Create a global variable in the context. Depending on which type of user a scenario is configured for, different variables
+    """Create a global variable in the context. Depending on which type of user a scenario is configured for, different variables
     are available. Check `grizzly.users` documentation for which context variables are available for each user.
 
     This step can be used if the feature file has multiple scenarios and all of them have the same context variables.
@@ -114,7 +114,7 @@ def step_setup_set_global_context_variable(context: Context, variable: str, valu
 
     E.g. `token.url` and `token/URL` results in:
 
-    ``` json
+    ```json
     {
         'token': {
             'url': '<value>'
@@ -126,7 +126,7 @@ def step_setup_set_global_context_variable(context: Context, variable: str, valu
 
     E.g. `Client ID` results in `client_id`.
 
-    ``` gherkin
+    ```gherkin
     And set global context variable "token.url" to "http://example.com/api/auth"
     And set global context variable "token/client_id" to "aaaa-bbbb-cccc-dddd"
     And set global context variable "token/client secret" to "aasdfasdfasdf=="
@@ -139,7 +139,7 @@ def step_setup_set_global_context_variable(context: Context, variable: str, valu
     Data type of values will be guessed, if not explicitly specified by the type of variable used (`Atomic*`). E.g. the last two
     examples above will result in:
 
-    ``` json
+    ```json
     {
         'validate_certificates': False,
         'run_id': 13
@@ -149,15 +149,32 @@ def step_setup_set_global_context_variable(context: Context, variable: str, valu
     Args:
         variable (str): variable name, as used in templates
         value (str): variable value
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     context_variable = create_context_variable(grizzly, variable, value)
 
     grizzly.setup.global_context = merge_dicts(grizzly.setup.global_context, context_variable)
 
 
-@given(u'add callback "{callback_name}" for message type "{message_type}" from {from_node:MessageDirection} to {to_node:MessageDirection}')
+@given(u'register callback "{callback_name}" for message type "{message_type}" from {from_node:MessageDirection} to {to_node:MessageDirection}')
 def step_setup_message_type_callback(context: Context, callback_name: str, message_type: str, from_node: str, to_node: str) -> None:
+    """Register a custom callback function for a custom message type, that should be sent from client/server to client/server (exclusive).
+
+    ```python title="steps/custom.py"
+    def my_custom_callback(env: Environment, msg: Message) -> None:
+        print(msg)
+    ```
+
+    ```gherkin
+    Given register callback "steps.custom.my_custom_callback" for message type "custom_msg" from client to server
+    ```
+
+    Args:
+        callback_name (str): full namespace and method name to the callback function
+        message_type (str): unique name of the message
+        from_node (MessageDirection): server or client, exclusive
+        to_node (MessageDirection): client or server, exclusive
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
 
     assert from_node != to_node, f'cannot register message handler that sends from {from_node} and is received at {to_node}'
diff --git a/grizzly/steps/background/shapes.py b/grizzly/steps/background/shapes.py
index 718abbe4..55c87a3d 100644
--- a/grizzly/steps/background/shapes.py
+++ b/grizzly/steps/background/shapes.py
@@ -1,7 +1,7 @@
-'''
+"""
 @anchor pydoc:grizzly.steps.background.shapes Shapes
 This module contains step implementations that describes how the load for all scenarios in a feature will look like.
-'''
+"""
 from typing import Any, Dict, cast
 
 import parse
@@ -27,9 +27,9 @@ def parse_user_gramatical_number(text: str) -> str:
 
 @given(u'"{value}" {grammar:UserGramaticalNumber}')
 def step_shapes_user_count(context: Context, value: str, **kwargs: Dict[str, Any]) -> None:
-    '''Set number of users that will generate load.
+    """Set number of users that will generate load.
 
-    ``` gherkin
+    ```gherkin
     Given "5" users
     Given "1" user
     Given "{{ user_count }}"
@@ -37,7 +37,8 @@ def step_shapes_user_count(context: Context, value: str, **kwargs: Dict[str, Any
 
     Args:
         user_count (int): Number of users locust should create
-    '''
+        grammar (UserGramaticalNumber): one of `user`, `users`
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     assert value[0] != '$', 'this expression does not support $conf or $env variables'
     user_count = max(int(round(float(resolve_variable(grizzly, value)), 0)), 1)
@@ -55,9 +56,9 @@ def step_shapes_user_count(context: Context, value: str, **kwargs: Dict[str, Any
 
 @given(u'spawn rate is "{value}" {grammar:UserGramaticalNumber} per second')
 def step_shapes_spawn_rate(context: Context, value: str, **kwargs: Dict[str, Any]) -> None:
-    '''Set rate in which locust shall swarm new user instances.
+    """Set rate in which locust shall swarm new user instances.
 
-    ``` gherkin
+    ```gherkin
     And spawn rate is "5" users per second
     And spawn rate is "1" user per second
     And spawn rate is "0.1" users per second
@@ -65,7 +66,8 @@ def step_shapes_spawn_rate(context: Context, value: str, **kwargs: Dict[str, Any
 
     Args:
         spawn_rate (float): number of users per second
-    '''
+        grammar (UserGramaticalNumber): one of `user`, `users`
+    """
     assert isinstance(value, str), f'{value} is not a string'
     assert value[0] != '$', 'this expression does not support $conf or $env variables'
     grizzly = cast(GrizzlyContext, context.grizzly)
diff --git a/grizzly/steps/scenario/__init__.py b/grizzly/steps/scenario/__init__.py
index 39f532ab..8667197a 100644
--- a/grizzly/steps/scenario/__init__.py
+++ b/grizzly/steps/scenario/__init__.py
@@ -1,8 +1,8 @@
-'''
+"""
 @anchor pydoc:grizzly.steps.scenario Scenario
 This package contains step implementations that only is allowed in the `Scenario` section in a feature file.
 
-``` gherkin
+```gherkin
 Feature: Example
     Background:
         # Not here!
@@ -11,7 +11,7 @@
 ```
 
 The steps in the `Scenario` section modifies the context only for the scenario that they are defined in.
-'''
+"""
 
 # flake8: noqa: F401,F403
 from .tasks import *
diff --git a/grizzly/steps/scenario/response.py b/grizzly/steps/scenario/response.py
index e75c4869..fb43db9f 100644
--- a/grizzly/steps/scenario/response.py
+++ b/grizzly/steps/scenario/response.py
@@ -50,7 +50,7 @@ def step_response_save_matches(context: Context, target: ResponseTarget, express
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     # only token is matched and saved in TOKEN, by using regexp match groups
     And value for variable "TOKEN" is "none"
     Then save response metadata "$.Authentication" that matches "Bearer (.*)$" in variabel "TOKEN"
@@ -73,7 +73,7 @@ def step_response_save_matches(context: Context, target: ResponseTarget, express
     ```
 
     Args:
-        target (enum): "metadata" or "payload", depending on which part of the response should be used
+        target (ResponseTarget): `metadata` or `payload`, depending on which part of the response should be used
         expression (str): JSON path or XPath expression for finding the property
         match_with (str): static value or a regular expression
         variable (str): name of the already initialized variable to save the value in
@@ -93,7 +93,7 @@ def step_response_save(context: Context, target: ResponseTarget, expression: str
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     Then save response metadata "$.Authentication" in variable "HEADER_AUTHENTICATION"
 
     Then save response payload "$.Result.ShipmentId" in variable "ShipmentId"
@@ -102,7 +102,7 @@ def step_response_save(context: Context, target: ResponseTarget, expression: str
     ```
 
     Args:
-        target (enum): "metadata" or "payload", depending on which part of the response should be used
+        target (ResponseTarget): `metadata` or `payload`, depending on which part of the response should be used
         expression (str): JSON path or XPath expression for finding the property
         variable (str): name of the already initialized variable to save the value in
     """
@@ -115,7 +115,7 @@ def step_response_validate(context: Context, target: ResponseTarget, expression:
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And restart scenario on failure
     When response metadata "$.['content-type']" is not ".*application/json.*" fail request
     When response metadata "$.['x-test-command']" is "abort" fail request
@@ -128,7 +128,7 @@ def step_response_validate(context: Context, target: ResponseTarget, expression:
     ```
 
     Args:
-        target (enum): "metadata" or "payload", depending on which part of the response should be used
+        target (ResponseTarget): `metadata` or `payload`, depending on which part of the response should be used
         expression (str): JSON path or XPath expression for finding the property
         condition (enum): "is" or "is not" depending on negative or postive matching
         match_with (str): static value or a regular expression
@@ -145,7 +145,7 @@ def step_response_allow_status_codes(context: Context, status_list: str) -> None
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     Then get request with name "test-get-1" from endpoint "/api/test"
     And allow response status "200,302"
 
@@ -182,7 +182,7 @@ def step_response_allow_status_codes_table(context: Context) -> None:
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     Then get request with name "test-get-1" from endpoint "/api/test"
     Then get request with name "test-get-2" from endpoint "/api/test"
     And allow response status
@@ -227,7 +227,7 @@ def step_response_content_type(context: Context, content_type: TransformerConten
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And set response content type to "json"
     And set response content type to "application/json"
     And set response content type to "xml"
@@ -237,7 +237,7 @@ def step_response_content_type(context: Context, content_type: TransformerConten
     ```
 
     Args:
-        content_type (TransformerContentType): expected content type of response
+        content_type (ContentType): expected content type of response
     """
 
     assert content_type != TransformerContentType.UNDEFINED, 'It is not allowed to set UNDEFINED with this step'
diff --git a/grizzly/steps/scenario/results.py b/grizzly/steps/scenario/results.py
index fe8f7ac7..c1e2b781 100644
--- a/grizzly/steps/scenario/results.py
+++ b/grizzly/steps/scenario/results.py
@@ -1,8 +1,8 @@
-'''
+"""
 @anchor pydoc:grizzly.steps.scenario.results Results
 This module contains step implementations that validates the total response results for all {@pylink grizzly.tasks} in a scenario, based on
 locust statistics like response time and failures.
-'''
+"""
 from typing import cast
 
 from grizzly.types.behave import Context, when
@@ -11,7 +11,7 @@
 
 @when(u'fail ratio is greater than "{fail_ratio:d}"% fail scenario')
 def step_results_fail_ratio(context: Context, fail_ratio: int) -> None:
-    '''Sets how many percentages of requests that are allowed to fail before the whole scenario will be set as failed.
+    """Sets how many percentages of requests that are allowed to fail before the whole scenario will be set as failed.
 
     This step cannot be used in combination with:
 
@@ -23,13 +23,13 @@ def step_results_fail_ratio(context: Context, fail_ratio: int) -> None:
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     When fail ratio is greater than "8"% fail scenario
     ```
 
     Args:
         fail_ratio (int): percentage of requests that are allowed to fail
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     assert grizzly.scenario.failure_exception is None, f"cannot use step 'fail ratio is greater than \"{fail_ratio}\" fail scenario' togheter with 'on failure' steps"
     grizzly.scenario.validation.fail_ratio = fail_ratio / 100.0
@@ -37,40 +37,40 @@ def step_results_fail_ratio(context: Context, fail_ratio: int) -> None:
 
 @when(u'average response time is greater than "{avg_response_time:d}" milliseconds fail scenario')
 def step_results_avg_response_time(context: Context, avg_response_time: int) -> None:
-    '''Sets the average response time (milliseconds) that all requests in a scenario must be below
+    """Sets the average response time (milliseconds) that all requests in a scenario must be below
     for it to pass.
 
     Default behavior is not to validate the result for a scenario based on average response time.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     When average response time is greater than "200" milliseconds fail scenario
     ```
 
     Args:
         avg_response_time (int): allowed average response time in milliseconds
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.scenario.validation.avg_response_time = avg_response_time
 
 
 @when(u'response time percentile "{percentile:d}"% is greater than "{response_time:d}" milliseconds fail scenario')
 def step_results_response_time_percentile(context: Context, percentile: float, response_time: int) -> None:
-    '''Sets the response time that a specified percentile of the requests needs to be below for the scenario to pass.
+    """Sets the response time that a specified percentile of the requests needs to be below for the scenario to pass.
 
     Default behavior is not to validate the result for a scenario based on percetile response times.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     When response time percentile "95"% is greater than "200" milliseconds fail scenario
     ```
 
     Args:
         percentile (int): percentile to validate (1-100)
         response_time (int): response time in milliseconds
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.scenario.validation.response_time_percentile = GrizzlyContextScenarioResponseTimePercentile(
         response_time=response_time,
diff --git a/grizzly/steps/scenario/setup.py b/grizzly/steps/scenario/setup.py
index fd0056be..7ec7f631 100644
--- a/grizzly/steps/scenario/setup.py
+++ b/grizzly/steps/scenario/setup.py
@@ -1,4 +1,4 @@
-'''This module contains step implementations that setup the load test scenario with parameters that is going to be used in the scenario they are defined in.'''
+"""This module contains step implementations that setup the load test scenario with parameters that is going to be used in the scenario they are defined in."""
 from typing import Optional, cast
 
 import parse
@@ -29,7 +29,7 @@ def parse_iteration_gramatical_number(text: str) -> str:
 
 @given(u'set context variable "{variable}" to "{value}"')
 def step_setup_set_context_variable(context: Context, variable: str, value: str) -> None:
-    '''Sets a variable in the scenario context.
+    """Sets a variable in the scenario context.
 
     Variable names can contain (one or more) dot (`.`) or slash (`/`) to indicate that the variable has a nested structure. E.g. `token.url`
     and `token/url` results in `{'token': {'url': '<value'>}}`
@@ -41,7 +41,7 @@ def step_setup_set_context_variable(context: Context, variable: str, value: str)
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And set context variable "token.url" to "https://example.com/api/auth"
     And set context variable "token/client_id" to "aaaa-bbbb-cccc-dddd"
     And set context variable "token/client secret" to "aasdfasdfasdf=="
@@ -53,7 +53,7 @@ def step_setup_set_context_variable(context: Context, variable: str, value: str)
     Args:
         variable (str): name, can contain `.` and `/`
         value (str): value, data type will be guessed and casted
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     context_variable = create_context_variable(grizzly, variable, value)
 
@@ -62,14 +62,14 @@ def step_setup_set_context_variable(context: Context, variable: str, value: str)
 
 @given(u'repeat for "{value}" {iteration_number:IterationGramaticalNumber}')
 def step_setup_iterations(context: Context, value: str, iteration_number: str) -> None:
-    '''Sets how many iterations of the {@pylink grizzly.tasks} in the scenario should execute.
+    """Sets how many iterations of the {@pylink grizzly.tasks} in the scenario should execute.
 
     Default value is `1`. A value of `0` means to run until all test data is consumed, or that the (optional) specified
     runtime for the scenario is reached.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And repeat for "10" iterations
     And repeat for "1" iteration
     And value for variable "leveranser" is "100"
@@ -78,7 +78,7 @@ def step_setup_iterations(context: Context, value: str, iteration_number: str) -
 
     Args:
         value (str): number of iterations of the scenario, can be a templatning string or a environment configuration variable
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     should_resolve = is_template(value) or value[0] == '$'
     iterations = max(int(round(float(resolve_variable(grizzly, value)), 0)), 0)
@@ -104,7 +104,7 @@ def step_setup_pace(context: Context, pace_time: str) -> None:
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     Then set iteration time to "2000" milliseconds
     Then set iteration time to "{{ pace }}" milliseconds
     ```
@@ -124,14 +124,14 @@ def step_setup_pace(context: Context, pace_time: str) -> None:
 
 @given(u'set alias "{alias}" for variable "{variable}"')
 def step_setup_set_variable_alias(context: Context, alias: str, variable: str) -> None:
-    '''Creates an alias for a variable that points to another structure in the context.
+    """Creates an alias for a variable that points to another structure in the context.
 
     This is useful if you have test data that somehow should change the behavior for a
     user, e.g. username and password.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And value for variable "AtomicCsvReader.users" is "users.csv | repeat=True"
     And set alias "auth.user.username" for variable "AtomicCsvReader.users.username"
     And set alias "auth.user.password" for variable "AtomicCsvReader.users.password"
@@ -142,7 +142,7 @@ def step_setup_set_variable_alias(context: Context, alias: str, variable: str) -
     Args:
         alias (str): which node in the context that should get the value of `variable`
         variable (str): an already initialized variable that should be renamed
-    '''
+    """
 
     grizzly = cast(GrizzlyContext, context.grizzly)
 
@@ -159,32 +159,32 @@ def step_setup_set_variable_alias(context: Context, alias: str, variable: str) -
 
 @given(u'log all requests')
 def step_setup_log_all_requests(context: Context) -> None:
-    '''Sets if all requests should be logged to a file.
+    """Sets if all requests should be logged to a file.
 
     By default only failed requests (and responses) will be logged.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And log all requests
     ```
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.scenario.context['log_all_requests'] = True
 
 
 @given(u'stop user on failure')
 def step_setup_stop_user_on_failure(context: Context) -> None:
-    '''Stop user if a request fails.
+    """Stop user if a request fails.
 
     Default behavior is to continue the scenario if a request fails.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And stop user on failure
     ```
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.scenario.failure_exception = StopUser
     context.config.stop = True
@@ -192,16 +192,16 @@ def step_setup_stop_user_on_failure(context: Context) -> None:
 
 @given(u'restart scenario on failure')
 def step_setup_restart_scenario_on_failure(context: Context) -> None:
-    '''Restart scenario, from first task, if a request fails.
+    """Restart scenario, from first task, if a request fails.
 
     Default behavior is to continue the scenario if a request fails.
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And restart scenario on failure
     ```
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
     grizzly.scenario.failure_exception = RestartScenario
     context.config.stop = False
@@ -210,7 +210,7 @@ def step_setup_restart_scenario_on_failure(context: Context) -> None:
 @then(u'metadata "{key}" is "{value}"')
 @given(u'metadata "{key}" is "{value}"')
 def step_setup_metadata(context: Context, key: str, value: str) -> None:
-    '''Set a metadata (header) value to be used by the user when sending requests.
+    """Set a metadata (header) value to be used by the user when sending requests.
 
     When step expression is used before any tasks has been added in the scenario the metadata will
     be used for all requests the specified loadtesting user executes in the scenario.
@@ -223,20 +223,20 @@ def step_setup_metadata(context: Context, key: str, value: str) -> None:
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     And metadata "Content-Type" is "application/xml"
     And metadata "Ocp-Apim-Subscription-Key" is "9asdf00asdf00adsf034"
     ```
 
     Or, for use in one request only, specify metadata after the request:
-    ``` gherkin
+    ```gherkin
     Then post request ...
     And metadata "x-header" is "{{ value }}"
 
     Then get "https://{{ client_url }}" with name "client-http" and save response payload in "payload"
     And metadata "Ocp-Apim-Subscription-Key" is "deadbeefb00f"
     ```
-    '''
+    """
 
     grizzly = cast(GrizzlyContext, context.grizzly)
     casted_value = resolve_variable(grizzly, value)
diff --git a/grizzly/steps/scenario/tasks.py b/grizzly/steps/scenario/tasks.py
deleted file mode 100644
index 33528ac4..00000000
--- a/grizzly/steps/scenario/tasks.py
+++ /dev/null
@@ -1,934 +0,0 @@
-"""
-This module contains step implementations that creates requests executed by the specified {@pylink grizzly.users}
-in the scenario.
-"""
-from typing import cast
-from json import loads as jsonloads, JSONDecodeError
-
-from grizzly.types import RequestDirection, RequestMethod
-from grizzly.types.behave import Context, register_type, then, given, when
-from grizzly.steps._helpers import add_request_task, get_task_client, is_template
-from grizzly.context import GrizzlyContext
-from grizzly.tasks import (
-    LogMessageTask,
-    WaitTask,
-    TransformerTask,
-    UntilRequestTask,
-    DateTask,
-    AsyncRequestGroupTask,
-    TimerTask,
-    TaskWaitTask,
-    LoopTask,
-    ConditionalTask,
-    KeystoreTask,
-)
-
-from grizzly_extras.transformer import TransformerContentType
-
-
-register_type(
-    Direction=RequestDirection.from_string,
-    Method=RequestMethod.from_string,
-    ContentType=TransformerContentType.from_string,
-)
-
-
-@then(u'{method:Method} request with name "{name}" from endpoint "{endpoint}" until "{condition}"')
-def step_task_request_with_name_endpoint_until(context: Context, method: RequestMethod, name: str, endpoint: str, condition: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.until} task, see task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Then get request with name "test-get" from endpoint "/api/test | content_type=json" until "$.`this`[?success==true]"
-    Then receive request with name "test-receive" from endpoint "queue:receive-queue | content_type=xml" until "/header/success[. == 'True']"
-    ```
-
-    Args:
-        method (RequestMethod): type of request
-        name (str): name of the requests in logs, can contain variables
-        direction (RequestDirection): one of `to` or `from` depending on the value of `method`
-        endpoint (str): URI relative to `host` in the scenario, can contain variables and in certain cases `user_class_name` specific parameters
-        condition (str): JSON or XPath expression for specific value in response payload
-    """
-
-    assert method.direction == RequestDirection.FROM, 'this step is only valid for request methods with direction FROM'
-    assert context.text is None, 'this step does not have support for step text'
-
-    request_tasks = add_request_task(context, method=method, source=context.text, name=name, endpoint=endpoint, in_scenario=False)
-
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.async_group is None, f'until tasks cannot be in an async request group, close group {grizzly.scenario.tasks.tmp.async_group.name} first'
-
-    for request_task, substitues in request_tasks:
-        condition_rendered = condition
-        for key, value in substitues.items():
-            condition_rendered = condition_rendered.replace(f'{{{{ {key} }}}}', value)
-
-        grizzly.scenario.tasks.add(UntilRequestTask(
-            grizzly,
-            request=request_task,
-            condition=condition_rendered,
-        ))
-
-
-@then(u'{method:Method} request with name "{name}" {direction:Direction} endpoint "{endpoint}"')
-def step_task_request_text_with_name_endpoint(context: Context, method: RequestMethod, name: str, direction: RequestDirection, endpoint: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.request} task, where optional payload is defined directly in the feature file.
-    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
-
-    * If `method` in the expression is `get` or `receive`; the `direction` **must** be `from`.
-
-    * If `method` in the expression is `post`, `pust`, or `send`; the `direction` **must** be `to`, and payload defined in the feature file.
-
-    Example:
-
-    ``` gherkin
-    Then post request with name "test-post" to endpoint "/api/test"
-        \"\"\"
-        {
-            "test": "hello world"
-        }
-        \"\"\"
-    Then put request with name "test-put" to endpoint "/api/test"
-        \"\"\"
-        {
-            "test": "hello world"
-        }
-        \"\"\"
-    Then get request with name "test-get" from endpoint "/api/test"
-
-    Then send request with name "test-send" to endpoint "queue:receive-queue"
-        \"\"\"
-        {
-            "value": "do something"
-        }
-        \"\"\"
-    Then receive request with name "test-receive" from endpoint "queue:receive-queue"
-    ```
-
-    Args:
-        method (RequestMethod): type of request
-        name (str): name of the requests in logs, can contain variables
-        direction (RequestDirection): one of `to` or `from` depending on the value of `method`
-        endpoint (str): URI relative to `host` in the scenario, can contain variables and in certain cases `user_class_name` specific parameters
-    """
-
-    assert isinstance(direction, RequestDirection), 'invalid direction specified in expression'
-
-    if method.direction == RequestDirection.FROM:
-        assert context.text is None, f'step text is not allowed for {method.name}'
-        assert direction == RequestDirection.FROM, f'"to endpoint" is not allowed for {method.name}, use "from endpoint"'
-    elif method.direction == RequestDirection.TO:
-        assert context.text is not None, f'step text is mandatory for {method.name}'
-        assert direction == RequestDirection.TO, f'"from endpoint" is not allowed for {method.name}, use "to endpoint"'
-
-    add_request_task(context, method=method, source=context.text, name=name, endpoint=endpoint)
-
-
-@then(u'{method:Method} request "{source}" with name "{name}" to endpoint "{endpoint}"')
-def step_task_request_file_with_name_endpoint(context: Context, method: RequestMethod, source: str, name: str, endpoint: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.request} task, where the payload is defined in a template file.
-
-    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
-
-    Example:
-
-    ``` gherkin
-    Then send request "test/request.j2.json" with name "test-send" to endpoint "queue:receive-queue"
-    Then post request "test/request.j2.json" with name "test-post" to endpoint "/api/test"
-    Then put request "test/request.j2.json" with name "test-put" to endpoint "/api/test"
-    ```
-
-    Args:
-        method (RequestMethod): type of request
-        source (str): path to a template file relative to the directory `requests/`, which **must** exist in the directory the feature file is located
-        name (str): name of the requests in logs, can contain variables
-        endpoint (str): URI relative to `host` in the scenario, can contain variables and in certain cases `user_class_name` specific parameters
-    """
-    assert method.direction == RequestDirection.TO, f'{method.name} is not allowed'
-    assert context.text is None, f'step text is not allowed for {method.name}'
-    assert not is_template(source), 'source file cannot be a template'
-    add_request_task(context, method=method, source=source, name=name, endpoint=endpoint)
-
-
-@then(u'{method:Method} request "{source}" with name "{name}"')
-def step_task_request_file_with_name(context: Context, method: RequestMethod, source: str, name: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.request} task, with the same `endpoint` as the previous Request task, where the
-    payload is defined in a template file.
-
-    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
-
-    Example:
-
-    ``` gherkin
-    Then post request "test/request1.j2.json" with name "test-post1" to endpoint "/api/test"
-    Then post request "test/request2.j2.json" with name "test-post2"
-
-    # same as
-    Then post request "test/request1.j2.json" with name "test-post1" to endpoint "/api/test"
-    Then post request "test/request2.j2.json" with name "test-post2" to endpoint "/api/test"
-    ```
-
-    Args:
-        method (RequestMethod): type of request
-        source (str): path to a template file relative to the directory `requests/`, which **must** exist in the directory the feature file is located
-        name (str): name of the requests in logs, can contain variables
-    """
-    assert method.direction == RequestDirection.TO, f'{method.name} is not allowed'
-    assert context.text is None, f'step text is not allowed for {method.name}'
-    assert not is_template(source), 'source file cannot be a template'
-    add_request_task(context, method=method, source=source, name=name)
-
-
-@then(u'{method:Method} request with name "{name}"')
-def step_task_request_text_with_name(context: Context, method: RequestMethod, name: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.request} task, where optional payload is defined directly in the feature file.
-
-    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
-
-    If `method` in the expression is `post`, `put` or `send` the payload in the request **must** be defined directly in the feature file after the step.
-    This step is useful if `method` and `endpoint` are the same as previous request, but the payload should be different.
-
-    Example:
-
-    ``` gherkin
-    # example-1
-    Then post request with name "test-post-1" to endpoint "/api/test"
-        \"\"\"
-        {
-            "value": "hello world!"
-        }
-        \"\"\"
-    Then post request with name "test-post-2"
-        \"\"\"
-        {
-            "value": "i have good news!"
-        }
-        \"\"\"
-
-    # same as example-1
-    Then post request with name "test-post-1" to endpoint "/api/test"
-        \"\"\"
-        {
-            "value": "hello world!"
-        }
-        \"\"\"
-    Then post request with name "test-post-2" to endpoint "/api/test"
-        \"\"\"
-        {
-            "value": "i have good news!"
-        }
-        \"\"\"
-
-    # example-2
-    Then get request with name "test-get-1" from endpoint "/api/test"
-    Then get request with name "test-get-2"
-
-    # same as example-2
-    Then get request with name "test-get-1" from endpoint "/api/test"
-    Then get request with name "test-get-2" from endpoint "/api/test"
-    ```
-
-    Args:
-        method (RequestMethod): type of request
-        name (str): name of the requests in logs, can contain variables
-    """
-
-    if method.direction == RequestDirection.FROM:
-        assert context.text is None, f'Step text is not allowed for {method.name}'
-    elif method.direction == RequestDirection.TO:
-        assert context.text is not None, f'Step text is mandatory for {method.name}'
-
-    add_request_task(context, method=method, source=context.text, name=name)
-
-
-@then(u'wait for "{wait_time_expression}" seconds')
-def step_task_wait_seconds(context: Context, wait_time_expression: str) -> None:
-    """
-    Creates an instace of the {@pylink grizzly.tasks.wait} task. The scenario will wait the specified time (seconds) in
-    additional to the wait time specified by {@pylink grizzly.tasks.task_wait}.
-
-    See {@pylink grizzly.tasks.wait} task documentation for more information about the task.
-
-    Example:
-
-    ``` gherkin
-    And ask for value of variable "wait_time"
-    And wait "1.5..2.5" seconds between tasks
-    ...
-    Then wait for "1.5" seconds
-    ...
-    Then wait for "{{ wait_time }}" seconds
-    ```
-
-    Above combinations of steps will result in a wait time between 3 and 4 seconds for the first {@pylink grizzly.tasks} that is defined after the
-    `Then wait for...`-step.
-
-    Args:
-        wait_time (float): wait time in seconds
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    if not is_template(wait_time_expression):
-        try:
-            assert float(wait_time_expression) > 0.0, 'wait time cannot be less than 0.0 seconds'
-        except ValueError:
-            raise AssertionError(f'"{wait_time_expression}" is not a template nor a float')
-
-    grizzly.scenario.tasks.add(WaitTask(time_expression=wait_time_expression))
-
-
-@then(u'log message "{message}"')
-def step_task_log_message(context: Context, message: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.log_message} task. Prints a log message in the console, useful for troubleshooting values
-    of variables or set markers in log files.
-
-    The message supports {@link framework.usage.variables.templating}. See {@pylink grizzly.tasks.log_message} task documentation for more
-    information about the task.
-
-    Example:
-
-    ``` gherkin
-    And log message "context_variable='{{ context_variable }}'
-    ```
-
-    Args:
-        message (str): message to print
-    """
-
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    grizzly.scenario.tasks.add(LogMessageTask(message=message))
-
-
-@then(u'parse "{content}" as "{content_type:ContentType}" and save value of "{expression}" in variable "{variable}"')
-def step_task_transform(context: Context, content: str, content_type: TransformerContentType, expression: str, variable: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.transformer} task. Transforms the specified `content` with `content_type` to an
-    object that an transformer can extract information from with the specified `expression`.
-
-    See {@pylink grizzly.tasks.transformer} task documentation for more information about the task.
-
-    Example:
-
-    ``` gherkin
-    And value for variable "document_id" is "None"
-    And value for variable "document_title" is "None"
-    And value for variable "document" is "{\"document\": {\"id\": \"DOCUMENT_8843-1\", \"title\": \"TPM Report 2021\"}}"
-    ...
-    Then parse "{{ document }}" as "json" and save value of "$.document.id" in variable "document_id"
-    Then parse "{{ document }}" as "json" and save value of "$.document.title" in variable "document_title"
-    ```
-
-    Args:
-        contents (str): contents to parse, supports {@link framework.usage.variables.templating} or a static string
-        content_type (TransformerContentType): MIME type of `contents`
-        expression (str): JSON or XPath expression for specific value in `contents`
-        variable (str): name of variable to save value to, must have been initialized
-    """
-
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    grizzly.scenario.tasks.add(TransformerTask(
-        grizzly,
-        content=content,
-        content_type=content_type,
-        expression=expression,
-        variable=variable,
-    ))
-
-
-@then(u'get "{endpoint}" with name "{name}" and save response payload in "{payload_variable}" and metadata in "{metadata_variable}"')
-def step_task_client_get_endpoint_payload_metadata(context: Context, endpoint: str, name: str, payload_variable: str, metadata_variable: str) -> None:
-    """
-    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
-    based on the URL scheme specified in `endpoint`. Gets information from another host or endpoint than the scenario
-    is load testing and saves the response in a variable.
-
-    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
-
-    Example:
-
-    ``` gherkin
-    Then get "https://www.example.org/example.json" with name "example-1" and save response payload in "example_openapi" and metadata in "example_metadata"
-    Then get "http://{{ endpoint }}" with name "example-2" and save response payload in "endpoint_result" and metadata in "result_metadata"
-    ```
-
-    Args:
-        endpoint (str): information about where to get information, see the specific getter task implementations for more information
-        name (str): name of the request, used in request statistics
-        payload_variable (str): name of, initialized, variable where response payload will be saved in
-        metadata_variable (str): name of, initialized, variable where response metadata will be saved in
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
-        RequestDirection.FROM,
-        endpoint,
-        name,
-        payload_variable=payload_variable,
-        metadata_variable=metadata_variable,
-        text=context.text,
-    ))
-
-
-@then(u'get "{endpoint}" with name "{name}" and save response payload in "{variable}"')
-def step_task_client_get_endpoint_payload(context: Context, endpoint: str, name: str, variable: str) -> None:
-    """
-    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
-    based on the URL scheme specified in `endpoint`. Gets information from another host or endpoint than the scenario
-    is load testing and saves the response in a variable.
-
-    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
-
-    Example:
-
-    ``` gherkin
-    Then get "https://www.example.org/example.json" with name "example-1" and save response payload in "example_openapi"
-    Then get "http://{{ endpoint }}" with name "example-2" and save response payload in "endpoint_result"
-    ```
-
-    Args:
-        endpoint (str): information about where to get information, see the specific getter task implementations for more information
-        name (str): name of the request, used in request statistics
-        variable (str): name of, initialized, variable where response payload will be saved in
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
-        RequestDirection.FROM,
-        endpoint,
-        name,
-        payload_variable=variable,
-        metadata_variable=None,
-        text=context.text,
-    ))
-
-
-@then(u'get "{endpoint}" with name "{name}" until "{condition}"')
-def step_task_client_get_endpoint_until(context: Context, endpoint: str, name: str, condition: str) -> None:
-    """
-    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
-    based on the URL scheme specified in `endpoint`. Gets information, repeated from another host or endpoint than the scenario
-    is load testing until the response matches `expression`.
-
-    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
-
-    Example:
-
-    ``` gherkin
-    Then get "https://www.example.org/example.json" with name "example-1" until "$.response[status='Success']
-    Then get "http://{{ endpoint }}" with name "example-2" until "//*[@status='Success']"
-    ```
-
-    Args:
-        endpoint (str): information about where to get information, see the specific getter task implementations for more information
-        name (str): name of the request, used in request statistics
-        condition (str): JSON or XPath expression for specific value in response payload
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    client_request = get_task_client(grizzly, endpoint)(
-        RequestDirection.FROM,
-        endpoint,
-        name,
-        text=context.text,
-    )
-
-    grizzly.scenario.tasks.add(UntilRequestTask(
-        grizzly,
-        request=client_request,
-        condition=condition,
-    ))
-
-
-@then(u'put "{source}" to "{endpoint}" with name "{name}" as "{destination}"')
-def step_task_client_put_endpoint_file_destination(context: Context, source: str, endpoint: str, name: str, destination: str) -> None:
-    """
-    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
-    based on the URL scheme specified in `endpoint`. Puts information, source being a file, to another host or endpoint than the scenario
-    is load testing and saves the response in a variable
-
-    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
-
-    Example:
-
-    ``` gherkin
-    Then put "test-file.json" to "bs://my-storage?AccountKey=aaaabbb=&Container=my-container" with name "upload-file" as "uploaded-test-file.json"
-    ```
-
-    Args:
-        source (str): relative path to file in `feature/requests`, supports {@link framework.usage.variables.templating}
-        endpoint (str): information about where to get information, see the specific getter task implementations for more information
-        name (str): name of the request, used in request statistics
-        destination (str): name of source on the destination
-    """
-    assert context.text is None, 'step text is not allowed for this step expression'
-    assert not is_template(source), 'source file cannot be a template'
-
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
-        RequestDirection.TO,
-        endpoint,
-        name,
-        source=source,
-        destination=destination,
-    ))
-
-
-@then(u'put "{source}" to "{endpoint}" with name "{name}"')
-def step_task_client_put_endpoint_file(context: Context, source: str, endpoint: str, name: str) -> None:
-    """
-    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
-    based on the URL scheme specified in `endpoint`. Puts information, source being a file, to another host or endpoint than the scenario
-    is load testing and saves the response in a variable
-
-    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
-
-    Example:
-
-    ``` gherkin
-    Then put "test-file.json" to "bs://my-storage?AccountKey=aaaabbb=&Container=my-container" with name "upload-file"
-    ```
-
-    Args:
-        source (str): relative path to file in `feature/requests`, supports {@link framework.usage.variables.templating}
-        endpoint (str): information about where to get information, see the specific getter task implementations for more information
-        name (str): name of the request, used in request statistics
-    """
-    assert context.text is None, 'step text is not allowed for this step expression'
-    assert not is_template(source), 'source file cannot be a template'
-
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
-        RequestDirection.TO,
-        endpoint,
-        name,
-        source=source,
-        destination=None,
-    ))
-
-
-@then(u'parse date "{value}" and save in variable "{variable}"')
-def step_task_date(context: Context, value: str, variable: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.date} task. Parses a datetime string and transforms it according to specified
-    arguments.
-
-    See {@pylink grizzly.tasks.date} task documentation for more information about arguments.
-
-    This step is useful when changes has to be made to a datetime representation during an iteration of a scenario.
-
-    Example:
-
-    ``` gherkin
-    ...
-    And value for variable "date1" is "none"
-    And value for variable "date2" is "none"
-    And value for variable "date3" is "none"
-    And value for variable "AtomicDate.test" is "now"
-
-    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
-    Then parse date "{{ AtomicDate.test }} | offset=-1D" and save in variable "date2"
-    Then parse date "{{ datetime.now() }} | offset=1Y" and save in variable "date3"
-    ```
-
-    Args:
-        value (str): datetime string and arguments
-        variable (str): name of, initialized, variable where response will be saved in
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    assert variable in grizzly.state.variables, f'variable {variable} has not been initialized'
-
-    grizzly.scenario.tasks.add(DateTask(
-        value=value,
-        variable=variable,
-    ))
-
-
-@given(u'an async request group with name "{name}"')
-def step_task_async_group_start(context: Context, name: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.async_group} task. All {@pylink grizzly.tasks.request} tasks created after this step will be added to the
-    request group, until the group is closed.
-
-    See {@pylink grizzly.tasks.async_group} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Given an async request group with name "async-group-1"
-    Then post request with name "test-post-2" to endpoint "/api/test"
-        \"\"\"
-        {
-            "value": "i have good news!"
-        }
-        \"\"\"
-
-    Then get request with name "test-get-1" from endpoint "/api/test"
-    And close async request group
-    ```
-
-    In this example, the `put` and `get` requests will run asynchronously, and both requests will block following requests until both are finished.
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.async_group is None, f'async request group "{grizzly.scenario.tasks.tmp.async_group.name}" has not been closed'
-
-    grizzly.scenario.tasks.tmp.async_group = AsyncRequestGroupTask(name=name)
-
-
-@then(u'close async request group')
-def step_task_async_group_close(context: Context) -> None:
-    """
-    Closes the instance created in {@pylink grizzly.steps.scenario.tasks.step_task_async_group_start}, and adds the {@pylink grizzly.tasks.async_group} task to the list of tasks
-    that the scenario is going to execute.
-
-    See {@pylink grizzly.tasks.async_group} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Given an async request group with name "async-group-1"
-    Then post request with name "test-post-2" to endpoint "/api/test"
-        \"\"\"
-        {
-            "value": "i have good news!"
-        }
-        \"\"\"
-
-    Then get request with name "test-get-1" from endpoint "/api/test"
-    And close async request group
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    async_group = grizzly.scenario.tasks.tmp.async_group
-
-    assert async_group is not None, 'no async request group is open'
-    assert len(async_group.tasks) > 0, f'there are no requests in async group "{async_group.name}"'
-
-    grizzly.scenario.tasks.tmp.async_group = None
-    grizzly.scenario.tasks.add(async_group)
-
-
-@then(u'start timer with name "{name}"')
-def step_task_timer_start(context: Context, name: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.timer} task. Starts a timer to measure the "request time" for all tasks between
-    the start and stop of the timer.
-
-    See {@pylink grizzly.tasks.timer} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Then start timer with name "parsing-xml"
-    ...
-    And stop timer with name "parsing-xml"
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    assert name not in grizzly.scenario.tasks.tmp.timers, f'timer with name {name} has already been defined'
-
-    task = TimerTask(name=name)
-
-    grizzly.scenario.tasks.tmp.timers.update({
-        name: task,
-    })
-
-    grizzly.scenario.tasks.add(task)
-
-
-@then(u'stop timer with name "{name}"')
-def step_task_timer_stop(context: Context, name: str) -> None:
-    """
-    Adds the instance created by {@pylink grizzly.steps.scenario.tasks.step_task_timer_start} to the list of scenario tasks.
-
-    See {@pylink grizzly.tasks.timer} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Then start timer with name "parsing-xml"
-    ...
-    And stop timer with name "parsing-xml"
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    task = grizzly.scenario.tasks.tmp.timers.get(name, None)
-
-    assert task is not None, f'timer with name {name} has not been defined'
-
-    grizzly.scenario.tasks.add(task)
-    grizzly.scenario.tasks.tmp.timers.update({name: None})
-
-
-@given(u'wait "{min_time:g}..{max_time:g}" seconds between tasks')
-def step_task_wait_between(context: Context, min_time: float, max_time: float) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.task_wait} task. Sets number of, randomly, seconds the {@pylink grizzly.users}
-    will wait between executing each task.
-
-    See {@pylink grizzly.tasks.task_wait} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    And wait "1.4..1.7" seconds between tasks
-    # wait between 1.4 and 1.7 seconds
-    Then get request with name "test-get-1" from endpoint "..."
-    # wait between 1.4 and 1.7 seconds
-    Then get request with name "test-get-2" from endpoint "..."
-    # wait between 1.4 and 1.7 seconds
-    And wait "0.1" seconds between tasks
-    # wait 0.1 seconds
-    Then get request with name "test-get-3" from endpoint "..."
-    # wait 0.1 seconds
-    ...
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    if min_time > max_time:
-        min_time, max_time = max_time, min_time
-
-    grizzly.scenario.tasks.add(TaskWaitTask(min_time=min_time, max_time=max_time))
-
-
-@given(u'wait "{time:g}" seconds between tasks')
-def step_task_wait_constant(context: Context, time: float) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.task_wait} task. Sets number of, constant, seconds the {@pylink grizzly.users}
-    will wait between executing each task.
-
-    See {@pylink grizzly.tasks.task_wait} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    And wait "1.4" seconds between tasks
-    # wait 1.4 seconds
-    Then get request with name "test-get-1" from endpoint "..."
-    # wait 1.4 seconds
-    Then get request with name "test-get-2" from endpoint "..."
-    # wait 1.4 seconds
-    And wait "0.1" seconds between tasks
-    # wait 0.1 seconds
-    Then get request with name "test-get-3" from endpoint "..."
-    # wait 0.1 seconds
-    ...
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    grizzly.scenario.tasks.add(TaskWaitTask(time))
-
-
-@when(u'condition "{condition}" with name "{name}" is true, execute these tasks')
-def step_task_conditional_if(context: Context, condition: str, name: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.conditional} task which executes different sets of task depending on `condition`.
-    Also sets the task in a state that any following tasks will be run when `condition` is true.
-
-    See {@pylink grizzly.tasks.conditional} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    When condition "{{ value | int > 0 }}" with name "value-conditional" is true, execute these tasks
-    Then get request with name "get-when-true" from endpoint "/api/true"
-    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
-    But if condition is false, execute these tasks
-    Then get request with name "get-when-false" from endpoint "/api/false"
-    Then end condition
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.conditional is None, f'cannot create a new conditional while "{grizzly.scenario.tasks.tmp.conditional.name}" is still open'
-
-    grizzly.scenario.tasks.tmp.conditional = ConditionalTask(
-        name=name,
-        condition=condition,
-    )
-    grizzly.scenario.tasks.tmp.conditional.switch(True)
-
-
-@then(u'if condition is false, execute these tasks')
-def step_task_conditional_else(context: Context) -> None:
-    """
-    Changes the state of {@pylink grizzly.tasks.conditional} task instance created by {@pylink grizzly.steps.scenario.tasks.step_task_conditional_if}
-    so that any following tasks will be run when `condition` is false.
-
-    See {@pylink grizzly.tasks.conditional} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    When condition "{{ value | int > 0 }}" with name "value-conditional" is true, execute these tasks
-    Then get request with name "get-when-true" from endpoint "/api/true"
-    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
-    But if condition is false, execute these tasks
-    Then get request with name "get-when-false" from endpoint "/api/false"
-    Then end condition
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.conditional is not None, 'there are no open conditional, you need to create one first'
-
-    grizzly.scenario.tasks.tmp.conditional.switch(False)
-
-
-@then(u'end condition')
-def step_task_conditional_end(context: Context) -> None:
-    """
-    Closes the {@pylink grizzly.tasks.conditional} task instance created by {@pylink grizzly.steps.scenario.tasks.step_task_conditional_if}.
-    This means that any following tasks specified will not be part of the conditional.
-
-    See {@pylink grizzly.tasks.conditional} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    When condition "{{ value | int > 0 }}" with name "value-conditional" is true, execute these tasks
-    Then get request with name "get-when-true" from endpoint "/api/true"
-    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
-    But if condition is false, execute these tasks
-    Then get request with name "get-when-false" from endpoint "/api/false"
-    Then end condition
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.conditional is not None, 'there are no open conditional, you need to create one before closing it'
-
-    conditional = grizzly.scenario.tasks.tmp.conditional
-    grizzly.scenario.tasks.tmp.conditional = None
-    grizzly.scenario.tasks.add(conditional)
-
-
-@then(u'loop "{values}" as variable "{variable}" with name "{name}"')
-def step_task_loop_start(context: Context, values: str, variable: str, name: str) -> None:
-    """
-    Creates an instance of the {@pylink grizzly.tasks.loop} tasks which executes all wrapped tasks with a value from the list `values`.
-    `values` **must** be a valid JSON list and supports {@link framework.usage.variables.templating}.
-
-    See {@pylink grizzly.tasks.loop} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Then loop "{{ loop_values }}" as variable "loop_value" with name "test-loop"
-    Then log message "loop_value={{ loop_value }}"
-    Then end loop
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.loop is None, f'loop task "{grizzly.scenario.tasks.tmp.loop.name}" is already open, close it first'
-
-    grizzly.scenario.tasks.tmp.loop = LoopTask(
-        grizzly=grizzly,
-        name=name,
-        values=values,
-        variable=variable
-    )
-
-
-@then(u'end loop')
-def step_task_loop_end(context: Context) -> None:
-    """
-    Closes the {@pylink grizzly.tasks.loop} task created by {@pylink grizzly.steps.scenario.tasks.step_task_loop_start}.
-    This means that any following tasks specified will not be part of the loop.
-
-    See {@pylink grizzly.tasks.loop} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    Then loop "{{ loop_values }}" as variable "loop_value" with name "test-loop"
-    Then log message "loop_value={{ loop_value }}"
-    Then end loop
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-
-    assert grizzly.scenario.tasks.tmp.loop is not None, 'there are no open loop, you need to create one before closing it'
-
-    loop = grizzly.scenario.tasks.tmp.loop
-    grizzly.scenario.tasks.tmp.loop = None
-    grizzly.scenario.tasks.add(loop)
-
-
-@then(u'get "{key}" from keystore and save in variable "{variable}", with default value "{default_value}"')
-def step_task_keystore_get_default(context: Context, key: str, variable: str, default_value: str) -> None:
-    """
-    Get a value for `key` using the {@pylink grizzly.tasks}, if `key` does not exist in the keystore `default_value` will be used and will be set in the keystore to next
-    iteration. `default_value` must be JSON serializable (string values must be single-quoted).
-
-    See {@pylink grizzly.tasks.keystore} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    And value for variable "foobar" is "none"
-    Then get "foobar" from keystore and save in variable "foobar", with default value "{'hello': 'world'}"
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    if "'" in default_value:
-        default_value = default_value.replace("'", '"')
-
-    try:
-        grizzly.scenario.tasks.add(KeystoreTask(key, 'get', variable, jsonloads(default_value)))
-    except JSONDecodeError:
-        raise AssertionError(f'"{default_value}" is not valid JSON')
-
-
-@then(u'get "{key}" from keystore and save in variable "{variable}"')
-def step_task_keystore_get(context: Context, key: str, variable: str) -> None:
-    """
-    Get a value for `key` using the {@pylink grizzly.tasks.keystore} task.
-
-    See {@pylink grizzly.tasks.keystore} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    And value for variable "foobar" is "none"
-    Then get "foobar" from keystore and save in variable "foobar"
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    grizzly.scenario.tasks.add(KeystoreTask(key, 'get', variable))
-
-
-@then(u'set "{key}" in keystore with value "{value}"')
-def step_task_keystore_set(context: Context, key: str, value: str) -> None:
-    """
-    Set a value for `key` using the {@pylink grizzly.tasks.keystore} task. `value` must be JSON serializable (string values must be single-quoted).
-
-    See {@pylink grizzly.tasks.keystore} task documentation for more information.
-
-    Example:
-
-    ``` gherkin
-    And value for variable "foobar" is "{'hello': 'world'}"
-    Then set "foobar" in keystore with value "{{ foobar }}"
-    ```
-    """
-    grizzly = cast(GrizzlyContext, context.grizzly)
-    if "'" in value:
-        value = value.replace("'", '"')
-
-    try:
-        grizzly.scenario.tasks.add(KeystoreTask(key, 'set', jsonloads(value)))
-    except JSONDecodeError:
-        raise AssertionError(f'"{value}" is not valid JSON')
diff --git a/grizzly/steps/scenario/tasks/__init__.py b/grizzly/steps/scenario/tasks/__init__.py
new file mode 100644
index 00000000..7482b5f6
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/__init__.py
@@ -0,0 +1,15 @@
+# flake8: noqa: F401,F403
+from .async_group import *
+from .conditional import *
+from .date import *
+from .keystore import *
+from .log_message import *
+from .loop import *
+from .until import *  # needs to be fore .request
+from .request import *
+from .timer import *
+from .transformer import *
+from .wait_between import *
+from .wait_explicit import *
+
+from .clients import *
diff --git a/grizzly/steps/scenario/tasks/async_group.py b/grizzly/steps/scenario/tasks/async_group.py
new file mode 100644
index 00000000..0e6e972c
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/async_group.py
@@ -0,0 +1,74 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.async_group} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then, given
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import AsyncRequestGroupTask
+
+
+@given(u'an async request group with name "{name}"')
+def step_task_async_group_start(context: Context, name: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.async_group} task. All {@pylink grizzly.tasks.request} tasks created after this step will be added to the
+    request group, until the group is closed.
+
+    See {@pylink grizzly.tasks.async_group} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Given an async request group with name "async-group-1"
+    Then post request with name "test-post-2" to endpoint "/api/test"
+        \"\"\"
+        {
+            "value": "i have good news!"
+        }
+        \"\"\"
+
+    Then get request with name "test-get-1" from endpoint "/api/test"
+    And close async request group
+    ```
+
+    In this example, the `put` and `get` requests will run asynchronously, and both requests will block following requests until both are finished.
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.async_group is None, f'async request group "{grizzly.scenario.tasks.tmp.async_group.name}" has not been closed'
+
+    grizzly.scenario.tasks.tmp.async_group = AsyncRequestGroupTask(name=name)
+
+
+@then(u'close async request group')
+def step_task_async_group_close(context: Context) -> None:
+    """
+    Closes the instance created in {@pylink grizzly.steps.scenario.tasks.async_group.step_task_async_group_start}, and adds the {@pylink grizzly.tasks.async_group}
+    task to the list of tasks that the scenario is going to execute.
+
+    See {@pylink grizzly.tasks.async_group} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Given an async request group with name "async-group-1"
+    Then post request with name "test-post-2" to endpoint "/api/test"
+        \"\"\"
+        {
+            "value": "i have good news!"
+        }
+        \"\"\"
+
+    Then get request with name "test-get-1" from endpoint "/api/test"
+    And close async request group
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    async_group = grizzly.scenario.tasks.tmp.async_group
+
+    assert async_group is not None, 'no async request group is open'
+    assert len(async_group.tasks) > 0, f'there are no requests in async group "{async_group.name}"'
+
+    grizzly.scenario.tasks.tmp.async_group = None
+    grizzly.scenario.tasks.add(async_group)
diff --git a/grizzly/steps/scenario/tasks/clients.py b/grizzly/steps/scenario/tasks/clients.py
new file mode 100644
index 00000000..df305069
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/clients.py
@@ -0,0 +1,145 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.clients} tasks.
+"""
+from typing import cast
+
+from grizzly.types import RequestDirection
+from grizzly.types.behave import Context, then
+from grizzly.steps._helpers import get_task_client, is_template
+from grizzly.context import GrizzlyContext
+
+
+@then(u'get "{endpoint}" with name "{name}" and save response payload in "{payload_variable}" and metadata in "{metadata_variable}"')
+def step_task_client_get_endpoint_payload_metadata(context: Context, endpoint: str, name: str, payload_variable: str, metadata_variable: str) -> None:
+    """
+    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
+    based on the URL scheme specified in `endpoint`. Gets information from another host or endpoint than the scenario
+    is load testing and saves the response in a variable.
+
+    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
+
+    Example:
+
+    ```gherkin
+    Then get "https://www.example.org/example.json" with name "example-1" and save response payload in "example_openapi" and metadata in "example_metadata"
+    Then get "http://{{ endpoint }}" with name "example-2" and save response payload in "endpoint_result" and metadata in "result_metadata"
+    ```
+
+    Args:
+        endpoint (str): information about where to get information, see the specific getter task implementations for more information
+        name (str): name of the request, used in request statistics
+        payload_variable (str): name of, initialized, variable where response payload will be saved in
+        metadata_variable (str): name of, initialized, variable where response metadata will be saved in
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
+        RequestDirection.FROM,
+        endpoint,
+        name,
+        payload_variable=payload_variable,
+        metadata_variable=metadata_variable,
+        text=context.text,
+    ))
+
+
+@then(u'get "{endpoint}" with name "{name}" and save response payload in "{variable}"')
+def step_task_client_get_endpoint_payload(context: Context, endpoint: str, name: str, variable: str) -> None:
+    """
+    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
+    based on the URL scheme specified in `endpoint`. Gets information from another host or endpoint than the scenario
+    is load testing and saves the response in a variable.
+
+    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
+
+    Example:
+
+    ```gherkin
+    Then get "https://www.example.org/example.json" with name "example-1" and save response payload in "example_openapi"
+    Then get "http://{{ endpoint }}" with name "example-2" and save response payload in "endpoint_result"
+    ```
+
+    Args:
+        endpoint (str): information about where to get information, see the specific getter task implementations for more information
+        name (str): name of the request, used in request statistics
+        variable (str): name of, initialized, variable where response payload will be saved in
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
+        RequestDirection.FROM,
+        endpoint,
+        name,
+        payload_variable=variable,
+        metadata_variable=None,
+        text=context.text,
+    ))
+
+
+@then(u'put "{source}" to "{endpoint}" with name "{name}" as "{destination}"')
+def step_task_client_put_endpoint_file_destination(context: Context, source: str, endpoint: str, name: str, destination: str) -> None:
+    """
+    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
+    based on the URL scheme specified in `endpoint`. Puts information, source being a file, to another host or endpoint than the scenario
+    is load testing and saves the response in a variable
+
+    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
+
+    Example:
+
+    ```gherkin
+    Then put "test-file.json" to "bs://my-storage?AccountKey=aaaabbb=&Container=my-container" with name "upload-file" as "uploaded-test-file.json"
+    ```
+
+    Args:
+        source (str): relative path to file in `feature/requests`, supports {@link framework.usage.variables.templating}
+        endpoint (str): information about where to get information, see the specific getter task implementations for more information
+        name (str): name of the request, used in request statistics
+        destination (str): name of source on the destination
+    """
+    assert context.text is None, 'step text is not allowed for this step expression'
+    assert not is_template(source), 'source file cannot be a template'
+
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
+        RequestDirection.TO,
+        endpoint,
+        name,
+        source=source,
+        destination=destination,
+    ))
+
+
+@then(u'put "{source}" to "{endpoint}" with name "{name}"')
+def step_task_client_put_endpoint_file(context: Context, source: str, endpoint: str, name: str) -> None:
+    """
+    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
+    based on the URL scheme specified in `endpoint`. Puts information, source being a file, to another host or endpoint than the scenario
+    is load testing and saves the response in a variable
+
+    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
+
+    Example:
+
+    ```gherkin
+    Then put "test-file.json" to "bs://my-storage?AccountKey=aaaabbb=&Container=my-container" with name "upload-file"
+    ```
+
+    Args:
+        source (str): relative path to file in `feature/requests`, supports {@link framework.usage.variables.templating}
+        endpoint (str): information about where to get information, see the specific getter task implementations for more information
+        name (str): name of the request, used in request statistics
+    """
+    assert context.text is None, 'step text is not allowed for this step expression'
+    assert not is_template(source), 'source file cannot be a template'
+
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    grizzly.scenario.tasks.add(get_task_client(grizzly, endpoint)(
+        RequestDirection.TO,
+        endpoint,
+        name,
+        source=source,
+        destination=None,
+    ))
diff --git a/grizzly/steps/scenario/tasks/conditional.py b/grizzly/steps/scenario/tasks/conditional.py
new file mode 100644
index 00000000..6c4ac91e
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/conditional.py
@@ -0,0 +1,92 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.conditional} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then, when
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import ConditionalTask
+
+
+@when(u'condition "{condition}" with name "{name}" is true, execute these tasks')
+def step_task_conditional_if(context: Context, condition: str, name: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.conditional} task which executes different sets of task depending on `condition`.
+    Also sets the task in a state that any following tasks will be run when `condition` is true.
+
+    See {@pylink grizzly.tasks.conditional} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    When condition "{{ value | int > 0 }}" with name "value-conditional" is true, execute these tasks
+    Then get request with name "get-when-true" from endpoint "/api/true"
+    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
+    But if condition is false, execute these tasks
+    Then get request with name "get-when-false" from endpoint "/api/false"
+    Then end condition
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.conditional is None, f'cannot create a new conditional while "{grizzly.scenario.tasks.tmp.conditional.name}" is still open'
+
+    grizzly.scenario.tasks.tmp.conditional = ConditionalTask(
+        name=name,
+        condition=condition,
+    )
+    grizzly.scenario.tasks.tmp.conditional.switch(True)
+
+
+@then(u'if condition is false, execute these tasks')
+def step_task_conditional_else(context: Context) -> None:
+    """
+    Changes the state of {@pylink grizzly.tasks.conditional} task instance created by {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_if}
+    so that any following tasks will be run when `condition` is false.
+
+    See {@pylink grizzly.tasks.conditional} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    When condition "{{ value | int > 0 }}" with name "value-conditional" is true, execute these tasks
+    Then get request with name "get-when-true" from endpoint "/api/true"
+    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
+    But if condition is false, execute these tasks
+    Then get request with name "get-when-false" from endpoint "/api/false"
+    Then end condition
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.conditional is not None, 'there are no open conditional, you need to create one first'
+
+    grizzly.scenario.tasks.tmp.conditional.switch(False)
+
+
+@then(u'end condition')
+def step_task_conditional_end(context: Context) -> None:
+    """
+    Closes the {@pylink grizzly.tasks.conditional} task instance created by {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_if}.
+    This means that any following tasks specified will not be part of the conditional.
+
+    See {@pylink grizzly.tasks.conditional} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    When condition "{{ value | int > 0 }}" with name "value-conditional" is true, execute these tasks
+    Then get request with name "get-when-true" from endpoint "/api/true"
+    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
+    But if condition is false, execute these tasks
+    Then get request with name "get-when-false" from endpoint "/api/false"
+    Then end condition
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.conditional is not None, 'there are no open conditional, you need to create one before closing it'
+
+    conditional = grizzly.scenario.tasks.tmp.conditional
+    grizzly.scenario.tasks.tmp.conditional = None
+    grizzly.scenario.tasks.add(conditional)
diff --git a/grizzly/steps/scenario/tasks/date.py b/grizzly/steps/scenario/tasks/date.py
new file mode 100644
index 00000000..dedb532c
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/date.py
@@ -0,0 +1,45 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.date} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import DateTask
+
+
+@then(u'parse date "{value}" and save in variable "{variable}"')
+def step_task_date(context: Context, value: str, variable: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.date} task. Parses a datetime string and transforms it according to specified
+    arguments.
+
+    See {@pylink grizzly.tasks.date} task documentation for more information about arguments.
+
+    This step is useful when changes has to be made to a datetime representation during an iteration of a scenario.
+
+    Example:
+
+    ```gherkin
+    ...
+    And value for variable "date1" is "none"
+    And value for variable "date2" is "none"
+    And value for variable "date3" is "none"
+    And value for variable "AtomicDate.test" is "now"
+
+    Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"
+    Then parse date "{{ AtomicDate.test }} | offset=-1D" and save in variable "date2"
+    Then parse date "{{ datetime.now() }} | offset=1Y" and save in variable "date3"
+    ```
+
+    Args:
+        value (str): datetime string and arguments
+        variable (str): name of, initialized, variable where response will be saved in
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    assert variable in grizzly.state.variables, f'variable {variable} has not been initialized'
+
+    grizzly.scenario.tasks.add(DateTask(
+        value=value,
+        variable=variable,
+    ))
diff --git a/grizzly/steps/scenario/tasks/keystore.py b/grizzly/steps/scenario/tasks/keystore.py
new file mode 100644
index 00000000..e9591c11
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/keystore.py
@@ -0,0 +1,76 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.keystore} task.
+"""
+from typing import cast
+from json import JSONDecodeError, loads as jsonloads
+
+from grizzly.types.behave import Context, then
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import KeystoreTask
+
+
+@then(u'get "{key}" from keystore and save in variable "{variable}", with default value "{default_value}"')
+def step_task_keystore_get_default(context: Context, key: str, variable: str, default_value: str) -> None:
+    """
+    Get a value for `key` using the {@pylink grizzly.tasks.keystore} task, if `key` does not exist in the keystore `default_value` will
+    be used and will be set in the keystore to next iteration. `default_value` must be JSON serializable (string values must be single-quoted).
+
+    See {@pylink grizzly.tasks.keystore} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    And value for variable "foobar" is "none"
+    Then get "foobar" from keystore and save in variable "foobar", with default value "{'hello': 'world'}"
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    if "'" in default_value:
+        default_value = default_value.replace("'", '"')
+
+    try:
+        grizzly.scenario.tasks.add(KeystoreTask(key, 'get', variable, jsonloads(default_value)))
+    except JSONDecodeError:
+        raise AssertionError(f'"{default_value}" is not valid JSON')
+
+
+@then(u'get "{key}" from keystore and save in variable "{variable}"')
+def step_task_keystore_get(context: Context, key: str, variable: str) -> None:
+    """
+    Get a value for `key` using the {@pylink grizzly.tasks.keystore} task.
+
+    See {@pylink grizzly.tasks.keystore} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    And value for variable "foobar" is "none"
+    Then get "foobar" from keystore and save in variable "foobar"
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    grizzly.scenario.tasks.add(KeystoreTask(key, 'get', variable))
+
+
+@then(u'set "{key}" in keystore with value "{value}"')
+def step_task_keystore_set(context: Context, key: str, value: str) -> None:
+    """
+    Set a value for `key` using the {@pylink grizzly.tasks.keystore} task. `value` must be JSON serializable (string values must be single-quoted).
+
+    See {@pylink grizzly.tasks.keystore} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    And value for variable "foobar" is "{'hello': 'world'}"
+    Then set "foobar" in keystore with value "{{ foobar }}"
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    if "'" in value:
+        value = value.replace("'", '"')
+
+    try:
+        grizzly.scenario.tasks.add(KeystoreTask(key, 'set', jsonloads(value)))
+    except JSONDecodeError:
+        raise AssertionError(f'"{value}" is not valid JSON')
diff --git a/grizzly/steps/scenario/tasks/log_message.py b/grizzly/steps/scenario/tasks/log_message.py
new file mode 100644
index 00000000..95177822
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/log_message.py
@@ -0,0 +1,31 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.log_message} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import LogMessageTask
+
+
+@then(u'log message "{message}"')
+def step_task_log_message(context: Context, message: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.log_message} task. Prints a log message in the console, useful for troubleshooting values
+    of variables or set markers in log files.
+
+    The message supports {@link framework.usage.variables.templating}. See {@pylink grizzly.tasks.log_message} task documentation for more
+    information about the task.
+
+    Example:
+
+    ```gherkin
+    And log message "context_variable='{{ context_variable }}'
+    ```
+
+    Args:
+        message (str): message to print
+    """
+
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    grizzly.scenario.tasks.add(LogMessageTask(message=message))
diff --git a/grizzly/steps/scenario/tasks/loop.py b/grizzly/steps/scenario/tasks/loop.py
new file mode 100644
index 00000000..38cc7d50
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/loop.py
@@ -0,0 +1,61 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.loop} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import LoopTask
+
+
+@then(u'loop "{values}" as variable "{variable}" with name "{name}"')
+def step_task_loop_start(context: Context, values: str, variable: str, name: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.loop} tasks which executes all wrapped tasks with a value from the list `values`.
+    `values` **must** be a valid JSON list and supports {@link framework.usage.variables.templating}.
+
+    See {@pylink grizzly.tasks.loop} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Then loop "{{ loop_values }}" as variable "loop_value" with name "test-loop"
+    Then log message "loop_value={{ loop_value }}"
+    Then end loop
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.loop is None, f'loop task "{grizzly.scenario.tasks.tmp.loop.name}" is already open, close it first'
+
+    grizzly.scenario.tasks.tmp.loop = LoopTask(
+        grizzly=grizzly,
+        name=name,
+        values=values,
+        variable=variable
+    )
+
+
+@then(u'end loop')
+def step_task_loop_end(context: Context) -> None:
+    """
+    Closes the {@pylink grizzly.tasks.loop} task created by {@pylink grizzly.steps.scenario.tasks.loop.step_task_loop_start}.
+    This means that any following tasks specified will not be part of the loop.
+
+    See {@pylink grizzly.tasks.loop} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Then loop "{{ loop_values }}" as variable "loop_value" with name "test-loop"
+    Then log message "loop_value={{ loop_value }}"
+    Then end loop
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.loop is not None, 'there are no open loop, you need to create one before closing it'
+
+    loop = grizzly.scenario.tasks.tmp.loop
+    grizzly.scenario.tasks.tmp.loop = None
+    grizzly.scenario.tasks.add(loop)
diff --git a/grizzly/steps/scenario/tasks/request.py b/grizzly/steps/scenario/tasks/request.py
new file mode 100644
index 00000000..58b53709
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/request.py
@@ -0,0 +1,188 @@
+"""
+This module contains step implementations that creates requests executed by the {@pylink grizzly.users}
+in the scenario.
+"""
+from grizzly.types import RequestDirection, RequestMethod
+from grizzly.steps._helpers import add_request_task, is_template
+from grizzly.types.behave import Context, then, register_type
+
+
+register_type(
+    Direction=RequestDirection.from_string,
+    Method=RequestMethod.from_string,
+)
+
+
+@then(u'{method:Method} request with name "{name}" {direction:Direction} endpoint "{endpoint}"')
+def step_task_request_text_with_name_endpoint(context: Context, method: RequestMethod, name: str, direction: RequestDirection, endpoint: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.request} task, where optional payload is defined directly in the feature file.
+    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
+
+    * If `Method` in the expression is `get` or `receive`; the `direction` **must** be `from`.
+
+    * If `Method` in the expression is `post`, `pust`, or `send`; the `direction` **must** be `to`, and payload defined in the feature file.
+
+    Example:
+
+    ```gherkin
+    Then post request with name "test-post" to endpoint "/api/test"
+        \"\"\"
+        {
+            "test": "hello world"
+        }
+        \"\"\"
+    Then put request with name "test-put" to endpoint "/api/test"
+        \"\"\"
+        {
+            "test": "hello world"
+        }
+        \"\"\"
+    Then get request with name "test-get" from endpoint "/api/test"
+
+    Then send request with name "test-send" to endpoint "queue:receive-queue"
+        \"\"\"
+        {
+            "value": "do something"
+        }
+        \"\"\"
+    Then receive request with name "test-receive" from endpoint "queue:receive-queue"
+    ```
+
+    Args:
+        method (Method): type of request, either of type "from" or "to"
+        name (str): name of the requests in logs, can contain variables
+        direction (RequestDirection): one of `to` or `from` depending on the value of `method`
+        endpoint (str): URI relative to `host` in the scenario, can contain variables and in certain cases `user_class_name` specific parameters
+    """
+
+    assert isinstance(direction, RequestDirection), 'invalid direction specified in expression'
+
+    if method.direction == RequestDirection.FROM:
+        assert context.text is None, f'step text is not allowed for {method.name}'
+        assert direction == RequestDirection.FROM, f'"to endpoint" is not allowed for {method.name}, use "from endpoint"'
+    elif method.direction == RequestDirection.TO:
+        assert context.text is not None, f'step text is mandatory for {method.name}'
+        assert direction == RequestDirection.TO, f'"from endpoint" is not allowed for {method.name}, use "to endpoint"'
+
+    add_request_task(context, method=method, source=context.text, name=name, endpoint=endpoint)
+
+
+@then(u'{method:Method} request "{source}" with name "{name}" to endpoint "{endpoint}"')
+def step_task_request_file_with_name_endpoint(context: Context, method: RequestMethod, source: str, name: str, endpoint: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.request} task, where the payload is defined in a template file.
+
+    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
+
+    Example:
+
+    ```gherkin
+    Then send request "test/request.j2.json" with name "test-send" to endpoint "queue:receive-queue"
+    Then post request "test/request.j2.json" with name "test-post" to endpoint "/api/test"
+    Then put request "test/request.j2.json" with name "test-put" to endpoint "/api/test"
+    ```
+
+    Args:
+        method (Method): type of "to" request
+        source (str): path to a template file relative to the directory `requests/`, which **must** exist in the directory the feature file is located
+        name (str): name of the requests in logs, can contain variables
+        endpoint (str): URI relative to `host` in the scenario, can contain variables and in certain cases `user_class_name` specific parameters
+    """
+    assert method.direction == RequestDirection.TO, f'{method.name} is not allowed'
+    assert context.text is None, f'step text is not allowed for {method.name}'
+    assert not is_template(source), 'source file cannot be a template'
+    add_request_task(context, method=method, source=source, name=name, endpoint=endpoint)
+
+
+@then(u'{method:Method} request "{source}" with name "{name}"')
+def step_task_request_file_with_name(context: Context, method: RequestMethod, source: str, name: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.request} task, with the same `endpoint` as the previous Request task, where the
+    payload is defined in a template file.
+
+    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
+
+    Example:
+
+    ```gherkin
+    Then post request "test/request1.j2.json" with name "test-post1" to endpoint "/api/test"
+    Then post request "test/request2.j2.json" with name "test-post2"
+
+    # same as
+    Then post request "test/request1.j2.json" with name "test-post1" to endpoint "/api/test"
+    Then post request "test/request2.j2.json" with name "test-post2" to endpoint "/api/test"
+    ```
+
+    Args:
+        method (Method): type of "to" request
+        source (str): path to a template file relative to the directory `requests/`, which **must** exist in the directory the feature file is located
+        name (str): name of the requests in logs, can contain variables
+    """
+    assert method.direction == RequestDirection.TO, f'{method.name} is not allowed'
+    assert context.text is None, f'step text is not allowed for {method.name}'
+    assert not is_template(source), 'source file cannot be a template'
+    add_request_task(context, method=method, source=source, name=name)
+
+
+@then(u'{method:Method} request with name "{name}"')
+def step_task_request_text_with_name(context: Context, method: RequestMethod, name: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.request} task, where optional payload is defined directly in the feature file.
+
+    See {@pylink grizzly.tasks.request} task documentation for more information about arguments.
+
+    If `method` in the expression is `post`, `put` or `send` the payload in the request **must** be defined directly in the feature file after the step.
+    This step is useful if `method` and `endpoint` are the same as previous request, but the payload should be different.
+
+    Example:
+
+    ```gherkin
+    # example-1
+    Then post request with name "test-post-1" to endpoint "/api/test"
+        \"\"\"
+        {
+            "value": "hello world!"
+        }
+        \"\"\"
+    Then post request with name "test-post-2"
+        \"\"\"
+        {
+            "value": "i have good news!"
+        }
+        \"\"\"
+
+    # same as example-1
+    Then post request with name "test-post-1" to endpoint "/api/test"
+        \"\"\"
+        {
+            "value": "hello world!"
+        }
+        \"\"\"
+    Then post request with name "test-post-2" to endpoint "/api/test"
+        \"\"\"
+        {
+            "value": "i have good news!"
+        }
+        \"\"\"
+
+    # example-2
+    Then get request with name "test-get-1" from endpoint "/api/test"
+    Then get request with name "test-get-2"
+
+    # same as example-2
+    Then get request with name "test-get-1" from endpoint "/api/test"
+    Then get request with name "test-get-2" from endpoint "/api/test"
+    ```
+
+    Args:
+        method (Method): type of request, either "from" or "to"
+        name (str): name of the requests in logs, can contain variables
+    """
+
+    if method.direction == RequestDirection.FROM:
+        assert context.text is None, f'Step text is not allowed for {method.name}'
+    elif method.direction == RequestDirection.TO:
+        assert context.text is not None, f'Step text is mandatory for {method.name}'
+
+    add_request_task(context, method=method, source=context.text, name=name)
diff --git a/grizzly/steps/scenario/tasks/timer.py b/grizzly/steps/scenario/tasks/timer.py
new file mode 100644
index 00000000..df7dd2a2
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/timer.py
@@ -0,0 +1,61 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.timer} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import TimerTask
+
+
+@then(u'start timer with name "{name}"')
+def step_task_timer_start(context: Context, name: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.timer} task. Starts a timer to measure the "request time" for all tasks between
+    the start and stop of the timer.
+
+    See {@pylink grizzly.tasks.timer} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Then start timer with name "parsing-xml"
+    ...
+    And stop timer with name "parsing-xml"
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    assert name not in grizzly.scenario.tasks.tmp.timers, f'timer with name {name} has already been defined'
+
+    task = TimerTask(name=name)
+
+    grizzly.scenario.tasks.tmp.timers.update({
+        name: task,
+    })
+
+    grizzly.scenario.tasks.add(task)
+
+
+@then(u'stop timer with name "{name}"')
+def step_task_timer_stop(context: Context, name: str) -> None:
+    """
+    Adds the instance created by {@pylink grizzly.steps.scenario.tasks.timer.step_task_timer_start} to the list of scenario tasks.
+
+    See {@pylink grizzly.tasks.timer} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Then start timer with name "parsing-xml"
+    ...
+    And stop timer with name "parsing-xml"
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    task = grizzly.scenario.tasks.tmp.timers.get(name, None)
+
+    assert task is not None, f'timer with name {name} has not been defined'
+
+    grizzly.scenario.tasks.add(task)
+    grizzly.scenario.tasks.tmp.timers.update({name: None})
diff --git a/grizzly/steps/scenario/tasks/transformer.py b/grizzly/steps/scenario/tasks/transformer.py
new file mode 100644
index 00000000..ce2de7e6
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/transformer.py
@@ -0,0 +1,51 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.transformer} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then, register_type
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import TransformerTask
+
+from grizzly_extras.transformer import TransformerContentType
+
+
+register_type(
+    ContentType=TransformerContentType.from_string,
+)
+
+
+@then(u'parse "{content}" as "{content_type:ContentType}" and save value of "{expression}" in variable "{variable}"')
+def step_task_transform(context: Context, content: str, content_type: TransformerContentType, expression: str, variable: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.transformer} task. Transforms the specified `content` with `content_type` to an
+    object that an transformer can extract information from with the specified `expression`.
+
+    See {@pylink grizzly.tasks.transformer} task documentation for more information about the task.
+
+    Example:
+
+    ```gherkin
+    And value for variable "document_id" is "None"
+    And value for variable "document_title" is "None"
+    And value for variable "document" is "{\"document\": {\"id\": \"DOCUMENT_8843-1\", \"title\": \"TPM Report 2021\"}}"
+    ...
+    Then parse "{{ document }}" as "json" and save value of "$.document.id" in variable "document_id"
+    Then parse "{{ document }}" as "json" and save value of "$.document.title" in variable "document_title"
+    ```
+
+    Args:
+        contents (str): contents to parse, supports {@link framework.usage.variables.templating} or a static string
+        content_type (TransformerContentType): MIME type of `contents`
+        expression (str): JSON or XPath expression for specific value in `contents`
+        variable (str): name of variable to save value to, must have been initialized
+    """
+
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    grizzly.scenario.tasks.add(TransformerTask(
+        grizzly,
+        content=content,
+        content_type=content_type,
+        expression=expression,
+        variable=variable,
+    ))
diff --git a/grizzly/steps/scenario/tasks/until.py b/grizzly/steps/scenario/tasks/until.py
new file mode 100644
index 00000000..09f49f9a
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/until.py
@@ -0,0 +1,93 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.until} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, then, register_type
+from grizzly.types import RequestDirection, RequestMethod
+from grizzly.steps._helpers import add_request_task, get_task_client
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import UntilRequestTask
+
+
+register_type(
+    Method=RequestMethod.from_string,
+)
+
+
+@then(u'{method:Method} request with name "{name}" from endpoint "{endpoint}" until "{condition}"')
+def step_task_request_with_name_endpoint_until(context: Context, method: RequestMethod, name: str, endpoint: str, condition: str) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.until} task, see task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    Then get request with name "test-get" from endpoint "/api/test | content_type=json" until "$.`this`[?success==true]"
+    Then receive request with name "test-receive" from endpoint "queue:receive-queue | content_type=xml" until "/header/success[. == 'True']"
+    ```
+
+    Args:
+        method (Method): type of "from" request
+        name (str): name of the requests in logs, can contain variables
+        direction (RequestDirection): one of `to` or `from` depending on the value of `method`
+        endpoint (str): URI relative to `host` in the scenario, can contain variables and in certain cases `user_class_name` specific parameters
+        condition (str): JSON or XPath expression for specific value in response payload
+    """
+
+    assert method.direction == RequestDirection.FROM, 'this step is only valid for request methods with direction FROM'
+    assert context.text is None, 'this step does not have support for step text'
+
+    request_tasks = add_request_task(context, method=method, source=context.text, name=name, endpoint=endpoint, in_scenario=False)
+
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    assert grizzly.scenario.tasks.tmp.async_group is None, f'until tasks cannot be in an async request group, close group {grizzly.scenario.tasks.tmp.async_group.name} first'
+
+    for request_task, substitues in request_tasks:
+        condition_rendered = condition
+        for key, value in substitues.items():
+            condition_rendered = condition_rendered.replace(f'{{{{ {key} }}}}', value)
+
+        grizzly.scenario.tasks.add(UntilRequestTask(
+            grizzly,
+            request=request_task,
+            condition=condition_rendered,
+        ))
+
+
+@then(u'get "{endpoint}" with name "{name}" until "{condition}"')
+def step_task_client_get_endpoint_until(context: Context, endpoint: str, name: str, condition: str) -> None:
+    """
+    Creates an instance of a {@pylink grizzly.tasks.clients} task, actual implementation of the task is determined
+    based on the URL scheme specified in `endpoint`. Gets information, repeated from another host or endpoint than the scenario
+    is load testing until the response matches `expression`.
+
+    See {@pylink grizzly.tasks.clients} task documentation for more information about client tasks.
+
+    Example:
+
+    ```gherkin
+    Then get "https://www.example.org/example.json" with name "example-1" until "$.response[status='Success']
+    Then get "http://{{ endpoint }}" with name "example-2" until "//*[@status='Success']"
+    ```
+
+    Args:
+        endpoint (str): information about where to get information, see the specific getter task implementations for more information
+        name (str): name of the request, used in request statistics
+        condition (str): JSON or XPath expression for specific value in response payload
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    client_request = get_task_client(grizzly, endpoint)(
+        RequestDirection.FROM,
+        endpoint,
+        name,
+        text=context.text,
+    )
+
+    grizzly.scenario.tasks.add(UntilRequestTask(
+        grizzly,
+        request=client_request,
+        condition=condition,
+    ))
diff --git a/grizzly/steps/scenario/tasks/wait_between.py b/grizzly/steps/scenario/tasks/wait_between.py
new file mode 100644
index 00000000..22275c05
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/wait_between.py
@@ -0,0 +1,67 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.wait_between} task.
+"""
+from typing import cast
+
+from grizzly.types.behave import Context, given
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import WaitBetweenTask
+
+
+@given(u'wait "{min_time:g}..{max_time:g}" seconds between tasks')
+def step_task_wait_between_random(context: Context, min_time: float, max_time: float) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.wait_between} task. Sets number of, randomly, seconds the {@pylink grizzly.users}
+    will wait between executing each task.
+
+    See {@pylink grizzly.tasks.wait_between} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    And wait "1.4..1.7" seconds between tasks
+    # wait between 1.4 and 1.7 seconds
+    Then get request with name "test-get-1" from endpoint "..."
+    # wait between 1.4 and 1.7 seconds
+    Then get request with name "test-get-2" from endpoint "..."
+    # wait between 1.4 and 1.7 seconds
+    And wait "0.1" seconds between tasks
+    # wait 0.1 seconds
+    Then get request with name "test-get-3" from endpoint "..."
+    # wait 0.1 seconds
+    ...
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    if min_time > max_time:
+        min_time, max_time = max_time, min_time
+
+    grizzly.scenario.tasks.add(WaitBetweenTask(min_time=min_time, max_time=max_time))
+
+
+@given(u'wait "{time:g}" seconds between tasks')
+def step_task_wait_between_constant(context: Context, time: float) -> None:
+    """
+    Creates an instance of the {@pylink grizzly.tasks.wait_between} task. Sets number of, constant, seconds the {@pylink grizzly.users}
+    will wait between executing each task.
+
+    See {@pylink grizzly.tasks.wait_between} task documentation for more information.
+
+    Example:
+
+    ```gherkin
+    And wait "1.4" seconds between tasks
+    # wait 1.4 seconds
+    Then get request with name "test-get-1" from endpoint "..."
+    # wait 1.4 seconds
+    Then get request with name "test-get-2" from endpoint "..."
+    # wait 1.4 seconds
+    And wait "0.1" seconds between tasks
+    # wait 0.1 seconds
+    Then get request with name "test-get-3" from endpoint "..."
+    # wait 0.1 seconds
+    ...
+    ```
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+    grizzly.scenario.tasks.add(WaitBetweenTask(time))
diff --git a/grizzly/steps/scenario/tasks/wait_explicit.py b/grizzly/steps/scenario/tasks/wait_explicit.py
new file mode 100644
index 00000000..4098100f
--- /dev/null
+++ b/grizzly/steps/scenario/tasks/wait_explicit.py
@@ -0,0 +1,45 @@
+"""
+This module contains step implementations for the {@pylink grizzly.tasks.wait_explicit} task.
+"""
+from typing import cast
+
+from grizzly.steps._helpers import is_template
+from grizzly.types.behave import Context, then
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import ExplicitWaitTask
+
+
+@then(u'wait for "{wait_time_expression}" seconds')
+def step_task_wait_explicit(context: Context, wait_time_expression: str) -> None:
+    """
+    Creates an instace of the {@pylink grizzly.tasks.wait_explicit} task. The scenario will wait the specified time (seconds) in
+    additional to the wait time specified by {@pylink grizzly.tasks.wait_between}.
+
+    See {@pylink grizzly.tasks.wait_explicit} task documentation for more information about the task.
+
+    Example:
+
+    ```gherkin
+    And ask for value of variable "wait_time"
+    And wait "1.5..2.5" seconds between tasks
+    ...
+    Then wait for "1.5" seconds
+    ...
+    Then wait for "{{ wait_time }}" seconds
+    ```
+
+    Above combinations of steps will result in a wait time between 3 and 4 seconds for the first {@pylink grizzly.tasks} that is defined after the
+    `Then wait for...`-step.
+
+    Args:
+        wait_time (float): wait time in seconds
+    """
+    grizzly = cast(GrizzlyContext, context.grizzly)
+
+    if not is_template(wait_time_expression):
+        try:
+            assert float(wait_time_expression) > 0.0, 'wait time cannot be less than 0.0 seconds'
+        except ValueError:
+            raise AssertionError(f'"{wait_time_expression}" is not a template nor a float')
+
+    grizzly.scenario.tasks.add(ExplicitWaitTask(time_expression=wait_time_expression))
diff --git a/grizzly/steps/scenario/user.py b/grizzly/steps/scenario/user.py
index c039343c..4445178d 100644
--- a/grizzly/steps/scenario/user.py
+++ b/grizzly/steps/scenario/user.py
@@ -16,7 +16,7 @@ def step_user_type_with_weight(context: Context, user_class_name: str, weight_va
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     Given a user of type "RestApi" with weight "2" load testing "..."
     Given a user of type "MessageQueue" with weight "1" load testing "..."
     Given a user of type "ServiceBus" with weight "1" load testing "..."
@@ -47,7 +47,7 @@ def step_user_type(context: Context, user_class_name: str, host: str) -> None:
 
     Example:
 
-    ``` gherkin
+    ```gherkin
     Given a user of type "RestApi" load testing "http://api.example.com"
     Given a user of type "MessageQueue" load testing "mq://mqm:secret@mq.example.com/?QueueManager=QMGR01&Channel=Channel01"
     Given a user of type "ServiceBus" load testing "sb://sb.example.com/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123def456ghi789="
diff --git a/grizzly/steps/setup.py b/grizzly/steps/setup.py
index 760a31c7..4609b32d 100644
--- a/grizzly/steps/setup.py
+++ b/grizzly/steps/setup.py
@@ -13,7 +13,7 @@
 @then(u'ask for value of variable "{name}"')
 @given(u'ask for value of variable "{name}"')
 def step_setup_variable_value_ask(context: Context, name: str) -> None:
-    '''This step is used to indicate for `grizzly-cli` that it should ask for an initial value for the variable.
+    """This step is used to indicate for `grizzly-cli` that it should ask for an initial value for the variable.
     It will then inject the value into the locust runtime environment, and in this step read it and insert it
     into the locust context which grizzly will use to setup locust.
 
@@ -22,13 +22,13 @@ def step_setup_variable_value_ask(context: Context, name: str) -> None:
 
     Use this step for variables that should have different initial values for each run of the feature.
 
-    ``` gherkin
+    ```gherkin
     And ask for value for variable "AtomicIntegerIncrementer.messageID"
     ```
 
     Args:
         name (str): variable name used in templates
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
 
     value = environ.get(f'TESTDATA_VARIABLE_{name}', None)
@@ -43,7 +43,7 @@ def step_setup_variable_value_ask(context: Context, name: str) -> None:
 
 @given(u'value for variable "{name}" is "{value}"')
 def step_setup_variable_value(context: Context, name: str, value: str) -> None:
-    '''Use this step to initialize a variable that should have the same [start] value for every run of
+    """Use this step to initialize a variable that should have the same [start] value for every run of
     the scenario. If this step is used for a variable that has already been initialized, it is assumed that the value will change during runtime
     so instead a {@pylink grizzly.tasks.set_variable} task will be added instead. The {@pylink grizzly.testdata.variables} must have implemented
     support for being settable.
@@ -65,7 +65,7 @@ def step_setup_variable_value(context: Context, name: str, value: str) -> None:
 
     Example:
 
-    ``` gherkin title="example.feature"
+    ```gherkin title="example.feature"
     Feature:
         Background:
             And ask for value of variable "messageID"
@@ -85,7 +85,7 @@ def step_setup_variable_value(context: Context, name: str, value: str) -> None:
     Args:
         name (str): variable name
         value (Any): initial value
-    '''
+    """
     grizzly = cast(GrizzlyContext, context.grizzly)
 
     module_name, variable_type, variable_name, _ = GrizzlyVariables.get_variable_spec(name)
diff --git a/grizzly/steps/utils.py b/grizzly/steps/utils.py
index 6c29a368..c9ea99c0 100644
--- a/grizzly/steps/utils.py
+++ b/grizzly/steps/utils.py
@@ -1,18 +1,19 @@
-'''
+"""
 @anchor pydoc:grizzly.steps.background.utils Utils
 This package contains steps that can be useful during development or troubleshooting of a
 feature file, but should not be included in a finished, testable, feature.
-'''
+"""
 
 from grizzly.types.behave import Context, then
 
 
 @then(u'fail')
 def step_utils_fail(context: Context) -> None:
-    '''Force a failed scenario. Can be useful when writing a new scenario.
+    """Force a failed scenario. Can be useful when writing a new scenario. The scenario will fail before `locust` has started, so only when
+    the scenario is setup.
 
-    ``` gherkin
+    ```gherkin
     Then fail
     ```
-    '''
+    """
     assert 0
diff --git a/grizzly/tasks/__init__.py b/grizzly/tasks/__init__.py
index 020a7e4f..01c27d18 100644
--- a/grizzly/tasks/__init__.py
+++ b/grizzly/tasks/__init__.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks Tasks
 Tasks are functionality that is executed by `locust` at run time as they are specified in the feature file.
 
@@ -48,7 +48,7 @@ def step_run_testtask(context: Context) -> None:
 ```
 
 There are examples of this in the {@link framework.example}.
-'''
+"""
 from __future__ import annotations
 from abc import ABC, ABCMeta, abstractmethod
 from typing import TYPE_CHECKING, Any, Callable, List, Type, Set, Optional, Union, overload, cast
@@ -248,14 +248,14 @@ def __call__(self, cls: Type[GrizzlyTask]) -> Type[GrizzlyTask]:
 
 
 from .request import RequestTask, RequestTaskHandlers, RequestTaskResponse
-from .wait import WaitTask
+from .wait_explicit import ExplicitWaitTask
 from .log_message import LogMessageTask
 from .transformer import TransformerTask
 from .until import UntilRequestTask
 from .date import DateTask
 from .async_group import AsyncRequestGroupTask
 from .timer import TimerTask
-from .task_wait import TaskWaitTask
+from .wait_between import WaitBetweenTask
 from .conditional import ConditionalTask
 from .loop import LoopTask
 from .set_variable import SetVariableTask
@@ -267,13 +267,13 @@ def __call__(self, cls: Type[GrizzlyTask]) -> Type[GrizzlyTask]:
     'RequestTaskResponse',
     'RequestTask',
     'LogMessageTask',
-    'WaitTask',
+    'ExplicitWaitTask',
     'TransformerTask',
     'UntilRequestTask',
     'DateTask',
     'AsyncRequestGroupTask',
     'TimerTask',
-    'TaskWaitTask',
+    'WaitBetweenTask',
     'ConditionalTask',
     'LoopTask',
     'SetVariableTask',
diff --git a/grizzly/tasks/async_group.py b/grizzly/tasks/async_group.py
index dd35cb83..1f8dbf69 100644
--- a/grizzly/tasks/async_group.py
+++ b/grizzly/tasks/async_group.py
@@ -8,9 +8,9 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_async_group_start}
+* {@pylink grizzly.steps.scenario.tasks.async_group.step_task_async_group_start}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_async_group_close}
+* {@pylink grizzly.steps.scenario.tasks.async_group.step_task_async_group_close}
 
 Requests are added to the group with the same step implementations as {@pylink grizzly.tasks.request} task.
 
diff --git a/grizzly/tasks/clients/__init__.py b/grizzly/tasks/clients/__init__.py
index 50a6c21f..68c03561 100644
--- a/grizzly/tasks/clients/__init__.py
+++ b/grizzly/tasks/clients/__init__.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.clients Clients
 Client tasks is functionality that is executed by locust and is registred to an URL scheme.
 These tasks is used to make a request to another host than the scenario is actually load testing.
@@ -14,7 +14,7 @@
 If `endpoint` is a template variable which includes the scheme, the scheme for the request must be specified so the
 correct `grizzly.tasks.client` implementation is used. The additional scheme will be removed when the request is
 performed.
-'''
+"""
 import logging
 import traceback
 
diff --git a/grizzly/tasks/clients/blobstorage.py b/grizzly/tasks/clients/blobstorage.py
index b1caa81c..960dbf79 100644
--- a/grizzly/tasks/clients/blobstorage.py
+++ b/grizzly/tasks/clients/blobstorage.py
@@ -1,4 +1,4 @@
-'''This task performs Azure Blob Storage put operations to a specified endpoint.
+"""This task performs Azure Blob Storage put operations to a specified endpoint.
 
 This is useful if the scenario is another user type than `BlobStorageUser`, but the scenario still requires an action towards a blob container.
 
@@ -6,7 +6,7 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_put_endpoint_file_destination}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_put_endpoint_file_destination}
 
 ## Arguments
 
@@ -24,7 +24,7 @@
 
 ### `endpoint`
 
-``` plain
+```plain
 bs[s]://<AccountName>?AccountKey=<AccountKey>&Container=<Container>[&Overwrite=<bool>]
 ```
 
@@ -39,7 +39,7 @@
 ### `destination`
 
 The MIME type of an uploaded file will automagically be guessed based on the [rendered] destination file extension.
-'''
+"""
 import logging
 
 from typing import Optional, List, cast
diff --git a/grizzly/tasks/clients/http.py b/grizzly/tasks/clients/http.py
index a758c7db..1387cd2e 100644
--- a/grizzly/tasks/clients/http.py
+++ b/grizzly/tasks/clients/http.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.clients.http HTTP
 This task performs a HTTP request to a specified endpoint.
 
@@ -8,9 +8,9 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_payload}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_get_endpoint_payload}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_payload_metadata}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_get_endpoint_payload_metadata}
 
 ## Arguments
 
@@ -25,7 +25,7 @@
 To enable authentication for `HttpClientTask` the `auth` context tree has to be correctly set. This is done by using
 {@pylink grizzly.steps.scenario.setup.step_setup_set_context_variable} where the branches are prefixed with `<host>/`, e.g.:
 
-``` gherkin
+```gherkin
 And value for variable "foobar" is "none"
 And value for variable "url" is "https://www.example.com/api/test"
 And set context variable "www.example.com/auth.user.username" to "bob"
@@ -41,7 +41,7 @@
 requests towards `www.example.com`.
 
 For more details, see {@pylink grizzly.auth.aad}.
-'''
+"""
 from typing import Optional, Dict, Any
 from json import dumps as jsondumps
 from time import time
diff --git a/grizzly/tasks/clients/messagequeue.py b/grizzly/tasks/clients/messagequeue.py
index eb7d15a7..a328e397 100644
--- a/grizzly/tasks/clients/messagequeue.py
+++ b/grizzly/tasks/clients/messagequeue.py
@@ -1,22 +1,22 @@
 # pylint: disable=line-too-long
-'''This task performs IBM MQM get and put opertions to a specified queue or topic.
+"""This task performs IBM MQM get and put opertions to a specified queue or topic.
 
 This is useful if the scenario is another user type than `MessageQueueUser`, but the scenario still requires an action towards an MQ server.
 Use {@pylink grizzly.tasks.transformer} task to extract specific parts of the message.
 
 Grizzly *must* have been installed with the extra `mq` package and native IBM MQ libraries must be installed for being able to use this variable:
 
-``` plain
+```plain
 pip3 install grizzly-loadtester[mq]
 ```
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_payload}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_get_endpoint_payload}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_payload_metadata}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_get_endpoint_payload_metadata}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_put_endpoint_file}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_put_endpoint_file}
 
 ## Arguments
 
@@ -34,7 +34,7 @@
 
 ### `endpoint`
 
-``` plain
+```plain
 mq[s]://<username>:<password>@]<hostname>[:<port>]/<endpoint>?QueueManager=<queue manager>&Channel=<channel>[&wait=<wait>][&heartbeat=<heartbeat>][&KeyFile=<key repo path>[&SslCipher=<ssl cipher>][&CertLabel=<certificate label>]][&HeaderType=<header type>][&MaxMessageSize=<number of bytes>]
 ```
 
@@ -69,7 +69,7 @@
 * `HeaderType` _str_ (optional) - header type, can be `RFH2` for sending gzip compressed messages using RFH2 header, default `None`
 
 * `MaxMessageSize` _int_ (optional) - maximum number of bytes a message can be for the client to accept it, default is `None` which implies that the client will throw `MQRC_TRUNCATED_MSG_FAILED`, adjust buffer and try again.
-'''  # noqa: E501
+"""  # noqa: E501
 from contextlib import contextmanager
 from typing import Optional, Dict, Any, Generator, List, cast
 from urllib.parse import urlparse, parse_qs, unquote
diff --git a/grizzly/tasks/clients/servicebus.py b/grizzly/tasks/clients/servicebus.py
index f44cfec9..e5bec498 100644
--- a/grizzly/tasks/clients/servicebus.py
+++ b/grizzly/tasks/clients/servicebus.py
@@ -3,11 +3,11 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_payload}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_get_endpoint_payload}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_payload_metadata}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_get_endpoint_payload_metadata}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_put_endpoint_file}
+* {@pylink grizzly.steps.scenario.tasks.clients.step_task_client_put_endpoint_file}
 
 ## Arguments
 
@@ -27,7 +27,7 @@
 
 Value of this is basically the "Connection String" from Azure (without `Endpoint=` prefix), with some additional information.
 
-``` plain
+```plain
 sb://<sbns resource name>.servicebus.windows.net/[queue:<queue name>|topic:<topic name>[/subscription:<subscription name>]][/expression:<expression>];SharedAccessKeyName=<policy name>;SharedAccessKey=<access key>[#[Consume=<consume>][&][MessageWait=<wait>][&][ContentType<content type>]]
 ```
 
diff --git a/grizzly/tasks/conditional.py b/grizzly/tasks/conditional.py
index afb65ba0..52ba898d 100644
--- a/grizzly/tasks/conditional.py
+++ b/grizzly/tasks/conditional.py
@@ -4,19 +4,20 @@
 
 This is useful when a set of tasks should be executed if `condition` is `True`, and another set of tasks if `condition` is `False`.
 
-All tasks created between {@pylink grizzly.steps.scenario.tasks.step_task_conditional_if} and {@pylink grizzly.steps.scenario.tasks.step_task_conditional_end}
-will be wrapped in this instance and executed conditionally. If the task has its own `name` attribute, it will be prefixed with this tasks `name`.
+All tasks created between {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_if} and
+{@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_end} will be wrapped in this instance and executed conditionally.
+If the task has its own `name` attribute, it will be prefixed with this tasks `name`.
 
-The {@pylink grizzly.steps.scenario.tasks.step_task_conditional_else} step expression is optional, if not used no additional tasks will be executed if
+The {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_else} step expression is optional, if not used no additional tasks will be executed if
 `condition` is false.
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_conditional_if}
+* {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_if}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_conditional_else} (optional)
+* {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_else} (optional)
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_conditional_end}
+* {@pylink grizzly.steps.scenario.tasks.conditional.step_task_conditional_end}
 
 ## Statistics
 
diff --git a/grizzly/tasks/date.py b/grizzly/tasks/date.py
index e7cd9643..61bc0a1e 100644
--- a/grizzly/tasks/date.py
+++ b/grizzly/tasks/date.py
@@ -7,7 +7,7 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_date}
+* {@pylink grizzly.steps.scenario.tasks.date.step_task_date}
 
 ## Arguments
 
@@ -19,11 +19,11 @@
 
 ### `value`
 
-``` plain
+```plain
 <date> [| format=<format>][, timezone=<timezone>][, offset=<offset>]
 ```
 
-* `date` _str/datetime_ - string representation of a date and/or time or a `datetime` object, e.g. `datetime.now()`
+* `date` _str | datetime_ - string representation of a date and/or time or a `datetime` object, e.g. `datetime.now()`
 
 * `format` _str_ - a python [`strftime` format string](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) or `ISO-8601:[DateTime|Time][:ms][:no-sep]`, this argument is required
 
diff --git a/grizzly/tasks/keystore.py b/grizzly/tasks/keystore.py
index 377bd903..08980100 100644
--- a/grizzly/tasks/keystore.py
+++ b/grizzly/tasks/keystore.py
@@ -9,11 +9,11 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_keystore_get}
+* {@pylink grizzly.steps.scenario.tasks.keystore.step_task_keystore_get}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_keystore_get_default}
+* {@pylink grizzly.steps.scenario.tasks.keystore.step_task_keystore_get_default}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_keystore_set}
+* {@pylink grizzly.steps.scenario.tasks.keystore.step_task_keystore_set}
 
 ## Statistics
 
diff --git a/grizzly/tasks/log_message.py b/grizzly/tasks/log_message.py
index bb3d3254..da73257e 100644
--- a/grizzly/tasks/log_message.py
+++ b/grizzly/tasks/log_message.py
@@ -1,11 +1,11 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.log_message Log Message
 This task calls the `grizzly` logger to print a log message at level `INFO`. It can be used to visualize values for
 {@link framework.usage.variables.templating} variables.
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_log_message}
+* {@pylink grizzly.steps.scenario.tasks.log_message.step_task_log_message}
 
 ## Statistics
 
@@ -14,7 +14,7 @@
 ## Arguments
 
 * `message` _str_ - message to log at `INFO` level, can be a template
-'''
+"""
 from typing import TYPE_CHECKING, Any
 
 from . import GrizzlyTask, template, grizzlytask
diff --git a/grizzly/tasks/loop.py b/grizzly/tasks/loop.py
index 9b0087bc..09524831 100644
--- a/grizzly/tasks/loop.py
+++ b/grizzly/tasks/loop.py
@@ -2,14 +2,14 @@
 @anchor pydoc:grizzly.tasks.loop Loop
 This task executes the wraped tasks for all values in provided list.
 
-All task created between {@pylink grizzly.steps.scenario.tasks.step_task_loop_start} and {@pylink grizzly.steps.scenario.tasks.step_task_loop_end}
+All task created between {@pylink grizzly.steps.scenario.tasks.loop.step_task_loop_start} and {@pylink grizzly.steps.scenario.tasks.loop.step_task_loop_end}
 will be wrapped in this instance and executed for all values in the provided list (must be in JSON format).
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_loop_start}
+* {@pylink grizzly.steps.scenario.tasks.loop.step_task_loop_start}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_loop_end}
+* {@pylink grizzly.steps.scenario.tasks.loop.step_task_loop_end}
 
 ## Statistics
 
diff --git a/grizzly/tasks/request.py b/grizzly/tasks/request.py
index 6cfebdbb..6b2ed0fd 100644
--- a/grizzly/tasks/request.py
+++ b/grizzly/tasks/request.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.request Request
 This task calls the `request` method of a `grizzly.users` implementation.
 
@@ -10,13 +10,13 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_request_text_with_name_endpoint}
+* {@pylink grizzly.steps.scenario.tasks.request.step_task_request_text_with_name_endpoint}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_request_file_with_name_endpoint}
+* {@pylink grizzly.steps.scenario.tasks.request.step_task_request_file_with_name_endpoint}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_request_file_with_name}
+* {@pylink grizzly.steps.scenario.tasks.request.step_task_request_file_with_name}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_request_text_with_name}
+* {@pylink grizzly.steps.scenario.tasks.request.step_task_request_text_with_name}
 
 ## Statistics
 
@@ -38,7 +38,7 @@
 
 All arguments will be removed from `endpoint` before creating the task instance.
 
-``` plain
+```plain
 <endpoint> [| content_type=<content_type>]
 ```
 
@@ -48,15 +48,14 @@
 
 Specifying MIME/content type as an argument to `endpoint` is the same as using {@pylink grizzly.steps.scenario.response.step_response_content_type}.
 
-``` gherkin
+```gherkin
 Then put request "test/request.j2.json" with name "test-put" to endpoint "/api/test | content_type=json"
 
 # same as
 Then put request "test/request.j2.json" with name "test-put" to endpoint "/api/test"
 And set response content type to "application/json"
 ```
-
-'''
+"""
 from typing import TYPE_CHECKING, Dict, List, Optional, Any
 
 from jinja2.environment import Template
diff --git a/grizzly/tasks/set_variable.py b/grizzly/tasks/set_variable.py
index c50e6865..d5c24c74 100644
--- a/grizzly/tasks/set_variable.py
+++ b/grizzly/tasks/set_variable.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.set_variable Set variable
 This task sets a testdata variable during runtime.
 
@@ -15,7 +15,7 @@
 * `variable` _str_ - name of the variable that should be set
 
 * `value` _value_ - value of the variable being set, must be a template
-'''
+"""
 from __future__ import annotations
 from typing import TYPE_CHECKING, Any, Optional, MutableMapping, Type, cast
 
diff --git a/grizzly/tasks/timer.py b/grizzly/tasks/timer.py
index e1e4f4b0..b9d67697 100644
--- a/grizzly/tasks/timer.py
+++ b/grizzly/tasks/timer.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.timer Timer
 This task "wraps" a group of other tasks, that might not have any requests and hence no statistics, to measure
 how long time they took. Request content length for this task in the scenario is number of tasks between starting and
@@ -9,9 +9,9 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_timer_start}
+* {@pylink grizzly.steps.scenario.tasks.timer.step_task_timer_start}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_timer_stop}
+* {@pylink grizzly.steps.scenario.tasks.timer.step_task_timer_stop}
 
 ## Statistics
 
@@ -22,7 +22,7 @@
 ## Arguments
 
 * `name` _str_ - name of the timer
-'''
+"""
 from typing import TYPE_CHECKING, Any
 from hashlib import sha1
 from time import perf_counter
diff --git a/grizzly/tasks/transformer.py b/grizzly/tasks/transformer.py
index 2795ccfb..a7bab8c8 100644
--- a/grizzly/tasks/transformer.py
+++ b/grizzly/tasks/transformer.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.tasks.transformer Transformer
 This task transforms a variable value to a document of correct type, so an expression can be used to extract
 values from the document to be used in another variable.
@@ -10,7 +10,7 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_transform}
+* {@pylink grizzly.steps.scenario.tasks.transformer.step_task_transform}
 
 ## Statistics
 
@@ -26,7 +26,7 @@
 * `expression` _str_ - JSON- or XPath expression to extract specific values in `contents`
 
 * `variable` _str_ - name of variable to save value to, must have been intialized
-'''
+"""
 from time import perf_counter
 from typing import TYPE_CHECKING, List, Callable, Any, Type
 
diff --git a/grizzly/tasks/until.py b/grizzly/tasks/until.py
index db90c3d6..0ab9c2d7 100644
--- a/grizzly/tasks/until.py
+++ b/grizzly/tasks/until.py
@@ -5,9 +5,9 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_request_with_name_endpoint_until}
+* {@pylink grizzly.steps.scenario.tasks.until.step_task_request_with_name_endpoint_until}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_client_get_endpoint_until}
+* {@pylink grizzly.steps.scenario.tasks.until.step_task_client_get_endpoint_until}
 
 ## Statistics
 
@@ -27,7 +27,7 @@
 
 ### `condition`
 
-``` plain
+```plain
 <expression> [| [retries=<retries>][, wait=<wait>][, expected_matches=<expected_matches>]]
 ```
 
@@ -38,7 +38,6 @@
 * `wait` _float_ (optional) - number of seconds to wait between retries (default `1.0`)
 
 * `expected_matches` _int_ (optional): number of matches that the expression should match (default `1`)
-
 """
 import json
 import logging
diff --git a/grizzly/tasks/task_wait.py b/grizzly/tasks/wait_between.py
similarity index 85%
rename from grizzly/tasks/task_wait.py
rename to grizzly/tasks/wait_between.py
index 405ea57f..26f76db1 100644
--- a/grizzly/tasks/task_wait.py
+++ b/grizzly/tasks/wait_between.py
@@ -1,5 +1,5 @@
-'''
-@anchor pydoc:grizzly.tasks.task_wait Task Wait
+"""
+@anchor pydoc:grizzly.tasks.wait_between Wait Between
 This task sets the wait time between tasks in a scenario.
 
 The default is to wait `0` seconds between each task.
@@ -12,9 +12,9 @@
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_wait_constant}
+* {@pylink grizzly.steps.scenario.tasks.wait_between.step_task_wait_between_constant}
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_wait_between}
+* {@pylink grizzly.steps.scenario.tasks.wait_between.step_task_wait_between_random}
 
 ## Statistics
 
@@ -25,7 +25,7 @@
 * `min_time` _float_ - minimum time to wait
 
 * `max_time` _float_ (optional) - maximum time to wait
-'''
+"""
 from typing import TYPE_CHECKING, Optional, Any
 
 from locust import between, constant
@@ -36,7 +36,7 @@
     from grizzly.scenarios import GrizzlyScenario
 
 
-class TaskWaitTask(GrizzlyTask):
+class WaitBetweenTask(GrizzlyTask):
     min_time: float
     max_time: Optional[float]
 
diff --git a/grizzly/tasks/wait.py b/grizzly/tasks/wait_explicit.py
similarity index 82%
rename from grizzly/tasks/wait.py
rename to grizzly/tasks/wait_explicit.py
index c04b28f0..4c0c0fe0 100644
--- a/grizzly/tasks/wait.py
+++ b/grizzly/tasks/wait_explicit.py
@@ -1,15 +1,15 @@
-'''
-@anchor pydoc:grizzly.tasks.wait Wait
+"""
+@anchor pydoc:grizzly.tasks.wait_explicit Explicit Wait
 This task executes a `gevent.sleep` and is used to manually create delays between steps in a scenario.
 
 ## Step implementations
 
-* {@pylink grizzly.steps.scenario.tasks.step_task_wait_seconds}
+* {@pylink grizzly.steps.scenario.tasks.wait_explicit.step_task_wait_explicit}
 
 ## Arguments
 
-* `time_expression` _str_ - float as string or a {@pydocfractions of seconds to excplicitly sleep in the scenario
-'''
+* `time_expression` _str_ - float as string or a fractions of seconds to excplicitly sleep in the scenario, supports {@link framework.usage.variables.templating}
+"""
 from typing import TYPE_CHECKING, Any
 
 from gevent import sleep as gsleep
@@ -23,7 +23,7 @@
 
 
 @template('time_expression')
-class WaitTask(GrizzlyTask):
+class ExplicitWaitTask(GrizzlyTask):
     time_expression: str
 
     def __init__(self, time_expression: str) -> None:
diff --git a/grizzly/users/base/response_handler.py b/grizzly/users/base/response_handler.py
index ece9ceee..e9cdb3c2 100644
--- a/grizzly/users/base/response_handler.py
+++ b/grizzly/users/base/response_handler.py
@@ -38,7 +38,7 @@ def get_match(
         user: GrizzlyUser,
         condition: bool = False
     ) -> Tuple[Optional[str], str, str]:
-        '''Contains common logic for both save and validation handlers.
+        """Contains common logic for both save and validation handlers.
 
         Args:
             input_context (Tuple[TransformerContentType, Any]): content type and transformed payload
@@ -46,7 +46,7 @@ def get_match(
             match_with (str): regular expression that the extracted value must match
             user (ContextVariablesUser): user that executed task (request)
             condition (bool): used by validation handler for negative matching
-        '''
+        """
         input_content_type, input_payload = input_context
         j2env = self.grizzly.state.jinja2
         rendered_expression = j2env.from_string(self.expression).render(user.context_variables)
diff --git a/grizzly/users/blobstorage.py b/grizzly/users/blobstorage.py
index daec1246..01551b95 100644
--- a/grizzly/users/blobstorage.py
+++ b/grizzly/users/blobstorage.py
@@ -1,4 +1,4 @@
-'''Put files to Azure Blob Storage.
+"""Put files to Azure Blob Storage.
 
 ## Request methods
 
@@ -11,7 +11,7 @@
 
 Format of `host` is the following:
 
-``` plain
+```plain
 [DefaultEndpointsProtocol=]https;EndpointSuffix=<hostname>;AccountName=<account name>;AccountKey=<account key>
 ```
 
@@ -22,11 +22,11 @@
 
 Example of how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "BlobStorage" load testing "DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName=examplestorage;AccountKey=xxxyyyyzzz=="
 Then send request "test/blob.file" to endpoint "azure-blobstorage-container-name"
 ```
-'''
+"""
 import os
 
 from typing import Dict, Any, Tuple
diff --git a/grizzly/users/dummy.py b/grizzly/users/dummy.py
index cb83621a..f1080b64 100644
--- a/grizzly/users/dummy.py
+++ b/grizzly/users/dummy.py
@@ -1,4 +1,4 @@
-'''Does nothing.
+"""Does nothing.
 
 Can be used with any tasks except {@pylink grizzly.tasks.request}.
 
@@ -10,10 +10,10 @@
 
 Example of how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "Dummy" load testing "/dev/null"
 ```
-'''
+"""
 from typing import Any, Dict, Tuple
 
 from grizzly.types import GrizzlyResponse
diff --git a/grizzly/users/iothub.py b/grizzly/users/iothub.py
index 3f5f29ee..6ebfa510 100644
--- a/grizzly/users/iothub.py
+++ b/grizzly/users/iothub.py
@@ -1,4 +1,4 @@
-'''Put files to Azure IoT hub.
+"""Put files to Azure IoT hub.
 
 ## Request methods
 
@@ -11,7 +11,7 @@
 
 Format of `host` is the following:
 
-``` plain
+```plain
 HostName=<hostname>;DeviceId=<device key>;SharedAccessKey=<access key>
 ```
 
@@ -21,11 +21,11 @@
 
 Example of how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "IotHub" load testing "HostName=my_iot_host_name;DeviceId=my_device;SharedAccessKey=xxxyyyyzzz=="
 Then send request "test/blob.file" to endpoint "uploaded_blob_filename"
 ```
-'''
+"""
 
 from typing import Dict, Any, Tuple
 from urllib.parse import urlparse, parse_qs
diff --git a/grizzly/users/messagequeue.py b/grizzly/users/messagequeue.py
index cf40d573..39ee5ed4 100644
--- a/grizzly/users/messagequeue.py
+++ b/grizzly/users/messagequeue.py
@@ -1,4 +1,4 @@
-'''Get and put messages on with IBM MQ queues.
+"""Get and put messages on with IBM MQ queues.
 
 User is based on `pymqi` for communicating with IBM MQ. However `pymqi` uses native libraries which `gevent` (used by `locust`) cannot patch,
 which causes any calls in `pymqi` to block the rest of `locust`. To get around this, the user implementation communicates with a stand-alone
@@ -19,14 +19,14 @@
 
 Format of `host` is the following:
 
-``` plain
+```plain
 mq://<hostname>:<port>/?QueueManager=<queue manager name>&Channel=<channel name>
 ```
 
 `endpoint` in the request is the name of an MQ queue. This can also be combined with an expression, if
 a specific message is to be retrieved from the queue. The format of endpoint is:
 
-``` plain
+```plain
 queue:<queue_name>[, expression:<expression>][, max_message_size:<max_message_size>]
 ```
 
@@ -39,7 +39,7 @@
 
 Example of how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "MessageQueue" load testing "mq://mq.example.com/?QueueManager=QM01&Channel=SRVCONN01"
 Then put request "test/queue-message.j2.json" with name "queue-message" to endpoint "queue:INCOMING.MESSAGES"
 ```
@@ -51,7 +51,7 @@
 To keep the connection alive during longer waiting periods, a heartbeat interval can be configured using the
 `connection.heartbeat_interval` (seconds) context variable (default 300).
 
-``` gherkin
+```gherkin
 Given a user of type "MessageQueue" load testing "mq://mq.example.com/?QueueManager=QM01&Channel=SRVCONN01"
 And set context variable "message.wait" to "5"
 Then get request with name "get-queue-message" from endpoint "queue:INCOMING.MESSAGES"
@@ -66,7 +66,7 @@
 up until the specified `message.wait` seconds has elapsed. To use expressions, a content type must be specified for the get
 request, e.g. `application/xml`:
 
-``` gherkin
+```gherkin
 Given a user of type "MessageQueue" load testing "mq://mq.example.com/?QueueManager=QM01&Channel=SRVCONN01"
 And set context variable "message.wait" to "5"
 Then get request with name "get-specific-queue-message" from endpoint "queue:INCOMING.MESSAGES, expression: //document[@id='abc123']"
@@ -77,7 +77,7 @@
 
 #### Username and password
 
-``` gherkin
+```gherkin
 Given a user of type "MessageQueue" load testing "mq://mqm:admin@mq.example.com/?QueueManager=QM01&Channel=SRVCONN01"
 And set context variable "auth.username" to "<username>"
 And set context variable "auth.password" to "<password>"
@@ -88,7 +88,7 @@
 A [key repository](https://www.ibm.com/docs/en/ibm-mq/7.5?topic=wstulws-setting-up-key-repository-unix-linux-windows-systems)
 (3 files; `.kdb`, `.rdb` and `.sth`) for the user is needed, and is specified with `auth.key_file` excluding the file extension.
 
-``` gherkin
+```gherkin
 Given a user of type "MessageQueue" load testing "mq://mqm:admin@mq.example.com/?QueueManager=QM01&Channel=SRVCONN01"
 And set context variable "auth.username" to "<username>"
 And set context variable "auth.password" to "<password>"
@@ -105,7 +105,7 @@
 compressed messages. When receiving messages, the RFH2 is automatically detected and (somewhat) supported. If RFH2 should be
 added when sending messages, with gzip compression, the context variable `message.header_type` should be set to `RFH2`:
 
-``` gherkin
+```gherkin
 Given a user of type "MessageQueue" load testing "mq://mq.example.com/?QueueManager=QM01&Channel=SRVCONN01"
 And set context variable "message.header_type" to "rfh2"
 Then put request "test/queue-message.j2.json" with name "gzipped-message" to endpoint "queue:GZIPPED.MESSAGES"
@@ -116,11 +116,11 @@
 
 To set a user value in the RFH2 header of the message, set `metadata` after the request, e.g.:
 
-``` gherkin
+```gherkin
 Then put request "test/queue-message.j2.json" with name "gzipped-message" to endpoint "queue:GZIPPED.MESSAGES"
 And metadata "filename" is "my_filename"
 ```
-'''
+"""
 import logging
 
 from typing import Dict, Any, Generator, Tuple, Optional, cast
diff --git a/grizzly/users/restapi.py b/grizzly/users/restapi.py
index c45ff678..115eab26 100644
--- a/grizzly/users/restapi.py
+++ b/grizzly/users/restapi.py
@@ -15,7 +15,7 @@
 
 Format of `host` is the following:
 
-``` plain
+```plain
 http[s]://<hostname>
 ```
 
@@ -23,14 +23,14 @@
 
 Example on how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "RestApi" load testing "https://api.example.com"
 Then post request "test/request.j2.json" to endpoint "/api/test"
 Then get request from endpoint "/api/test"
 ```
 
 To change how often the token should be refreshed, default is 3000 seconds:
-``` gherkin
+```gherkin
 And set context variable "auth.refresh_time" to "3500"
 ```
 
@@ -48,7 +48,7 @@
 
 Example:
 
-``` gherkin
+```gherkin
 Then post request "path/my_template.j2.xml" with name "FormPost" to endpoint "example.url.com | content_type=multipart/form-data, multipart_form_data_filename=my_filename, multipart_form_data_name=form_name"
 ```
 """  # noqa: E501
diff --git a/grizzly/users/servicebus.py b/grizzly/users/servicebus.py
index bfdc6242..dc707233 100644
--- a/grizzly/users/servicebus.py
+++ b/grizzly/users/servicebus.py
@@ -1,4 +1,4 @@
-'''Send and receive messages on Azure Service Bus queues and topics.
+"""Send and receive messages on Azure Service Bus queues and topics.
 
 !!! note
     If `message.wait` is not set, `azure.servicebus` will wait until there is a message available, and hence block the scenario.
@@ -25,14 +25,14 @@
 
 Format of `host` is the following:
 
-``` plain
+```plain
 [Endpoint=]sb://<hostname>/;SharedAccessKeyName=<shared key name>;SharedAccessKey=<shared key>
 ```
 
 `endpoint` in the request must have the prefix `queue:` or `topic:` followed by the name of the targeted
 type. When receiving messages from a topic, the argument `subscription:` is mandatory. The format of endpoint is:
 
-``` plain
+```plain
 [queue|topic]:<endpoint name>[, subscription:<subscription name>][, expression:<expression>]
 ```
 
@@ -43,7 +43,7 @@
 
 Example of how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "ServiceBus" load testing "sb://sb.example.com/;SharedAccessKeyName=authorization-key;SharedAccessKey=c2VjcmV0LXN0dWZm"
 And set context variable "message.wait" to "5"
 Then send request "queue-send" to endpoint "queue:shared-queue"
@@ -58,7 +58,7 @@
 endpoint. If no matching messages was found when peeking, it is repeated again after a slight delay, up until the specified `message.wait` seconds has
 elapsed. To use expressions, a content type must be specified for the request, e.g. `application/xml`.
 
-``` gherkin
+```gherkin
 Given a user of type "ServiceBus" load testing "sb://sb.example.com/;SharedAccessKeyName=authorization-key;SharedAccessKey=c2VjcmV0LXN0dWZm"
 And set context variable "message.wait" to "5"
 Then receive request "queue-recv" from endpoint "queue:shared-queue, expression:$.document[?(@.name=='TPM report')].id"
@@ -66,7 +66,7 @@
 Then receive request "topic-recv" from endpoint "topic:shared-topic, subscription:my-subscription, expression:/documents/document[@name='TPM Report']/id/text()"
 And set response content type to "application/xml"
 ```
-'''
+"""
 import logging
 
 from typing import Generator, Dict, Any, Tuple, Optional, Set, cast
diff --git a/grizzly/users/sftp.py b/grizzly/users/sftp.py
index 5cbbf1ff..e78e5c55 100644
--- a/grizzly/users/sftp.py
+++ b/grizzly/users/sftp.py
@@ -1,4 +1,4 @@
-'''Communicates with Secure File Transport Protocol.
+"""Communicates with Secure File Transport Protocol.
 
 !!! attention
     Both local and remote files will be overwritten if they already exists. Downloaded files will be stored in `requests/download`.
@@ -14,7 +14,7 @@
 
 Format of `host` is the following:
 
-``` plain
+```plain
 sftp://<host>[:<port>]
 ```
 
@@ -22,14 +22,14 @@
 
 Example of how to use it in a scenario:
 
-``` gherkin
+```gherkin
 Given a user of type "Sftp" load testing "sftp://sftp.example.com"
 And set context variable "auth.username" to "bob"
 And set context variable "auth.password" to "great-scott-42-file-bar"
 Then put request "test/blob.file" to endpoint "/pub/blobs"
 Then get request from endpoint "/pub/blobs/blob.file"
 ```
-'''
+"""
 from typing import Any, Dict, Tuple, Optional
 from urllib.parse import urlparse
 from os import path, environ, mkdir
diff --git a/grizzly_extras/novella.py b/grizzly_extras/novella.py
new file mode 100644
index 00000000..6c0dbaa1
--- /dev/null
+++ b/grizzly_extras/novella.py
@@ -0,0 +1,661 @@
+from __future__ import annotations
+import logging
+import re
+
+from typing import Any, Dict, List, Union, Optional, NamedTuple, Callable, Match, Generator, Tuple, cast
+from pathlib import Path
+from enum import Enum
+from dataclasses import dataclass, field
+from tokenize import tokenize, TokenInfo, TokenError
+from token import OP, STRING, NAME
+from io import BytesIO
+from ast import literal_eval
+
+import frontmatter
+import mistune
+
+from novella.markdown.preprocessor import MarkdownPreprocessor, MarkdownFiles, MarkdownFile
+from novella.novella import NovellaContext
+from novella.templates.mkdocs import MkdocsTemplate, MkdocsUpdateConfigAction
+from novella.markdown.flavor import MkDocsFlavor
+from novella.action import CopyFilesAction, RunAction
+from novella.markdown.tags.anchor import AnchorTagProcessor
+from novella.markdown.preprocessor import MarkdownPreprocessorAction
+from pydoc_markdown.novella.preprocessor import PydocTagPreprocessor
+from pydoc_markdown.contrib.renderers.markdown import MarkdownRenderer as PydocMarkdownRenderer
+from mistune.renderers.markdown import MarkdownRenderer
+from mistune.inline_parser import InlineParser
+from mistune.core import BlockState, InlineState
+
+
+logger = logging.getLogger('grizzly.novella')
+
+
+class GrizzlyMkdocsTemplate(MkdocsTemplate):  # pragma: no cover
+    def define_pipeline(self, context: NovellaContext) -> None:
+        context.option("serve", description="Use mkdocs serve", flag=True)
+        context.option("port", description="The port to serve under", default="8000")
+        context.option("site-dir", description='Build directory for MkDocs (defaults to "_site")', default="_site", metavar='PATH')
+        context.option("base-url", description='The base URL to prefix to autogenerated link inside the documentation.', metavar='URL')
+
+        copy_files = cast(CopyFilesAction, context.do('copy-files', name='copy-files'))
+        copy_files.paths = [self.content_directory]
+
+        for extension in ['yml', 'yaml']:
+            if (context.project_directory / f'mkdocs.{extension}').exists():
+                copy_files.paths.append(f'mkdocs.{extension}')
+                break
+
+        update_config = cast(MkdocsUpdateConfigAction, context.do('mkdocs-update-config', name='mkdocs-update-config'))
+        update_config.content_directory = self.content_directory
+
+        preprocessor = cast(MarkdownPreprocessorAction, context.do('preprocess-markdown', name='preprocess-markdown'))
+        preprocessor.path = self.content_directory
+        preprocessor.use('grizzly')  # <-- diff compared to MkdocsTemplate.define_pipeline
+
+        def configure_anchor(anchor: AnchorTagProcessor) -> None:
+            anchor.flavor = MkDocsFlavor(cast('str | None', context.options['base-url']) or self.base_url or '')
+
+        context.delay(lambda: preprocessor.preprocessor('anchor', cast(Any, configure_anchor)))
+        context.delay(lambda: preprocessor.preprocessor('grizzly'))  # <!-- diff compared to MkdocsTemplate.define_pipeline
+
+        def configure_run(run: RunAction) -> None:
+            run.args = ['mkdocs']
+            if context.options['serve']:
+                port = int(str(context.options['port']))
+                run.supports_reloading = True
+                run.args += ['serve', '--dev-addr', f'localhost:{port}']
+            else:
+                run.args += ['build', '-d', context.project_directory / str(context.options['site-dir'])]
+        context.do('run', configure_run, name='mkdocs-run')
+
+
+class MarkdownAstType(Enum):
+    TEXT = 'text'
+    PARAGRAPH = 'paragraph'
+    HEADER = 'heading'
+    BLANK_LINE = 'blank_line'
+    INLINE_HTML = 'inline_html'
+    BLOCK_HTML = 'block_html'
+    BLOCK_TEXT = 'block_text'
+    BLOCK_CODE = 'block_code'
+    LINK = 'link'
+    STRONG = 'strong'
+    LIST = 'list'
+    IMAGE = 'image'
+    CODEPSPAN = 'codespan'
+    BLOCK_QUOTE = 'block_quote'
+    LIST_ITEM = 'list_item'
+    SOFTBREAK = 'softbreak'
+    EMPHASIS = 'emphasis'
+
+    NONE = None
+
+    @classmethod
+    def from_value(cls, value: Optional[str]) -> MarkdownAstType:
+        for enum_value in cls:
+            if enum_value.value == value:
+                return enum_value
+
+        raise ValueError(f'"{value}" is not a valid value of {cls.__name__}')
+
+
+NO_CHILD: Dict[str, Any] = {'type': None, 'raw': None}
+
+
+@dataclass
+class MarkdownAstNode:
+    ast: Dict[str, Any]
+    index: int
+
+    _first_child: Optional[MarkdownAstNode] = field(init=False, default=None)
+    keep: bool = field(init=False, default=True)
+
+    @property
+    def first_child(self) -> MarkdownAstNode:
+        if self._first_child is None:
+            for child_node in self.ast.get('children', [NO_CHILD]):
+                child = MarkdownAstNode(child_node, self.index)
+                if child.type != MarkdownAstType.BLANK_LINE:
+                    self._first_child = child
+                    break
+
+            if self._first_child is None:
+                self._first_child = MarkdownAstNode(NO_CHILD, self.index)
+
+        return self._first_child
+
+    def get_child(self, index: int) -> MarkdownAstNode:
+        child_node = self.ast.get('children', [NO_CHILD])[index]
+
+        return MarkdownAstNode(child_node, self.index)
+
+    @property
+    def type(self) -> MarkdownAstType:
+        return MarkdownAstType.from_value(self.ast.get('type', None))
+
+    @property
+    def raw(self) -> Optional[str]:
+        return cast(Optional[str], self.ast.get('raw', None))
+
+
+class MarkdownHeading(NamedTuple):
+    text: str
+    level: int
+
+
+def make_human_readable(input: str) -> str:
+    words: List[str] = []
+
+    for word in input.split('_'):
+        words.append(word.capitalize())
+
+    output = ' '.join(words)
+
+    for word in ['http', 'sftp', 'api', 'csv']:
+        output = output.replace(word.capitalize(), word.upper())
+        output = output.replace(word, word.upper())
+
+    to_replace = dict(Iot='IoT', hub='Hub')
+    for value, replace_value in to_replace.items():
+        output = output.replace(value, replace_value)
+
+    return output
+
+
+def _create_nav_node(target: List[Union[str, Dict[str, str]]], path: str, node: Path, with_index: bool = True) -> None:
+    if not (node.is_file() and (node.stem == '__init__' or not node.stem.startswith('_'))):
+        return
+
+    if node.stem == '__init__':
+        if not with_index:
+            return
+
+        target.insert(0, f'{path}/index.md')
+    else:
+        target.append({make_human_readable(node.stem): f'{path}/{node.stem}.md'})
+
+
+def mkdocs_update_config(config: Dict[str, Any]) -> None:  # pragma: no cover
+    root = Path.cwd().parent
+    config_nav_tasks = config['nav'][3]['Framework'][0]['Usage'][0]['Tasks']
+    config_nav_tasks_clients = config_nav_tasks.pop()
+    tasks = root / 'grizzly' / 'tasks'
+
+    nav_tasks: List[Union[str, Dict[str, str]]] = []
+    for task in tasks.iterdir():
+        _create_nav_node(nav_tasks, 'framework/usage/tasks', task)
+    config_nav_tasks.extend(nav_tasks)
+
+    tasks_clients = tasks / 'clients'
+    nav_tasks_clients: List[Union[str, Dict[str, str]]] = []
+    for task_client in tasks_clients.iterdir():
+        _create_nav_node(nav_tasks_clients, 'framework/usage/tasks/clients', task_client)
+    config_nav_tasks_clients['Clients'] = nav_tasks_clients
+    config_nav_tasks.append(config_nav_tasks_clients)
+
+    config_nav_testdata = config['nav'][3]['Framework'][0]['Usage'][1]['Variables'][1]
+    nav_testdata: List[Union[str, Dict[str, str]]] = []
+    variables = Path.cwd() / '..' / 'grizzly' / 'testdata' / 'variables'
+    for variable in variables.iterdir():
+        _create_nav_node(nav_testdata, 'framework/usage/variables/testdata', variable)
+    config_nav_testdata['Testdata'] = nav_testdata
+
+    config_nav_users = config['nav'][3]['Framework'][0]['Usage'][2]
+    users = root / 'grizzly' / 'users'
+    nav_users: List[Union[str, Dict[str, str]]] = []
+    for user in users.iterdir():
+        _create_nav_node(nav_users, 'framework/usage/load-users', user)
+
+    config_nav_users['Load Users'] = nav_users
+
+    config_nav_steps = config['nav'][3]['Framework'][0]['Usage'][3]['Steps']
+    steps = root / 'grizzly' / 'steps'
+    for step in steps.iterdir():
+        _create_nav_node(config_nav_steps, 'framework/usage/steps', step)
+
+    config_nav_steps_background = config_nav_steps[1]
+    steps_background = steps / 'background'
+    nav_steps_background: List[Union[str, Dict[str, str]]] = []
+    for step in steps_background.iterdir():
+        _create_nav_node(nav_steps_background, 'framework/usage/steps/background', step)
+
+    config_nav_steps_background['Background'] = nav_steps_background
+
+    config_nav_steps_scenario = config_nav_steps[2]
+    steps_scenario = steps / 'scenario'
+    nav_steps_scenario: List[Union[str, Dict[str, str]]] = []
+    for step in steps_scenario.iterdir():
+        _create_nav_node(nav_steps_scenario, 'framework/usage/steps/scenario', step)
+
+    config_nav_steps_scenario['Scenario'] = nav_steps_scenario
+
+    steps_scenario_tasks = steps_scenario / 'tasks'
+    nav_steps_scenario_tasks: List[Union[str, Dict[str, str]]] = []
+    for task in steps_scenario_tasks.iterdir():
+        _create_nav_node(nav_steps_scenario_tasks, 'framework/usage/steps/scenario/tasks', task, with_index=False)
+
+    config_nav_steps_scenario['Scenario'].append({'Tasks': nav_steps_scenario_tasks})
+
+
+def preprocess_markdown_update_with_header_levels(processor: MarkdownPreprocessor, levels: Dict[str, int]) -> None:  # pragma: no cover
+    if isinstance(processor, PydocTagPreprocessor) and isinstance(processor._renderer, PydocMarkdownRenderer):
+        processor._renderer.header_level_by_type.update(levels)
+
+
+def _generate_dynamic_page(input_file: Path, output_path: Path, title: str, namespace: str) -> None:
+    if not (input_file.is_file() and (input_file.stem == '__init__' or not input_file.stem.startswith('_'))):
+        return
+
+    if input_file.stem == '__init__':
+        filename = 'index'
+    else:
+        filename = input_file.stem
+        title = f'{title} / {make_human_readable(input_file.stem)}'
+        namespace = f'{namespace}.{input_file.stem}'
+
+    file = output_path / f'{filename}.md'
+    file.parent.mkdir(parents=True, exist_ok=True)
+    if not file.exists():
+        file.write_text(f'''---
+title: {title}
+---
+@pydoc {namespace}
+''')
+
+
+def generate_dynamic_pages(directory: Path) -> None:  # pragma: no cover
+    root = Path.cwd().parent
+
+    tasks = root / 'grizzly' / 'tasks'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'tasks'
+    for task in tasks.iterdir():
+        _generate_dynamic_page(task, output_path, 'Tasks', 'grizzly.tasks')
+
+    tasks_clients = tasks / 'clients'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'tasks' / 'clients'
+    for task_client in tasks_clients.iterdir():
+        _generate_dynamic_page(task_client, output_path, 'Clients', 'grizzly.tasks.clients')
+
+    variables = root / 'grizzly' / 'testdata' / 'variables'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'variables' / 'testdata'
+    for variable in variables.iterdir():
+        _generate_dynamic_page(variable, output_path, 'Testdata', 'grizzly.testdata.variables')
+
+    steps = root / 'grizzly' / 'steps'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'steps'
+    for step in steps.iterdir():
+        _generate_dynamic_page(step, output_path, 'Steps', 'grizzly.steps')
+
+    steps_background = steps / 'background'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'steps' / 'background'
+    for step in steps_background.iterdir():
+        _generate_dynamic_page(step, output_path, 'Steps / Background', 'grizzly.steps.background')
+
+    steps_scenario = steps / 'scenario'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'steps' / 'scenario'
+    for step in steps_scenario.iterdir():
+        _generate_dynamic_page(step, output_path, 'Steps / Scenario', 'grizzly.steps.scenario')
+
+    steps_scenario_tasks = steps_scenario / 'tasks'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'steps' / 'scenario' / 'tasks'
+    for task in steps_scenario_tasks.iterdir():
+        _generate_dynamic_page(task, output_path, 'Steps / Scenario / Tasks', 'grizzly.steps.scenario.tasks')
+
+    users = root / 'grizzly' / 'users'
+    output_path = directory / 'content' / 'framework' / 'usage' / 'load-users'
+    for user in users.iterdir():
+        _generate_dynamic_page(user, output_path, 'Load Users', 'grizzly.users')
+
+
+class GrizzlyMarkdownInlineParser(InlineParser):
+    def parse_codespan(self, match: Match, state: InlineState) -> int:
+        """
+        Default `mistune.inline_parser.InlineParser.parse_codespan` escapes code, which messes
+        things up, it will escape some character to their HTML entity representation, which will be literal
+        when rendering as HTML (& -> &amp; etc.).
+        """
+        marker = match.group(0)
+        # require same marker with same length at end
+
+        pattern = re.compile(r'(.*?[^`])' + marker + r'(?!`)', re.S)
+
+        pos = match.end()
+        m = pattern.match(state.src, pos)
+        if m:
+            end_pos = m.end()
+            code = m.group(1)
+            # Line endings are treated like spaces
+            code = code.replace('\n', ' ')
+            if len(code.strip()):
+                if code.startswith(' ') and code.endswith(' '):
+                    code = code[1:-1]
+            state.append_token({'type': 'codespan', 'raw': code})
+            #                                               ^
+            # only diff compared tomistune.inline_parser.InlineParser.parse_codespan
+            return end_pos
+        else:
+            state.append_token({'type': 'text', 'raw': marker})
+            return pos
+
+
+class GrizzlyMarkdown:
+    _markdown: mistune.Markdown
+    _document: MarkdownFile
+    _ast_tree_original: List[Dict[str, Any]]
+    _ast_tree_modified: List[Dict[str, Any]]
+    _index: int
+    ignore_until: Optional[Callable[[MarkdownAstNode], bool]]
+
+    def __init__(self, markdown: mistune.Markdown, document: MarkdownFile) -> None:
+        self._markdown = markdown
+        self._document = document
+        self._index = 0
+        self.ignore_until = None
+
+    @classmethod
+    def _is_anchor(cls, value: str) -> bool:
+        tokens = [token for token in cls._get_tokens(value) if token.type in [OP, NAME, STRING]]
+
+        try:
+            return tokens[0].type == OP and tokens[0].string == '<' and tokens[1].type == NAME and tokens[1].string == 'a' and tokens[-1].type == OP and tokens[-1].string == '>'
+        except IndexError:
+            return False
+
+    @classmethod
+    def _get_header(cls, node: MarkdownAstNode) -> MarkdownHeading:
+        text = ''.join([child.get('raw', '') for child in node.ast.get('children', [])])
+        level = node.ast.get('attrs', {}).get('level', 0)
+
+        return MarkdownHeading(text, level)
+
+    @property
+    def index(self) -> int:
+        return self._index
+
+    @index.setter
+    def index(self, value: int) -> None:
+        self._index = value
+
+    def get_code_block(self, start_node: MarkdownAstNode) -> Optional[str]:
+        code_block: Optional[str] = None
+
+        for index in range(start_node.index + 1, len(self._ast_tree_original)):
+            node = MarkdownAstNode(self._ast_tree_original[index], index)
+
+            # do not look beyond next header
+            if node.type == MarkdownAstType.HEADER:
+                break
+
+            if node.type == MarkdownAstType.BLOCK_CODE and node.raw is not None:
+                code_block = node.raw
+                self.index = index + 1
+                break
+
+        return code_block
+
+    @classmethod
+    def _get_tokens(cls, text: str) -> List[TokenInfo]:
+        tokens: List[TokenInfo] = []
+
+        try:
+            for token in tokenize(BytesIO(text.encode('utf8')).readline):
+                tokens.append(token)
+        except TokenError as e:
+            if 'EOF in multi-line statement' not in str(e):
+                raise
+
+        return tokens
+
+    @classmethod
+    def get_step_expression_from_code_block(cls, code_block: str) -> Optional[Tuple[str, str]]:
+        tokens = cls._get_tokens(code_block)
+
+        for index, token in enumerate(tokens):
+            if token.type == OP and token.string == '@':
+                step_type = tokens[index + 1].string
+                future_index = index + 2
+                future_token = tokens[future_index]
+
+                if not (future_token.type == OP and future_token.string == '('):
+                    continue
+
+                future_index += 1
+
+                while not (future_token.type == OP and future_token.string == ')') and future_index < len(tokens):
+                    future_token = tokens[future_index]
+                    if future_token.type == STRING:
+                        return (step_type, cast(str, literal_eval(future_token.string)),)
+
+                    future_index += 1
+
+        return None
+
+    def to_ast(self, content: str) -> List[Dict[str, Any]]:
+        return cast(List[Dict[str, Any]], self._markdown(content))
+
+    def next(self) -> Generator[MarkdownAstNode, None, None]:
+        for index, node_ast in enumerate(self._ast_tree_original):
+            node = MarkdownAstNode(node_ast, index)
+
+            if index < self.index:
+                continue
+
+            if self.ignore_until is not None:
+                if not self.ignore_until(node):
+                    self.ignore_until = None
+                continue
+
+            self.index = index
+
+            if node.type == MarkdownAstType.BLANK_LINE:
+                self._ast_tree_modified.append(node.ast)
+                continue
+
+            yield node
+
+            if node.keep:
+                self._ast_tree_modified.insert(node.index, node.ast)
+
+    def peek(self) -> MarkdownAstNode:
+        for index in range(self.index + 1, len(self._ast_tree_original)):
+            node = MarkdownAstNode(self._ast_tree_original[index], self.index + index)
+
+            if node.type != MarkdownAstType.BLANK_LINE:
+                return node
+
+        return MarkdownAstNode(self._ast_tree_original[self.index + 1], self.index + 1)
+
+    @classmethod
+    def ast_reformat_block_code(cls, node: MarkdownAstNode) -> MarkdownAstNode:
+        if node.type == MarkdownAstType.BLOCK_CODE and node.raw is not None and node.raw.startswith('```'):
+            style = node.ast.get('style', 'indent')
+            if style == 'indent':
+                indent = '    '
+            else:
+                indent = ''
+            code_lines = [f'{indent}{line}' for line in node.raw.splitlines()]
+            marker = code_lines[-1].strip()[-3:]
+            if code_lines[-1].strip() != marker:
+                raw = '\n'.join(code_lines[1:])
+                raw = raw[:-3]
+            else:
+                raw = '\n'.join(code_lines[1:-1])
+            _, info = code_lines[0].split(marker, 1)
+            node.ast = {
+                'type': 'block_code',
+                'raw': raw,
+                'style': 'indent',
+                'marker': f'{indent}{marker}',
+                'attrs': {
+                    'info': info.strip(),
+                },
+            }
+
+        return node
+
+    @classmethod
+    def ast_reformat_admonitions(cls, node: MarkdownAstNode) -> MarkdownAstNode:
+        if node.type == MarkdownAstType.PARAGRAPH and (node.first_child.raw or '').startswith('!!!'):
+            indent_next = False
+            for index, child_ast in enumerate(node.ast.get('children', [])):
+                if index == 0:
+                    continue
+
+                child = MarkdownAstNode(child_ast, node.index)
+                if child.type == MarkdownAstType.SOFTBREAK:
+                    indent_next = True
+                    continue
+
+                if indent_next and child.type == MarkdownAstType.TEXT and child.raw is not None:
+                    child.ast['raw'] = f'    {child.raw}'
+                    node.ast['children'][index] = child.ast
+                    indent_next = False
+
+        return node
+
+    @classmethod
+    def ast_reformat_recursive(cls, node: MarkdownAstNode, func: Callable[[MarkdownAstNode], MarkdownAstNode]) -> MarkdownAstNode:
+        node = func(node)
+        for i, child_ast in enumerate(node.ast.get('children', [])):
+            child = func(MarkdownAstNode(child_ast, node.index))
+
+            for j, grand_child_ast in enumerate(child.ast.get('children', [])):
+                grand_child = func(MarkdownAstNode(grand_child_ast, node.index))
+
+                if grand_child.ast != grand_child_ast:
+                    child.ast['children'][j] = grand_child.ast
+
+            if child.ast != child_ast:
+                node.ast['children'][i] = child.ast
+
+        return node
+
+    def _process_content(self, content: str) -> str:
+        self._ast_tree_original = self.to_ast(content)
+        self._ast_tree_modified: List[Dict[str, Any]] = []
+
+        single_docstring_class = content.count('## Class ') <= 1
+
+        move_forward = False
+
+        for node in self.next():
+            # <!-- work around for indented code blocks
+            # mkdocs material `===` messes up ast parser, let's trick the renderer
+            # so it works
+            node = self.ast_reformat_recursive(node, self.ast_reformat_block_code)
+            # // -->
+
+            # <!-- workaround for admonitions (!!!)
+            node = self.ast_reformat_recursive(node, self.ast_reformat_admonitions)
+            # // -->
+
+            if not move_forward:
+                if node.type != MarkdownAstType.PARAGRAPH:
+                    continue
+
+                if node.first_child.type != MarkdownAstType.INLINE_HTML:
+                    continue
+
+                text = node.first_child.raw
+
+                if text is None:
+                    continue
+
+                if not self._is_anchor(text):
+                    continue
+
+                next_node = self.peek()
+                if next_node.type != MarkdownAstType.HEADER:
+                    continue
+
+                move_forward = True
+                continue
+            else:
+                move_forward = False
+                header = self._get_header(node)
+
+                # no class documentation, and any methods under it
+                if header.text.startswith('Class '):
+                    if single_docstring_class:
+                        def condition(_: MarkdownAstNode) -> bool:
+                            return True
+
+                        node.keep = False
+                        self.ignore_until = condition
+                        continue
+
+                # rewrite headers for step implementation, replace function name with step expression
+                if header.text.startswith('step'):
+                    code_block = self.get_code_block(node)
+                    if code_block is None:
+                        continue
+
+                    step = self.get_step_expression_from_code_block(code_block)
+
+                    if step is not None:
+                        step_type, step_expression = step
+                        _, _, header_new = header.text.split('_', 2)
+                        header_new = f'{step_type.capitalize()} {header_new.replace("_", " ")}'
+
+                        node.ast.update({'children': [{'type': 'text', 'raw': header_new}]})
+                        self._ast_tree_modified.append({
+                            'type': MarkdownAstType.BLOCK_CODE.value,
+                            'raw': f'{step_type.capitalize()} {step_expression}',
+                            'style': 'fenced',
+                            'marker': '```',
+                            'attrs': {
+                                'info': 'gherkin',
+                            }
+                        })
+
+        # remove orphan stuff that might be left in the end
+        last_node: Optional[MarkdownAstNode] = None
+
+        for index, node_ast in enumerate(reversed(self._ast_tree_modified)):
+            node = MarkdownAstNode(node_ast, -index)
+
+            if (
+                not node.type == MarkdownAstType.BLANK_LINE
+                and not (
+                    node.type == MarkdownAstType.PARAGRAPH
+                    and node.first_child.type == MarkdownAstType.INLINE_HTML
+                    and self._is_anchor(node.first_child.raw or '')
+                )
+            ):
+                last_node = node
+                break
+
+        if last_node is not None and last_node.index < 0:
+            self._ast_tree_modified = self._ast_tree_modified[:last_node.index]
+
+        renderer = MarkdownRenderer()
+
+        content = cast(str, renderer(self._ast_tree_modified, state=BlockState()))
+
+        return content
+
+    def __call__(self) -> None:
+        content = self._document.content
+
+        if not frontmatter.checks(content):
+            content = self._process_content(content)
+        else:
+            frontmatter_document = frontmatter.loads(content)
+            frontmatter_document.content = self._process_content(frontmatter_document.content)
+            content = cast(str, frontmatter.dumps(frontmatter_document))
+
+        self._document.content = content
+
+
+class GrizzlyMarkdownProcessor(MarkdownPreprocessor):
+    _markdown: mistune.Markdown
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+
+        self._markdown = mistune.Markdown(inline=GrizzlyMarkdownInlineParser())
+
+    def process_files(self, files: MarkdownFiles) -> None:
+        for file in files:
+            GrizzlyMarkdown(self._markdown, file)()
diff --git a/grizzly_extras/text.py b/grizzly_extras/text.py
index acf73364..3106351d 100644
--- a/grizzly_extras/text.py
+++ b/grizzly_extras/text.py
@@ -8,7 +8,7 @@ class permutation:
     Decorator used to annotate `parse` methods that are not using {@pylink grizzly_extras.text.PermutationEnum} as a base.
     This could be for example parse methods that uses regular expressions via `parse.with_pattern`.
 
-    ``` python
+    ```python
     import parse
 
     from behave import register_type
@@ -56,7 +56,7 @@ class PermutationEnum(Enum, metaclass=PermutationMeta):
 
     Consider the following `Enum`, being mapped to a custom `parse` type named `FruitType`:
 
-    ``` python
+    ```python
     from behave import register_type
 
 
@@ -82,7 +82,7 @@ def from_string(cls, value: str) -> Fruit:
 
     When permutated, it will only produce one step expression and all instances of the variable in the expression will have been replaced with nothing.
 
-    ``` gherkin
+    ```gherkin
     Then I want to eat a "{fruit:FruitType}"  # -->
 
     Then I want to eat a ""
@@ -98,7 +98,7 @@ def from_string(cls, value: str) -> Fruit:
 
     When permutated, it will produce the number of step expressions as values in the enum.
 
-    ``` gherkin
+    ```gherkin
     Then I want to eat a {fruit:FruitType}  # -->
 
     Then I want to eat a banana
@@ -112,7 +112,7 @@ def from_string(cls, value: str) -> Fruit:
 
     When permutated, combination of all enum values will be produced, if the variable type occurs the same number of times as values in the enum.
 
-    ``` gherkin
+    ```gherkin
     Then I want to eat a {fruit1:FruitType}, a {fruit2:FruitType} and a {fruit3:FruitType}  # -->
 
     Then I want to eat a banana, a apple and a orange
@@ -122,7 +122,7 @@ def from_string(cls, value: str) -> Fruit:
 
     Variable occurs `2..N` times in the expression, and should produce more than one combination of the step expression.
 
-    ``` gherkin
+    ```gherkin
     Then I want to eat a {fruit1:FruitType}, a {fruit2:FruitType} and a {fruit3:FruitType}  # -->
 
     Then I want to eat a banana, a apple and a orange
diff --git a/grizzly_extras/transformer.py b/grizzly_extras/transformer.py
index c6a3d229..d8478de5 100644
--- a/grizzly_extras/transformer.py
+++ b/grizzly_extras/transformer.py
@@ -1,8 +1,8 @@
-'''
+"""
 @anchor pydoc:grizzly_extras.transformer Transformer
 This modules contains the means to transform a raw string to format that is possible to easy search
 for attributes and their value in.
-'''
+"""
 import re
 
 from abc import ABCMeta
diff --git a/pyproject.toml b/pyproject.toml
index a3a13b28..3a5c5fcc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,12 @@ Tracker = 'https://github.com/Biometria-se/grizzly/issues'
 [project.scripts]
 async-messaged = "grizzly_extras.async_message.daemon:main"
 
+[project.entry-points."novella.markdown.preprocessors"]
+grizzly = "grizzly_extras.novella:GrizzlyMarkdownProcessor"
+
+[project.entry-points."novella.templates"]
+grizzly-mkdocs = "grizzly_extras.novella:GrizzlyMkdocsTemplate"
+
 [project.optional-dependencies]
 mq = [
     "pymqi ==1.12.10"
@@ -102,7 +108,9 @@ docs = [
     "mkdocs >=1.3.0",
     "mkdocs-material >=8.3.2",
     "packaging >=21.3",
-    "grizzly-loadtester-cli"
+    "grizzly-loadtester-cli",
+    "python-frontmatter",
+    "mistune >=3.0.2"
 ]
 
 [tool.setuptools_scm]
@@ -268,6 +276,10 @@ ignore_missing_imports = true
 module = "piplicenses.*"
 ignore_missing_imports = true
 
+[[tool.mypy.overrides]]
+module = "frontmatter.*"
+ignore_missing_imports = true
+
 [tool.coverage.run]
 omit = ["tests/*", "setup.py", ".pytest_tmp/*"]
 
diff --git a/script/docs-generate.bash b/script/docs-generate.bash
index 716b9afe..2faf44d0 100755
--- a/script/docs-generate.bash
+++ b/script/docs-generate.bash
@@ -37,11 +37,12 @@ main() {
     esac
 
     >&2 echo "-- installing grizzly-${pypi_suffix}"
-    if ! python -m pip install --no-cache-dir grizzly-loadtester-${pypi_suffix}[dev] 2>&1 > /tmp/grizzly-cli-install.log; then
-        cat /tmp/grizzly-cli-install.log
+    if ! python -m pip install --no-cache-dir grizzly-loadtester-${pypi_suffix}[dev] &> /tmp/grizzly-${pypi_suffix}-install.log; then
+        >&2 echo "!! failed:"
+        >&2 cat /tmp/grizzly-${pypi_suffix}-install.log
     fi
 
-    rm -rf /tmp/grizzly-cli-install.log || true
+    rm -rf /tmp/grizzly-${pypi_suffix}-install.log || true
 
     case "${what}" in
         --usage)


Test patch:
diff --git a/grizzly/testdata/variables/csv_reader.py b/grizzly/testdata/variables/csv_reader.py
index 5804631e..fc4ef746 100644
--- a/grizzly/testdata/variables/csv_reader.py
+++ b/grizzly/testdata/variables/csv_reader.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.csv_reader CSV Reader
 This variable reads a CSV file and provides a new row from the CSV file each time it is accessed.
 
@@ -15,29 +15,27 @@
 
 ## Example
 
-`requests/example.csv`:
-
-``` plain
+```plain title="requests/example.csv"
 username,password
 bob1,some-password
 alice1,some-other-password
 bob2,password
 ```
 
-``` gherkin
+```gherkin
 And value for variable "AtomicCsvReader.example" is "example.csv | random=False, repeat=True"
 Then post request with name "authenticate" to endpoint "/api/v1/authenticate"
-  """
+  \"\"\"
   {
       "username": "{{ AtomicCsvReader.example.username }}",
       "password": "{{ AtomicCsvReader.example.password }}"
   }
-  """
+  \"\"\"
 ```
 
 First request the payload will be:
 
-``` json
+```json
 {
     "username": "bob1",
     "password": "some-password"
@@ -46,7 +44,7 @@
 
 Second request:
 
-``` json
+```json
 {
     "username": "alice1",
     "password": "some-other-password"
@@ -54,7 +52,7 @@
 ```
 
 etc.
-'''
+"""
 import os
 
 from typing import Dict, List, Any, Type, Optional, cast
diff --git a/grizzly/testdata/variables/csv_writer.py b/grizzly/testdata/variables/csv_writer.py
index 3510f4eb..9ecf29c5 100644
--- a/grizzly/testdata/variables/csv_writer.py
+++ b/grizzly/testdata/variables/csv_writer.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.csv_writer CSV Writer
 This variable writes to a CSV file.
 
@@ -17,13 +17,12 @@
 
 ## Example
 
-``` gherkin
+```gherkin
 And value for variable "AtomicCsvWriter.output" is "output.csv | headers='foo,bar'"
 ...
 And value for variable "AtomicCsvWriter.output" is "{{ foo_value }}, {{ bar_value }}"
 ```
-
-'''
+"""
 import os
 
 from typing import Dict, Any, Type, Optional, cast
diff --git a/grizzly/testdata/variables/date.py b/grizzly/testdata/variables/date.py
index 4a34d77a..375e6d8b 100644
--- a/grizzly/testdata/variables/date.py
+++ b/grizzly/testdata/variables/date.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.date Date
 This variable is used to format and use dates.
 
@@ -15,19 +15,19 @@
 
 ## Example
 
-``` gherkin
+```gherkin
 And value for variable "AtomicDate.arrival" is "now | format='%Y-%m-%dT%H:%M:%S.000Z', timezone=UTC"
 ```
 
 This can then be used in a template:
 
-``` json
+```json
 {
     "arrival": "{{ AtomicDate.arrival }}",
     "location": "Port of Shanghai"
 }
 ```
-'''
+"""
 from typing import Union, Dict, Any, List, Type, Optional, cast
 from datetime import datetime
 
@@ -37,8 +37,7 @@
 try:
     from zoneinfo import ZoneInfo, ZoneInfoNotFoundError  # pylint: disable=import-error
 except ImportError:
-    # pyright: reportMissingImports=false
-    from backports.zoneinfo import ZoneInfo, ZoneInfoNotFoundError  # type: ignore[no-redef]  # pylint: disable=import-error
+    from backports.zoneinfo import ZoneInfo, ZoneInfoNotFoundError  # type: ignore[no-redef]  # pylint: disable=import-error  # pyright: ignore[reportMissingImports]
 
 from grizzly_extras.arguments import split_value, parse_arguments
 
diff --git a/grizzly/testdata/variables/directory_contents.py b/grizzly/testdata/variables/directory_contents.py
index 1eaba306..984d5882 100644
--- a/grizzly/testdata/variables/directory_contents.py
+++ b/grizzly/testdata/variables/directory_contents.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.directory_contents Directory Contents
 This variable provides a list of files in the specified directory.
 
@@ -15,7 +15,7 @@
 
 With the following directory structure:
 
-``` plain
+```plain
 .
 └── requests
     └── files
@@ -26,13 +26,13 @@
         └── file5.bin
 ```
 
-``` gherkin
+```gherkin
 And value for variable "AtomicDirectoryContents.files" is "files/ | repeat=True, random=False"
 And put request "{{ AtomicDirectoryContents.files }}" with name "put-file" to endpoint "/tmp"
 ```
 
 First request will provide `file1.bin`, second `file2.bin` etc.
-'''
+"""
 import os
 
 from typing import Dict, List, Any, Type, Optional, cast
diff --git a/grizzly/testdata/variables/integer_incrementer.py b/grizzly/testdata/variables/integer_incrementer.py
index 7a5c5f16..bf58f660 100644
--- a/grizzly/testdata/variables/integer_incrementer.py
+++ b/grizzly/testdata/variables/integer_incrementer.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.integer_incrementer Integer Incrementer
 This variable provides an unique integer each time it is accessed.
 
@@ -16,14 +16,14 @@
 
 ## Example
 
-``` gherkin title="example.feature"
+```gherkin title="example.feature"
 And value for variable "AtomicIntegerIncrementer.unique_id" is "100 | step=10"
 And value for variable "AtomicIntegerIncrementer.persistent" is "10 | step=5, persist=True"
 ```
 
 This can then be used in a template:
 
-``` json
+```json
 {
     "id": {{ AtomicIntegerIncrementer.unique_id }}
 }
@@ -73,7 +73,7 @@
     3. `35`
 
     4. ...
-'''
+"""
 from typing import Union, Dict, Any, Type, Optional, cast
 
 from grizzly_extras.arguments import split_value, parse_arguments
diff --git a/grizzly/testdata/variables/random_integer.py b/grizzly/testdata/variables/random_integer.py
index 6fd3e963..d3e42220 100644
--- a/grizzly/testdata/variables/random_integer.py
+++ b/grizzly/testdata/variables/random_integer.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.random_integer Random Integer
 This variable provides an random integer between specified interval.
 
@@ -12,19 +12,19 @@
 
 ## Example
 
-``` gherkin
+```gherkin
 And value for variable "AtomicRandomInteger.weight" is "10..30"
 ```
 
 This can then be used in a template:
-``` json
+```json
 {
     "weight_tons": {{ AtomicRandomInteger.weight }}
 }
 ```
 
 `AtomicRandomInteger.weight` will then be anything between, and including, `10` and `30`.
-'''
+"""
 from typing import Type, Dict, cast
 from random import randint
 
diff --git a/grizzly/testdata/variables/random_string.py b/grizzly/testdata/variables/random_string.py
index 5c2bb404..64caee68 100644
--- a/grizzly/testdata/variables/random_string.py
+++ b/grizzly/testdata/variables/random_string.py
@@ -1,4 +1,4 @@
-'''
+"""
 @anchor pydoc:grizzly.testdata.variables.random_string Random String
 This variable generates a specified number of unique strings, based on a string format pattern.
 
@@ -21,20 +21,20 @@
 
 ## Example
 
-``` gherkin
+```gherkin
 And value for variable "AtomicRandomString.registration_plate_number" is "%s%sZ%d%d0 | upper=True, count=100"
 And value for variable "AtomicRandomString.uuid" is "%g | count=100"
 ```
 
 This can then be used in a template:
-``` json
+```json
 {
     "registration_plate_number": "{{ AtomicRandomString.registration_plate_number }}"
 }
 ```
 
 `AtomicRandomString.registration_plate_number` will then be a string in the format `[A-Z][A-Z]Z[0-9][0-9]0` and there will be `100` unique values for disposal.
-'''
+"""
 from typing import Dict, List, Any, Callable, Optional, Set, Type, cast
 from random import randint, choice
 from string import ascii_letters
diff --git a/tests/e2e/steps/background/test_setup.py b/tests/e2e/steps/background/test_setup.py
index e88e9f03..98b65403 100644
--- a/tests/e2e/steps/background/test_setup.py
+++ b/tests/e2e/steps/background/test_setup.py
@@ -215,7 +215,7 @@ def validator(context: Context) -> None:
 
     feature_file = e2e_fixture.test_steps(
         background=[
-            f'And add callback "steps.helpers.message_callback" for message type "{message_type}" from {from_node} to {to_node}',
+            f'And register callback "steps.helpers.message_callback" for message type "{message_type}" from {from_node} to {to_node}',
         ],
         identifier=message_type,
     )
diff --git a/tests/e2e/steps/scenario/test_tasks.py b/tests/e2e/steps/scenario/test_tasks.py
index 3ecceff6..925d71f9 100644
--- a/tests/e2e/steps/scenario/test_tasks.py
+++ b/tests/e2e/steps/scenario/test_tasks.py
@@ -312,7 +312,7 @@ def validate_requests(context: Context) -> None:
 
 def test_e2e_step_task_wait_seconds(e2e_fixture: End2EndFixture) -> None:
     def validate_task_wait(context: Context) -> None:
-        from grizzly.tasks import WaitTask
+        from grizzly.tasks import ExplicitWaitTask
         grizzly = cast(GrizzlyContext, context.grizzly)
 
         tasks = grizzly.scenario.tasks()
@@ -321,15 +321,15 @@ def validate_task_wait(context: Context) -> None:
         assert len(tasks) == 3
 
         task = tasks[0]
-        assert isinstance(task, WaitTask)
+        assert isinstance(task, ExplicitWaitTask)
         assert task.time_expression == '13.37'
 
         task = tasks[1]
-        assert isinstance(task, WaitTask)
+        assert isinstance(task, ExplicitWaitTask)
         assert task.time_expression == '0.123'
 
         task = tasks[2]
-        assert isinstance(task, WaitTask)
+        assert isinstance(task, ExplicitWaitTask)
         assert task.time_expression == '{{ wait_time }}'
 
     e2e_fixture.add_validator(validate_task_wait)
@@ -980,7 +980,7 @@ def after_feature(context: Context, feature: Feature) -> None:
 
 def test_e2e_step_task_request_wait(e2e_fixture: End2EndFixture) -> None:
     def validate_request_wait(context: Context) -> None:
-        from grizzly.tasks import TaskWaitTask
+        from grizzly.tasks import WaitBetweenTask
         grizzly = cast(GrizzlyContext, context.grizzly)
 
         grizzly.scenario.tasks().pop()  # remove dummy
@@ -989,25 +989,25 @@ def validate_request_wait(context: Context) -> None:
 
         task = grizzly.scenario.tasks()[0]
 
-        assert isinstance(task, TaskWaitTask), f'{type(task)} is not expected TaskWaitTask'
+        assert isinstance(task, WaitBetweenTask), f'{type(task)} is not expected TaskWaitTask'
         assert task.min_time == 15
         assert task.max_time == 18
 
         task = grizzly.scenario.tasks()[1]
 
-        assert isinstance(task, TaskWaitTask), f'{type(task)} is not expected TaskWaitTask'
+        assert isinstance(task, WaitBetweenTask), f'{type(task)} is not expected TaskWaitTask'
         assert task.min_time == 1.4
         assert task.max_time == 1.7
 
         task = grizzly.scenario.tasks()[2]
 
-        assert isinstance(task, TaskWaitTask), f'{type(task)} is not expected TaskWaitTask'
+        assert isinstance(task, WaitBetweenTask), f'{type(task)} is not expected TaskWaitTask'
         assert task.min_time == 15
         assert task.max_time is None
 
         task = grizzly.scenario.tasks()[3]
 
-        assert isinstance(task, TaskWaitTask), f'{type(task)} is not expected TaskWaitTask'
+        assert isinstance(task, WaitBetweenTask), f'{type(task)} is not expected TaskWaitTask'
         assert task.min_time == 1.4
         assert task.max_time is None
 
diff --git a/tests/unit/test_grizzly/scenarios/test_iterator.py b/tests/unit/test_grizzly/scenarios/test_iterator.py
index b7f06e9a..bd59e899 100644
--- a/tests/unit/test_grizzly/scenarios/test_iterator.py
+++ b/tests/unit/test_grizzly/scenarios/test_iterator.py
@@ -18,7 +18,7 @@
 from grizzly.scenarios import iterator
 from grizzly.testdata.communication import TestdataConsumer
 from grizzly.testdata.utils import transform
-from grizzly.tasks import WaitTask, LogMessageTask, grizzlytask
+from grizzly.tasks import ExplicitWaitTask, LogMessageTask, grizzlytask
 from grizzly.exceptions import RestartScenario, StopScenario
 from grizzly.types import ScenarioState
 from grizzly.testdata.utils import templatingfilter
@@ -96,12 +96,12 @@ def mocked_wait(time: float) -> None:
                     assert sleep_time == time
 
                 mocker.patch(
-                    'grizzly.tasks.wait.gsleep',
+                    'grizzly.tasks.wait_explicit.gsleep',
                     mocked_wait,
                 )
 
             generate_mocked_wait(1.5)
-            iterator.IteratorScenario.populate(WaitTask(time_expression='1.5'))
+            iterator.IteratorScenario.populate(ExplicitWaitTask(time_expression='1.5'))
             assert len(parent.tasks) == 4
 
             task_method = parent.tasks[-2]
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/__init__.py b/tests/unit/test_grizzly/steps/scenario/tasks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_async_group.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_async_group.py
new file mode 100644
index 00000000..a39c2321
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_async_group.py
@@ -0,0 +1,50 @@
+import pytest
+
+from grizzly.types import RequestDirection, RequestMethod
+from grizzly.steps import step_task_async_group_start, step_task_async_group_close, step_task_request_text_with_name_endpoint
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_async_group_start(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert getattr(grizzly.scenario.tasks.tmp, 'async_group', '') is None
+
+    step_task_async_group_start(behave, 'async-test-1')
+
+    assert grizzly.scenario.tasks.tmp.async_group is not None
+    assert grizzly.scenario.tasks.tmp.async_group.name == 'async-test-1'
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_async_group_start(behave, 'async-test-2')
+    assert str(ae.value) == 'async request group "async-test-1" has not been closed'
+
+
+def test_step_task_async_group_end(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert len(grizzly.scenario.tasks()) == 0
+    assert getattr(grizzly.scenario.tasks.tmp, 'async_group', '') is None
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_async_group_close(behave)
+    assert str(ae.value) == 'no async request group is open'
+
+    step_task_async_group_start(behave, 'async-test-1')
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_async_group_close(behave)
+    assert str(ae.value) == 'there are no requests in async group "async-test-1"'
+    assert grizzly.scenario.tasks.tmp.async_group is not None
+
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test', direction=RequestDirection.FROM, endpoint='/api/test')
+    assert len(grizzly.scenario.tasks) == 0  # OK here
+
+    step_task_async_group_close(behave)
+
+    assert len(grizzly.scenario.tasks()) == 1
+    assert grizzly.scenario.tasks.tmp.async_group is None
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_clients.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_clients.py
new file mode 100644
index 00000000..a4e22da9
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_clients.py
@@ -0,0 +1,189 @@
+from typing import cast
+
+import pytest
+
+from grizzly.steps import (
+    step_task_client_get_endpoint_payload_metadata,
+    step_task_client_get_endpoint_payload,
+    step_task_client_put_endpoint_file,
+    step_task_client_put_endpoint_file_destination,
+)
+from grizzly.context import GrizzlyContext
+from grizzly.tasks.clients import HttpClientTask
+from tests.fixtures import BehaveFixture
+
+
+try:
+    import pymqi
+except:
+    from grizzly_extras import dummy_pymqi as pymqi
+
+
+def test_step_task_client_get_endpoint_payload_metadata(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_get_endpoint_payload_metadata(behave, 'obscure.example.com', 'step-name', 'test', 'metadata')
+    assert 'could not find scheme in "obscure.example.com"' in str(ae)
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_get_endpoint_payload_metadata(behave, 'obscure://obscure.example.com', 'step-name', 'test', 'metadata')
+    assert 'no client task registered for obscure' in str(ae)
+
+    with pytest.raises(ValueError) as ve:
+        step_task_client_get_endpoint_payload_metadata(behave, 'http://www.example.org', 'step-name', 'test', 'metadata')
+    assert 'HttpClientTask: variable test has not been initialized' in str(ve)
+
+    if pymqi.__name__ != 'grizzly_extras.dummy_pymqi':
+        with pytest.raises(ValueError) as ve:
+            step_task_client_get_endpoint_payload_metadata(behave, 'mq://mq.example.org', 'step-name', 'test', 'metadata')
+        assert 'MessageQueueClientTask: variable test has not been initialized' in str(ve)
+
+    grizzly.state.variables['test'] = 'none'
+
+    with pytest.raises(ValueError) as ve:
+        step_task_client_get_endpoint_payload_metadata(behave, 'http://www.example.org', 'step-name', 'test', 'metadata')
+    assert 'HttpClientTask: variable metadata has not been initialized' in str(ve)
+
+    if pymqi.__name__ != 'grizzly_extras.dummy_pymqi':
+        with pytest.raises(ValueError) as ve:
+            step_task_client_get_endpoint_payload_metadata(behave, 'mq://mq.example.org', 'step-name', 'test', 'metadata')
+        assert 'MessageQueueClientTask: variable metadata has not been initialized' in str(ve)
+
+    grizzly.state.variables['metadata'] = 'none'
+
+    assert len(grizzly.scenario.tasks()) == 0
+    step_task_client_get_endpoint_payload_metadata(behave, 'http://www.example.org', 'step-name', 'test', 'metadata')
+    assert len(grizzly.scenario.tasks()) == 1
+
+    grizzly.state.variables['endpoint_url'] = 'https://example.org'
+    step_task_client_get_endpoint_payload_metadata(behave, 'https://{{ endpoint_url }}', 'step-name', 'test', 'metadata')
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, HttpClientTask)
+    assert task.endpoint == '{{ endpoint_url }}'
+    assert sorted(task.get_templates()) == sorted(['{{ endpoint_url }}', '{{ test }} {{ metadata }}'])
+
+    behave.text = '1=1'
+    with pytest.raises(NotImplementedError) as nie:
+        step_task_client_get_endpoint_payload_metadata(behave, 'https://{{ endpoint_url }}', 'step-name', 'test', 'metadata')
+    assert str(nie.value) == 'HttpClientTask has not implemented support for step text'
+
+
+def test_step_task_client_get_endpoint_payload(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_get_endpoint_payload(behave, 'obscure.example.com', 'step-name', 'test')
+    assert 'could not find scheme in "obscure.example.com"' in str(ae)
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_get_endpoint_payload(behave, 'obscure://obscure.example.com', 'step-name', 'test')
+    assert 'no client task registered for obscure' in str(ae)
+
+    with pytest.raises(ValueError) as ve:
+        step_task_client_get_endpoint_payload(behave, 'http://www.example.org', 'step-name', 'test')
+    assert 'HttpClientTask: variable test has not been initialized' in str(ve)
+
+    if pymqi.__name__ != 'grizzly_extras.dummy_pymqi':
+        with pytest.raises(ValueError) as ve:
+            step_task_client_get_endpoint_payload(behave, 'mq://mq.example.org', 'step-name', 'test')
+        assert 'MessageQueueClientTask: variable test has not been initialized' in str(ve)
+
+    grizzly.state.variables['test'] = 'none'
+
+    assert len(grizzly.scenario.tasks()) == 0
+    step_task_client_get_endpoint_payload(behave, 'http://www.example.org', 'step-name', 'test')
+    assert len(grizzly.scenario.tasks()) == 1
+
+    grizzly.state.variables['endpoint_url'] = 'https://example.org'
+    step_task_client_get_endpoint_payload(behave, 'https://{{ endpoint_url }}', 'step-name', 'test')
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, HttpClientTask)
+    assert task.endpoint == '{{ endpoint_url }}'
+    assert sorted(task.get_templates()) == sorted(['{{ endpoint_url }}', '{{ test }}'])
+
+    behave.text = '1=1'
+    with pytest.raises(NotImplementedError) as nie:
+        step_task_client_get_endpoint_payload(behave, 'https://{{ endpoint_url }}', 'step-name', 'test')
+    assert str(nie.value) == 'HttpClientTask has not implemented support for step text'
+
+
+def test_step_task_client_put_endpoint_file(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    behave.text = 'hello'
+
+    assert len(grizzly.scenario.orphan_templates) == 0
+    assert len(grizzly.scenario.tasks()) == 0
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_put_endpoint_file(behave, 'file.json', 'http://example.org/put', 'step-name')
+    assert 'step text is not allowed for this step expression' in str(ae.value)
+
+    behave.text = None
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_put_endpoint_file(behave, 'file-{{ suffix }}.json', 'http://{{ url }}', 'step-name')
+    assert 'source file cannot be a template' == str(ae.value)
+
+    step_task_client_put_endpoint_file(behave, 'file-test.json', 'http://{{ url }}', 'step-name')
+
+    assert len(grizzly.scenario.tasks()) == 1
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, HttpClientTask)
+    assert task.source == 'file-test.json'
+    assert task.destination is None
+    assert task.endpoint == '{{ url }}'
+
+    templates = task.get_templates()
+    assert len(templates) == 1
+    assert sorted(templates) == sorted([
+        '{{ url }}',
+    ])
+
+
+def test_step_task_client_put_endpoint_file_destination(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    behave.text = 'hello'
+
+    assert len(grizzly.scenario.orphan_templates) == 0
+    assert len(grizzly.scenario.tasks()) == 0
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_put_endpoint_file_destination(behave, 'file.json', 'http://example.org/put', 'step-name', 'uploaded-file.json')
+    assert 'step text is not allowed for this step expression' in str(ae.value)
+
+    behave.text = None
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_put_endpoint_file_destination(behave, 'file-{{ suffix }}.json', 'http://{{ url }}', 'step-name', 'uploaded-file-{{ suffix }}.json')
+    assert 'source file cannot be a template' == str(ae.value)
+
+    step_task_client_put_endpoint_file_destination(behave, 'file-test.json', 'http://{{ url }}', 'step-name', 'uploaded-file-{{ suffix }}.json')
+
+    assert len(grizzly.scenario.tasks()) == 1
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, HttpClientTask)
+    assert task.source == 'file-test.json'
+    assert task.destination == 'uploaded-file-{{ suffix }}.json'
+    assert task.endpoint == '{{ url }}'
+
+    templates = task.get_templates()
+    assert len(templates) == 2
+    assert sorted(templates) == sorted([
+        '{{ url }}',
+        'uploaded-file-{{ suffix }}.json',
+    ])
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_conditional.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_conditional.py
new file mode 100644
index 00000000..a9d02ae8
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_conditional.py
@@ -0,0 +1,103 @@
+from typing import cast
+
+import pytest
+
+from grizzly.types import RequestMethod, RequestDirection
+from grizzly.tasks import ConditionalTask
+from grizzly.steps import (
+    step_task_conditional_if,
+    step_task_conditional_end,
+    step_task_conditional_else,
+    step_task_wait_between_constant,
+    step_task_log_message,
+    step_task_request_text_with_name_endpoint,
+    step_task_async_group_start,
+    step_task_async_group_close,
+)
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_conditional_if(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert getattr(grizzly.scenario.tasks.tmp, 'conditional', '') is None
+
+    step_task_conditional_if(behave, '{{ value | int == 10 }}', 'conditional-1')
+
+    assert grizzly.scenario.tasks.tmp.conditional is not None
+    assert grizzly.scenario.tasks.tmp.conditional.name == 'conditional-1'
+    assert grizzly.scenario.tasks.tmp.conditional._pointer
+    assert grizzly.scenario.tasks.tmp.conditional.tasks == {True: []}
+
+    step_task_wait_between_constant(behave, 1.4)
+    step_task_log_message(behave, 'hello world')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-get', RequestDirection.FROM, '/api/test')
+    step_task_async_group_start(behave, 'async-group')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-async-get-1', RequestDirection.FROM, '/api/test')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-async-get-2', RequestDirection.FROM, '/api/test')
+    step_task_async_group_close(behave)
+
+    assert list(grizzly.scenario.tasks.tmp.conditional.tasks.keys()) == [True]
+    assert len(grizzly.scenario.tasks.tmp.conditional.tasks[True]) == 4
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_conditional_if(behave, '{{ value | int == 20 }}', 'conditional-2')
+    assert str(ae.value) == 'cannot create a new conditional while "conditional-1" is still open'
+
+
+def test_step_task_conditional_else(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert getattr(grizzly.scenario.tasks.tmp, 'conditional', '') is None
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_conditional_else(behave)
+    assert str(ae.value) == 'there are no open conditional, you need to create one first'
+
+    test_step_task_conditional_if(behave_fixture)
+
+    assert grizzly.scenario.tasks.tmp.conditional is not None
+
+    step_task_conditional_else(behave)
+
+    assert grizzly.scenario.tasks.tmp.conditional.tasks.get(False, None) == []
+
+    step_task_wait_between_constant(behave, 3.7)
+    step_task_log_message(behave, 'foo bar')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-async-get-3', RequestDirection.FROM, '/api/test')
+
+    assert list(grizzly.scenario.tasks.tmp.conditional.tasks.keys()) == [True, False]
+    assert len(grizzly.scenario.tasks.tmp.conditional.tasks[True]) == 4
+    assert len(grizzly.scenario.tasks.tmp.conditional.tasks[False]) == 3
+
+
+def test_step_task_conditional_end(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert getattr(grizzly.scenario.tasks.tmp, 'conditional', '') is None
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_conditional_end(behave)
+    assert str(ae.value) == 'there are no open conditional, you need to create one before closing it'
+
+    test_step_task_conditional_else(behave_fixture)
+
+    assert grizzly.scenario.tasks.tmp.conditional is not None
+
+    step_task_conditional_end(behave)
+
+    assert len(grizzly.scenario.tasks()) == 1
+    conditional = cast(ConditionalTask, grizzly.scenario.tasks()[-1])
+
+    assert conditional.name == 'conditional-1'
+    assert conditional.condition == '{{ value | int == 10 }}'
+    assert list(conditional.tasks.keys()) == [True, False]
+    assert len(conditional.tasks[True]) == 4
+    assert len(conditional.tasks[False]) == 3
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_date.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_date.py
new file mode 100644
index 00000000..5a0a265b
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_date.py
@@ -0,0 +1,33 @@
+from typing import cast
+
+import pytest
+
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import DateTask
+from grizzly.steps import step_task_date
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_date(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_date(behave, '{{ datetime.now() }} | offset=1D', 'date_variable')
+    assert 'variable date_variable has not been initialized' in str(ae)
+
+    grizzly.state.variables['date_variable'] = 'none'
+
+    step_task_date(behave, '{{ datetime.now() }} | offset=1D', 'date_variable')
+
+    assert len(grizzly.scenario.tasks()) == 1
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, DateTask)
+    assert task.value == '{{ datetime.now() }}'
+    assert task.variable == 'date_variable'
+    assert task.arguments.get('offset') == '1D'
+    templates = task.get_templates()
+    assert len(templates) == 1
+    assert templates[0] == '{{ datetime.now() }}'
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_keystore.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_keystore.py
new file mode 100644
index 00000000..6bb29d2f
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_keystore.py
@@ -0,0 +1,100 @@
+import pytest
+
+from grizzly.tasks import KeystoreTask
+from grizzly.steps import step_task_keystore_get, step_task_keystore_get_default, step_task_keystore_set
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_keystore_get(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    grizzly.scenario.tasks.clear()
+
+    grizzly.state.variables.update({'foobar': 'none'})
+
+    step_task_keystore_get(behave, 'foobar', 'foobar')
+
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, KeystoreTask)
+    assert task.key == 'foobar'
+    assert task.action == 'get'
+    assert task.action_context == 'foobar'
+    assert task.default_value is None
+
+
+def test_step_task_keystore_get_default(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    grizzly.scenario.tasks.clear()
+
+    grizzly.state.variables.update({'foobar': 'none'})
+
+    step_task_keystore_get_default(behave, 'barfoo', 'foobar', "{'hello': 'world'}")
+
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, KeystoreTask)
+    assert task.key == 'barfoo'
+    assert task.action == 'get'
+    assert task.action_context == 'foobar'
+    assert task.default_value == {'hello': 'world'}
+
+    step_task_keystore_get_default(behave, 'barfoo', 'foobar', '"hello"')
+
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, KeystoreTask)
+    assert task.key == 'barfoo'
+    assert task.action == 'get'
+    assert task.action_context == 'foobar'
+    assert task.default_value == 'hello'
+
+    step_task_keystore_get_default(behave, 'barfoo', 'foobar', "'hello'")
+
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, KeystoreTask)
+    assert task.key == 'barfoo'
+    assert task.action == 'get'
+    assert task.action_context == 'foobar'
+    assert task.default_value == 'hello'
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_keystore_get_default(behave, 'barfoo', 'foobar', 'hello')
+    assert str(ae.value) == '"hello" is not valid JSON'
+
+
+def test_step_task_keystore_set(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    grizzly.scenario.tasks.clear()
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_keystore_set(behave, 'foobar', 'hello')
+    assert str(ae.value) == '"hello" is not valid JSON'
+
+    step_task_keystore_set(behave, 'foobar', "'hello'")
+
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, KeystoreTask)
+    assert task.key == 'foobar'
+    assert task.action == 'set'
+    assert task.action_context == 'hello'
+
+    step_task_keystore_set(behave, 'foobar', "['hello', 'world']")
+
+    task = grizzly.scenario.tasks()[-1]
+
+    assert isinstance(task, KeystoreTask)
+    assert task.key == 'foobar'
+    assert task.action == 'set'
+    assert task.action_context == ['hello', 'world']
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_log_message.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_log_message.py
new file mode 100644
index 00000000..39f8d3f6
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_log_message.py
@@ -0,0 +1,19 @@
+from typing import cast
+
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import LogMessageTask
+from grizzly.steps import step_task_log_message
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_log_message(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    step_task_log_message(behave, 'hello {{ world }}')
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, LogMessageTask)
+    assert task.message == 'hello {{ world }}'
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_loop.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_loop.py
new file mode 100644
index 00000000..50ef43da
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_loop.py
@@ -0,0 +1,59 @@
+
+import pytest
+
+from grizzly.types import RequestMethod, RequestDirection
+from grizzly.steps import (
+    step_task_loop_start,
+    step_task_loop_end,
+    step_setup_variable_value,
+    step_task_request_text_with_name_endpoint,
+    step_task_conditional_if,
+    step_task_conditional_end,
+)
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_loop(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert getattr(grizzly.scenario.tasks.tmp, 'loop', '') is None
+
+    with pytest.raises(ValueError) as ve:
+        step_task_loop_start(behave, '["hello", "world"]', 'foobar', 'test-loop')
+    assert str(ve.value) == 'LoopTask: foobar has not been initialized'
+
+    step_setup_variable_value(behave, 'foobar', 'none')
+
+    step_task_loop_start(behave, '["hello", "world"]', 'foobar', 'test-loop')
+
+    assert grizzly.scenario.tasks.tmp.loop is not None
+    assert len(grizzly.scenario.tasks()) == 0
+    assert len(grizzly.scenario.tasks) == 0  # OK here
+
+    assert grizzly.scenario.tasks.tmp.loop.name == 'test-loop'
+    assert grizzly.scenario.tasks.tmp.loop.values == '["hello", "world"]'
+    assert grizzly.scenario.tasks.tmp.loop.variable == 'foobar'
+    assert len(grizzly.scenario.tasks.tmp.loop.tasks) == 0
+
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-get-1', RequestDirection.FROM, '/api/test/1')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-get-2', RequestDirection.FROM, '/api/test/2')
+
+    step_task_conditional_if(behave, '{{ value | int > 0 }}', 'test-conditional')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-cond-get-1', RequestDirection.FROM, '/api/test/1')
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-cond-get-2', RequestDirection.FROM, '/api/test/2')
+    step_task_conditional_end(behave)
+
+    assert len(grizzly.scenario.tasks.tmp.loop.tasks) == 3
+    assert len(grizzly.scenario.tasks()) == 3
+
+    step_task_loop_end(behave)
+
+    assert len(grizzly.scenario.tasks()) == 1
+    assert getattr(grizzly.scenario.tasks.tmp, 'loop', '') is None
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_loop_end(behave)
+    assert str(ae.value) == 'there are no open loop, you need to create one before closing it'
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_request.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_request.py
new file mode 100644
index 00000000..007b0beb
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_request.py
@@ -0,0 +1,163 @@
+from typing import cast
+
+import pytest
+
+from parse import compile
+
+from grizzly.context import GrizzlyContext
+from grizzly.types import RequestMethod, RequestDirection
+from grizzly.steps import (
+    step_task_request_file_with_name_endpoint,
+    step_task_request_file_with_name,
+    step_task_request_text_with_name_endpoint,
+    step_task_request_text_with_name,
+)
+
+from tests.fixtures import BehaveFixture
+
+
+def test_parse_method() -> None:
+    p = compile(
+        'value {method:Method} world',
+        extra_types=dict(
+            Method=RequestMethod.from_string,
+        ),
+    )
+
+    assert RequestMethod.get_vector() == (False, True,)
+
+    for method in RequestMethod:
+        actual = p.parse(f'value {method.name.lower()} world')['method']
+        assert actual == method
+
+    with pytest.raises(ValueError):
+        p.parse('value asdf world')
+
+
+def test_parse_direction() -> None:
+    p = compile(
+        'value {direction:Direction} world',
+        extra_types=dict(
+            Direction=RequestDirection.from_string,
+        ),
+    )
+
+    assert RequestDirection.get_vector() == (False, True,)
+
+    for direction in RequestDirection:
+        actual = p.parse(f'value {direction.name} world')['direction']
+        assert actual == direction
+
+    with pytest.raises(ValueError):
+        p.parse('value asdf world')
+
+
+@pytest.mark.parametrize('method', RequestDirection.TO.methods)
+def test_step_task_request_file_with_name_endpoint(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    step_task_request_file_with_name_endpoint(behave, method, '{}', 'the_name', 'the_container')
+
+
+@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
+def test_step_task_request_file_with_name_endpoint_wrong_direction(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_file_with_name_endpoint(behave, method, '{}', 'the_name', 'the_container')
+    assert f'{method.name} is not allowed' in str(ae)
+
+
+@pytest.mark.parametrize('method', RequestDirection.TO.methods)
+def test_step_task_request_file_with_name(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(ValueError):
+        step_task_request_file_with_name(behave, method, '{}', f'{method.name}-test')
+
+    step_task_request_file_with_name_endpoint(behave, method, '{}', f'{method.name}-test', f'/api/test/{method.name.lower()}')
+    step_task_request_file_with_name(behave, method, '{}', f'{method.name}-test')
+
+
+@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
+def test_step_task_request_file_with_name_wrong_direction(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    with pytest.raises(AssertionError) as ae:
+        # step_request_to_payload_file_with_name_endpoint(behave, method, '{}', f'{method.name}-test', f'/api/test/{method.name.lower()}')
+        step_task_request_file_with_name(behave, method, '{}', f'{method.name}-test')
+    assert f'{method.name} is not allowed' in str(ae)
+
+
+@pytest.mark.parametrize('method', RequestDirection.TO.methods)
+def test_step_task_request_text_with_name_endpoint_to(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+    behave.text = '{}'
+
+    step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.TO, '/api/test')
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.FROM, '/api/test')
+    assert f'"from endpoint" is not allowed for {method.name}, use "to endpoint"' in str(ae)
+
+
+@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
+def test_step_task_request_text_with_name_endpoint_from(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    behave.text = '{}'
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.TO, '/api/test')
+    assert f'step text is not allowed for {method.name}' in str(ae)
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.FROM, '/api/test')
+    assert f'step text is not allowed for {method.name}' in str(ae)
+
+
+@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
+def test_step_task_request_text_with_name_endpoint_no_text(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+    behave.text = None
+
+    step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.FROM, '/api/test')
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.TO, '/api/test')
+    assert f'"to endpoint" is not allowed for {method.name}, use "from endpoint"' in str(ae)
+
+
+def test_step_task_request_text_with_name_endpoint_no_direction(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_text_with_name_endpoint(behave, 'GET', 'test-name', 'asdf', '/api/test')
+    assert 'invalid direction specified in expression' in str(ae)
+
+
+def test_step_task_request_text_with_name(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    behave.text = '{}'
+
+    with pytest.raises(ValueError):
+        step_task_request_text_with_name(behave, RequestMethod.POST, 'test-name')
+
+    step_task_request_text_with_name_endpoint(behave, RequestMethod.POST, 'test-name', RequestDirection.TO, '/api/test')
+
+    behave.text = None
+    with pytest.raises(ValueError):
+        step_task_request_text_with_name(behave, RequestMethod.GET, 'test-name')
+
+    with pytest.raises(AssertionError):
+        step_task_request_text_with_name(behave, RequestMethod.POST, 'test-name')
+
+    behave.text = '{}'
+    step_task_request_text_with_name(behave, RequestMethod.POST, 'test-name')
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_timer.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_timer.py
new file mode 100644
index 00000000..2a4f1e34
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_timer.py
@@ -0,0 +1,36 @@
+import pytest
+
+from grizzly.tasks import TimerTask
+from grizzly.steps import step_task_timer_start, step_task_timer_stop
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_timer_start_and_stop(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert grizzly.scenario.tasks.tmp.timers == {}
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_timer_stop(behave, 'test-timer-1')
+    assert str(ae.value) == 'timer with name test-timer-1 has not been defined'
+
+    step_task_timer_start(behave, 'test-timer-1')
+
+    timer = grizzly.scenario.tasks.tmp.timers.get('test-timer-1', None)
+    assert isinstance(timer, TimerTask)
+    assert timer.name == 'test-timer-1'
+    assert grizzly.scenario.tasks()[-1] is timer
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_timer_start(behave, 'test-timer-1')
+    assert str(ae.value) == 'timer with name test-timer-1 has already been defined'
+
+    step_task_timer_stop(behave, 'test-timer-1')
+
+    assert grizzly.scenario.tasks.tmp.timers == {
+        'test-timer-1': None,
+    }
+
+    assert grizzly.scenario.tasks()[-2] is grizzly.scenario.tasks()[-1]
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_transformer.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_transformer.py
new file mode 100644
index 00000000..4ecdb7b8
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_transformer.py
@@ -0,0 +1,143 @@
+from typing import cast
+from json import dumps as jsondumps
+
+import pytest
+
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import TransformerTask
+from grizzly.steps import step_task_transform
+from grizzly_extras.transformer import TransformerContentType
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_transform_json(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(ValueError) as ve:
+        step_task_transform(
+            behave,
+            jsondumps({
+                'document': {
+                    'id': 'DOCUMENT_8483-1',
+                    'title': 'TPM Report 2020',
+                },
+            }),
+            TransformerContentType.JSON,
+            '$.document.id',
+            'document_id',
+        )
+    assert 'TransformerTask: document_id has not been initialized' in str(ve)
+
+    grizzly.state.variables['document_id'] = 'None'
+    step_task_transform(
+        behave,
+        jsondumps({
+            'document': {
+                'id': 'DOCUMENT_8483-1',
+                'title': 'TPM Report 2020',
+            },
+        }),
+        TransformerContentType.JSON,
+        '$.document.id',
+        'document_id',
+    )
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, TransformerTask)
+    assert task.content_type == TransformerContentType.JSON
+    assert task.expression == '$.document.id'
+    assert task.variable == 'document_id'
+
+    assert len(grizzly.scenario.orphan_templates) == 0
+
+    step_task_transform(
+        behave,
+        jsondumps({
+            'document': {
+                'id': 'DOCUMENT_8483-1',
+                'title': 'TPM Report {{ year }}',
+            },
+        }),
+        TransformerContentType.JSON,
+        '$.document.id',
+        'document_id',
+    )
+
+    templates = grizzly.scenario.tasks()[-1].get_templates()
+
+    assert len(templates) == 1
+    assert templates[-1] == jsondumps({
+        'document': {
+            'id': 'DOCUMENT_8483-1',
+            'title': 'TPM Report {{ year }}',
+        },
+    })
+
+
+def test_step_task_transform_xml(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(ValueError) as ve:
+        step_task_transform(
+            behave,
+            '''<?xml version="1.0" encoding="utf-8"?>
+<document>
+    <id>DOCUMENT_8483-1</id>
+    <title>TPM Report 2022</title>
+</document>
+            ''',
+            TransformerContentType.XML,
+            '/document/id/text()',
+            'document_id',
+        )
+    assert 'TransformerTask: document_id has not been initialized' in str(ve)
+
+    grizzly.state.variables['document_id'] = 'None'
+    step_task_transform(
+        behave,
+        '''<?xml version="1.0" encoding="utf-8"?>
+<document>
+    <id>DOCUMENT_8483-1</id>
+    <title>TPM Report 2022</title>
+</document>
+        ''',
+        TransformerContentType.XML,
+        '/document/id/text()',
+        'document_id',
+    )
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, TransformerTask)
+    assert task.content_type == TransformerContentType.XML
+    assert task.expression == '/document/id/text()'
+    assert task.variable == 'document_id'
+
+    assert len(grizzly.scenario.orphan_templates) == 0
+
+    step_task_transform(
+        behave,
+        '''<?xml version="1.0" encoding="utf-8"?>
+<document>
+    <id>DOCUMENT_8483-1</id>
+    <title>TPM Report {{ year }}</title>
+</document>
+        ''',
+        TransformerContentType.XML,
+        '/document/id/text()',
+        'document_id',
+    )
+
+    templates = grizzly.scenario.tasks()[-1].get_templates()
+
+    assert len(templates) == 1
+    assert templates[-1] == '''<?xml version="1.0" encoding="utf-8"?>
+<document>
+    <id>DOCUMENT_8483-1</id>
+    <title>TPM Report {{ year }}</title>
+</document>
+        '''
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_until.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_until.py
new file mode 100644
index 00000000..5f7934e0
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_until.py
@@ -0,0 +1,116 @@
+from typing import List, cast
+
+import pytest
+
+from grizzly.context import GrizzlyContext
+from grizzly.types import RequestMethod
+from grizzly.types.behave import Row, Table
+from grizzly.tasks import UntilRequestTask
+from grizzly.tasks.clients import HttpClientTask
+from grizzly.steps import step_task_request_with_name_endpoint_until, step_task_client_get_endpoint_until
+from grizzly_extras.transformer import TransformerContentType
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_request_with_name_endpoint_until(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert len(grizzly.scenario.tasks()) == 0
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_with_name_endpoint_until(behave, RequestMethod.POST, 'test', '/api/test', '$.`this`[?status="ready"]')
+    assert 'this step is only valid for request methods with direction FROM' in str(ae)
+
+    behave.text = 'foo bar'
+    with pytest.raises(AssertionError) as ae:
+        step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/test', '$.`this`[?status="ready"]')
+    assert 'this step does not have support for step text' in str(ae)
+
+    behave.text = None
+
+    with pytest.raises(ValueError) as ve:
+        step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/test', '$.`this`[?status="ready"]')
+    assert 'content type must be specified for request' in str(ve)
+
+    step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/test | content_type=json', '$.`this`[?status="ready"]')
+
+    assert len(grizzly.scenario.tasks()) == 1
+
+    rows: List[Row] = []
+    rows.append(Row(['endpoint'], ['{{ variable }}']))
+    rows.append(Row(['endpoint'], ['foo']))
+    rows.append(Row(['endpoint'], ['bar']))
+    behave.table = Table(['endpoint'], rows=rows)
+
+    step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/{{ endpoint }} | content_type=json', '$.`this`[?status="{{ endpoint }}"]')
+
+    assert len(grizzly.scenario.tasks()) == 4
+    tasks = cast(List[UntilRequestTask], grizzly.scenario.tasks())
+
+    templates: List[str] = []
+
+    assert tasks[-1].request.endpoint == '/api/bar'
+    assert tasks[-1].condition == '$.`this`[?status="bar"]'
+    templates += tasks[-1].get_templates()
+    assert tasks[-2].request.endpoint == '/api/foo'
+    assert tasks[-2].condition == '$.`this`[?status="foo"]'
+    templates += tasks[-2].get_templates()
+    assert tasks[-3].request.endpoint == '/api/{{ variable }}'
+    assert tasks[-3].condition == '$.`this`[?status="{{ variable }}"]'
+    templates += tasks[-3].get_templates()
+
+    assert len(templates) == 2
+    assert sorted(templates) == sorted([
+        '$.`this`[?status="{{ variable }}"]',
+        '/api/{{ variable }}',
+    ])
+
+
+def test_step_task_client_get_endpoint_until(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_get_endpoint_until(behave, 'api.example.io/api/test', 'step-name', '$.`this`[?status=true]')
+    assert str(ae.value) == 'could not find scheme in "api.example.io/api/test"'
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_client_get_endpoint_until(behave, 'foo://api.example.io/api/test', 'step-name', '$.`this`[?status=true]')
+    assert str(ae.value) == 'no client task registered for foo'
+
+    with pytest.raises(ValueError) as ve:
+        step_task_client_get_endpoint_until(behave, 'https://api.example.io/api/test', 'step-name', '$.`this`[?status=true]')
+    assert str(ve.value) == 'content type must be specified for request'
+
+    with pytest.raises(ValueError) as ve:
+        step_task_client_get_endpoint_until(behave, 'https://api.example.io/api/test | content_type=json', 'step-name', '$.`this`[?success=true] | foo=bar, bar=foo')
+    assert str(ve.value) == 'unsupported arguments foo, bar'
+
+    step_task_client_get_endpoint_until(behave, 'https://api.example.io/api/test | content_type=json', 'step-name', '$.`this`[?success=true]')
+
+    assert len(grizzly.scenario.tasks()) == 1
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, UntilRequestTask)
+    assert isinstance(task.request, HttpClientTask)
+    assert task.request.content_type == TransformerContentType.JSON
+    assert task.request.endpoint == 'https://api.example.io/api/test'
+
+    grizzly.state.configuration['test.host'] = 'https://api.example.com'
+
+    step_task_client_get_endpoint_until(behave, 'https://$conf::test.host$/api/test | content_type=json', 'step-name', '$.`this`[success=false]')
+
+    assert len(grizzly.scenario.tasks()) == 2
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, UntilRequestTask)
+    assert isinstance(task.request, HttpClientTask)
+    assert task.request.content_type == TransformerContentType.JSON
+    assert task.request.endpoint == 'https://api.example.com/api/test'
+
+    behave.text = '1=1'
+    with pytest.raises(NotImplementedError) as nie:
+        step_task_client_get_endpoint_until(behave, 'https://$conf::test.host$/api/test | content_type=json', 'step-name', '$.`this`[success=false]')
+    assert str(nie.value) == 'HttpClientTask has not implemented support for step text'
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_wait_between.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_wait_between.py
new file mode 100644
index 00000000..cada4377
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_wait_between.py
@@ -0,0 +1,45 @@
+from typing import cast
+
+from grizzly.tasks import WaitBetweenTask
+from grizzly.steps import step_task_wait_between_random, step_task_wait_between_constant
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_wait_between_random(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert len(grizzly.scenario.tasks()) == 0
+
+    step_task_wait_between_random(behave, 1.4, 1.7)
+
+    assert len(grizzly.scenario.tasks()) == 1
+
+    task = cast(WaitBetweenTask, grizzly.scenario.tasks()[-1])
+    assert task.min_time == 1.4
+    assert task.max_time == 1.7
+
+    step_task_wait_between_random(behave, 30, 20)
+
+    assert len(grizzly.scenario.tasks()) == 2
+
+    task = cast(WaitBetweenTask, grizzly.scenario.tasks()[-1])
+    assert task.min_time == 20
+    assert task.max_time == 30
+
+
+def test_step_task_wait_between_constant(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = behave_fixture.grizzly
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    assert len(grizzly.scenario.tasks()) == 0
+
+    step_task_wait_between_constant(behave, 10)
+
+    assert len(grizzly.scenario.tasks()) == 1
+
+    task = cast(WaitBetweenTask, grizzly.scenario.tasks()[-1])
+    assert task.min_time == 10
+    assert task.max_time is None
diff --git a/tests/unit/test_grizzly/steps/scenario/tasks/test_wait_explicit.py b/tests/unit/test_grizzly/steps/scenario/tasks/test_wait_explicit.py
new file mode 100644
index 00000000..ae5d65d3
--- /dev/null
+++ b/tests/unit/test_grizzly/steps/scenario/tasks/test_wait_explicit.py
@@ -0,0 +1,37 @@
+from typing import cast
+
+import pytest
+
+from grizzly.context import GrizzlyContext
+from grizzly.tasks import ExplicitWaitTask
+from grizzly.steps import step_task_wait_explicit
+
+from tests.fixtures import BehaveFixture
+
+
+def test_step_task_wait_explicit(behave_fixture: BehaveFixture) -> None:
+    behave = behave_fixture.context
+    grizzly = cast(GrizzlyContext, behave.grizzly)
+    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_wait_explicit(behave, '-1.0')
+    assert str(ae.value) == 'wait time cannot be less than 0.0 seconds'
+
+    with pytest.raises(AssertionError) as ae:
+        step_task_wait_explicit(behave, 'foobar')
+    assert str(ae.value) == '"foobar" is not a template nor a float'
+
+    step_task_wait_explicit(behave, '1.337')
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, ExplicitWaitTask)
+    assert task.time_expression == '1.337'
+
+    grizzly.state.variables['wait_time'] = '126'
+
+    step_task_wait_explicit(behave, '{{ wait_time }}')
+
+    task = grizzly.scenario.tasks()[-1]
+    assert isinstance(task, ExplicitWaitTask)
+    assert task.time_expression == '{{ wait_time }}'
diff --git a/tests/unit/test_grizzly/steps/scenario/test_response.py b/tests/unit/test_grizzly/steps/scenario/test_response.py
index 101424ad..4a5f0c49 100644
--- a/tests/unit/test_grizzly/steps/scenario/test_response.py
+++ b/tests/unit/test_grizzly/steps/scenario/test_response.py
@@ -7,7 +7,7 @@
 from grizzly.context import GrizzlyContext
 from grizzly.types import RequestMethod, ResponseTarget
 from grizzly.types.behave import Table, Row
-from grizzly.tasks import RequestTask, WaitTask
+from grizzly.tasks import RequestTask, ExplicitWaitTask
 from grizzly.steps import *  # pylint: disable=unused-wildcard-import  # noqa: F403
 
 from grizzly_extras.transformer import TransformerContentType
@@ -360,7 +360,7 @@ def test_step_response_content_type(behave_fixture: BehaveFixture) -> None:
         step_response_content_type(behave, TransformerContentType.JSON)
     assert 'There are no requests in the scenario' in str(ae)
 
-    grizzly.scenario.tasks.add(WaitTask(time_expression='1.0'))
+    grizzly.scenario.tasks.add(ExplicitWaitTask(time_expression='1.0'))
 
     with pytest.raises(AssertionError) as ae:
         step_response_content_type(behave, TransformerContentType.JSON)
diff --git a/tests/unit/test_grizzly/steps/scenario/test_tasks.py b/tests/unit/test_grizzly/steps/scenario/test_tasks.py
deleted file mode 100644
index d2e8a966..00000000
--- a/tests/unit/test_grizzly/steps/scenario/test_tasks.py
+++ /dev/null
@@ -1,979 +0,0 @@
-from typing import List, cast
-
-import pytest
-
-from parse import compile
-from json import dumps as jsondumps
-
-from grizzly.context import GrizzlyContext
-from grizzly.types import RequestMethod, RequestDirection
-from grizzly.types.behave import Table, Row
-from grizzly.tasks import TransformerTask, LogMessageTask, WaitTask, TimerTask, TaskWaitTask, ConditionalTask, KeystoreTask
-from grizzly.tasks.clients import HttpClientTask
-from grizzly.steps import *  # pylint: disable=unused-wildcard-import  # noqa: F403
-
-from grizzly_extras.transformer import TransformerContentType
-
-try:
-    import pymqi
-except:
-    from grizzly_extras import dummy_pymqi as pymqi
-
-from tests.fixtures import BehaveFixture
-
-
-def test_parse_method() -> None:
-    p = compile(
-        'value {method:Method} world',
-        extra_types=dict(
-            Method=RequestMethod.from_string,
-        ),
-    )
-
-    assert RequestMethod.get_vector() == (False, True,)
-
-    for method in RequestMethod:
-        actual = p.parse(f'value {method.name.lower()} world')['method']
-        assert actual == method
-
-    with pytest.raises(ValueError):
-        p.parse('value asdf world')
-
-
-def test_parse_direction() -> None:
-    p = compile(
-        'value {direction:Direction} world',
-        extra_types=dict(
-            Direction=RequestDirection.from_string,
-        ),
-    )
-
-    assert RequestDirection.get_vector() == (False, True,)
-
-    for direction in RequestDirection:
-        actual = p.parse(f'value {direction.name} world')['direction']
-        assert actual == direction
-
-    with pytest.raises(ValueError):
-        p.parse('value asdf world')
-
-
-def test_step_task_request_with_name_endpoint_until(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert len(grizzly.scenario.tasks()) == 0
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_with_name_endpoint_until(behave, RequestMethod.POST, 'test', '/api/test', '$.`this`[?status="ready"]')
-    assert 'this step is only valid for request methods with direction FROM' in str(ae)
-
-    behave.text = 'foo bar'
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/test', '$.`this`[?status="ready"]')
-    assert 'this step does not have support for step text' in str(ae)
-
-    behave.text = None
-
-    with pytest.raises(ValueError) as ve:
-        step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/test', '$.`this`[?status="ready"]')
-    assert 'content type must be specified for request' in str(ve)
-
-    step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/test | content_type=json', '$.`this`[?status="ready"]')
-
-    assert len(grizzly.scenario.tasks()) == 1
-
-    rows: List[Row] = []
-    rows.append(Row(['endpoint'], ['{{ variable }}']))
-    rows.append(Row(['endpoint'], ['foo']))
-    rows.append(Row(['endpoint'], ['bar']))
-    behave.table = Table(['endpoint'], rows=rows)
-
-    step_task_request_with_name_endpoint_until(behave, RequestMethod.GET, 'test', '/api/{{ endpoint }} | content_type=json', '$.`this`[?status="{{ endpoint }}"]')
-
-    assert len(grizzly.scenario.tasks()) == 4
-    tasks = cast(List[UntilRequestTask], grizzly.scenario.tasks())
-
-    templates: List[str] = []
-
-    assert tasks[-1].request.endpoint == '/api/bar'
-    assert tasks[-1].condition == '$.`this`[?status="bar"]'
-    templates += tasks[-1].get_templates()
-    assert tasks[-2].request.endpoint == '/api/foo'
-    assert tasks[-2].condition == '$.`this`[?status="foo"]'
-    templates += tasks[-2].get_templates()
-    assert tasks[-3].request.endpoint == '/api/{{ variable }}'
-    assert tasks[-3].condition == '$.`this`[?status="{{ variable }}"]'
-    templates += tasks[-3].get_templates()
-
-    assert len(templates) == 2
-    assert sorted(templates) == sorted([
-        '$.`this`[?status="{{ variable }}"]',
-        '/api/{{ variable }}',
-    ])
-
-
-@pytest.mark.parametrize('method', RequestDirection.TO.methods)
-def test_step_task_request_file_with_name_endpoint(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    step_task_request_file_with_name_endpoint(behave, method, '{}', 'the_name', 'the_container')
-
-
-@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
-def test_step_task_request_file_with_name_endpoint_wrong_direction(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_file_with_name_endpoint(behave, method, '{}', 'the_name', 'the_container')
-    assert f'{method.name} is not allowed' in str(ae)
-
-
-@pytest.mark.parametrize('method', RequestDirection.TO.methods)
-def test_step_task_request_file_with_name(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(ValueError):
-        step_task_request_file_with_name(behave, method, '{}', f'{method.name}-test')
-
-    step_task_request_file_with_name_endpoint(behave, method, '{}', f'{method.name}-test', f'/api/test/{method.name.lower()}')
-    step_task_request_file_with_name(behave, method, '{}', f'{method.name}-test')
-
-
-@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
-def test_step_task_request_file_with_name_wrong_direction(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    with pytest.raises(AssertionError) as ae:
-        # step_request_to_payload_file_with_name_endpoint(behave, method, '{}', f'{method.name}-test', f'/api/test/{method.name.lower()}')
-        step_task_request_file_with_name(behave, method, '{}', f'{method.name}-test')
-    assert f'{method.name} is not allowed' in str(ae)
-
-
-@pytest.mark.parametrize('method', RequestDirection.TO.methods)
-def test_step_task_request_text_with_name_endpoint_to(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-    behave.text = '{}'
-
-    step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.TO, '/api/test')
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.FROM, '/api/test')
-    assert f'"from endpoint" is not allowed for {method.name}, use "to endpoint"' in str(ae)
-
-
-@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
-def test_step_task_request_text_with_name_endpoint_from(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    behave.text = '{}'
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.TO, '/api/test')
-    assert f'step text is not allowed for {method.name}' in str(ae)
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.FROM, '/api/test')
-    assert f'step text is not allowed for {method.name}' in str(ae)
-
-
-@pytest.mark.parametrize('method', RequestDirection.FROM.methods)
-def test_step_task_request_text_with_name_endpoint_no_text(behave_fixture: BehaveFixture, method: RequestMethod) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-    behave.text = None
-
-    step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.FROM, '/api/test')
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_text_with_name_endpoint(behave, method, 'test-name', RequestDirection.TO, '/api/test')
-    assert f'"to endpoint" is not allowed for {method.name}, use "from endpoint"' in str(ae)
-
-
-def test_step_task_request_text_with_name_endpoint_no_direction(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    with pytest.raises(AssertionError) as ae:
-        step_task_request_text_with_name_endpoint(behave, 'GET', 'test-name', 'asdf', '/api/test')
-    assert 'invalid direction specified in expression' in str(ae)
-
-
-def test_step_task_request_text_with_name(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    behave.text = '{}'
-
-    with pytest.raises(ValueError):
-        step_task_request_text_with_name(behave, RequestMethod.POST, 'test-name')
-
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.POST, 'test-name', RequestDirection.TO, '/api/test')
-
-    behave.text = None
-    with pytest.raises(ValueError):
-        step_task_request_text_with_name(behave, RequestMethod.GET, 'test-name')
-
-    with pytest.raises(AssertionError):
-        step_task_request_text_with_name(behave, RequestMethod.POST, 'test-name')
-
-    behave.text = '{}'
-    step_task_request_text_with_name(behave, RequestMethod.POST, 'test-name')
-
-
-def test_step_task_wait_seconds(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_wait_seconds(behave, '-1.0')
-    assert str(ae.value) == 'wait time cannot be less than 0.0 seconds'
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_wait_seconds(behave, 'foobar')
-    assert str(ae.value) == '"foobar" is not a template nor a float'
-
-    step_task_wait_seconds(behave, '1.337')
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, WaitTask)
-    assert task.time_expression == '1.337'
-
-    grizzly.state.variables['wait_time'] = '126'
-
-    step_task_wait_seconds(behave, '{{ wait_time }}')
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, WaitTask)
-    assert task.time_expression == '{{ wait_time }}'
-
-
-def test_step_task_log_message(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    step_task_log_message(behave, 'hello {{ world }}')
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, LogMessageTask)
-    assert task.message == 'hello {{ world }}'
-
-
-def test_step_task_transform_json(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(ValueError) as ve:
-        step_task_transform(
-            behave,
-            jsondumps({
-                'document': {
-                    'id': 'DOCUMENT_8483-1',
-                    'title': 'TPM Report 2020',
-                },
-            }),
-            TransformerContentType.JSON,
-            '$.document.id',
-            'document_id',
-        )
-    assert 'TransformerTask: document_id has not been initialized' in str(ve)
-
-    grizzly.state.variables['document_id'] = 'None'
-    step_task_transform(
-        behave,
-        jsondumps({
-            'document': {
-                'id': 'DOCUMENT_8483-1',
-                'title': 'TPM Report 2020',
-            },
-        }),
-        TransformerContentType.JSON,
-        '$.document.id',
-        'document_id',
-    )
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, TransformerTask)
-    assert task.content_type == TransformerContentType.JSON
-    assert task.expression == '$.document.id'
-    assert task.variable == 'document_id'
-
-    assert len(grizzly.scenario.orphan_templates) == 0
-
-    step_task_transform(
-        behave,
-        jsondumps({
-            'document': {
-                'id': 'DOCUMENT_8483-1',
-                'title': 'TPM Report {{ year }}',
-            },
-        }),
-        TransformerContentType.JSON,
-        '$.document.id',
-        'document_id',
-    )
-
-    templates = grizzly.scenario.tasks()[-1].get_templates()
-
-    assert len(templates) == 1
-    assert templates[-1] == jsondumps({
-        'document': {
-            'id': 'DOCUMENT_8483-1',
-            'title': 'TPM Report {{ year }}',
-        },
-    })
-
-
-def test_step_task_transform_xml(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(ValueError) as ve:
-        step_task_transform(
-            behave,
-            '''<?xml version="1.0" encoding="utf-8"?>
-<document>
-    <id>DOCUMENT_8483-1</id>
-    <title>TPM Report 2022</title>
-</document>
-            ''',
-            TransformerContentType.XML,
-            '/document/id/text()',
-            'document_id',
-        )
-    assert 'TransformerTask: document_id has not been initialized' in str(ve)
-
-    grizzly.state.variables['document_id'] = 'None'
-    step_task_transform(
-        behave,
-        '''<?xml version="1.0" encoding="utf-8"?>
-<document>
-    <id>DOCUMENT_8483-1</id>
-    <title>TPM Report 2022</title>
-</document>
-        ''',
-        TransformerContentType.XML,
-        '/document/id/text()',
-        'document_id',
-    )
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, TransformerTask)
-    assert task.content_type == TransformerContentType.XML
-    assert task.expression == '/document/id/text()'
-    assert task.variable == 'document_id'
-
-    assert len(grizzly.scenario.orphan_templates) == 0
-
-    step_task_transform(
-        behave,
-        '''<?xml version="1.0" encoding="utf-8"?>
-<document>
-    <id>DOCUMENT_8483-1</id>
-    <title>TPM Report {{ year }}</title>
-</document>
-        ''',
-        TransformerContentType.XML,
-        '/document/id/text()',
-        'document_id',
-    )
-
-    templates = grizzly.scenario.tasks()[-1].get_templates()
-
-    assert len(templates) == 1
-    assert templates[-1] == '''<?xml version="1.0" encoding="utf-8"?>
-<document>
-    <id>DOCUMENT_8483-1</id>
-    <title>TPM Report {{ year }}</title>
-</document>
-        '''
-
-
-def test_step_task_client_get_endpoint_payload_metadata(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_get_endpoint_payload_metadata(behave, 'obscure.example.com', 'step-name', 'test', 'metadata')
-    assert 'could not find scheme in "obscure.example.com"' in str(ae)
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_get_endpoint_payload_metadata(behave, 'obscure://obscure.example.com', 'step-name', 'test', 'metadata')
-    assert 'no client task registered for obscure' in str(ae)
-
-    with pytest.raises(ValueError) as ve:
-        step_task_client_get_endpoint_payload_metadata(behave, 'http://www.example.org', 'step-name', 'test', 'metadata')
-    assert 'HttpClientTask: variable test has not been initialized' in str(ve)
-
-    if pymqi.__name__ != 'grizzly_extras.dummy_pymqi':
-        with pytest.raises(ValueError) as ve:
-            step_task_client_get_endpoint_payload_metadata(behave, 'mq://mq.example.org', 'step-name', 'test', 'metadata')
-        assert 'MessageQueueClientTask: variable test has not been initialized' in str(ve)
-
-    grizzly.state.variables['test'] = 'none'
-
-    with pytest.raises(ValueError) as ve:
-        step_task_client_get_endpoint_payload_metadata(behave, 'http://www.example.org', 'step-name', 'test', 'metadata')
-    assert 'HttpClientTask: variable metadata has not been initialized' in str(ve)
-
-    if pymqi.__name__ != 'grizzly_extras.dummy_pymqi':
-        with pytest.raises(ValueError) as ve:
-            step_task_client_get_endpoint_payload_metadata(behave, 'mq://mq.example.org', 'step-name', 'test', 'metadata')
-        assert 'MessageQueueClientTask: variable metadata has not been initialized' in str(ve)
-
-    grizzly.state.variables['metadata'] = 'none'
-
-    assert len(grizzly.scenario.tasks()) == 0
-    step_task_client_get_endpoint_payload_metadata(behave, 'http://www.example.org', 'step-name', 'test', 'metadata')
-    assert len(grizzly.scenario.tasks()) == 1
-
-    grizzly.state.variables['endpoint_url'] = 'https://example.org'
-    step_task_client_get_endpoint_payload_metadata(behave, 'https://{{ endpoint_url }}', 'step-name', 'test', 'metadata')
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, HttpClientTask)
-    assert task.endpoint == '{{ endpoint_url }}'
-    assert sorted(task.get_templates()) == sorted(['{{ endpoint_url }}', '{{ test }} {{ metadata }}'])
-
-    behave.text = '1=1'
-    with pytest.raises(NotImplementedError) as nie:
-        step_task_client_get_endpoint_payload_metadata(behave, 'https://{{ endpoint_url }}', 'step-name', 'test', 'metadata')
-    assert str(nie.value) == 'HttpClientTask has not implemented support for step text'
-
-
-def test_step_task_client_get_endpoint_payload(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_get_endpoint_payload(behave, 'obscure.example.com', 'step-name', 'test')
-    assert 'could not find scheme in "obscure.example.com"' in str(ae)
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_get_endpoint_payload(behave, 'obscure://obscure.example.com', 'step-name', 'test')
-    assert 'no client task registered for obscure' in str(ae)
-
-    with pytest.raises(ValueError) as ve:
-        step_task_client_get_endpoint_payload(behave, 'http://www.example.org', 'step-name', 'test')
-    assert 'HttpClientTask: variable test has not been initialized' in str(ve)
-
-    if pymqi.__name__ != 'grizzly_extras.dummy_pymqi':
-        with pytest.raises(ValueError) as ve:
-            step_task_client_get_endpoint_payload(behave, 'mq://mq.example.org', 'step-name', 'test')
-        assert 'MessageQueueClientTask: variable test has not been initialized' in str(ve)
-
-    grizzly.state.variables['test'] = 'none'
-
-    assert len(grizzly.scenario.tasks()) == 0
-    step_task_client_get_endpoint_payload(behave, 'http://www.example.org', 'step-name', 'test')
-    assert len(grizzly.scenario.tasks()) == 1
-
-    grizzly.state.variables['endpoint_url'] = 'https://example.org'
-    step_task_client_get_endpoint_payload(behave, 'https://{{ endpoint_url }}', 'step-name', 'test')
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, HttpClientTask)
-    assert task.endpoint == '{{ endpoint_url }}'
-    assert sorted(task.get_templates()) == sorted(['{{ endpoint_url }}', '{{ test }}'])
-
-    behave.text = '1=1'
-    with pytest.raises(NotImplementedError) as nie:
-        step_task_client_get_endpoint_payload(behave, 'https://{{ endpoint_url }}', 'step-name', 'test')
-    assert str(nie.value) == 'HttpClientTask has not implemented support for step text'
-
-
-def test_step_task_client_get_endpoint_until(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_get_endpoint_until(behave, 'api.example.io/api/test', 'step-name', '$.`this`[?status=true]')
-    assert str(ae.value) == 'could not find scheme in "api.example.io/api/test"'
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_get_endpoint_until(behave, 'foo://api.example.io/api/test', 'step-name', '$.`this`[?status=true]')
-    assert str(ae.value) == 'no client task registered for foo'
-
-    with pytest.raises(ValueError) as ve:
-        step_task_client_get_endpoint_until(behave, 'https://api.example.io/api/test', 'step-name', '$.`this`[?status=true]')
-    assert str(ve.value) == 'content type must be specified for request'
-
-    with pytest.raises(ValueError) as ve:
-        step_task_client_get_endpoint_until(behave, 'https://api.example.io/api/test | content_type=json', 'step-name', '$.`this`[?success=true] | foo=bar, bar=foo')
-    assert str(ve.value) == 'unsupported arguments foo, bar'
-
-    step_task_client_get_endpoint_until(behave, 'https://api.example.io/api/test | content_type=json', 'step-name', '$.`this`[?success=true]')
-
-    assert len(grizzly.scenario.tasks()) == 1
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, UntilRequestTask)
-    assert isinstance(task.request, HttpClientTask)
-    assert task.request.content_type == TransformerContentType.JSON
-    assert task.request.endpoint == 'https://api.example.io/api/test'
-
-    grizzly.state.configuration['test.host'] = 'https://api.example.com'
-
-    step_task_client_get_endpoint_until(behave, 'https://$conf::test.host$/api/test | content_type=json', 'step-name', '$.`this`[success=false]')
-
-    assert len(grizzly.scenario.tasks()) == 2
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, UntilRequestTask)
-    assert isinstance(task.request, HttpClientTask)
-    assert task.request.content_type == TransformerContentType.JSON
-    assert task.request.endpoint == 'https://api.example.com/api/test'
-
-    behave.text = '1=1'
-    with pytest.raises(NotImplementedError) as nie:
-        step_task_client_get_endpoint_until(behave, 'https://$conf::test.host$/api/test | content_type=json', 'step-name', '$.`this`[success=false]')
-    assert str(nie.value) == 'HttpClientTask has not implemented support for step text'
-
-
-def test_step_task_date(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_date(behave, '{{ datetime.now() }} | offset=1D', 'date_variable')
-    assert 'variable date_variable has not been initialized' in str(ae)
-
-    grizzly.state.variables['date_variable'] = 'none'
-
-    step_task_date(behave, '{{ datetime.now() }} | offset=1D', 'date_variable')
-
-    assert len(grizzly.scenario.tasks()) == 1
-
-    task = grizzly.scenario.tasks()[-1]
-    assert isinstance(task, DateTask)
-    assert task.value == '{{ datetime.now() }}'
-    assert task.variable == 'date_variable'
-    assert task.arguments.get('offset') == '1D'
-    templates = task.get_templates()
-    assert len(templates) == 1
-    assert templates[0] == '{{ datetime.now() }}'
-
-
-def test_step_task_client_put_endpoint_file_destination(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    behave.text = 'hello'
-
-    assert len(grizzly.scenario.orphan_templates) == 0
-    assert len(grizzly.scenario.tasks()) == 0
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_put_endpoint_file_destination(behave, 'file.json', 'http://example.org/put', 'step-name', 'uploaded-file.json')
-    assert 'step text is not allowed for this step expression' in str(ae.value)
-
-    behave.text = None
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_put_endpoint_file_destination(behave, 'file-{{ suffix }}.json', 'http://{{ url }}', 'step-name', 'uploaded-file-{{ suffix }}.json')
-    assert 'source file cannot be a template' == str(ae.value)
-
-    step_task_client_put_endpoint_file_destination(behave, 'file-test.json', 'http://{{ url }}', 'step-name', 'uploaded-file-{{ suffix }}.json')
-
-    assert len(grizzly.scenario.tasks()) == 1
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, HttpClientTask)
-    assert task.source == 'file-test.json'
-    assert task.destination == 'uploaded-file-{{ suffix }}.json'
-    assert task.endpoint == '{{ url }}'
-
-    templates = task.get_templates()
-    assert len(templates) == 2
-    assert sorted(templates) == sorted([
-        '{{ url }}',
-        'uploaded-file-{{ suffix }}.json',
-    ])
-
-
-def test_step_task_client_put_endpoint_file(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = cast(GrizzlyContext, behave.grizzly)
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    behave.text = 'hello'
-
-    assert len(grizzly.scenario.orphan_templates) == 0
-    assert len(grizzly.scenario.tasks()) == 0
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_put_endpoint_file(behave, 'file.json', 'http://example.org/put', 'step-name')
-    assert 'step text is not allowed for this step expression' in str(ae.value)
-
-    behave.text = None
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_client_put_endpoint_file(behave, 'file-{{ suffix }}.json', 'http://{{ url }}', 'step-name')
-    assert 'source file cannot be a template' == str(ae.value)
-
-    step_task_client_put_endpoint_file(behave, 'file-test.json', 'http://{{ url }}', 'step-name')
-
-    assert len(grizzly.scenario.tasks()) == 1
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, HttpClientTask)
-    assert task.source == 'file-test.json'
-    assert task.destination is None
-    assert task.endpoint == '{{ url }}'
-
-    templates = task.get_templates()
-    assert len(templates) == 1
-    assert sorted(templates) == sorted([
-        '{{ url }}',
-    ])
-
-
-def test_step_task_async_group_start(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert getattr(grizzly.scenario.tasks.tmp, 'async_group', '') is None
-
-    step_task_async_group_start(behave, 'async-test-1')
-
-    assert grizzly.scenario.tasks.tmp.async_group is not None
-    assert grizzly.scenario.tasks.tmp.async_group.name == 'async-test-1'
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_async_group_start(behave, 'async-test-2')
-    assert str(ae.value) == 'async request group "async-test-1" has not been closed'
-
-
-def test_step_task_async_group_end(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert len(grizzly.scenario.tasks()) == 0
-    assert getattr(grizzly.scenario.tasks.tmp, 'async_group', '') is None
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_async_group_close(behave)
-    assert str(ae.value) == 'no async request group is open'
-
-    step_task_async_group_start(behave, 'async-test-1')
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_async_group_close(behave)
-    assert str(ae.value) == 'there are no requests in async group "async-test-1"'
-    assert grizzly.scenario.tasks.tmp.async_group is not None
-
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test', direction=RequestDirection.FROM, endpoint='/api/test')
-    assert len(grizzly.scenario.tasks) == 0  # OK here
-
-    step_task_async_group_close(behave)
-
-    assert len(grizzly.scenario.tasks()) == 1
-    assert grizzly.scenario.tasks.tmp.async_group is None
-
-
-def test_step_task_timer_start_and_stop(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert grizzly.scenario.tasks.tmp.timers == {}
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_timer_stop(behave, 'test-timer-1')
-    assert str(ae.value) == 'timer with name test-timer-1 has not been defined'
-
-    step_task_timer_start(behave, 'test-timer-1')
-
-    timer = grizzly.scenario.tasks.tmp.timers.get('test-timer-1', None)
-    assert isinstance(timer, TimerTask)
-    assert timer.name == 'test-timer-1'
-    assert grizzly.scenario.tasks()[-1] is timer
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_timer_start(behave, 'test-timer-1')
-    assert str(ae.value) == 'timer with name test-timer-1 has already been defined'
-
-    step_task_timer_stop(behave, 'test-timer-1')
-
-    assert grizzly.scenario.tasks.tmp.timers == {
-        'test-timer-1': None,
-    }
-
-    assert grizzly.scenario.tasks()[-2] is grizzly.scenario.tasks()[-1]
-
-
-def test_step_task_request_wait_between(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert len(grizzly.scenario.tasks()) == 0
-
-    step_task_wait_between(behave, 1.4, 1.7)
-
-    assert len(grizzly.scenario.tasks()) == 1
-
-    task = cast(TaskWaitTask, grizzly.scenario.tasks()[-1])
-    assert task.min_time == 1.4
-    assert task.max_time == 1.7
-
-    step_task_wait_between(behave, 30, 20)
-
-    assert len(grizzly.scenario.tasks()) == 2
-
-    task = cast(TaskWaitTask, grizzly.scenario.tasks()[-1])
-    assert task.min_time == 20
-    assert task.max_time == 30
-
-
-def test_step_task_wait_constant(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert len(grizzly.scenario.tasks()) == 0
-
-    step_task_wait_constant(behave, 10)
-
-    assert len(grizzly.scenario.tasks()) == 1
-
-    task = cast(TaskWaitTask, grizzly.scenario.tasks()[-1])
-    assert task.min_time == 10
-    assert task.max_time is None
-
-
-def test_step_task_conditional_if(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert getattr(grizzly.scenario.tasks.tmp, 'conditional', '') is None
-
-    step_task_conditional_if(behave, '{{ value | int == 10 }}', 'conditional-1')
-
-    assert grizzly.scenario.tasks.tmp.conditional is not None
-    assert grizzly.scenario.tasks.tmp.conditional.name == 'conditional-1'
-    assert grizzly.scenario.tasks.tmp.conditional._pointer
-    assert grizzly.scenario.tasks.tmp.conditional.tasks == {True: []}
-
-    step_task_wait_constant(behave, 1.4)
-    step_task_log_message(behave, 'hello world')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-get', RequestDirection.FROM, '/api/test')
-    step_task_async_group_start(behave, 'async-group')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-async-get-1', RequestDirection.FROM, '/api/test')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-async-get-2', RequestDirection.FROM, '/api/test')
-    step_task_async_group_close(behave)
-
-    assert list(grizzly.scenario.tasks.tmp.conditional.tasks.keys()) == [True]
-    assert len(grizzly.scenario.tasks.tmp.conditional.tasks[True]) == 4
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_conditional_if(behave, '{{ value | int == 20 }}', 'conditional-2')
-    assert str(ae.value) == 'cannot create a new conditional while "conditional-1" is still open'
-
-
-def test_step_task_conditional_else(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert getattr(grizzly.scenario.tasks.tmp, 'conditional', '') is None
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_conditional_else(behave)
-    assert str(ae.value) == 'there are no open conditional, you need to create one first'
-
-    test_step_task_conditional_if(behave_fixture)
-
-    assert grizzly.scenario.tasks.tmp.conditional is not None
-
-    step_task_conditional_else(behave)
-
-    assert grizzly.scenario.tasks.tmp.conditional.tasks.get(False, None) == []
-
-    step_task_wait_constant(behave, 3.7)
-    step_task_log_message(behave, 'foo bar')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-async-get-3', RequestDirection.FROM, '/api/test')
-
-    assert list(grizzly.scenario.tasks.tmp.conditional.tasks.keys()) == [True, False]
-    assert len(grizzly.scenario.tasks.tmp.conditional.tasks[True]) == 4
-    assert len(grizzly.scenario.tasks.tmp.conditional.tasks[False]) == 3
-
-
-def test_step_task_conditional_end(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert getattr(grizzly.scenario.tasks.tmp, 'conditional', '') is None
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_conditional_end(behave)
-    assert str(ae.value) == 'there are no open conditional, you need to create one before closing it'
-
-    test_step_task_conditional_else(behave_fixture)
-
-    assert grizzly.scenario.tasks.tmp.conditional is not None
-
-    step_task_conditional_end(behave)
-
-    assert len(grizzly.scenario.tasks()) == 1
-    conditional = cast(ConditionalTask, grizzly.scenario.tasks()[-1])
-
-    assert conditional.name == 'conditional-1'
-    assert conditional.condition == '{{ value | int == 10 }}'
-    assert list(conditional.tasks.keys()) == [True, False]
-    assert len(conditional.tasks[True]) == 4
-    assert len(conditional.tasks[False]) == 3
-
-
-def test_step_task_loop(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    assert getattr(grizzly.scenario.tasks.tmp, 'loop', '') is None
-
-    with pytest.raises(ValueError) as ve:
-        step_task_loop_start(behave, '["hello", "world"]', 'foobar', 'test-loop')
-    assert str(ve.value) == 'LoopTask: foobar has not been initialized'
-
-    step_setup_variable_value(behave, 'foobar', 'none')
-
-    step_task_loop_start(behave, '["hello", "world"]', 'foobar', 'test-loop')
-
-    assert grizzly.scenario.tasks.tmp.loop is not None
-    assert len(grizzly.scenario.tasks()) == 0
-    assert len(grizzly.scenario.tasks) == 0  # OK here
-
-    assert grizzly.scenario.tasks.tmp.loop.name == 'test-loop'
-    assert grizzly.scenario.tasks.tmp.loop.values == '["hello", "world"]'
-    assert grizzly.scenario.tasks.tmp.loop.variable == 'foobar'
-    assert len(grizzly.scenario.tasks.tmp.loop.tasks) == 0
-
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-get-1', RequestDirection.FROM, '/api/test/1')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-get-2', RequestDirection.FROM, '/api/test/2')
-
-    step_task_conditional_if(behave, '{{ value | int > 0 }}', 'test-conditional')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-cond-get-1', RequestDirection.FROM, '/api/test/1')
-    step_task_request_text_with_name_endpoint(behave, RequestMethod.GET, 'test-cond-get-2', RequestDirection.FROM, '/api/test/2')
-    step_task_conditional_end(behave)
-
-    assert len(grizzly.scenario.tasks.tmp.loop.tasks) == 3
-    assert len(grizzly.scenario.tasks()) == 3
-
-    step_task_loop_end(behave)
-
-    assert len(grizzly.scenario.tasks()) == 1
-    assert getattr(grizzly.scenario.tasks.tmp, 'loop', '') is None
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_loop_end(behave)
-    assert str(ae.value) == 'there are no open loop, you need to create one before closing it'
-
-
-def test_step_task_keystore_get(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    grizzly.scenario.tasks.clear()
-
-    grizzly.state.variables.update({'foobar': 'none'})
-
-    step_task_keystore_get(behave, 'foobar', 'foobar')
-
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, KeystoreTask)
-    assert task.key == 'foobar'
-    assert task.action == 'get'
-    assert task.action_context == 'foobar'
-    assert task.default_value is None
-
-
-def test_step_task_keystore_get_default(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    grizzly.scenario.tasks.clear()
-
-    grizzly.state.variables.update({'foobar': 'none'})
-
-    step_task_keystore_get_default(behave, 'barfoo', 'foobar', "{'hello': 'world'}")
-
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, KeystoreTask)
-    assert task.key == 'barfoo'
-    assert task.action == 'get'
-    assert task.action_context == 'foobar'
-    assert task.default_value == {'hello': 'world'}
-
-    step_task_keystore_get_default(behave, 'barfoo', 'foobar', '"hello"')
-
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, KeystoreTask)
-    assert task.key == 'barfoo'
-    assert task.action == 'get'
-    assert task.action_context == 'foobar'
-    assert task.default_value == 'hello'
-
-    step_task_keystore_get_default(behave, 'barfoo', 'foobar', "'hello'")
-
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, KeystoreTask)
-    assert task.key == 'barfoo'
-    assert task.action == 'get'
-    assert task.action_context == 'foobar'
-    assert task.default_value == 'hello'
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_keystore_get_default(behave, 'barfoo', 'foobar', 'hello')
-    assert str(ae.value) == '"hello" is not valid JSON'
-
-
-def test_step_task_keystore_set(behave_fixture: BehaveFixture) -> None:
-    behave = behave_fixture.context
-    grizzly = behave_fixture.grizzly
-    grizzly.scenarios.create(behave_fixture.create_scenario('test scenario'))
-
-    grizzly.scenario.tasks.clear()
-
-    with pytest.raises(AssertionError) as ae:
-        step_task_keystore_set(behave, 'foobar', 'hello')
-    assert str(ae.value) == '"hello" is not valid JSON'
-
-    step_task_keystore_set(behave, 'foobar', "'hello'")
-
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, KeystoreTask)
-    assert task.key == 'foobar'
-    assert task.action == 'set'
-    assert task.action_context == 'hello'
-
-    step_task_keystore_set(behave, 'foobar', "['hello', 'world']")
-
-    task = grizzly.scenario.tasks()[-1]
-
-    assert isinstance(task, KeystoreTask)
-    assert task.key == 'foobar'
-    assert task.action == 'set'
-    assert task.action_context == ['hello', 'world']
diff --git a/tests/unit/test_grizzly/steps/test__helpers.py b/tests/unit/test_grizzly/steps/test__helpers.py
index 4577a5d9..4eecbb97 100644
--- a/tests/unit/test_grizzly/steps/test__helpers.py
+++ b/tests/unit/test_grizzly/steps/test__helpers.py
@@ -14,7 +14,7 @@
 from grizzly.tasks.async_group import AsyncRequestGroupTask
 from grizzly.types import RequestMethod, ResponseTarget, ResponseAction
 from grizzly.types.behave import Table, Row
-from grizzly.tasks import RequestTask, WaitTask
+from grizzly.tasks import RequestTask, ExplicitWaitTask
 from grizzly.testdata.utils import templatingfilter
 from grizzly.steps._helpers import (
     add_validation_handler,
@@ -151,7 +151,7 @@ def uppercase(value: str) -> str:
 
         if not as_async:
             tasks.clear()
-            tasks.append(WaitTask(time_expression='1.0'))
+            tasks.append(ExplicitWaitTask(time_expression='1.0'))
 
             with pytest.raises(ValueError) as e:
                 add_request_task(behave, method=RequestMethod.PUT, source='template.j2.json')
@@ -345,7 +345,7 @@ def test_add_save_handler(behave_fixture: BehaveFixture, locust_fixture: LocustF
     assert user.context_variables.get('test-variable-payload', 'payload') is None
 
     # previous non RequestTask task
-    tasks.append(WaitTask(time_expression='1.0'))
+    tasks.append(ExplicitWaitTask(time_expression='1.0'))
 
     grizzly.state.variables['test'] = 'none'
     with pytest.raises(ValueError):
diff --git a/tests/unit/test_grizzly/tasks/test_task_wait.py b/tests/unit/test_grizzly/tasks/test_wait_between.py
similarity index 77%
rename from tests/unit/test_grizzly/tasks/test_task_wait.py
rename to tests/unit/test_grizzly/tasks/test_wait_between.py
index 5e8b690d..2ce987dc 100644
--- a/tests/unit/test_grizzly/tasks/test_task_wait.py
+++ b/tests/unit/test_grizzly/tasks/test_wait_between.py
@@ -1,20 +1,20 @@
 import pytest
 
 from locust.exception import MissingWaitTimeError
-from grizzly.tasks import TaskWaitTask
+from grizzly.tasks import WaitBetweenTask
 
 from tests.fixtures import GrizzlyFixture
 
 
-class TestTaskWaitTask:
+class TestWaitBetweenTask:
     def test___init__(self) -> None:
-        task_factory = TaskWaitTask(1.0)
+        task_factory = WaitBetweenTask(1.0)
 
         assert task_factory.min_time == 1.0
         assert task_factory.max_time is None
         assert task_factory.__template_attributes__ == set()
 
-        task_factory = TaskWaitTask(2.0, 13.0)
+        task_factory = WaitBetweenTask(2.0, 13.0)
         assert task_factory.min_time == 2.0
         assert task_factory.max_time == 13.0
 
@@ -27,14 +27,14 @@ def test___call__(self, grizzly_fixture: GrizzlyFixture) -> None:
         with pytest.raises(MissingWaitTimeError):
             parent.wait_time()
 
-        task = TaskWaitTask(1.0, 12.0)()
+        task = WaitBetweenTask(1.0, 12.0)()
 
         task(parent)
 
         wait_time = parent.wait_time()
         assert wait_time >= 1.0 and wait_time <= 12.0
 
-        task = TaskWaitTask(13.0)()
+        task = WaitBetweenTask(13.0)()
 
         task(parent)
 
diff --git a/tests/unit/test_grizzly/tasks/test_wait.py b/tests/unit/test_grizzly/tasks/test_wait_explicit.py
similarity index 93%
rename from tests/unit/test_grizzly/tasks/test_wait.py
rename to tests/unit/test_grizzly/tasks/test_wait_explicit.py
index c0578329..53e03530 100644
--- a/tests/unit/test_grizzly/tasks/test_wait.py
+++ b/tests/unit/test_grizzly/tasks/test_wait_explicit.py
@@ -2,17 +2,17 @@
 
 from pytest_mock import MockerFixture
 
-from grizzly.tasks import WaitTask
+from grizzly.tasks import ExplicitWaitTask
 from grizzly.exceptions import StopUser
 
 from tests.fixtures import GrizzlyFixture
 
 
-class TestWaitTask:
+class TestExplicitWaitTask:
     def test(self, mocker: MockerFixture, grizzly_fixture: GrizzlyFixture) -> None:
         parent = grizzly_fixture()
 
-        task_factory = WaitTask(time_expression='1.0')
+        task_factory = ExplicitWaitTask(time_expression='1.0')
 
         assert task_factory.time_expression == '1.0'
         assert task_factory.__template_attributes__ == {'time_expression'}
@@ -20,8 +20,8 @@ def test(self, mocker: MockerFixture, grizzly_fixture: GrizzlyFixture) -> None:
 
         assert callable(task)
 
-        import grizzly.tasks.wait
-        gsleep_spy = mocker.patch.object(grizzly.tasks.wait, 'gsleep', autospec=True)
+        import grizzly.tasks.wait_explicit
+        gsleep_spy = mocker.patch.object(grizzly.tasks.wait_explicit, 'gsleep', autospec=True)
         request_fire_spy = mocker.spy(parent.user.environment.events.request, 'fire')
 
         task(parent)
diff --git a/tests/unit/test_grizzly/test_context.py b/tests/unit/test_grizzly/test_context.py
index a064a331..d889ebc0 100644
--- a/tests/unit/test_grizzly/test_context.py
+++ b/tests/unit/test_grizzly/test_context.py
@@ -26,7 +26,7 @@
 )
 
 from grizzly.types import MessageDirection, RequestMethod
-from grizzly.tasks import LogMessageTask, RequestTask, WaitTask, AsyncRequestGroupTask, LoopTask, ConditionalTask
+from grizzly.tasks import LogMessageTask, RequestTask, ExplicitWaitTask, AsyncRequestGroupTask, LoopTask, ConditionalTask
 
 
 from tests.helpers import TestTask, get_property_decorated_attributes
@@ -349,7 +349,7 @@ def test_tasks(self, request_task: RequestTaskFixture, behave_fixture: BehaveFix
         scenario.tasks.add(second_request)
         assert scenario.tasks() == [request, second_request]
 
-        wait_task = WaitTask(time_expression='1.337')
+        wait_task = ExplicitWaitTask(time_expression='1.337')
         scenario.tasks.add(wait_task)
         assert scenario.tasks() == [request, second_request, wait_task]
 
diff --git a/tests/unit/test_grizzly/test_locust.py b/tests/unit/test_grizzly/test_locust.py
index c5e4e2df..a0a29f0b 100644
--- a/tests/unit/test_grizzly/test_locust.py
+++ b/tests/unit/test_grizzly/test_locust.py
@@ -36,7 +36,7 @@
 from grizzly.types.locust import Environment
 from grizzly.types.behave import Context, Scenario
 from grizzly.context import GrizzlyContext
-from grizzly.tasks import LogMessageTask, RequestTask, WaitTask
+from grizzly.tasks import LogMessageTask, RequestTask, ExplicitWaitTask
 from grizzly.tasks.clients import MessageQueueClientTask
 from grizzly.users import RestApiUser, MessageQueueUser
 from grizzly.scenarios import IteratorScenario
@@ -181,7 +181,7 @@ def test_setup_locust_scenarios(behave_fixture: BehaveFixture, noop_zmq: NoopZmq
 
     task = RequestTask(RequestMethod.GET, 'test-1', '/api/v1/test/1')
     grizzly.scenario.tasks.add(task)
-    grizzly.scenario.tasks.add(WaitTask(time_expression='1.5'))
+    grizzly.scenario.tasks.add(ExplicitWaitTask(time_expression='1.5'))
     grizzly.scenario.tasks.add(LogMessageTask(message='test message'))
 
     # incorrect user type
@@ -626,7 +626,7 @@ def mocked_popen___init__(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> N
     grizzly.scenarios.create(behave_fixture.create_scenario('test-non-mq'))
     grizzly.scenario.user.class_name = 'RestApiUser'
     grizzly.scenario.context['host'] = 'https://test.example.org'
-    grizzly.scenario.tasks.add(WaitTask(time_expression='1.5'))
+    grizzly.scenario.tasks.add(ExplicitWaitTask(time_expression='1.5'))
     task = RequestTask(RequestMethod.GET, 'test-1', '/api/v1/test/1')
     grizzly.scenario.tasks.add(task)
 
@@ -715,7 +715,7 @@ def mocked_popen___init__(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> N
     grizzly.scenarios.create(behave_fixture.create_scenario('test'))
     grizzly.scenario.user.class_name = 'RestApiUser'
     grizzly.scenario.context['host'] = 'https://test.example.org'
-    grizzly.scenario.tasks.add(WaitTask(time_expression='1.5'))
+    grizzly.scenario.tasks.add(ExplicitWaitTask(time_expression='1.5'))
     task = RequestTask(RequestMethod.GET, 'test-1', '/api/v1/test/1')
     grizzly.scenario.tasks.add(task)
     grizzly.setup.spawn_rate = 1
diff --git a/tests/unit/test_grizzly/users/test_servicebus.py b/tests/unit/test_grizzly/users/test_servicebus.py
index 4801bec7..fcf05597 100644
--- a/tests/unit/test_grizzly/users/test_servicebus.py
+++ b/tests/unit/test_grizzly/users/test_servicebus.py
@@ -13,7 +13,7 @@
 from grizzly.users.servicebus import ServiceBusUser
 from grizzly.types import RequestMethod
 from grizzly.types.locust import StopUser
-from grizzly.tasks import RequestTask, WaitTask
+from grizzly.tasks import RequestTask, ExplicitWaitTask
 from grizzly.context import GrizzlyContextScenario
 from grizzly_extras.async_message import AsyncMessageResponse, AsyncMessageError
 from grizzly_extras.transformer import TransformerContentType
@@ -47,7 +47,7 @@ def test_on_start(self, behave_fixture: BehaveFixture, mocker: MockerFixture, no
             scenario = GrizzlyContextScenario(2, behave=behave_fixture.create_scenario('test'))
             scenario.user.class_name = 'ServiceBusUser'
 
-            scenario.tasks.add(WaitTask(time_expression='1.54'))
+            scenario.tasks.add(ExplicitWaitTask(time_expression='1.54'))
             scenario.tasks.add(RequestTask(RequestMethod.SEND, name='test-send', endpoint='{{ endpoint }}'))
             scenario.tasks.add(RequestTask(RequestMethod.RECEIVE, name='test-receive', endpoint='queue:test-queue'))
             scenario.tasks.add(RequestTask(RequestMethod.SEND, name='test-send', endpoint='topic:test-topic'))
@@ -96,7 +96,7 @@ def test_on_stop(self, behave_fixture: BehaveFixture, mocker: MockerFixture, noo
             scenario = GrizzlyContextScenario(2, behave=behave_fixture.create_scenario('test'))
             scenario.user.class_name = 'ServiceBusUser'
 
-            scenario.tasks.add(WaitTask(time_expression='1.54'))
+            scenario.tasks.add(ExplicitWaitTask(time_expression='1.54'))
             scenario.tasks.add(RequestTask(RequestMethod.SEND, name='test-send', endpoint='{{ endpoint }}'))
             scenario.tasks.add(RequestTask(RequestMethod.RECEIVE, name='test-receive', endpoint='queue:test-queue'))
             scenario.tasks.add(RequestTask(RequestMethod.SEND, name='test-send', endpoint='topic:test-topic'))
diff --git a/tests/unit/test_grizzly_extras/test_novella.py b/tests/unit/test_grizzly_extras/test_novella.py
new file mode 100644
index 00000000..ffc1f76f
--- /dev/null
+++ b/tests/unit/test_grizzly_extras/test_novella.py
@@ -0,0 +1,316 @@
+from typing import List, Dict, Union
+from shutil import rmtree
+
+import pytest
+
+from grizzly_extras.novella import (
+    MarkdownAstType,
+    MarkdownAstNode,
+    NO_CHILD,
+    MarkdownHeading,
+    make_human_readable,
+    _create_nav_node,
+    _generate_dynamic_page,
+    GrizzlyMarkdown,
+)
+
+
+class TestMarkdownAstType:
+    def test_from_value(self) -> None:
+        assert MarkdownAstType.from_value('text') == MarkdownAstType.TEXT
+        assert MarkdownAstType.from_value('emphasis') == MarkdownAstType.EMPHASIS
+        assert MarkdownAstType.from_value(None) == MarkdownAstType.NONE
+
+        with pytest.raises(ValueError) as ve:
+            MarkdownAstType.from_value('foobar')
+        assert str(ve.value) == '"foobar" is not a valid value of MarkdownAstType'
+
+
+class TestMarkdownAstNode:
+    def test_first_child(self) -> None:
+        # <!-- no children
+        node = MarkdownAstNode({}, 0)
+
+        assert node.first_child.ast == NO_CHILD
+        assert node.first_child.type == MarkdownAstType.NONE
+        # -->
+
+        # <!-- first child is BLANK_LINE
+        node = MarkdownAstNode({'children': [{'type': MarkdownAstType.BLANK_LINE.value}, {'type': MarkdownAstType.TEXT.value, 'raw': 'foobar'}]}, 0)
+
+        assert node.first_child.type == MarkdownAstType.TEXT
+        assert node.first_child.raw == 'foobar'
+        # -->
+
+    def test_get_child(self) -> None:
+        # <!-- no children
+        node = MarkdownAstNode({}, 0)
+        with pytest.raises(IndexError):
+            node.get_child(10)
+        # -->
+
+        # <!-- 5th node text
+        node = MarkdownAstNode({'children': ([{}] * 4) + [{'type': 'text', 'raw': 'foobar'}]}, 0)
+        child_node = node.get_child(4)
+
+        assert child_node.type == MarkdownAstType.TEXT
+        assert child_node.raw == 'foobar'
+        # -->
+
+    @pytest.mark.parametrize('type', [e for e in MarkdownAstType])
+    def test_type(self, type: MarkdownAstType) -> None:
+        node = MarkdownAstNode({'type': type.value}, 13)
+        assert node.type == type
+
+    @pytest.mark.parametrize('raw', ['foobar', None, 'hello world'])
+    def test_raw(self, raw: str) -> None:
+        node = MarkdownAstNode({'raw': raw}, 37)
+        assert node.raw == raw
+
+
+def test_MarkdownHeading() -> None:
+    header = MarkdownHeading('foobar', 10)
+
+    assert header.text == 'foobar'
+    assert header.level == 10
+
+
+def test_make_human_readable() -> None:
+    assert make_human_readable('foo_bar_Http') == 'Foo Bar HTTP'
+    assert make_human_readable('Iot_Api_hub_hello_world_foo_bar') == 'IoT API Hub Hello World Foo Bar'
+
+
+def test__create_nav_node(tmp_path_factory: pytest.TempPathFactory) -> None:
+    test_context = tmp_path_factory.mktemp('test_context')
+    try:
+        target: List[Union[str, Dict[str, str]]] = []
+        # <!-- not a file
+        node = test_context
+        _create_nav_node(target, 'foobar', node)
+        assert target == []
+        # -->
+
+        # <!-- normal file
+        node = test_context / 'foobar_sftp_api.py'
+        node.touch()
+        _create_nav_node(target, 'foobar', node)
+        assert target == [{'Foobar SFTP API': 'foobar/foobar_sftp_api.md'}]
+        # -->
+
+        # <!-- __init__, no index
+        node = test_context / '__init__.py'
+        node.touch()
+        _create_nav_node(target, 'foobar', node, with_index=False)
+        assert target == [{'Foobar SFTP API': 'foobar/foobar_sftp_api.md'}]
+        # -->
+
+        # <!-- __init__, no index
+        _create_nav_node(target, 'hello', node)
+        assert target == ['hello/index.md', {'Foobar SFTP API': 'foobar/foobar_sftp_api.md'}]
+        # -->
+    finally:
+        rmtree(test_context)
+
+
+def test__generate_dynamic_page(tmp_path_factory: pytest.TempPathFactory) -> None:
+    test_context = tmp_path_factory.mktemp('test_context')
+    test_input = test_context / 'input'
+    test_input.mkdir()
+    test_output = test_context / 'output'
+    test_output.mkdir()
+
+    try:
+        # <!-- not a file
+        _generate_dynamic_page(test_input, test_output, 'foobar', 'foo.bar')
+        assert len(list(test_output.rglob('**/*'))) == 0
+        # -->
+
+        # <!-- normal file
+        input_file = test_input / 'foobar_sftp_api.py'
+        input_file.touch()
+        _generate_dynamic_page(input_file, test_output / 'foobar', 'Foobar', 'foo.bar')
+        assert not len(list(test_output.rglob('**/*'))) == 0
+        output_file = test_output / 'foobar' / 'foobar_sftp_api.md'
+        assert output_file.exists()
+        assert output_file.read_text() == '''---
+title: Foobar / Foobar SFTP API
+---
+@pydoc foo.bar.foobar_sftp_api
+'''
+        # -->
+
+        # <!-- init file
+        input_file = test_input / '__init__.py'
+        input_file.touch()
+        _generate_dynamic_page(input_file, test_output / 'foobar', 'Foobar', 'foo.bar')
+        assert not len(list(test_output.rglob('**/*'))) == 0
+        output_file = test_output / 'foobar' / 'index.md'
+        assert output_file.exists()
+        assert output_file.read_text() == '''---
+title: Foobar
+---
+@pydoc foo.bar
+'''
+        # -->
+
+        # <!-- normal file, do not overwrite
+        input_file = test_input / 'foobar_sftp_api.py'
+        input_file.touch()
+        _generate_dynamic_page(input_file, test_output / 'foobar', 'Barfoo', 'bar.foo')
+        assert not len(list(test_output.rglob('**/*'))) == 0
+        output_file = test_output / 'foobar' / 'foobar_sftp_api.md'
+        assert output_file.exists()
+        assert output_file.read_text() == '''---
+title: Foobar / Foobar SFTP API
+---
+@pydoc foo.bar.foobar_sftp_api
+'''
+        # -->
+    finally:
+        rmtree(test_context)
+
+
+class TestGrizzlyMarkdown:
+    def test__is_anchor(self) -> None:
+        assert not GrizzlyMarkdown._is_anchor('<a')
+        assert GrizzlyMarkdown._is_anchor('<a>')
+        assert GrizzlyMarkdown._is_anchor('<a id="#hello.world">')
+        assert not GrizzlyMarkdown._is_anchor('foobar')
+
+    def test__get_header(self) -> None:
+        node = MarkdownAstNode({}, 0)
+        assert GrizzlyMarkdown._get_header(node) == MarkdownHeading('', 0)
+
+        node = MarkdownAstNode({
+            'attrs': {'level': 3},
+            'children': [
+                {'raw': 'he'},
+                {'raw': 'llo'},
+                {'raw': ' world'},
+                {'raw': '!'},
+            ]
+        }, 0)
+        assert GrizzlyMarkdown._get_header(node) == MarkdownHeading('hello world!', 3)
+
+    def test__get_tokens(self) -> None:
+        tokens = GrizzlyMarkdown._get_tokens('<a href="')
+        assert [token.string for token in tokens] == ['utf-8', '<', 'a', 'href', '=', '"', '', '']
+
+    def test_get_step_expression_from_code_block(self) -> None:
+        code_block = '''
+@given(u'hello foobar world')
+def step_hello_foobar_world(context: Context) -> None:
+    pass
+'''
+        assert GrizzlyMarkdown.get_step_expression_from_code_block(code_block) == ('given', 'hello foobar world',)
+
+        code_block = '''
+# @TODO: some comment
+@then(u'what in the world')
+def step_what_in_the_world(context: Context) -> None:
+    pass
+'''
+        assert GrizzlyMarkdown.get_step_expression_from_code_block(code_block) == ('then', 'what in the world',)
+
+        assert GrizzlyMarkdown.get_step_expression_from_code_block('foobar') is None
+
+    def test_ast_reformat_block_code(self) -> None:
+        node = MarkdownAstNode({'type': 'paragraph', 'raw': 'foobar'}, 0)
+        assert GrizzlyMarkdown.ast_reformat_block_code(node).ast == node.ast
+
+        node = MarkdownAstNode({'type': 'block_code', 'raw': '```python\nimport sys\nsys.exit(0)```', 'style': 'indent', 'marker': '~~~'}, 0)
+        assert GrizzlyMarkdown.ast_reformat_block_code(node).ast == {
+            'type': 'block_code',
+            'raw': '    import sys\n    sys.exit(0)',
+            'style': 'indent',
+            'marker': '    ```',
+            'attrs': {
+                'info': 'python',
+            },
+        }
+
+        node = MarkdownAstNode({'type': 'block_code', 'raw': '```python\nimport sys\nsys.exit(0)\n```', 'style': 'indent', 'marker': '~~~'}, 0)
+        assert GrizzlyMarkdown.ast_reformat_block_code(node).ast == {
+            'type': 'block_code',
+            'raw': '    import sys\n    sys.exit(0)',
+            'style': 'indent',
+            'marker': '    ```',
+            'attrs': {
+                'info': 'python',
+            },
+        }
+
+    def test_ast_reformat_admonitions(self) -> None:
+        node = MarkdownAstNode({'type': 'block_code', 'raw': 'foobar'}, 0)
+        assert GrizzlyMarkdown.ast_reformat_admonitions(node).ast == {'type': 'block_code', 'raw': 'foobar'}
+
+        node = MarkdownAstNode({
+            'type': 'paragraph',
+            'children': [
+                {'raw': '!!! foobar'},
+                {'type': 'softbreak'},
+                {'type': 'text', 'raw': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'},
+                {'type': 'softbreak'},
+                {'type': 'text', 'raw': 'Donec quis mollis sapien. Fusce ac purus sit amet tortor rutrum congue.'},
+                {'type': 'softbreak'},
+                {'type': 'text', 'raw': 'Suspendisse potenti. Morbi non lacus sed eros ullamcorper cursus.'},
+            ],
+        }, 1)
+
+        assert GrizzlyMarkdown.ast_reformat_admonitions(node).ast == {
+            'type': 'paragraph',
+            'children': [
+                {'raw': '!!! foobar'},
+                {'type': 'softbreak'},
+                {'type': 'text', 'raw': '    Lorem ipsum dolor sit amet, consectetur adipiscing elit.'},
+                {'type': 'softbreak'},
+                {'type': 'text', 'raw': '    Donec quis mollis sapien. Fusce ac purus sit amet tortor rutrum congue.'},
+                {'type': 'softbreak'},
+                {'type': 'text', 'raw': '    Suspendisse potenti. Morbi non lacus sed eros ullamcorper cursus.'},
+            ],
+        }
+
+    def test_ast_reformat_recursive(self) -> None:
+        node = MarkdownAstNode({
+            'type': 'paragraph',
+            'children': [
+                {'raw': '!!! foobar'},
+                {'type': 'softbreak'},
+                {
+                    'type': 'text',
+                    'raw': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+                    'children': [
+                        {
+                            'type': 'paragraph',
+                            'children': [
+                                {'raw': '!!! bar'},
+                                {'type': 'softbreak'},
+                                {'type': 'text', 'raw': 'Suspendisse potenti. Morbi non lacus sed eros ullamcorper cursus.'},
+                            ],
+                        },
+                    ],
+                },
+            ],
+        }, 0)
+
+        assert GrizzlyMarkdown.ast_reformat_recursive(node, GrizzlyMarkdown.ast_reformat_admonitions).ast == {
+            'type': 'paragraph',
+            'children': [
+                {'raw': '!!! foobar'},
+                {'type': 'softbreak'},
+                {
+                    'type': 'text',
+                    'raw': '    Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+                    'children': [
+                        {
+                            'type': 'paragraph',
+                            'children': [
+                                {'raw': '!!! bar'},
+                                {'type': 'softbreak'},
+                                {'type': 'text', 'raw': '    Suspendisse potenti. Morbi non lacus sed eros ullamcorper cursus.'},
+                            ],
+                        },
+                    ],
+                },
+            ],
+        }