Skip to content

Instantly share code, notes, and snippets.

@gfranxman
Forked from ericmoritz/.gitignore
Created January 25, 2014 21:02
Show Gist options
  • Save gfranxman/8623560 to your computer and use it in GitHub Desktop.
Save gfranxman/8623560 to your computer and use it in GitHub Desktop.

look at example.py; run "make test" to verify I'm not full of shit.

"""
Dummy database for demostration purposes
"""
sections = {
"home": [1,2,3]
}
stories = {
1: "story-1",
2: "story-2",
3: "story-3"
}
def get_section(key):
return sections.get(key)
def get_story(key):
return stories.get(key)
"""I will attempt to explain Monads without using the word Monad again.
This was inspired by a discussion about simplifying unit testing
by minimizing logical branches per unittest.
Starting with a imaginary web framework we attempted to wrangle a complex
view function.
Starting with this view:
"""
import db
import template
import test
def view(section_key):
section = db.get_section(section_key)
if section:
stories = []
for story_id in section:
stories.append(
db.get_story(story_id)
)
c = {"stories": stories}
return template.render("section", c)
else:
return template.render("404", {})
# Two outcomes:
test.expect(
"['story-1', 'story-2', 'story-3']",
view("home")
)
test.expect(
"404 Not Found",
view("not-here")
)
"""
If we identify which expressions are impure, we can
identify where we would have to place mocks
"""
def view(section_key):
# impure
section = db.get_section(section_key)
# impure
if section:
# impure
stories = []
for story_id in section:
stories.append(
db.get_story(story_id)
)
# pure
c = {"stories": stories}
# impure
return template.render("section", c)
else:
# impure
return template.render("404", {})
"""
By identifying the impure expressions, we've identified where the
mocks need to go:
db.get_section
db.get_story
template.render
That is quite a few mocks; this is a complex function to test; lets simplify
our units:
"""
def _fetch_section(section_key):
return db.get_section(section_key)
db.get_section = test.mock(
# mock function to call
lambda key: [1,2],
# assert that it is called with this:
"fake-section-key"
)
test.expect([1,2], _fetch_section("fake-section-key"))
reload(db) # reset mocks
def _fetch_stories(section):
return [
db.get_story(story_id)
for story_id in section
]
db.get_story = test.mock(lambda key: {1: "s-1", 2: "s-2"}.get(key))
test.expect(["s-1", "s-2"], _fetch_stories([1,2]))
reload(db) # reset mocks
def _context(stories):
return {
"stories": stories
}
test.expect({"stories": "!stories!"}, _context("!stories!"))
def _render_section(context):
return template.render("section", context)
template.render = test.mock(lambda t,c: "!render!", "section", "!context!")
test.expect("!render!", _render_section("!context!"))
reload(template) # reset mocks
def view(request):
section = _fetch_section(request.GET['section']) # impure
if section:
stories = _fetch_stories(section) # impure
context = _context(stories) # pure
return _render_section(context) #impure
else:
return template.render(404, {})
""""
We're at the point where we have to test our view again.
We've abstracted our pure and impure expressions to make mocking
easier, but damn it, we have to mock our abstracted functions to
test that the if statement works correctly.
We want to get to the point of removing all logical branches from our
view function to avoid having to retest the sequence of operations.
We know that each step in the view works correctly because we tested them.
Mocking the entire view to test an if-statement is ridiculous
If we make our operations return something or None, we can chain them together
with a simple pattern.
The pattern looks like this:
def foo(value):
if value is not None:
return op(value)
This pattern takes an input that can be something or None and does
some operation on if it is not None.
Lets rewrite our abstracted functions using this pattern:
"""
def _fetch_section(section_key):
return db.get_section(section_key)
db.get_section = test.mock(lambda key: [1,2], "fake-section-key")
test.expect([1,2], _fetch_section("fake-section-key"))
# Ensure that if db.get_section returns None, we return None
db.get_section = test.mock(lambda key: None, "fake-section-key")
test.expect(None, _fetch_section("fake-section-key"))
reload(db) # reset mocks
def _fetch_stories(section):
if section:
return [
db.get_story(story_id)
for story_id in section
]
db.get_story = test.mock(lambda key: {1: "s-1", 2: "s-2"}.get(key))
test.expect(["s-1", "s-2"], _fetch_stories([1,2]))
# Return None if we get None
db.get_story = test.mock(lambda key: None)
test.expect(None, _fetch_stories(None))
reload(db) # reset mocks
def _context(stories):
if stories:
return {
"stories": stories
}
test.expect({"stories": "!stories!"}, _context("!stories!"))
# Return None if we get None
test.expect(None, _context(None))
def _render_section(context):
if context:
return template.render("section", context)
else:
return template.render("404", {})
template.render = test.mock(lambda t,c: "!render!", "section", "!context!")
test.expect("!render!", _render_section("!context!"))
# render 404 if we get None
template.render = test.mock(lambda t,c: "!render!", "404", {})
test.expect("!render!", _render_section(None))
reload(template) # reset mocks
def view(section_key):
section = _fetch_section(section_key)
stories = _fetch_stories(section)
context = _context(stories)
return _render_section(context)
"""Using this pattern, we move the decision to render a template or a 404
to the final step. Only if all operations return something, will we render
a template, otherwise we render a 404.
We've made the original complex view function into a sequence of operations.
These operations are tested in isolation using unit testing.
Let us prove that the view still functions as it did before:
"""
def view(section_key):
section = _fetch_section(section_key)
stories = _fetch_stories(section)
context = _context(stories)
return _render_section(context)
test.expect(
"['story-1', 'story-2', 'story-3']",
view("home")
)
test.expect(
"404 Not Found",
view("not-here")
)
"""Because we have tested each step in the sequence, testing the
sequence is less necessary.
If this view functions correctly once, it will continue to work
correctly as long as the unit test continue to pass.
We have a new problem however. All our steps have logical
branches. That means that we have two tests per unit for each branch.
Luckily, we have a pattern and patterns can be abstracted:
"""
def call_me_maybe(val, f):
if val:
return f(val)
def my_fun(val):
test.expect("in", val)
return "yes!"
test.expect(
"yes!",
call_me_maybe("in", my_fun)
)
def you_better_not_callme(val):
raise Exception("I told you so.")
test.expect(
None,
call_me_maybe(None, you_better_not_callme)
)
"""
Excellent, now we can return our functions back to their original form and remove the tests for None.
"""
def _fetch_section(section_key):
return db.get_section(section_key)
db.get_section = test.mock(lambda key: [1,2], "fake-section-key")
test.expect([1,2], _fetch_section("fake-section-key"))
reload(db) # reset mocks
def _fetch_stories(section):
return [
db.get_story(story_id)
for story_id in section
]
db.get_story = test.mock(lambda key: {1: "s-1", 2: "s-2"}.get(key))
test.expect(["s-1", "s-2"], _fetch_stories([1,2]))
reload(db) # reset mocks
def _context(stories):
return {
"stories": stories
}
test.expect({"stories": "!stories!"}, _context("!stories!"))
def _render_section(context):
if context:
return template.render("section", context)
else:
return template.render("404", {})
template.render = test.mock(lambda t,c: "!render!", "section", "!context!")
test.expect("!render!", _render_section("!context!"))
# render 404 if we get None
template.render = test.mock(lambda t,c: "!render!", "404", {})
test.expect("!render!", _render_section(None))
reload(template) # reset mocks
def view(section_key):
section = call_me_maybe(section_key, _fetch_section)
stories = call_me_maybe(section, _fetch_stories)
context = call_me_maybe(stories, _context)
return _render_section(context)
# Does it still work?
test.expect(
"['story-1', 'story-2', 'story-3']",
view("home")
)
test.expect(
"404 Not Found",
view("not-here")
)
"""Beautiful. We have chained everything together and made our
unittests simpiler by removing the logical branch.
The only function that needs the if statement is _render_section()
because that is where we decide to render a 404 or a section.
We're approaching a level of abstraction that we'll find the Zen of
Python will tell us to stop; The call_me_maybe function is approaching
non-obvious.
We should stop but we won't because we are calling every damn
function, every time. Can we short-circuit the sequence and return
None?
We'll we could but it isn't going to look pretty.
"""
def view(section_key):
context = call_me_maybe(
_fetch_section(section_key),
lambda section: call_me_maybe(
_fetch_stories(section),
lambda stories: _context(stories)
)
)
return _render_section(context)
# Does it still work?
test.expect(
"['story-1', 'story-2', 'story-3']",
view("home")
)
test.expect(
"404 Not Found",
view("not-here")
)
"""OMGWTF; Yeah, you're right. OMFWTF it is.
I'll let you unravel that nested node.js like pyramid of doom.
Get it yet? I'll wait longer...
Ok, I've waited long enough. A value goes into call_me_maybe and if
it is not None, call_me_maybe calls the callback. If the value is
none, call_me_maybe doesn't call the callback and the whole sequence stops.
Should we do these nested callbacks in Python? Not if you want the
scorn of your peers. In Python, it is likely better to use the call_me_maybe
function directly and live without short-circuiting or write a clever way
to abstract it away.
A clever way to abstract it away is exactly what Haskell does. Haskell's do
keyword transforms what looks like this:
do
section = fetch_section(section_key)
stories = fetch_stories(section)
context = context(stories)
render_section(context)
Into the pyramid of doom I wrote in Python.
If you have ever read anything about Monads you know they have a bind
function; The bind function is the glue between the steps.
Our call_me_maybe function is a port of the Haskell's
Maybe monad bind function. We used the call_me_maybe function to glue
our steps together.
That glue could be anything, and there are a number of Monads in
Haskell to glue things together.
The Maybe monad, and my implementation of its bind function is really helpful
in gluing together a sequence of steps together.
"""
print "all tests passed"
test:
python example.py
"""
Dummy template engine for demonstration purposes
"""
templates = {
"section": "{stories!r}",
"404": "404 Not Found"
}
def render(template, context):
return templates[template].format(**context)
def expect(expected, value):
assert expected == value, "{0!r} != {1!r}".format(expected, value)
def mock(return_cb, *expected_args, **expected_kwargs):
def mocked(*call_args, **call_kwargs):
if expected_args:
expect(expected_args, call_args)
if expected_kwargs:
expect(expected_kwargs, call_kwargs)
return return_cb(*call_args, **call_kwargs)
return mocked
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment