Skip to content

Commit 4653e07

Browse files
committed
make test failures inspectable; also, small cleanup
1 parent 11ffd37 commit 4653e07

File tree

2 files changed

+163
-47
lines changed

2 files changed

+163
-47
lines changed

unpythonic/syntax/testingtools.py

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from ..dynassign import dyn # for MacroPy's gen_sym
1616
from ..env import env
17-
from ..misc import callsite_filename, safeissubclass
17+
from ..misc import callsite_filename
1818
from ..conditions import cerror, handlers, restarts, invoke
1919
from ..collections import unbox
2020
from ..symbol import sym, gensym
@@ -57,20 +57,17 @@ def istestmacro(tree):
5757
_error = sym("_error") # used by the error[] macro
5858
_warn = sym("_warn") # used by the warn[] macro
5959

60-
_completed = sym("_completed") # thunk returned normally
61-
_signaled = sym("_signaled") # via unpythonic.conditions.signal and its sisters
62-
_raised = sym("_raised") # via raise
6360
def _observe(thunk):
6461
"""Run `thunk` and report how it fared.
6562
6663
Internal helper for implementing assert functions.
6764
6865
The return value is:
6966
70-
- `(_completed, return_value)` if the thunk completed normally
71-
- `(_signaled, condition_instance)` if a signal from inside
67+
- `(completed, return_value)` if the thunk completed normally
68+
- `(signaled, condition_instance)` if a signal from inside
7269
the dynamic extent of thunk propagated to this level.
73-
- `(_raised, exception_instance)` if an exception from inside
70+
- `(raised, exception_instance)` if an exception from inside
7471
the dynamic extent of thunk propagated to this level.
7572
"""
7673
def intercept(condition):
@@ -81,8 +78,7 @@ def intercept(condition):
8178
# it and let it fall through to the nearest enclosing `testset`, for
8279
# reporting. This can happen if a `test[]` is nested within a `with
8380
# test:` block, or if `test[]` expressions are nested.
84-
exctype = type(condition)
85-
if issubclass(exctype, fixtures.TestingException):
81+
if issubclass(type(condition), fixtures.TestingException):
8682
return # cancel and delegate to the next outer handler
8783
invoke("_got_signal", condition)
8884

@@ -92,12 +88,12 @@ def intercept(condition):
9288
ret = thunk()
9389
# We only reach this point if the restart was not invoked,
9490
# i.e. if thunk() completed normally.
95-
return _completed, ret
96-
return _signaled, unbox(sig)
91+
return fixtures.completed, ret
92+
return fixtures.signaled, unbox(sig)
9793
# This testing framework always signals, never raises, so we don't need any
9894
# special handling here.
9995
except Exception as err: # including ControlError raised by an unhandled `unpythonic.conditions.error`
100-
return _raised, err
96+
return fixtures.raised, err
10197

10298

10399
_unassigned = gensym("_unassigned") # runtime gensym / nonce value.
@@ -121,10 +117,11 @@ def unpythonic_assert(sourcecode, func, *, filename, lineno, message=None):
121117
`sourcecode` is a string representation of the source code expression
122118
that is being asserted.
123119
124-
`func` is the test itself, in the form a 1-argument function that
125-
takes as its only argument an `unpythonic.env`. (The `the[]`
126-
mechanism uses the `env` to store the value of the captured
127-
subexpression.)
120+
`func` is the test itself, as a 1-argument function that accepts
121+
as its only argument an `unpythonic.env`. The `the[]` mechanism
122+
uses this `env` to store the value of the captured subexpression.
123+
(It is also perfectly fine to not store anything there; the presence
124+
or absence of a captured value is detected automatically.)
128125
129126
The function should compute the desired test expression and return
130127
its value. If the result is falsey, the assertion fails.
@@ -134,7 +131,7 @@ def unpythonic_assert(sourcecode, func, *, filename, lineno, message=None):
134131
135132
`lineno` is the line number at the call site.
136133
137-
These are best extracted automatically using the `test[]` macro.
134+
These are best extracted automatically using the test macros.
138135
139136
`message` is an optional string, included in the generated error message
140137
if the assertion fails.
@@ -160,9 +157,11 @@ def unpythonic_assert(sourcecode, func, *, filename, lineno, message=None):
160157
custom_msg = ""
161158

