Skip to content

Commit

Permalink
feature: runner + peripheral components pertaining to the action of
Browse files Browse the repository at this point in the history
locating test declarations, preparing the test-execution environment,
isolation of SUT (system-under-test), compatibility with test-doubles
with the goal of achieving reduction of leaky abstractions to a
minimum.

brings parts from the branch: runner (HEAD: 13b0663)
as well as parts from remote `sure-nextgen`

Squashed commit of the following:

commit 13b0663
Author: Gabriel Falcão <gabriel@nacaolivre.org>
Date:   Wed Nov 9 01:57:18 2016 -0500

    drafting scenario behaviors
  • Loading branch information
gabrielfalcao committed Apr 29, 2023
1 parent 3aef950 commit c870a44
Show file tree
Hide file tree
Showing 8 changed files with 987 additions and 12 deletions.
28 changes: 16 additions & 12 deletions TODO.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,28 @@ TODO
Mock and Stubbing support
~~~~~~~~~~~~~~~~~~~~~~~~~

New way for adding behavior to scenarios


.. code:: python
import sure
from sure.scenario import BehaviorDefinition
class Example1(BehaviorDefinition):
context_namespace = 'example1'
def some_helper_function(value):
if value == 'foo':
return 'expected'
def main_function_that_depends_on_helper(param1, param2):
return some_helper_function(param1)
def setup(self, argument1):
self.data = {
'parameter': argument1
}
def test_main_function_succeeds_when_helper_returns_expected_result():
some_helper_function.stub.called_with('foo').returns(['expected'])
def teardown(self):
self.data = {}
result = main_function_that_depends_on_helper('foo', 'bar')
result.should.equal('expected')
@apply_behavior(Example1, argument1='hello-world')
def test_example_1(context):
context.example1.data.should.equal({
'parameter': 'hello-world',
})
13 changes: 13 additions & 0 deletions examples/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[draft] Changes in version 1.5.0
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Introducing the concept of BehaviorDefinition: a clean and
decoupled way to reutilize setup/teardown behaviors. So instead of
the classic massive setup/teardown methods and/or chaotic
``unittest.TestCase`` subclass inheritance every test can be
decorated with @apply_behavior(CustomBehaviorDefinitionTypeObject)
* Avoid using the word "test" in your "Behavior Definitions" so that
nose will not mistake your BehaviorDefinition with an actual test
case class and thus execute .setup() and .teardown() in an
undesired manner.
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-

import json
import requests
from gevent.pool import Pool
from flask import Flask, Response
from sqlalchemy import create_engine
from sqlalchemy import MetaData

from myapp.db import sqlalchemy_metadata
from sure.scenario import BehaviorDefinition, apply_behavior


# Changes in version 1.5.0 [draft]
# ~~~~~~~~~~~~~~~~~~~~~~~~
#
# * Introducing the concept of BehaviorDefinition: a clean and
# decoupled way to reutilize setup/teardown behaviors. So instead of
# the classic massive setup/teardown methods and/or chaotic
# ``unittest.TestCase`` subclass inheritance every test can be
# decorated with @apply_behavior(CustomBehaviorDefinitionTypeObject)
#
# * Avoid using the word "test" in your "Behavior Definitions" so that
# nose will not mistake your BehaviorDefinition with an actual test
# case class and thus execute .setup() and .teardown() in an
# undesired manner.



def get_json_request(self):
"""parses the request body as JSON without running any sort of validation"""
return json.loads(request.data)

def json_response(response_body, status=200, headers=None):
"""utility that automatically serializes the provided payload in JSON
and generates :py:`flask.Response` with the ``application/json``
content-type.
:param response_body: a python dictionary or any JSON-serializable python object.
"""
headers = headers or {}
serialized = json.dumps(response_body, indent=2)
headers[b'Content-Type'] = 'application/json'
return Response(serialized, status=code, headers=headers)


class GreenConcurrencyBehaviorDefinition(BehaviorDefinition):
# NOTE:
# ----
#
# * Sure uses ``context_namespace`` internally to namespace the
# self-assigned attributes into the context in order to prevent
# attribute name collision.

context_namespace = 'green'

def setup(self, pool_size=1):
self.pool = Pool(pool_size)


class UseFakeHTTPAPIServer(GreenConcurrencyBehaviorDefinition):
context_namespace = 'fake_api'

def setup(self, http_port):
# NOTES:
# ~~~~~~
#
# * GreenConcurrencyBehaviorDefinition.setup() is automatically called by
# * sure in the correct order
#
# * Sure automatically takes care of performing top-down calls
# to every parent of your behavior.
#
# * In simple words, this UseFakeHTTPAPIServer behavior will automatically call GreenConcurrencyBehaviorDefinition

# 1. Create a simple Flask server
self.server = Flask('fake.http.api.server')
# 2. Setup fake routes
self.server.add_url_rule('/auth', view_func=self.fake_auth_endpoint)
self.server.add_url_rule('/item/<uid>', view_func=self.fake_get_item_endpoint)
# 3. Run the server
self.pool.spawn(self..server.run, port=http_port)

def teardown(self):
self.server.stop()

def fake_auth_endpoint(self):
data = get_json_request()
data['token'] = '$2b$12$heKIpWg0wJQ6BeKxPSJCP.7Vf9hn8s6yFs8yGWnWdPZ48toXbjK9W',
return json_response(data)

def fake_get_item_endpoint(self, uid):
return json_response({
'uid': uid,
'title': 'fake item',
})


class CleanSlateDatabase(BehaviorDefinition):
# Sure uses ``context_namespace`` internally to namespace the
# self-assigned attributes into the context in order to prevent
# attribute name collision
context_namespace = 'db'

