Testing a Small Query Language in Python with Hypothesis

Hypothesis is an implementation of Property-based testing for Python, similar to QuickCheck in Haskell/Erlang and test.check in Clojure (among others). Basically, it allows the programmer to formulate invariants about their programs, and have an automated system attempt to generate counter-examples that invalidates them.

A Small Query Language

During my internship at CERN, I am developing a small (partial) two-way monitoring system to propagate alerts from storage filers to CERN’s incident management system. In the course of developing this monitor, I decided to invent a very minimal query/filtering language for logging events. It maps directly against Python objects using regular expressions (basically: “does object x have a property y matching regex z”?). The following is its grammar (written for the Grako parser-generator):

start = expression ;

expression
        =
        '(' expression ')' binary_operator '(' expression ')'
        | unary_operator '(' expression ')'
        | statement
       ;

binary_operator = 'AND' | 'OR';
unary_operator = 'NOT';
statement = field ':' "'" regex "'";
field =
      /[0-9A-Za-z_]+/
      ;

regex
    = /([^'])*/
    ;

An example (from a test configuration file) could be event_type:'disk.failed' (disk failures) or (source_type:'(?i)Aggregate') AND (NOT(source_name:'aggr0')) (log events from aggregates, but not aggr0).

The following invariants should hold, where q is any valid query:

  • NOT(NOT(q)) ≡ q
  • (q) AND (q) ≡ q
  • (q) OR (q) ≡ q
  • (q) OR (NOT(q)) is always True

In addition, the following properties should also hold:

  • key:'value' matches every object containing a property key with exact value value for any valid values of key and value (that is, valid Python variable names for key and more or less any string for value
  • key:'' matches every object that has an attribute key regardless of its value

Generating Examples

There are several types of inputs we need to generate to test the system. Let’s break them down:

  • objects with various fields
  • regular expressions
  • valid statements
  • valid queries

Let’s start from the top. As Python is a dynamic language, we can do crazy things, like dynamically generating objects from dictionaries. The following is a fairly common hack:

  1. class objectview(object):
  2. def __init__(self, d):
  3. self.__dict__ = d
  4.  
  5. def __repr__(self):
  6. return str(self.__dict__)
  7.  
  8. def __str__(self):
  9. return str(self.__dict__)

This allows us to instantiate an object with (almost) arbitrary fields:

  1. cat = objectview({'colour': 'red', 'fav_food_barcode': '1941230190'})
  2. >>> cat.colour
  3. 'red'
  4. >>> cat.fav_food_barcode
  5. '1941230190'

Given this, we can just generate valid objects using the @composite decorator in Hypothesis:

  1. @composite
  2. def objects(draw):
  3. ds = draw(dictionaries(keys=valid_properties,
  4. values=valid_values,
  5. min_size=1))
  6.  
  7. return objectview(ds)

Generating valid values is much simpler:

  1. valid_values = text()

Any text string is a valid string value. Of course! Properties are a bit trickier though:

  1. valid_properties = (characters(max_codepoint=91,
  2. whitelist_categories=["Ll", "Lu", "Nd"])
  3. .filter(lambda s: not s[0].isdigit()))

Variable names can’t start with a number, and has to be basically mostly ASCII, so we slightly modify and filter the characters strategy.

Statements can be generated in much the same way, using composite strategies:

  1. @composite
  2. def statements(draw):
  3. # any valid key followed by a valid regex
  4. key = draw(valid_properties)
  5. regex = draw(regexes)
  6.  
  7. return u"{key}:'{regex}'".format(key=key, regex=regex)

However, how do we produce regular expressions? Let’s start with some valid candidates:

  1. regex_string_candidates = characters(blacklist_characters=[u'?', u'\\', u"'"])

Then we can generate regular expressions using Hypothesis’ back-tracking functionality through assume(), which causes it to discard bad examples (in this instance is_valid_regex() simply tries to compile the string as a Python regular expression, and returns False if it fails):

  1. @composite
  2. def regex_strings(draw):
  3. maybe_regex = draw(regex_string_candidates)
  4. assume(is_valid_regex(maybe_regex))
  5. return maybe_regex

But we can also use recursive generation strategies to produce more complex regular expressions:

  1. regexes = recursive(regex_strings(), lambda subexps:
  2. # match one or more
  3. subexps.map(lambda re: u"({re})+".format(re=re)) |
  4.  
  5. # match zero or more
  6. subexps.map(lambda re: u"({re})*".format(re=re)) |
  7.  
  8. # Append "match any following"
  9. subexps.map(lambda re: u"{re}.*".format(re=re)) |
  10.  
  11. # Prepend "match any following"
  12. subexps.map(lambda re: u".*{re}".format(re=re)) |
  13.  
  14. # Prepend start of string
  15. subexps.map(lambda re: u"^{re}".format(re=re)) |
  16.  
  17. # Append end of string
  18. subexps.map(lambda re: u"{re}$".format(re=re)) |
  19.  
  20. # Append escaped backslash
  21. subexps.map(lambda re: u"{re}\\\\".format(re=re)) |
  22.  
  23. # Append escaped parenthesis
  24. subexps.map(lambda re: u"{re}\(".format(re=re)) |
  25.  
  26. # Append dot
  27. subexps.map(lambda re: u"{re}.".format(re=re)) |
  28.  
  29. # Match zero or one
  30. subexps.map(lambda re: u"({re})?".format(re=re)) |
  31.  
  32. # Match five to six occurrences
  33. subexps.map(lambda re: (u"({re})"
  34. .format(re=re)) + u"{5,6}") |
  35.  
  36. # concatenate two regexes
  37. tuples(subexps, subexps).map(lambda res: u"%s%s" % res) |
  38.  
  39. # OR two regexes
  40. tuples(subexps, subexps).map(lambda res: u"%s|%s" % res))

The same strategy also works for the highly recursive structure of the query language:

  1. queries = recursive(statements(),
  2. lambda subqueries:
  3. subqueries.map(negated_query) |
  4. tuples(subqueries, subqueries).map(ored_queries) |
  5. tuples(subqueries, subqueries).map(anded_queries))

Read as: “a valid query is any statement, or a any valid query negated, or two valid queries AND:ed or OR:ed”.

Making Assertions

To finally assert properties, we assert things similarly to how we would in normal unit tests. For example, let’s verify that the empty regular expression matches anything:

  1. @given(target=objects(), key=valid_properties)
  2. def test_query_for_empty_regex_always_matches(target, key):
  3. q = "{key}:''".format(key=key)
  4. assert query.matches_object(q, target)

Hypothesis immediately finds a counter-example:

>       assert query.matches_object(q, target)
E       assert False
E        +  where False = <function matches_object at 0x7fa76dc6f5f0>("A:''", {u'B': u''})
E        +    where <function matches_object at 0x7fa76dc6f5f0> = query.matches_object

key        = 'A'
q          = "A:''"
target     = {u'B': u''}

syncd/eql/test/test_hypothesis.py:188: AssertionError
---------- Hypothesis ---------
Falsifying example: test_query_for_empty_regex_always_matches(target={u'B': u''}, key=u'A')

An object which doesn’t have the specified property will not match the query, even if the query is looking for the empty string. Ok, so that’s a bad example depending on how we want to treat this edge-case. If we really did want the empty regular expression to match even objects which does not have their keys, this would have been a proper bug in the implementation. However, it makes more sense to require the object to have the property checked for, and so this is a bad counter-example. We can exclude it by adding assume(hasattr(target, key)) to the test, causing it to back-track on any examples where the target object does not have the key:

  1. @given(target=objects(), key=valid_properties)
  2. def test_query_for_empty_regex_always_matches(target, key):
  3. assume(hasattr(target, key))
  4.  
  5. q = "{key}:''".format(key=key)
  6.  
  7. assert query.matches_object(q, target)

Add new comment

You are here