162159
# special cases for unconditional failures
163-
if mode is _completed and test_result is _fail: # fail[...], e.g. unreachable line reached
160+
origin = "test"
161+
if mode is fixtures.completed and test_result is _fail: # fail[...], e.g. unreachable line reached
164162
fixtures._update(fixtures.tests_failed, +1)
165163
conditiontype = fixtures.TestFailure
164+
origin = "fail"
166165
if message is not None:
167166
# If a user-given message is specified for `fail[]`, it is all
168167
# that should be displayed. We don't want confusing noise such as
@@ -172,18 +171,20 @@ def unpythonic_assert(sourcecode, func, *, filename, lineno, message=None):
172171
error_msg = message
173172
else:
174173
error_msg = "Unconditional failure requested, no message."
175-
elif mode is _completed and test_result is _error: # error[...], e.g. dependency not installed
174+
elif mode is fixtures.completed and test_result is _error: # error[...], e.g. dependency not installed
176175
fixtures._update(fixtures.tests_errored, +1)
177176
conditiontype = fixtures.TestError
177+
origin = "error"
178178
if message is not None:
179179
error_msg = message
180180
else:
181181
error_msg = "Unconditional error requested, no message."
182-
elif mode is _completed and test_result is _warn: # warn[...], e.g. some test disabled for now
182+
elif mode is fixtures.completed and test_result is _warn: # warn[...], e.g. some test disabled for now
183183
fixtures._update(fixtures.tests_warned, +1)
184184
# HACK: warnings don't count into the test total
185185
fixtures._update(fixtures.tests_run, -1)
186186
conditiontype = fixtures.TestWarning
187+
origin = "warn"
187188
if message is not None:
188189
error_msg = message
189190
else:
@@ -195,18 +196,18 @@ def unpythonic_assert(sourcecode, func, *, filename, lineno, message=None):
195196
#
196197
# So we may as well use the same code path as the fail and error cases.
197198
# general cases
198-
elif mode is _completed:
199+
elif mode is fixtures.completed:
199200
if test_result:
200201
return
201202
fixtures._update(fixtures.tests_failed, +1)
202203
conditiontype = fixtures.TestFailure
203204
error_msg = "Test failed: {}, due to result = {}{}".format(sourcecode, value, custom_msg)
204-
elif mode is _signaled:
205+
elif mode is fixtures.signaled:
205206
fixtures._update(fixtures.tests_errored, +1)
206207
conditiontype = fixtures.TestError
207208
desc = fixtures.describe_exception(test_result)
208209
error_msg = "Test errored: {}{}, due to unexpected signal: {}".format(sourcecode, custom_msg, desc)
209-
else: # mode is _raised:
210+
else: # mode is fixtures.raised:
210211
fixtures._update(fixtures.tests_errored, +1)
211212
conditiontype = fixtures.TestError
212213
desc = fixtures.describe_exception(test_result)
@@ -221,72 +222,79 @@ def unpythonic_assert(sourcecode, func, *, filename, lineno, message=None):
221222
# If the client code does not install a handler, then a `ControlError`
222223
# exception is raised by the condition system; leaving a cerror unhandled
223224
# is an error.
224-
cerror(conditiontype(complete_msg))
225+
#
226+
# As well as forming an error message for humans, we provide the data
227+
# in a machine-readable format for run-time inspection.
228+
cerror(conditiontype(complete_msg, origin=origin, custom_message=message,
229+
filename=filename, lineno=lineno, sourcecode=sourcecode,
230+
mode=mode, result=test_result, captured_value=value))
225231

226232
def unpythonic_assert_signals(exctype, sourcecode, thunk, *, filename, lineno, message=None):
227233
"""Like `unpythonic_assert`, but assert that running `sourcecode` signals `exctype`.
228234
229235
"Signal" as in `unpythonic.conditions.signal` and its sisters `error`, `cerror`, `warn`.
230236
"""
231-
mode, result = _observe(thunk)
237+
mode, test_result = _observe(thunk)
232238
fixtures._update(fixtures.tests_run, +1)
233239

234240
if message is not None:
235241
custom_msg = ", with message '{}'".format(message)
236242
else:
237243
custom_msg = ""
238244

239-
if mode is _completed:
245+
if mode is fixtures.completed:
240246
fixtures._update(fixtures.tests_failed, +1)
241247
conditiontype = fixtures.TestFailure
242248
error_msg = "Test failed: {}{}, expected signal: {}, nothing was signaled.".format(sourcecode, custom_msg, fixtures.describe_exception(exctype))
243-
elif mode is _signaled:
244-
# allow both "signal(SomeError())" and "signal(SomeError)"
245-
if isinstance(result, exctype) or safeissubclass(result, exctype):
249+
elif mode is fixtures.signaled:
250+
if isinstance(test_result, exctype):
246251
return
247252
fixtures._update(fixtures.tests_errored, +1)
248253
conditiontype = fixtures.TestError
249-
desc = fixtures.describe_exception(result)
254+
desc = fixtures.describe_exception(test_result)
250255
error_msg = "Test errored: {}{}, expected signal: {}, got unexpected signal: {}".format(sourcecode, custom_msg, fixtures.describe_exception(exctype), desc)
251-
else: # mode is _raised:
256+
else: # mode is fixtures.raised:
252257
fixtures._update(fixtures.tests_errored, +1)
253258
conditiontype = fixtures.TestError
254-
desc = fixtures.describe_exception(result)
259+
desc = fixtures.describe_exception(test_result)
255260
error_msg = "Test errored: {}{}, expected signal: {}, got unexpected exception: {}".format(sourcecode, custom_msg, fixtures.describe_exception(exctype), desc)
256261

257262
complete_msg = "[{}:{}] {}".format(filename, lineno, error_msg)
258-
cerror(conditiontype(complete_msg))
263+
cerror(conditiontype(complete_msg, origin="test_signals", custom_message=message,
264+
filename=filename, lineno=lineno, sourcecode=sourcecode,
265+
mode=mode, result=test_result, captured_value=test_result))
259266

260267
def unpythonic_assert_raises(exctype, sourcecode, thunk, *, filename, lineno, message=None):
261268
"""Like `unpythonic_assert`, but assert that running `sourcecode` raises `exctype`."""
262-
mode, result = _observe(thunk)
269+
mode, test_result = _observe(thunk)
263270
fixtures._update(fixtures.tests_run, +1)
264271

265272
if message is not None:
266273
custom_msg = ", with message '{}'".format(message)
267274
else:
268275
custom_msg = ""
269276

270-
if mode is _completed:
277+
if mode is fixtures.completed:
271278
fixtures._update(fixtures.tests_failed, +1)
272279
conditiontype = fixtures.TestFailure
273280
error_msg = "Test failed: {}{}, expected exception: {}, nothing was raised.".format(sourcecode, custom_msg, fixtures.describe_exception(exctype))
274-
elif mode is _signaled:
281+
elif mode is fixtures.signaled:
275282
fixtures._update(fixtures.tests_errored, +1)
276283
conditiontype = fixtures.TestError
277-
desc = fixtures.describe_exception(result)
284+
desc = fixtures.describe_exception(test_result)
278285
error_msg = "Test errored: {}{}, expected exception: {}, got unexpected signal: {}".format(sourcecode, custom_msg, fixtures.describe_exception(exctype), desc)
279-
else: # mode is _raised:
280-
# allow both "raise SomeError()" and "raise SomeError"
281-
if isinstance(result, exctype) or safeissubclass(result, exctype):
286+
else: # mode is fixtures.raised:
287+
if isinstance(test_result, exctype):
282288
return
283289
fixtures._update(fixtures.tests_errored, +1)
284290
conditiontype = fixtures.TestError
285-
desc = fixtures.describe_exception(result)
291+
desc = fixtures.describe_exception(test_result)
286292
error_msg = "Test errored: {}{}, expected exception: {}, got unexpected exception: {}".format(sourcecode, custom_msg, fixtures.describe_exception(exctype), desc)
287293

288294
complete_msg = "[{}:{}] {}".format(filename, lineno, error_msg)
289-
cerror(conditiontype(complete_msg))
295+
cerror(conditiontype(complete_msg, origin="test_raises", custom_message=message,
296+
filename=filename, lineno=lineno, sourcecode=sourcecode,
297+
mode=mode, result=test_result, captured_value=test_result))
290298

291299

292300
# -----------------------------------------------------------------------------

unpythonic/test/fixtures.py

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,18 @@
118118

119119
from ..conditions import handlers, find_restart, invoke
120120
from ..collections import box, unbox
121+
from ..symbol import sym
121122

122123
from .ansicolor import TC, colorize
123124

124125
__all__ = ["session", "testset",
125126
"terminate",
126127
"returns_normally", "catch_signals",
127128
"TestConfig",
128-
"tests_run", "tests_failed", "tests_errored",
129-
"TestingException", "TestFailure", "TestError"]
129+
"tests_run", "tests_failed", "tests_errored", "tests_warned",
130+
"TestingException", "TestFailure", "TestError", "TestWarning",
131+
"completed", "signaled", "raised",
132+
"describe_exception"]
130133

131134
# Keep a global count (since Python last started) of how many unpythonic_asserts
132135
# have run and how many have failed, so that the client code can easily calculate
@@ -169,18 +172,123 @@ def _reset(counter):
169172
with _counter_update_lock:
170173
counter << 0
171174