def setup(self, sqlalchemy_database_uri='mysql://root@localhost/test-database'):
self.engine = create_engine(sqlalchemy_database_uri)
self.metadata = sqlalchemy_metadata
# Dropping the whole schema just in case a previous test
# execution fails and leaves the database dirty before having
# the chance to run .teardown()
self.metadata.drop_all(engine)
self.metadata.create_all(engine)


@apply_behavior(UseFakeHTTPAPIServer, http_port=5001)
def test_with_real_network_io(context):
response = requests.post('http://localhosst:5001/auth', data=json.dumps({'username': 'foobar'}))
response.headers.should.have.key('Content-Type').being.equal('application/json')
response.json().should.equal({
'token': '$2b$12$heKIpWg0wJQ6BeKxPSJCP.7Vf9hn8s6yFs8yGWnWdPZ48toXbjK9W',
'username': 'foobar',
})
119 changes: 119 additions & 0 deletions examples/unit-tests/behavior_definition_simplify_mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# flake8: noqa
# -*- coding: utf-8 -*-

# ===========================================
# Behavior definition examples in pseudo code
# ===========================================


####################################################################################

# UNIT TESTS
# ---------

# Goals:
# ~~~~~
#
# * Provide an API that leverages mocking ``sure.scenario.MockfulBehaviorDefinition``
# * Provide a didactic and fun way to keep the mocks organized
# * Make monkey-patching of modules more fun:
# * Always forward the mocked objects as keyword-arguments as
# opposed to the default behavior of ``@patch()`` decorator stack
# which uses positional arguments, polute test declarations and
# demands menial, extrenuous work in case of test refactoring.
# * ``self.mock.module(members={})`` is a shorthand for creating a dummy module object that contains the given members as properties

# Notes:
# ~~~~~
#
# * the following methods require exactly one positional argument: ``mock_name``, otherwise: ``raise AssertionError("self.mock.simple() and self.mock.magic() require one positional argument: its name.")``
# * ``self.mock.simple()``
# * ``self.mock.magic()``
# * self.mock.install() forwards keyword-arguments to ``mock.patch``, for example:
# * ``self.mock.install('myapp.api.http.db.engine.connect', return_value='DUMMY_CONNECTION')
# * ``self.mock.install('myapp.api.http.db.engine.connect', return_value='DUMMY_CONNECTION')
# * self.mock.install() always generates MagicMocks
# * self.mock.install() accepts the parameter ``argname='some_identifier`` to be passed the keyword-argument that contains the ``Mock`` instance returned by ``.install()``
# * When ``argname=None`` the mock instance will be passed with a keyword-arg whose value is derived from the last __name__ found in the patch target, that is: ``self.mock.install('myapp.core.BaseServer')`` will change the test signature to: ``def test_something(context, BaseServer)``.
# * self.mock.install() will automatically name the mock when the ``name=""`` keyword-arg is not provided
# * self.mock.uninstall() accepts one positional argument: identified and then automatically match it against:
# * self.scenario.forward_argument() allows for arbitrary parameters in the test function, that is: ``self.scenario.forward_argument(connection=self.connection)`` will change the test signature to: ``def test_something(context, connection)``.
# * Developers do *NOT* need to manually uninstall mocks, but that is
# still permitted to encompass the cases where it has to be
# accomplished in mid-test, for example:
#
# @apply_behavior(FakeSettings)
# @apply_behavior(PreventRedisIO)
# def test_something(context):
# # perform some action that requires a stubbed setting
# context.redis.mock.uninstall(name='
# # peform
#

# <unit-test-example-pseudo-code>
from mock import Mock
from myapp import settings
from myapp.api.http import APIServer
from sure.scenario import MockfulBehaviorDefinition, apply_behavior, require_behavior


class FakeSettings(MockfulBehaviorDefinition):
"""automatically patches myapp.settings and overriding its keys with
the provided kwargs."""

context_namespace = 'settings'

def setup(self, **kwargs):
# 1. make 2 copies:
# * one original copy for backup
# * one containing the overrides
self.original_state = settings.to_dict()
cloned_state = settings.to_dict()

# 2. Apply the overrides in the cloned state
cloned_state.update(kwargs)

# 3. Create a module mock containing the members
fake_settings = self.mock.module(members=fake_state)

# 4. Install the mocked settings
self.mock.install('myapp.api.http.settings', fake_settings)


class PreventRedisIO(MockfulBehaviorDefinition):
context_namespace = 'redis'

def setup(self):
self.connection = self.mock.simple('redis-connection-instance')
self.mock.install('myapp.api.http.RedisConnection', argname='connection', return_value=self.connection)

class PreventSQLIO(MockfulBehaviorDefinition):
context_namespace = 'sql'

def setup(self):
self.engine = self.mock.simple('sqlalchemy.engine')
self.connection = self.engine.return_value
self.mock.install('myapp.api.http.sqlengine', return_value=self.engine)
self.scenario.forward_argument(connection=self.connection)


class IOSafeAPIServer(BehaviorGroup):
layers = [
FakeSettings(SESSION_SECRET='dummy'),
PreventRedisIO(),
PreventSQLIO()
]


@apply_behavior(IOSafeAPIServer)
def test_api_get_user(context, connection, ):
('APIServer.handle_get_user() should try to retrieve from the redis cache first')

# Given a server
api = APIServer()

# When I call .handle_get_user()
response = api.handle_get_user(user_uuid='b1i6c00010ff1ceb00dab00b')

# Then it should have returned a response
response.should.be.a('flask.Response')

0 comments on commit c870a44

Please sign in to comment.