Make user_track use eventtracking
This commit is contained in:
0
openedx/core/lib/tests/__init__.py
Normal file
0
openedx/core/lib/tests/__init__.py
Normal file
0
openedx/core/lib/tests/assertions/__init__.py
Normal file
0
openedx/core/lib/tests/assertions/__init__.py
Normal file
251
openedx/core/lib/tests/assertions/events.py
Normal file
251
openedx/core/lib/tests/assertions/events.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Assertions related to event validation"""
|
||||
|
||||
import json
|
||||
import pprint
|
||||
|
||||
|
||||
def assert_event_matches(expected, actual, tolerate=None):
|
||||
"""
|
||||
Compare two event dictionaries.
|
||||
|
||||
Fail if any discrepancies exist, and output the list of all discrepancies. The intent is to produce clearer
|
||||
error messages than "{ some massive dict } != { some other massive dict }", instead enumerating the keys that
|
||||
differ. Produces period separated "paths" to keys in the output, so "context.foo" refers to the following
|
||||
structure:
|
||||
|
||||
{
|
||||
'context': {
|
||||
'foo': 'bar' # this key, value pair
|
||||
}
|
||||
}
|
||||
|
||||
The other key difference between this comparison and `assertEquals` is that it supports differing levels of
|
||||
tolerance for discrepancies. We don't want to litter our tests full of exact match tests because then anytime we
|
||||
add a field to all events, we have to go update every single test that has a hardcoded complete event structure in
|
||||
it. Instead we support making partial assertions about structure and content of the event. So if I say my expected
|
||||
event looks like this:
|
||||
|
||||
{
|
||||
'event_type': 'foo.bar',
|
||||
'event': {
|
||||
'user_id': 10
|
||||
}
|
||||
}
|
||||
|
||||
This method will raise an assertion error if the actual event either does not contain the above fields in their
|
||||
exact locations in the hierarchy, or if it does contain them but has different values for them. Note that it will
|
||||
*not* necessarily raise an assertion error if the actual event contains other fields that are not listed in the
|
||||
expected event. For example, the following event would not raise an assertion error:
|
||||
|
||||
{
|
||||
'event_type': 'foo.bar',
|
||||
'referer': 'http://example.com'
|
||||
'event': {
|
||||
'user_id': 10
|
||||
}
|
||||
}
|
||||
|
||||
Note that the extra "referer" field is not considered an error by default.
|
||||
|
||||
The `tolerate` parameter takes a set that allows you to specify varying degrees of tolerance for some common
|
||||
eventing related issues. See the `EventMatchTolerates` class for more information about the various flags that are
|
||||
supported here.
|
||||
|
||||
Example output if an error is found:
|
||||
|
||||
Unexpected differences found in structs:
|
||||
|
||||
* <path>: not found in actual
|
||||
* <path>: <expected_value> != <actual_value> (expected != actual)
|
||||
|
||||
Expected:
|
||||
{ <expected event }
|
||||
|
||||
Actual:
|
||||
{ <actual event> }
|
||||
|
||||
"<path>" is a "." separated string indicating the key that differed. In the examples above "event.user_id" would
|
||||
refer to the value of the "user_id" field contained within the dictionary referred to by the "event" field in the
|
||||
root dictionary.
|
||||
"""
|
||||
differences = get_event_differences(expected, actual, tolerate=tolerate)
|
||||
if len(differences) > 0:
|
||||
debug_info = [
|
||||
'',
|
||||
'Expected:',
|
||||
block_indent(expected),
|
||||
'Actual:',
|
||||
block_indent(actual),
|
||||
'Tolerating:',
|
||||
block_indent(EventMatchTolerates.default_if_not_defined(tolerate)),
|
||||
]
|
||||
differences = ['* ' + d for d in differences]
|
||||
message_lines = differences + debug_info
|
||||
raise AssertionError('Unexpected differences found in structs:\n\n' + '\n'.join(message_lines))
|
||||
|
||||
|
||||
class EventMatchTolerates(object):
|
||||
"""
|
||||
Represents groups of flags that specify the level of tolerance for deviation between an expected event and an actual
|
||||
event.
|
||||
|
||||
These are common event specific deviations that we don't want to handle with special case logic throughout our
|
||||
tests.
|
||||
"""
|
||||
|
||||
# Allow the "event" field to be a string, currently this is the case for all browser events.
|
||||
STRING_PAYLOAD = 'string_payload'
|
||||
|
||||
# Allow unexpected fields to exist in the top level event dictionary.
|
||||
ROOT_EXTRA_FIELDS = 'root_extra_fields'
|
||||
|
||||
# Allow unexpected fields to exist in the "context" dictionary. This is where new fields that appear in multiple
|
||||
# events are most commonly added, so we frequently want to tolerate variation here.
|
||||
CONTEXT_EXTRA_FIELDS = 'context_extra_fields'
|
||||
|
||||
# Allow unexpected fields to exist in the "event" dictionary. Typically in unit tests we don't want to allow this
|
||||
# type of variance since there are typically only a small number of tests for a particular event type.
|
||||
PAYLOAD_EXTRA_FIELDS = 'payload_extra_fields'
|
||||
|
||||
@classmethod
|
||||
def default(cls):
|
||||
"""A reasonable set of tolerated variations."""
|
||||
# NOTE: "payload_extra_fields" is deliberately excluded from this list since we want to detect erroneously added
|
||||
# fields in the payload by default.
|
||||
return {
|
||||
cls.STRING_PAYLOAD,
|
||||
cls.ROOT_EXTRA_FIELDS,
|
||||
cls.CONTEXT_EXTRA_FIELDS,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def lenient(cls):
|
||||
"""Allow all known variations."""
|
||||
return cls.default() | {
|
||||
cls.PAYLOAD_EXTRA_FIELDS
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def strict(cls):
|
||||
"""Allow no variation at all."""
|
||||
return frozenset()
|
||||
|
||||
@classmethod
|
||||
def default_if_not_defined(cls, tolerates=None):
|
||||
"""Use the provided tolerance or provide a default one if None was specified."""
|
||||
if tolerates is None:
|
||||
return cls.default()
|
||||
else:
|
||||
return tolerates
|
||||
|
||||
|
||||
def assert_events_equal(expected, actual):
|
||||
"""
|
||||
Strict comparison of two events.
|
||||
|
||||
This asserts that every field in the real event exactly matches the expected event.
|
||||
"""
|
||||
assert_event_matches(expected, actual, tolerate=EventMatchTolerates.strict())
|
||||
|
||||
|
||||
def get_event_differences(expected, actual, tolerate=None):
|
||||
"""Given two events, gather a list of differences between them given some set of tolerated variances."""
|
||||
tolerate = EventMatchTolerates.default_if_not_defined(tolerate)
|
||||
|
||||
# Some events store their payload in a JSON string instead of a dict. Comparing these strings can be problematic
|
||||
# since the keys may be in different orders, so we parse the string here if we were expecting a dict.
|
||||
if EventMatchTolerates.STRING_PAYLOAD in tolerate:
|
||||
expected = parse_event_payload(expected)
|
||||
actual = parse_event_payload(actual)
|
||||
|
||||
def should_strict_compare(path):
|
||||
"""
|
||||
We want to be able to vary the degree of strictness we apply depending on the testing context.
|
||||
|
||||
Some tests will want to assert that the entire event matches exactly, others will tolerate some variance in the
|
||||
context or root fields, but not in the payload (for example).
|
||||
"""
|
||||
if path == [] and EventMatchTolerates.ROOT_EXTRA_FIELDS in tolerate:
|
||||
return False
|
||||
elif path == ['event'] and EventMatchTolerates.PAYLOAD_EXTRA_FIELDS in tolerate:
|
||||
return False
|
||||
elif path == ['context'] and EventMatchTolerates.CONTEXT_EXTRA_FIELDS in tolerate:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
return compare_structs(expected, actual, should_strict_compare=should_strict_compare)
|
||||
|
||||
|
||||
def block_indent(text, spaces=4):
|
||||
"""
|
||||
Given a multi-line string, indent every line of it by the given number of spaces.
|
||||
|
||||
If `text` is not a string it is formatted using pprint.pformat.
|
||||
"""
|
||||
return '\n'.join([(' ' * spaces) + l for l in pprint.pformat(text).splitlines()])
|
||||
|
||||
|
||||
def parse_event_payload(event):
|
||||
"""
|
||||
Given an event, parse the "event" field as a JSON string.
|
||||
|
||||
Note that this may simply return the same event unchanged, or return a new copy of the event with the payload
|
||||
parsed. It will never modify the event in place.
|
||||
"""
|
||||
if 'event' in event and isinstance(event['event'], basestring):
|
||||
event = event.copy()
|
||||
try:
|
||||
event['event'] = json.loads(event['event'])
|
||||
except ValueError:
|
||||
pass
|
||||
return event
|
||||
|
||||
|
||||
def compare_structs(expected, actual, should_strict_compare=None, path=None):
|
||||
"""
|
||||
Traverse two structures to ensure that the `actual` structure contains all of the elements within the `expected`
|
||||
one.
|
||||
|
||||
Note that this performs a "deep" comparison, descending into dictionaries, lists and ohter collections to ensure
|
||||
that the structure matches the expectation.
|
||||
|
||||
If a particular value is not recognized, it is simply compared using the "!=" operator.
|
||||
"""
|
||||
if path is None:
|
||||
path = []
|
||||
differences = []
|
||||
|
||||
if isinstance(expected, dict) and isinstance(actual, dict):
|
||||
expected_keys = frozenset(expected.keys())
|
||||
actual_keys = frozenset(actual.keys())
|
||||
|
||||
for key in expected_keys - actual_keys:
|
||||
differences.append('{0}: not found in actual'.format(_path_to_string(path + [key])))
|
||||
|
||||
if should_strict_compare is not None and should_strict_compare(path):
|
||||
for key in actual_keys - expected_keys:
|
||||
differences.append('{0}: only defined in actual'.format(_path_to_string(path + [key])))
|
||||
|
||||
for key in expected_keys & actual_keys:
|
||||
child_differences = compare_structs(expected[key], actual[key], should_strict_compare, path + [key])
|
||||
differences.extend(child_differences)
|
||||
|
||||
elif expected != actual:
|
||||
differences.append('{path}: {a} != {b} (expected != actual)'.format(
|
||||
path=_path_to_string(path),
|
||||
a=repr(expected),
|
||||
b=repr(actual)
|
||||
))
|
||||
|
||||
return differences
|
||||
|
||||
|
||||
def is_matching_event(expected_event, actual_event, tolerate=None):
|
||||
"""Return True iff the `actual_event` matches the `expected_event` given the tolerances."""
|
||||
return len(get_event_differences(expected_event, actual_event, tolerate=tolerate)) == 0
|
||||
|
||||
|
||||
def _path_to_string(path):
|
||||
"""Convert a list of path elements into a single path string."""
|
||||
return '.'.join(path)
|
||||
Reference in New Issue
Block a user