175+
completed = sym("completed")
176+
completed.__doc__ = """TestingException `mode`: the test ran to completion normally.
177+
178+
This does not mean that the test assertion succeeded, but only that
179+
it exited normally (i.e. did not signal or raise).
180+
"""
181+
signaled = sym("signaled")
182+
signaled.__doc__ = """TestingException `mode`: the test signaled a condition.
183+
184+
The signal was not caught inside the test.
185+
186+
See `unpythonic.conditions.signal` and its sisters `error`, `cerror`, `warn`.
187+
"""
188+
raised = sym("raised")
189+
raised.__doc__ = """TestingException `mode`: the test raised an exception.
190+
191+
The exception was not caught inside the test.
192+
"""
172193
class TestingException(Exception):
173194
"""Base type for testing-related exceptions."""
195+
def __init__(self, *args, origin=None, custom_message=None,
196+
filename=None, lineno=None, sourcecode=None,
197+
mode=None, result=None, captured_value=None):
198+
"""Parameters:
199+
200+
`*args`: like in Exception. Usually just one, a human-readable
201+
error message as str.
202+
203+
Additionally, the test macros automatically fill in the following
204+
optional parameters, for runtime inspection. These are stored in
205+
instance attributes with the same name as the corresponding parameter:
206+
207+
`origin`: str, which of the test asserters produced this exception.
208+
One of "test", "test_signals", "test_raises", "fail", "error", "warn".
209+
210+
`custom_message`: str or None. The optional, user-provided human-readable
211+
custom failure message, if any.
212+
213+
Test blocks that just assert that the block returns normally
214+
(default behavior if no `return` used) are encouraged to carry
215+
a clarifying message, provided by the user, to explain what was
216+
expected to happen, if it turns out that the test fails or errors.
217+
218+
(Often, in such cases, the context alone is insufficient for a
219+
human not intimately familiar with the code to judge why the test
220+
block should or should not exit normally, so a message is useful.)
221+
222+
Any invocation of `fail[]`, `error[]` and `warn[]` will also
223+
typically carry such a message, since that's the whole point
224+
of using those constructs.
225+
226+
For any other use case though, the details are often clear enough
227+
from the code of the test assertion (or test block) itself, so
228+
there is no need for the user to include a custom failure message.
229+
230+
`filename`: str, the full path of the file containing the test in which
231+
the exception occurred.
232+
`lineno`: int, line number in `filename` (1-based, as usual).
233+
`sourcecode`: str, captured (actually unparsed from AST) source code of the test
234+
assertion (or test block). If a block, may have multiple lines.
235+
236+
`mode`: sym, how the test exited. One of `completed`, `signaled`, `raised` (which see).
237+
238+
`result`: If `mode is completed`, then the value of the test assertion (or the return
239+
value of a test block, respectively). Note test blocks that just assert
240+
that the block completes normally always return `True` when they complete
241+
normally.
242+
243+
If `mode is signaled`, the signal instance (an `Exception` object).
244+
245+
If `mode is raised`, the exception instance (an `Exception` object).
246+
247+
If you need to format an exception (and its chained exceptions, if any)
248+
for human consumption, in a notation as close as possible to what Python
249+
itself uses for reporting uncaught exceptions, see `describe_exception`.
250+
251+
`captured_value`: The value the test macros refer to as "result" in the error messages.
252+
If a `the[]` was used, the value of the `the[]` subexpression.
253+
254+
Else if the top level of the test assertion (or the return value of a test block,
255+
respectively) was a comparison, the value of the leftmost term.
256+
257+
Else `captured_value is result`.
258+
259+
Note that `test_signals` and `test_raises` do not support capturing;
260+
for them, it always holds that `captured_value is result`.
261+
"""
262+
super().__init__(*args)
263+
self.origin = origin
264+
self.custom_message = custom_message
265+
self.filename = filename
266+
self.lineno = lineno
267+
self.sourcecode = sourcecode
268+
self.mode = mode
269+
self.result = result
270+
self.captured_value = captured_value
174271
class TestFailure(TestingException):
175-
"""Exception type indicating that a test failed."""
272+
"""Exception: a test ran to completion normally, but the test assertion failed.
273+
274+
May also mean that a test was expected to signal or raise, but it didn't.
275+
"""
176276
class TestError(TestingException):
177-
"""Exception type indicating that a test did not run to completion.
277+
"""Exception: a test did not run to completion normally.
278+
279+
It was also not expected to signal or raise, at least not the
280+
exception type that was observed.
178281
179282
This can happen due to an unexpected exception, or an unhandled
180283
`error` (or `cerror`) condition.
181284
"""
182285
class TestWarning(TestingException):
183-
"""Exception type for a human-initiated test warning."""
286+
"""Exception: a human-initiated test warning.
287+
288+
Warnings (see the `warn[]` macro) can be used e.g. to mark tests that
289+
are temporarily disabled due to external factors, such as language-level
290+
compatibility issues, or a bug in a library yours depends on.
291+
"""
184292

185293
def maybe_colorize(s, *colors):
186294
"""Colorize `s` with ANSI color escapes if enabled in the global `TestConfig`.

0 commit comments

Comments
 (0)