Skip to content

Commit

Permalink
a bit of progress towards a functional runner
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielfalcao committed Jul 8, 2023
1 parent f3b9611 commit 4862925
Show file tree
Hide file tree
Showing 13 changed files with 1,093 additions and 2 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ test tests: clean | $(VENV)/bin/pytest # $(VENV)/bin/nosetests # @$(VENV)/bin/no

# run main command-line tool
run: | $(MAIN_CLI_PATH)
@$(MAIN_CLI_PATH) --help
$(MAIN_CLI_PATH) run tests

# Pushes release of this package to pypi
push-release: dist # pushes distribution tarballs of the current version
Expand Down
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')
123 changes: 123 additions & 0 deletions examples/unit-tests/setup_and_teardown_with_behavior.py
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',
})

0 comments on commit 4862925

Please sign in to comment.