Skip to content

Instantly share code, notes, and snippets.

@pablochacin
Last active July 6, 2019 12:56
Show Gist options
  • Save pablochacin/ed48bd27e09932a0c997821f1a6fffc8 to your computer and use it in GitHub Desktop.
Save pablochacin/ed48bd27e09932a0c997821f1a6fffc8 to your computer and use it in GitHub Desktop.
A minimalist BDD framework with fluent api
# Fluent BDD
Fluent BDD is a minimalistic testing framework with a fluent API.
A Scenario is defined as a sequence of Conditions, followed by
a series of Events and the Clauses that must be satiesfied at
the end. Multiple Conditions, Events and Clauses can be specified.
## Fluent API
The API for defining a test scenario follows a fluent style.
Function calls can be chanined in a close-to-english syntax.
Example:
```
Scenario("Test scenario").Given(f1, 1, 'A').When(f2,1, a=3, b=2).Then(f3).Is(True).Run()
```
Multiple Conditions, Events and Clauses can be chained using the 'And' connector:
Example:
```
Scenario("Multiple conditions") \
.Given(f1, 1, 'B').And(f1, 1, 'A') \
.When(f2, a=3, b=1) \
.Then(f3).Is(True) \
.Run()
## Function invocation
Actions, Events and Clauses receive a function as first parameter,
followed by a (potentially empty) sequence of arguments, and
an optional series of named arguments.
The functions used in the statements can be static methods or instance method.
Example:
```
class SUT:
def f(self):
return
def v(self):
return True
def verify(sut):
return sut.v()
s = SUT()
Scenario("Class methods").when(s.f).then(verify,s).Is(True).Run()
```
## Clauses and assertions
Clauses are defined as a invocation of a function followed by an assertion on the returning value.
Supported assertions are:
* Is(value)
* IsNot(value)
## Reusing scenarios
In some cases, multiple scenarios share the same Conditions or values. In that case,
it is possible to define a base scenario and use it as a basis for other scenarios.
Events, and Clauses, can also be copied.
For reusing a scenario, it must be built (not executed)
Example:
```
base = Scenario("Base") \
.Given(f,a, b) \
.Given(g,1) \
.Build()
Scenario("Test 1") \
.WithConditionsFrom(base) \
.When(f1,'c') \
.Then(f2).Is(True) \
.Run()
Scenario("Test 2") \
.WithConditionsFrom(base) \
.When(f,'a') \
.Then(f2).Is(False) \
.Run()
```
## Using values
Sometimes is convenient to execute the same test Scenario with multiple arguments for
the different statements. The `WithValues` function allows setting a list of named
parameters which can be used in any statement. Named arguments are references using
its name enclosed in '{}'.
Example:
```
Scenario("With Values") \
.AsVerbose()
.WithValues(('a', 'b','c','d'), \
[(1,2,True,True), \
(2,3,True,False)]) \
.Given(sut.f0) \
.When(sut.f2, '{a}', 'b') \
.Then(echo, '{c}').Is('{d}') \
.Run()
Executing 'With Values' with values {'a': 1, 'b': 2, 'c': True, 'd': True}
Executing condition f0()
Executing event f2(1,b)
Executing clause echo(True)
Scenario 'With Values' success
Executing 'With Values' with values {'a': 2, 'b': 3, 'c': True, 'd': False}
Executing condition f0()
Executing event f2(2,b)
Executing clause echo(True)
Scenario 'With Values' failed: Clause echo(True) failed with error: assertion Is(True,False) failed
```
Notice the function `f2` is called with the literal `b` as second argument while
the first argument `{a}` is replaced on each execution with the correspoding
value.
(c) 2019 Pablo Chacin
class Scenario:
class Condition:
def __init__(self, scenario):
self.scenario = scenario
def And(self, condition, *args, **kwargs):
self.scenario.add_condition(condition, args, kwargs)
return Scenario.Condition(self.scenario)
def When(self, event, *args, **kwargs):
self.scenario.add_event(event, args, kwargs)
return Scenario.Event(self.scenario)
def Build(self):
return self.scenario
class Event:
def __init__(self, scenario):
self.scenario = scenario
def And(self, event, *args, **kwargs):
self.scenario.add_event(event, args, kwargs)
return Scenario.Event(self.scenario)
def Then(self, clause, *args, **kwargs):
return Scenario.OpenClause(self.scenario, clause, args, kwargs)
def Build(self):
return self.scenario
class OpenClause:
def __init__(self, scenario, clause, args, kwargs):
self.scenario = scenario
self.clause = clause
self.args = args
self.kwargs = kwargs
def Is(self, value):
self.scenario.add_clause(self.clause, self.args, self.kwargs, Scenario.Assertion.Is, value)
return Scenario.Clause(self.scenario)
def IsNot(self, value):
self.scenario.add_clause(self.clause, self.args, self.kwargs, Scenario.Assertion.IsNot, value)
return Scenario.Clause(self.scenario)
class Clause:
def __init__(self, scenario):
self.scenario = scenario
def And(self, clause, *args, **kwargs):
return Scenario.OpenClause(self.scenario, clause, args, kwargs)
def Run(self):
self.scenario.Run()
def Build(self):
return self.scenario
class Assertion:
@staticmethod
def Is(expected, actual):
return expected == actual
@staticmethod
def IsNot(expected, actual):
return expected != actual
def __init__(self, title):
self.title = title
self.conditions = []
self.events = []
self.clauses = []
self.values = []
def add_condition(self, condition, args, kwargs):
if not condition.__call__:
raise ValueError("Condition must be a callable")
self.conditions.append((condition, args, kwargs))
def add_event(self, event, args, kwargs):
if not event.__call__:
raise ValueError("Condition must be a callable")
self.events.append((event, args, kwargs))
def add_clause(self, clause, args, kwargs, assertion, value):
if not clause.__call__:
raise ValueError("Condition must be a callable")
self.clauses.append((clause, args, kwargs, assertion, value))
def Given(self, condition, *args, **kwargs):
self.add_condition(condition, args, kwargs)
return Scenario.Condition(self)
def When(self, event, *args, **kwargs):
self.add_event(event, args, kwargs)
return Scenario.Event(self)
def WithConditionsFrom(self, parent):
if not isinstance(parent, Scenario):
raise ValueError("Parent must be a scenario")
self.conditions.extend(parent.conditions)
return self
def WithEventsFrom(self, parent):
if not isinstance(parent, Scenario):
raise ValueError("Parent must be a scenario")
self.events.extend(parent.events)
return self
def WithClausesFrom(self, parent):
if not isinstance(parent, Scenario):
raise ValueError("Parent must be a scenario")
self.clauses.extend(parent.clauses)
return self
def Build(self):
return self
def WithValues(self, arg_names, values):
for index, args in enumerate(values):
if len(arg_names) != len(args):
raise ValueError("Value tuple {} does not match number of argument names".format(index))
self.values = values
self.args_map = {}
for arg_pos, arg in enumerate(arg_names):
if not isinstance(arg, str):
raise ValueError("Value names must be strings")
if arg in self.args_map:
raise ValueError("Value names must be unique: {}".format(arg))
self.args_map[arg] = arg_pos
return self
def Run(self):
if self.values:
runs = len(self.values)
else:
runs = 1
for r in range(runs):
try:
self.current_run = r
if len(self.events) == 0:
raise ValueError("No events specified for scenario {}".format(self.title))
if len(self.clauses) == 0:
raise ValueError("No clauses specified for scenario {}".format(self.title))
for condition, args, kwargs in self.conditions:
try:
self._execute(condition, args, kwargs)
except Exception as ex:
condition_str = self._signature(condition, args, kwargs)
raise Exception("Condition {} Failed with error: {}".format(condition_str, ex))
for event, args, kwargs in self.events:
try:
self._execute(event, args, kwargs)
except Exception as ex:
event_str = self._signature(event, args, kwargs)
raise Exception("Event {} Failed with error: {}".format(event_str, ex))
for clause, args, kwargs, assertion, value in self.clauses:
clause_str = self._signature(clause, args, kwargs)
result = None
try:
result = self._execute(clause, args, kwargs)
except Exception as ex:
raise Exception("Clause {} Failed with error: {}".format(clause_str, ex))
assertion_value = self._get_value(value)
if not assertion(result, assertion_value):
raise Exception("Clause {} {} '{}' Actual '{}' ".format(clause_str, assertion.__name__, assertion_value, result))
print("Scenario {} success".format(self.title))
except Exception as ex:
print("Scenario {} failed: {}".format(self.title, ex.message))
finally:
self.current_run = 0
def _execute(self, func, args, kwargs):
args_values, kwargs_values = self._get_values(args, kwargs)
return func(*args_values, **kwargs_values)
def _get_value(self, arg):
if not self.values:
return arg
if arg not in self.args_map:
raise ValueError("Arg not defined {}".format(arg))
return self.values[self.current_run][self.args_map[arg]]
def _get_values(self, args, kwargs):
if not self.values:
return args, kwargs
args_values = []
for arg in args:
if not arg in self.args_map:
raise ValueError("Argument {} not defined".format(arg))
arg_pos = self.args_map[arg]
args_values.append(self.values[self.current_run][arg_pos])
kwargs_values = {}
for arg_name, arg_value in kwargs.items():
if not arg_name in self.args_map:
raise ValueError("Argument {} not defined".format(arg_name))
arg_pos = self.args_map[arg_value]
kwargs_values[arg_name] = self.values[0][arg_pos]
return tuple(args_values), kwargs_values
def _signature(self, function, args, kwargs):
args_values, kwargs_values = self._get_values(args, kwargs)
signature = "{}(".format(function.__name__)
if args:
signature = signature + (",".join(["%s"]*len(args_values)) % args_values)
if kwargs:
signature = signature + ','.join('{}={}'.format(k,v) for k,v in kwargs_values.items())
signature = signature+")"
return signature
from bdd import Scenario
def echo(a):
return a
class SUT:
def f0(self):
print("Executing f0()")
def f1(self, a):
print("Executing f1({})".format(a))
def f2(self, a, b):
print("Executing f2({},{})".format(a, b))
def fkw(self, a=None, b=None, c=None):
print("Executing fkw(a={}, b={}, c={})".format(a, b, c))
def fail(self):
print("Executing fail()")
raise Exception("Fail!")
def main():
sut = SUT()
Scenario("Success Test").Given(sut.f0).When(sut.f2, 1, 2).Then(echo, True).Is(True).Run()
Scenario("With Values").WithValues(('a', 'b','c'), [(1,2,True), (2,3,False)]).Given(sut.f0).When(sut.f2, 'a', 'b').Then(echo, 'c').Is('c').Run()
Scenario("No Conditions").When(sut.f2, 1, 2).Then(echo, True).Is(True).Run()
Scenario("Multiple Conditions").Given(sut.f0).And(sut.f1,'a').When(sut.f2, 1, 2).Then(echo, True).Is(True).Run()
Scenario("Kwargs").When(sut.fkw, a=1, b=2, c=3).Then(echo, True).Is(True).Run()
Scenario("Invalid Kwargs").When(sut.fkw, a=1, b=2, d=3).Then(echo, True).Is(True).Run()
parent = Scenario("Parent").Given(sut.f0).And(sut.f2,'a','b').Build()
Scenario("Copying Conditions").WithConditionsFrom(parent).When(sut.f2, 1, 2).Then(echo, True).Is(True).Run()
Scenario("Failing Assertion").Given(sut.f0).When(sut.f2, 1, 2).Then(echo, False).IsNot(False).Run()
Scenario("Failing condition").Given(sut.f0).When(sut.fail).Then(echo, True).Is(True).Run()
Scenario("Wrong function arity").Given(sut.f0).When(sut.f1,1,2).Then(echo, True).Is(True).Run()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment