diff options
Diffstat (limited to 'lib')
28 files changed, 2334 insertions, 207 deletions
diff --git a/lib/testtools/HACKING b/lib/testtools/HACKING index 8fe323cadd..60b1a90a8c 100644 --- a/lib/testtools/HACKING +++ b/lib/testtools/HACKING @@ -122,6 +122,7 @@ In no particular order: * Possibly write a blurb into NEWS. * Replace any additional references to NEXT with the version being released. + (should be none). * Create a source distribution and upload to pypi ('make release'). diff --git a/lib/testtools/LICENSE b/lib/testtools/LICENSE index bdc733fe04..071d7359d2 100644 --- a/lib/testtools/LICENSE +++ b/lib/testtools/LICENSE @@ -1,4 +1,5 @@ -Copyright (c) 2008 Jonathan M. Lange <jml@mumak.net> and the testtools authors. +Copyright (c) 2008-2010 Jonathan M. Lange <jml@mumak.net> and the testtools +authors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17,3 +18,22 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Some code in testtools/run.py taken from Python's unittest module: +Copyright (c) 1999-2003 Steve Purcell +Copyright (c) 2003-2010 Python Software Foundation + +This module is free software, and you may redistribute it and/or modify +it under the same terms as Python itself, so long as this copyright message +and disclaimer are retained in their original form. + +IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, +SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF +THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, +AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, +SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/lib/testtools/MANUAL b/lib/testtools/MANUAL index a040c2860d..db213669c9 100644 --- a/lib/testtools/MANUAL +++ b/lib/testtools/MANUAL @@ -52,10 +52,28 @@ given the exc_info for the exception, and can use this opportunity to attach more data (via the addDetails API) and potentially other uses. -TestCase.skip -~~~~~~~~~~~~~ +TestCase.patch +~~~~~~~~~~~~~~ + +``patch`` is a convenient way to monkey-patch a Python object for the duration +of your test. It's especially useful for testing legacy code. e.g.:: + + def test_foo(self): + my_stream = StringIO() + self.patch(sys, 'stderr', my_stream) + run_some_code_that_prints_to_stderr() + self.assertEqual('', my_stream.getvalue()) + +The call to ``patch`` above masks sys.stderr with 'my_stream' so that anything +printed to stderr will be captured in a StringIO variable that can be actually +tested. Once the test is done, the real sys.stderr is restored to its rightful +place. + -``skip`` is a simple way to have a test stop running and be reported as a +TestCase.skipTest +~~~~~~~~~~~~~~~~~ + +``skipTest`` is a simple way to have a test stop running and be reported as a skipped test, rather than a success/error/failure. This is an alternative to convoluted logic during test loading, permitting later and more localized decisions about the appropriateness of running a test. Many reasons exist to @@ -64,7 +82,9 @@ expensive and should not be run while on laptop battery power, or if the test is testing an incomplete feature (this is sometimes called a TODO). Using this feature when running your test suite with a TestResult object that is missing the ``addSkip`` method will result in the ``addError`` method being invoked -instead. +instead. ``skipTest`` was previously known as ``skip`` but as Python 2.7 adds +``skipTest`` support, the ``skip`` name is now deprecated (but no warning +is emitted yet - some time in the future we may do so). New assertion methods @@ -211,3 +231,16 @@ Running tests Testtools provides a convenient way to run a test suite using the testtools result object: python -m testtools.run testspec [testspec...]. + +Test discovery +-------------- + +Testtools includes a backported version of the Python 2.7 glue for using the +discover test discovery module. If you either have Python 2.7/3.1 or newer, or +install the 'discover' module, then you can invoke discovery:: + + python -m testtools.run discover [path] + +For more information see the Python 2.7 unittest documentation, or:: + + python -m testtools.run --help diff --git a/lib/testtools/Makefile b/lib/testtools/Makefile index 5e232e3394..0ad6f131d1 100644 --- a/lib/testtools/Makefile +++ b/lib/testtools/Makefile @@ -17,6 +17,8 @@ clean: find testtools -name "*.pyc" -exec rm '{}' \; release: + # An existing MANIFEST breaks distutils sometimes. Avoid that. + -rm MANIFEST ./setup.py sdist upload --sign apidocs: diff --git a/lib/testtools/NEWS b/lib/testtools/NEWS index 90d7fc492a..dc5e6df8f1 100644 --- a/lib/testtools/NEWS +++ b/lib/testtools/NEWS @@ -4,6 +4,127 @@ testtools NEWS NEXT ~~~~ +0.9.6 +~~~~~ + +Nothing major in this release, just enough small bits and pieces to make it +useful enough to upgrade to. + +In particular, a serious bug in assertThat() has been fixed, it's easier to +write Matchers, there's a TestCase.patch() method for those inevitable monkey +patches and TestCase.assertEqual gives slightly nicer errors. + +Improvements +------------ + + * 'TestCase.assertEqual' now formats errors a little more nicely, in the + style of bzrlib. + + * Added `PlaceHolder` and `ErrorHolder`, TestCase-like objects that can be + used to add results to a `TestResult`. + + * 'Mismatch' now takes optional description and details parameters, so + custom Matchers aren't compelled to make their own subclass. + + * jml added a built-in UTF8_TEXT ContentType to make it slightly easier to + add details to test results. See bug #520044. + + * Fix a bug in our built-in matchers where assertThat would blow up if any + of them failed. All built-in mismatch objects now provide get_details(). + + * New 'Is' matcher, which lets you assert that a thing is identical to + another thing. + + * New 'LessThan' matcher which lets you assert that a thing is less than + another thing. + + * TestCase now has a 'patch()' method to make it easier to monkey-patching + objects in tests. See the manual for more information. Fixes bug #310770. + + * MultiTestResult methods now pass back return values from the results it + forwards to. + +0.9.5 +~~~~~ + +This release fixes some obscure traceback formatting issues that probably +weren't affecting you but were certainly breaking our own test suite. + +Changes +------- + +* Jamu Kakar has updated classes in testtools.matchers and testtools.runtest + to be new-style classes, fixing bug #611273. + +Improvements +------------ + +* Martin[gz] fixed traceback handling to handle cases where extract_tb returns + a source line of None. Fixes bug #611307. + +* Martin[gz] fixed an unicode issue that was causing the tests to fail, + closing bug #604187. + +* testtools now handles string exceptions (although why would you want to use + them?) and formats their tracebacks correctly. Thanks to Martin[gz] for + fixing bug #592262. + +0.9.4 +~~~~~ + +This release overhauls the traceback formatting layer to deal with Python 2 +line numbers and traceback objects often being local user encoded strings +rather than unicode objects. Test discovery has also been added and Python 3.1 +is also supported. Finally, the Mismatch protocol has been extended to let +Matchers collaborate with tests in supplying detailed data about failures. + +Changes +------- + +* testtools.utils has been renamed to testtools.compat. Importing + testtools.utils will now generate a deprecation warning. + +Improvements +------------ + +* Add machinery for Python 2 to create unicode tracebacks like those used by + Python 3. This means testtools no longer throws on encountering non-ascii + filenames, source lines, or exception strings when displaying test results. + Largely contributed by Martin[gz] with some tweaks from Robert Collins. + +* James Westby has supplied test discovery support using the Python 2.7 + TestRunner in testtools.run. This requires the 'discover' module. This + closes bug #250764. + +* Python 3.1 is now supported, thanks to Martin[gz] for a partial patch. + This fixes bug #592375. + +* TestCase.addCleanup has had its docstring corrected about when cleanups run. + +* TestCase.skip is now deprecated in favour of TestCase.skipTest, which is the + Python2.7 spelling for skip. This closes bug #560436. + +* Tests work on IronPython patch from Martin[gz] applied. + +* Thanks to a patch from James Westby testtools.matchers.Mismatch can now + supply a get_details method, which assertThat will query to provide + additional attachments. This can be used to provide additional detail + about the mismatch that doesn't suite being included in describe(). For + instance, if the match process was complex, a log of the process could be + included, permitting debugging. + +* testtools.testresults.real._StringException will now answer __str__ if its + value is unicode by encoding with UTF8, and vice versa to answer __unicode__. + This permits subunit decoded exceptions to contain unicode and still format + correctly. + +0.9.3 +~~~~~ + +More matchers, Python 2.4 support, faster test cloning by switching to copy +rather than deepcopy and better output when exceptions occur in cleanups are +the defining characteristics of this release. + Improvements ------------ @@ -34,6 +155,11 @@ Improvements * The backtrace test result output tests should now pass on windows and other systems where os.sep is not '/'. +* When a cleanUp or tearDown exception occurs, it is now accumulated as a new + traceback in the test details, rather than as a separate call to addError / + addException. This makes testtools work better with most TestResult objects + and fixes bug #335816. + 0.9.2 ~~~~~ diff --git a/lib/testtools/README b/lib/testtools/README index 5e3dd07cd6..991f3d5a06 100644 --- a/lib/testtools/README +++ b/lib/testtools/README @@ -14,6 +14,10 @@ Licensing This project is distributed under the MIT license and copyright is owned by Jonathan M. Lange. See LICENSE for details. +Some code in testtools/run.py is taken from Python's unittest module, and +is copyright Steve Purcell and the Python Software Foundation, it is +distributed under the same license as Python, see LICENSE for details. + Dependencies ------------ diff --git a/lib/testtools/testtools/__init__.py b/lib/testtools/testtools/__init__.py index 0504d661d4..b1c9b661ed 100644 --- a/lib/testtools/testtools/__init__.py +++ b/lib/testtools/testtools/__init__.py @@ -1,13 +1,15 @@ -# Copyright (c) 2008, 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008, 2009, 2010 Jonathan M. Lange. See LICENSE for details. """Extensions to the standard Python unittest library.""" __all__ = [ 'clone_test_with_new_id', 'ConcurrentTestSuite', + 'ErrorHolder', 'ExtendedToOriginalDecorator', 'iterate_tests', 'MultiTestResult', + 'PlaceHolder', 'TestCase', 'TestResult', 'TextTestResult', @@ -25,6 +27,8 @@ from testtools.runtest import ( RunTest, ) from testtools.testcase import ( + ErrorHolder, + PlaceHolder, TestCase, clone_test_with_new_id, skip, @@ -40,8 +44,8 @@ from testtools.testresult import ( ) from testtools.testsuite import ( ConcurrentTestSuite, + iterate_tests, ) -from testtools.utils import iterate_tests # same format as sys.version_info: "A tuple containing the five components of # the version number: major, minor, micro, releaselevel, and serial. All @@ -55,4 +59,4 @@ from testtools.utils import iterate_tests # If the releaselevel is 'final', then the tarball will be major.minor.micro. # Otherwise it is major.minor.micro~$(revno). -__version__ = (0, 9, 2, 'final', 0) +__version__ = (0, 9, 7, 'dev', 0) diff --git a/lib/testtools/testtools/compat.py b/lib/testtools/testtools/compat.py new file mode 100644 index 0000000000..0dd2fe8bf9 --- /dev/null +++ b/lib/testtools/testtools/compat.py @@ -0,0 +1,246 @@ +# Copyright (c) 2008-2010 testtools developers. See LICENSE for details. + +"""Compatibility support for python 2 and 3.""" + + +import codecs +import linecache +import locale +import os +import re +import sys +import traceback + +__metaclass__ = type +__all__ = [ + '_b', + '_u', + 'advance_iterator', + 'str_is_unicode', + 'unicode_output_stream', + ] + + +__u_doc = """A function version of the 'u' prefix. + +This is needed becayse the u prefix is not usable in Python 3 but is required +in Python 2 to get a unicode object. + +To migrate code that was written as u'\u1234' in Python 2 to 2+3 change +it to be _u('\u1234'). The Python 3 interpreter will decode it +appropriately and the no-op _u for Python 3 lets it through, in Python +2 we then call unicode-escape in the _u function. +""" + +if sys.version_info > (3, 0): + def _u(s): + return s + _r = ascii + def _b(s): + """A byte literal.""" + return s.encode("latin-1") + advance_iterator = next + def istext(x): + return isinstance(x, str) + def classtypes(): + return (type,) + str_is_unicode = True +else: + def _u(s): + # The double replace mangling going on prepares the string for + # unicode-escape - \foo is preserved, \u and \U are decoded. + return (s.replace("\\", "\\\\").replace("\\\\u", "\\u") + .replace("\\\\U", "\\U").decode("unicode-escape")) + _r = repr + def _b(s): + return s + advance_iterator = lambda it: it.next() + def istext(x): + return isinstance(x, basestring) + def classtypes(): + import types + return (type, types.ClassType) + str_is_unicode = sys.platform == "cli" + +_u.__doc__ = __u_doc + + +def unicode_output_stream(stream): + """Get wrapper for given stream that writes any unicode without exception + + Characters that can't be coerced to the encoding of the stream, or 'ascii' + if valid encoding is not found, will be replaced. The original stream may + be returned in situations where a wrapper is determined unneeded. + + The wrapper only allows unicode to be written, not non-ascii bytestrings, + which is a good thing to ensure sanity and sanitation. + """ + if sys.platform == "cli": + # Best to never encode before writing in IronPython + return stream + try: + writer = codecs.getwriter(stream.encoding or "") + except (AttributeError, LookupError): + # GZ 2010-06-16: Python 3 StringIO ends up here, but probably needs + # different handling as it doesn't want bytestrings + return codecs.getwriter("ascii")(stream, "replace") + if writer.__module__.rsplit(".", 1)[1].startswith("utf"): + # The current stream has a unicode encoding so no error handler is needed + return stream + if sys.version_info > (3, 0): + # Python 3 doesn't seem to make this easy, handle a common case + try: + return stream.__class__(stream.buffer, stream.encoding, "replace", + stream.newlines, stream.line_buffering) + except AttributeError: + pass + return writer(stream, "replace") + + +# The default source encoding is actually "iso-8859-1" until Python 2.5 but +# using non-ascii causes a deprecation warning in 2.4 and it's cleaner to +# treat all versions the same way +_default_source_encoding = "ascii" + +# Pattern specified in <http://www.python.org/dev/peps/pep-0263/> +_cookie_search=re.compile("coding[:=]\s*([-\w.]+)").search + +def _detect_encoding(lines): + """Get the encoding of a Python source file from a list of lines as bytes + + This function does less than tokenize.detect_encoding added in Python 3 as + it does not attempt to raise a SyntaxError when the interpreter would, it + just wants the encoding of a source file Python has already compiled and + determined is valid. + """ + if not lines: + return _default_source_encoding + if lines[0].startswith("\xef\xbb\xbf"): + # Source starting with UTF-8 BOM is either UTF-8 or a SyntaxError + return "utf-8" + # Only the first two lines of the source file are examined + magic = _cookie_search("".join(lines[:2])) + if magic is None: + return _default_source_encoding + encoding = magic.group(1) + try: + codecs.lookup(encoding) + except LookupError: + # Some codecs raise something other than LookupError if they don't + # support the given error handler, but not the text ones that could + # actually be used for Python source code + return _default_source_encoding + return encoding + + +class _EncodingTuple(tuple): + """A tuple type that can have an encoding attribute smuggled on""" + + +def _get_source_encoding(filename): + """Detect, cache and return the encoding of Python source at filename""" + try: + return linecache.cache[filename].encoding + except (AttributeError, KeyError): + encoding = _detect_encoding(linecache.getlines(filename)) + if filename in linecache.cache: + newtuple = _EncodingTuple(linecache.cache[filename]) + newtuple.encoding = encoding + linecache.cache[filename] = newtuple + return encoding + + +def _get_exception_encoding(): + """Return the encoding we expect messages from the OS to be encoded in""" + if os.name == "nt": + # GZ 2010-05-24: Really want the codepage number instead, the error + # handling of standard codecs is more deterministic + return "mbcs" + # GZ 2010-05-23: We need this call to be after initialisation, but there's + # no benefit in asking more than once as it's a global + # setting that can change after the message is formatted. + return locale.getlocale(locale.LC_MESSAGES)[1] or "ascii" + + +def _exception_to_text(evalue): + """Try hard to get a sensible text value out of an exception instance""" + try: + return unicode(evalue) + except KeyboardInterrupt: + raise + except: + # Apparently this is what traceback._some_str does. Sigh - RBC 20100623 + pass + try: + return str(evalue).decode(_get_exception_encoding(), "replace") + except KeyboardInterrupt: + raise + except: + # Apparently this is what traceback._some_str does. Sigh - RBC 20100623 + pass + # Okay, out of ideas, let higher level handle it + return None + + +# GZ 2010-05-23: This function is huge and horrible and I welcome suggestions +# on the best way to break it up +_TB_HEADER = _u('Traceback (most recent call last):\n') +def _format_exc_info(eclass, evalue, tb, limit=None): + """Format a stack trace and the exception information as unicode + + Compatibility function for Python 2 which ensures each component of a + traceback is correctly decoded according to its origins. + + Based on traceback.format_exception and related functions. + """ + fs_enc = sys.getfilesystemencoding() + if tb: + list = [_TB_HEADER] + extracted_list = [] + for filename, lineno, name, line in traceback.extract_tb(tb, limit): + extracted_list.append(( + filename.decode(fs_enc, "replace"), + lineno, + name.decode("ascii", "replace"), + line and line.decode( + _get_source_encoding(filename), "replace"))) + list.extend(traceback.format_list(extracted_list)) + else: + list = [] + if evalue is None: + # Is a (deprecated) string exception + list.append(eclass.decode("ascii", "replace")) + elif isinstance(evalue, SyntaxError) and len(evalue.args) > 1: + # Avoid duplicating the special formatting for SyntaxError here, + # instead create a new instance with unicode filename and line + # Potentially gives duff spacing, but that's a pre-existing issue + filename, lineno, offset, line = evalue.args[1] + if line: + # Errors during parsing give the line from buffer encoded as + # latin-1 or utf-8 or the encoding of the file depending on the + # coding and whether the patch for issue #1031213 is applied, so + # give up on trying to decode it and just read the file again + bytestr = linecache.getline(filename, lineno) + if bytestr: + if lineno == 1 and bytestr.startswith("\xef\xbb\xbf"): + bytestr = bytestr[3:] + line = bytestr.decode(_get_source_encoding(filename), "replace") + del linecache.cache[filename] + else: + line = line.decode("ascii", "replace") + if filename: + filename = filename.decode(fs_enc, "replace") + evalue = eclass(evalue.args[0], (filename, lineno, offset, line)) + list.extend(traceback.format_exception_only(eclass, evalue)) + else: + sclass = eclass.__name__ + svalue = _exception_to_text(evalue) + if svalue: + list.append("%s: %s\n" % (sclass, svalue)) + elif svalue is None: + # GZ 2010-05-24: Not a great fallback message, but keep for the + # the same for compatibility for the moment + list.append("%s: <unprintable %s object>\n" % (sclass, sclass)) + else: + list.append("%s\n" % sclass) + return list diff --git a/lib/testtools/testtools/content.py b/lib/testtools/testtools/content.py index 353e3f0f46..843133aa1a 100644 --- a/lib/testtools/testtools/content.py +++ b/lib/testtools/testtools/content.py @@ -3,10 +3,10 @@ """Content - a MIME-like Content object.""" import codecs -from unittest import TestResult +from testtools.compat import _b from testtools.content_type import ContentType -from testtools.utils import _b +from testtools.testresult import TestResult class Content(object): @@ -86,6 +86,6 @@ class TracebackContent(Content): content_type = ContentType('text', 'x-traceback', {"language": "python", "charset": "utf8"}) self._result = TestResult() - value = self._result._exc_info_to_string(err, test) + value = self._result._exc_info_to_unicode(err, test) super(TracebackContent, self).__init__( content_type, lambda: [value.encode("utf8")]) diff --git a/lib/testtools/testtools/content_type.py b/lib/testtools/testtools/content_type.py index aded81b732..a936506e48 100644 --- a/lib/testtools/testtools/content_type.py +++ b/lib/testtools/testtools/content_type.py @@ -28,3 +28,6 @@ class ContentType(object): def __repr__(self): return "%s/%s params=%s" % (self.type, self.subtype, self.parameters) + + +UTF8_TEXT = ContentType('text', 'plain', {'charset': 'utf8'}) diff --git a/lib/testtools/testtools/matchers.py b/lib/testtools/testtools/matchers.py index 039c84b7c7..6a4c82a2fe 100644 --- a/lib/testtools/testtools/matchers.py +++ b/lib/testtools/testtools/matchers.py @@ -15,6 +15,8 @@ __all__ = [ 'Annotate', 'DocTestMatches', 'Equals', + 'Is', + 'LessThan', 'MatchesAll', 'MatchesAny', 'NotEquals', @@ -22,9 +24,10 @@ __all__ = [ ] import doctest +import operator -class Matcher: +class Matcher(object): """A pattern matcher. A Matcher must implement match and __str__ to be used by @@ -52,18 +55,53 @@ class Matcher: raise NotImplementedError(self.__str__) -class Mismatch: +class Mismatch(object): """An object describing a mismatch detected by a Matcher.""" + def __init__(self, description=None, details=None): + """Construct a `Mismatch`. + + :param description: A description to use. If not provided, + `Mismatch.describe` must be implemented. + :param details: Extra details about the mismatch. Defaults + to the empty dict. + """ + if description: + self._description = description + if details is None: + details = {} + self._details = details + def describe(self): """Describe the mismatch. This should be either a human-readable string or castable to a string. """ - raise NotImplementedError(self.describe_difference) + try: + return self._description + except AttributeError: + raise NotImplementedError(self.describe) + + def get_details(self): + """Get extra details about the mismatch. + + This allows the mismatch to provide extra information beyond the basic + description, including large text or binary files, or debugging internals + without having to force it to fit in the output of 'describe'. + + The testtools assertion assertThat will query get_details and attach + all its values to the test, permitting them to be reported in whatever + manner the test environment chooses. + + :return: a dict mapping names to Content objects. name is a string to + name the detail, and the Content object is the detail to add + to the result. For more information see the API to which items from + this dict are passed testtools.TestCase.addDetail. + """ + return getattr(self, '_details', {}) -class DocTestMatches: +class DocTestMatches(object): """See if a string matches a doctest example.""" def __init__(self, example, flags=0): @@ -102,7 +140,7 @@ class DocTestMatches: return self._checker.output_difference(self, with_nl, self.flags) -class DocTestMismatch: +class DocTestMismatch(Mismatch): """Mismatch object for DocTestMatches.""" def __init__(self, matcher, with_nl): @@ -113,63 +151,69 @@ class DocTestMismatch: return self.matcher._describe_difference(self.with_nl) -class Equals: - """Matches if the items are equal.""" +class _BinaryComparison(object): + """Matcher that compares an object to another object.""" def __init__(self, expected): self.expected = expected + def __str__(self): + return "%s(%r)" % (self.__class__.__name__, self.expected) + def match(self, other): - if self.expected == other: + if self.comparator(other, self.expected): return None - return EqualsMismatch(self.expected, other) + return _BinaryMismatch(self.expected, self.mismatch_string, other) - def __str__(self): - return "Equals(%r)" % self.expected + def comparator(self, expected, other): + raise NotImplementedError(self.comparator) -class EqualsMismatch: - """Two things differed.""" +class _BinaryMismatch(Mismatch): + """Two things did not match.""" - def __init__(self, expected, other): + def __init__(self, expected, mismatch_string, other): self.expected = expected + self._mismatch_string = mismatch_string self.other = other def describe(self): - return "%r != %r" % (self.expected, self.other) + return "%r %s %r" % (self.expected, self._mismatch_string, self.other) + + +class Equals(_BinaryComparison): + """Matches if the items are equal.""" + + comparator = operator.eq + mismatch_string = '!=' -class NotEquals: +class NotEquals(_BinaryComparison): """Matches if the items are not equal. In most cases, this is equivalent to `Not(Equals(foo))`. The difference only matters when testing `__ne__` implementations. """ - def __init__(self, expected): - self.expected = expected + comparator = operator.ne + mismatch_string = '==' - def __str__(self): - return 'NotEquals(%r)' % (self.expected,) - def match(self, other): - if self.expected != other: - return None - return NotEqualsMismatch(self.expected, other) +class Is(_BinaryComparison): + """Matches if the items are identical.""" + comparator = operator.is_ + mismatch_string = 'is not' -class NotEqualsMismatch: - """Two things are the same.""" - def __init__(self, expected, other): - self.expected = expected - self.other = other +class LessThan(_BinaryComparison): + """Matches if the item is less than the matchers reference object.""" - def describe(self): - return '%r == %r' % (self.expected, self.other) + comparator = operator.__lt__ + mismatch_string = 'is >=' -class MatchesAny: +class MatchesAny(object): """Matches if any of the matchers it is created with match.""" def __init__(self, *matchers): @@ -189,7 +233,7 @@ class MatchesAny: str(matcher) for matcher in self.matchers]) -class MatchesAll: +class MatchesAll(object): """Matches if all of the matchers it is created with match.""" def __init__(self, *matchers): @@ -210,7 +254,7 @@ class MatchesAll: return None -class MismatchesAll: +class MismatchesAll(Mismatch): """A mismatch with many child mismatches.""" def __init__(self, mismatches): @@ -224,7 +268,7 @@ class MismatchesAll: return '\n'.join(descriptions) -class Not: +class Not(object): """Inverts a matcher.""" def __init__(self, matcher): @@ -241,7 +285,7 @@ class Not: return None -class MatchedUnexpectedly: +class MatchedUnexpectedly(Mismatch): """A thing matched when it wasn't supposed to.""" def __init__(self, matcher, other): @@ -252,7 +296,7 @@ class MatchedUnexpectedly: return "%r matches %s" % (self.other, self.matcher) -class Annotate: +class Annotate(object): """Annotates a matcher with a descriptive string. Mismatches are then described as '<mismatch>: <annotation>'. @@ -271,7 +315,7 @@ class Annotate: return AnnotatedMismatch(self.annotation, mismatch) -class AnnotatedMismatch: +class AnnotatedMismatch(Mismatch): """A mismatch annotated with a descriptive string.""" def __init__(self, annotation, mismatch): diff --git a/lib/testtools/testtools/monkey.py b/lib/testtools/testtools/monkey.py new file mode 100644 index 0000000000..bb24764cb7 --- /dev/null +++ b/lib/testtools/testtools/monkey.py @@ -0,0 +1,97 @@ +# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. + +"""Helpers for monkey-patching Python code.""" + +__all__ = [ + 'MonkeyPatcher', + 'patch', + ] + + +class MonkeyPatcher(object): + """A set of monkey-patches that can be applied and removed all together. + + Use this to cover up attributes with new objects. Particularly useful for + testing difficult code. + """ + + # Marker used to indicate that the patched attribute did not exist on the + # object before we patched it. + _NO_SUCH_ATTRIBUTE = object() + + def __init__(self, *patches): + """Construct a `MonkeyPatcher`. + + :param *patches: The patches to apply, each should be (obj, name, + new_value). Providing patches here is equivalent to calling + `add_patch`. + """ + # List of patches to apply in (obj, name, value). + self._patches_to_apply = [] + # List of the original values for things that have been patched. + # (obj, name, value) format. + self._originals = [] + for patch in patches: + self.add_patch(*patch) + + def add_patch(self, obj, name, value): + """Add a patch to overwrite 'name' on 'obj' with 'value'. + + The attribute C{name} on C{obj} will be assigned to C{value} when + C{patch} is called or during C{run_with_patches}. + + You can restore the original values with a call to restore(). + """ + self._patches_to_apply.append((obj, name, value)) + + def patch(self): + """Apply all of the patches that have been specified with `add_patch`. + + Reverse this operation using L{restore}. + """ + for obj, name, value in self._patches_to_apply: + original_value = getattr(obj, name, self._NO_SUCH_ATTRIBUTE) + self._originals.append((obj, name, original_value)) + setattr(obj, name, value) + + def restore(self): + """Restore all original values to any patched objects. + + If the patched attribute did not exist on an object before it was + patched, `restore` will delete the attribute so as to return the + object to its original state. + """ + while self._originals: + obj, name, value = self._originals.pop() + if value is self._NO_SUCH_ATTRIBUTE: + delattr(obj, name) + else: + setattr(obj, name, value) + + def run_with_patches(self, f, *args, **kw): + """Run 'f' with the given args and kwargs with all patches applied. + + Restores all objects to their original state when finished. + """ + self.patch() + try: + return f(*args, **kw) + finally: + self.restore() + + +def patch(obj, attribute, value): + """Set 'obj.attribute' to 'value' and return a callable to restore 'obj'. + + If 'attribute' is not set on 'obj' already, then the returned callable + will delete the attribute when called. + + :param obj: An object to monkey-patch. + :param attribute: The name of the attribute to patch. + :param value: The value to set 'obj.attribute' to. + :return: A nullary callable that, when run, will restore 'obj' to its + original state. + """ + patcher = MonkeyPatcher((obj, attribute, value)) + patcher.patch() + return patcher.restore diff --git a/lib/testtools/testtools/run.py b/lib/testtools/testtools/run.py index c4f461ecfb..b6f2c491cd 100755 --- a/lib/testtools/testtools/run.py +++ b/lib/testtools/testtools/run.py @@ -8,10 +8,27 @@ For instance, to run the testtools test suite. $ python -m testtools.run testtools.tests.test_suite """ +import os +import unittest import sys -from testtools.tests import test_suite from testtools import TextTestResult +from testtools.compat import classtypes, istext, unicode_output_stream + + +defaultTestLoader = unittest.defaultTestLoader +defaultTestLoaderCls = unittest.TestLoader + +if getattr(defaultTestLoader, 'discover', None) is None: + try: + import discover + defaultTestLoader = discover.DiscoveringTestLoader() + defaultTestLoaderCls = discover.DiscoveringTestLoader + have_discover = True + except ImportError: + have_discover = False +else: + have_discover = True class TestToolsTestRunner(object): @@ -19,7 +36,7 @@ class TestToolsTestRunner(object): def run(self, test): "Run the given test case or test suite." - result = TextTestResult(sys.stdout) + result = TextTestResult(unicode_output_stream(sys.stdout)) result.startTestRun() try: return test.run(result) @@ -27,13 +44,239 @@ class TestToolsTestRunner(object): result.stopTestRun() +#################### +# Taken from python 2.7 and slightly modified for compatibility with +# older versions. Delete when 2.7 is the oldest supported version. +# Modifications: +# - Use have_discover to raise an error if the user tries to use +# discovery on an old version and doesn't have discover installed. +# - If --catch is given check that installHandler is available, as +# it won't be on old python versions. +# - print calls have been been made single-source python3 compatibile. +# - exception handling likewise. +# - The default help has been changed to USAGE_AS_MAIN and USAGE_FROM_MODULE +# removed. +# - A tweak has been added to detect 'python -m *.run' and use a +# better progName in that case. + +FAILFAST = " -f, --failfast Stop on first failure\n" +CATCHBREAK = " -c, --catch Catch control-C and display results\n" +BUFFEROUTPUT = " -b, --buffer Buffer stdout and stderr during test runs\n" + +USAGE_AS_MAIN = """\ +Usage: %(progName)s [options] [tests] + +Options: + -h, --help Show this message + -v, --verbose Verbose output + -q, --quiet Minimal output +%(failfast)s%(catchbreak)s%(buffer)s +Examples: + %(progName)s test_module - run tests from test_module + %(progName)s module.TestClass - run tests from module.TestClass + %(progName)s module.Class.test_method - run specified test method + +[tests] can be a list of any number of test modules, classes and test +methods. + +Alternative Usage: %(progName)s discover [options] + +Options: + -v, --verbose Verbose output +%(failfast)s%(catchbreak)s%(buffer)s -s directory Directory to start discovery ('.' default) + -p pattern Pattern to match test files ('test*.py' default) + -t directory Top level directory of project (default to + start directory) + +For test discovery all test modules must be importable from the top +level directory of the project. +""" + + +class TestProgram(object): + """A command-line program that runs a set of tests; this is primarily + for making test modules conveniently executable. + """ + USAGE = USAGE_AS_MAIN + + # defaults for testing + failfast = catchbreak = buffer = progName = None + + def __init__(self, module='__main__', defaultTest=None, argv=None, + testRunner=None, testLoader=defaultTestLoader, + exit=True, verbosity=1, failfast=None, catchbreak=None, + buffer=None): + if istext(module): + self.module = __import__(module) + for part in module.split('.')[1:]: + self.module = getattr(self.module, part) + else: + self.module = module + if argv is None: + argv = sys.argv + + self.exit = exit + self.failfast = failfast + self.catchbreak = catchbreak + self.verbosity = verbosity + self.buffer = buffer + self.defaultTest = defaultTest + self.testRunner = testRunner + self.testLoader = testLoader + progName = argv[0] + if progName.endswith('%srun.py' % os.path.sep): + elements = progName.split(os.path.sep) + progName = '%s.run' % elements[-2] + else: + progName = os.path.basename(argv[0]) + self.progName = progName + self.parseArgs(argv) + self.runTests() + + def usageExit(self, msg=None): + if msg: + print(msg) + usage = {'progName': self.progName, 'catchbreak': '', 'failfast': '', + 'buffer': ''} + if self.failfast != False: + usage['failfast'] = FAILFAST + if self.catchbreak != False: + usage['catchbreak'] = CATCHBREAK + if self.buffer != False: + usage['buffer'] = BUFFEROUTPUT + print(self.USAGE % usage) + sys.exit(2) + + def parseArgs(self, argv): + if len(argv) > 1 and argv[1].lower() == 'discover': + self._do_discovery(argv[2:]) + return + + import getopt + long_opts = ['help', 'verbose', 'quiet', 'failfast', 'catch', 'buffer'] + try: + options, args = getopt.getopt(argv[1:], 'hHvqfcb', long_opts) + for opt, value in options: + if opt in ('-h','-H','--help'): + self.usageExit() + if opt in ('-q','--quiet'): + self.verbosity = 0 + if opt in ('-v','--verbose'): + self.verbosity = 2 + if opt in ('-f','--failfast'): + if self.failfast is None: + self.failfast = True + # Should this raise an exception if -f is not valid? + if opt in ('-c','--catch'): + if self.catchbreak is None: + self.catchbreak = True + # Should this raise an exception if -c is not valid? + if opt in ('-b','--buffer'): + if self.buffer is None: + self.buffer = True + # Should this raise an exception if -b is not valid? + if len(args) == 0 and self.defaultTest is None: + # createTests will load tests from self.module + self.testNames = None + elif len(args) > 0: + self.testNames = args + if __name__ == '__main__': + # to support python -m unittest ... + self.module = None + else: + self.testNames = (self.defaultTest,) + self.createTests() + except getopt.error: + exc_info = sys.exc_info() + msg = exc_info[1] + self.usageExit(msg) + + def createTests(self): + if self.testNames is None: + self.test = self.testLoader.loadTestsFromModule(self.module) + else: + self.test = self.testLoader.loadTestsFromNames(self.testNames, + self.module) + + def _do_discovery(self, argv, Loader=defaultTestLoaderCls): + # handle command line args for test discovery + if not have_discover: + raise AssertionError("Unable to use discovery, must use python 2.7 " + "or greater, or install the discover package.") + self.progName = '%s discover' % self.progName + import optparse + parser = optparse.OptionParser() + parser.prog = self.progName + parser.add_option('-v', '--verbose', dest='verbose', default=False, + help='Verbose output', action='store_true') + if self.failfast != False: + parser.add_option('-f', '--failfast', dest='failfast', default=False, + help='Stop on first fail or error', + action='store_true') + if self.catchbreak != False: + parser.add_option('-c', '--catch', dest='catchbreak', default=False, + help='Catch ctrl-C and display results so far', + action='store_true') + if self.buffer != False: + parser.add_option('-b', '--buffer', dest='buffer', default=False, + help='Buffer stdout and stderr during tests', + action='store_true') + parser.add_option('-s', '--start-directory', dest='start', default='.', + help="Directory to start discovery ('.' default)") + parser.add_option('-p', '--pattern', dest='pattern', default='test*.py', + help="Pattern to match tests ('test*.py' default)") + parser.add_option('-t', '--top-level-directory', dest='top', default=None, + help='Top level directory of project (defaults to start directory)') + + options, args = parser.parse_args(argv) + if len(args) > 3: + self.usageExit() + + for name, value in zip(('start', 'pattern', 'top'), args): + setattr(options, name, value) + + # only set options from the parsing here + # if they weren't set explicitly in the constructor + if self.failfast is None: + self.failfast = options.failfast + if self.catchbreak is None: + self.catchbreak = options.catchbreak + if self.buffer is None: + self.buffer = options.buffer + + if options.verbose: + self.verbosity = 2 + + start_dir = options.start + pattern = options.pattern + top_level_dir = options.top + + loader = Loader() + self.test = loader.discover(start_dir, pattern, top_level_dir) + + def runTests(self): + if (self.catchbreak + and getattr(unittest, 'installHandler', None) is not None): + unittest.installHandler() + if self.testRunner is None: + self.testRunner = runner.TextTestRunner + if isinstance(self.testRunner, classtypes()): + try: + testRunner = self.testRunner(verbosity=self.verbosity, + failfast=self.failfast, + buffer=self.buffer) + except TypeError: + # didn't accept the verbosity, buffer or failfast arguments + testRunner = self.testRunner() + else: + # it is assumed to be a TestRunner instance + testRunner = self.testRunner + self.result = testRunner.run(self.test) + if self.exit: + sys.exit(not self.result.wasSuccessful()) +################ + + if __name__ == '__main__': - import optparse - from unittest import TestProgram - parser = optparse.OptionParser(__doc__) - args = parser.parse_args()[1] - if not args: - parser.error("No testspecs given.") runner = TestToolsTestRunner() - program = TestProgram(module=None, argv=[sys.argv[0]] + args, - testRunner=runner) + program = TestProgram(argv=sys.argv, testRunner=runner) diff --git a/lib/testtools/testtools/runtest.py b/lib/testtools/testtools/runtest.py index 053e2205a7..34954935ac 100644 --- a/lib/testtools/testtools/runtest.py +++ b/lib/testtools/testtools/runtest.py @@ -12,7 +12,7 @@ import sys from testtools.testresult import ExtendedToOriginalDecorator -class RunTest: +class RunTest(object): """An object to run a test. RunTest objects are used to implement the internal logic involved in @@ -36,6 +36,8 @@ class RunTest: classes in the list). :ivar exception_caught: An object returned when _run_user catches an exception. + :ivar _exceptions: A list of caught exceptions, used to do the single + reporting of error/failure/skip etc. """ def __init__(self, case, handlers=None): @@ -48,6 +50,7 @@ class RunTest: self.case = case self.handlers = handlers or [] self.exception_caught = object() + self._exceptions = [] def run(self, result=None): """Run self.case reporting activity to result. @@ -86,7 +89,16 @@ class RunTest: result.startTest(self.case) self.result = result try: + self._exceptions = [] self._run_core() + if self._exceptions: + # One or more caught exceptions, now trigger the test's + # reporting method for just one. + e = self._exceptions.pop() + for exc_class, handler in self.handlers: + if isinstance(e, exc_class): + handler(self.case, self.result, e) + break finally: result.stopTest(self.case) return result @@ -96,7 +108,9 @@ class RunTest: if self.exception_caught == self._run_user(self.case._run_setup, self.result): # Don't run the test method if we failed getting here. - self.case._runCleanups(self.result) + e = self.case._runCleanups(self.result) + if e is not None: + self._exceptions.append(e) return # Run everything from here on in. If any of the methods raise an # exception we'll have failed. @@ -112,8 +126,9 @@ class RunTest: failed = True finally: try: - if not self._run_user( - self.case._runCleanups, self.result): + e = self._run_user(self.case._runCleanups, self.result) + if e is not None: + self._exceptions.append(e) failed = True finally: if not failed: @@ -129,14 +144,12 @@ class RunTest: return fn(*args) except KeyboardInterrupt: raise - except Exception: - # Note that bare exceptions are not caught, so raised strings will - # escape: but they are deprecated anyway. + except: exc_info = sys.exc_info() e = exc_info[1] + self.case.onException(exc_info) for exc_class, handler in self.handlers: - self.case.onException(exc_info) if isinstance(e, exc_class): - handler(self.case, self.result, e) + self._exceptions.append(e) return self.exception_caught raise e diff --git a/lib/testtools/testtools/testcase.py b/lib/testtools/testtools/testcase.py index fd70141e6d..48eec71d41 100644 --- a/lib/testtools/testtools/testcase.py +++ b/lib/testtools/testtools/testcase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2008, 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. """Test case related stuff.""" @@ -17,14 +17,16 @@ try: except ImportError: wraps = None import itertools +from pprint import pformat import sys import types import unittest from testtools import content +from testtools.compat import advance_iterator +from testtools.monkey import patch from testtools.runtest import RunTest from testtools.testresult import TestResult -from testtools.utils import advance_iterator try: @@ -76,6 +78,7 @@ class TestCase(unittest.TestCase): unittest.TestCase.__init__(self, *args, **kwargs) self._cleanups = [] self._unique_id_gen = itertools.count(1) + self._traceback_id_gen = itertools.count(0) self.__setup_called = False self.__teardown_called = False self.__details = {} @@ -88,6 +91,9 @@ class TestCase(unittest.TestCase): (_UnexpectedSuccess, self._report_unexpected_success), (Exception, self._report_error), ] + if sys.version_info < (2, 6): + # Catch old-style string exceptions with None as the instance + self.exception_handlers.append((type(None), self._report_error)) def __eq__(self, other): eq = getattr(unittest.TestCase, '__eq__', None) @@ -117,10 +123,23 @@ class TestCase(unittest.TestCase): """ return self.__details + def patch(self, obj, attribute, value): + """Monkey-patch 'obj.attribute' to 'value' while the test is running. + + If 'obj' has no attribute, then the monkey-patch will still go ahead, + and the attribute will be deleted instead of restored to its original + value. + + :param obj: The object to patch. Can be anything. + :param attribute: The attribute on 'obj' to patch. + :param value: The value to set 'obj.attribute' to. + """ + self.addCleanup(patch(obj, attribute, value)) + def shortDescription(self): return self.id() - def skip(self, reason): + def skipTest(self, reason): """Cause this test to be skipped. This raises self.skipException(reason). skipException is raised @@ -133,6 +152,10 @@ class TestCase(unittest.TestCase): """ raise self.skipException(reason) + # skipTest is how python2.7 spells this. Sometime in the future + # This should be given a deprecation decorator - RBC 20100611. + skip = skipTest + def _formatTypes(self, classOrIterable): """Format a class or a bunch of classes for display in an error.""" className = getattr(classOrIterable, '__name__', None) @@ -145,9 +168,10 @@ class TestCase(unittest.TestCase): See the docstring for addCleanup for more information. - Returns True if all cleanups ran without error, False otherwise. + :return: None if all cleanups ran without error, the most recently + raised exception from the cleanups otherwise. """ - ok = True + last_exception = None while self._cleanups: function, arguments, keywordArguments = self._cleanups.pop() try: @@ -155,15 +179,16 @@ class TestCase(unittest.TestCase): except KeyboardInterrupt: raise except: - self._report_error(self, result, None) - ok = False - return ok + exc_info = sys.exc_info() + self._report_traceback(exc_info) + last_exception = exc_info[1] + return last_exception def addCleanup(self, function, *arguments, **keywordArguments): """Add a cleanup function to be called after tearDown. Functions added with addCleanup will be called in reverse order of - adding after the test method and before tearDown. + adding after tearDown, or after setUp if setUp raises an exception. If a function added with addCleanup raises an exception, the error will be recorded as a test error, and the next cleanup will then be @@ -198,6 +223,28 @@ class TestCase(unittest.TestCase): content.ContentType('text', 'plain'), lambda: [reason.encode('utf8')])) + def assertEqual(self, expected, observed, message=''): + """Assert that 'expected' is equal to 'observed'. + + :param expected: The expected value. + :param observed: The observed value. + :param message: An optional message to include in the error. + """ + try: + return super(TestCase, self).assertEqual(expected, observed) + except self.failureException: + lines = [] + if message: + lines.append(message) + lines.extend( + ["not equal:", + "a = %s" % pformat(expected), + "b = %s" % pformat(observed), + '']) + self.fail('\n'.join(lines)) + + failUnlessEqual = assertEquals = assertEqual + def assertIn(self, needle, haystack): """Assert that needle is in haystack.""" self.assertTrue( @@ -261,6 +308,14 @@ class TestCase(unittest.TestCase): mismatch = matcher.match(matchee) if not mismatch: return + existing_details = self.getDetails() + for (name, content) in mismatch.get_details().items(): + full_name = name + suffix = 1 + while full_name in existing_details: + full_name = "%s-%d" % (name, suffix) + suffix += 1 + self.addDetail(full_name, content) self.fail('Match failed. Matchee: "%s"\nMatcher: %s\nDifference: %s\n' % (matchee, matcher, mismatch.describe())) @@ -288,8 +343,7 @@ class TestCase(unittest.TestCase): predicate(*args, **kwargs) except self.failureException: exc_info = sys.exc_info() - self.addDetail('traceback', - content.TracebackContent(exc_info, self)) + self._report_traceback(exc_info) raise _ExpectedFailure(exc_info) else: raise _UnexpectedSuccess(reason) @@ -323,12 +377,14 @@ class TestCase(unittest.TestCase): :seealso addOnException: """ + if exc_info[0] not in [ + TestSkipped, _UnexpectedSuccess, _ExpectedFailure]: + self._report_traceback(exc_info) for handler in self.__exception_handlers: handler(exc_info) @staticmethod def _report_error(self, result, err): - self._report_traceback() result.addError(self, details=self.getDetails()) @staticmethod @@ -337,7 +393,6 @@ class TestCase(unittest.TestCase): @staticmethod def _report_failure(self, result, err): - self._report_traceback() result.addFailure(self, details=self.getDetails()) @staticmethod @@ -349,9 +404,13 @@ class TestCase(unittest.TestCase): self._add_reason(reason) result.addSkip(self, details=self.getDetails()) - def _report_traceback(self): - self.addDetail('traceback', - content.TracebackContent(sys.exc_info(), self)) + def _report_traceback(self, exc_info): + tb_id = advance_iterator(self._traceback_id_gen) + if tb_id: + tb_label = 'traceback-%d' % tb_id + else: + tb_label = 'traceback' + self.addDetail(tb_label, content.TracebackContent(exc_info, self)) @staticmethod def _report_unexpected_success(self, result, err): @@ -414,15 +473,102 @@ class TestCase(unittest.TestCase): self.__teardown_called = True +class PlaceHolder(object): + """A placeholder test. + + `PlaceHolder` implements much of the same interface as `TestCase` and is + particularly suitable for being added to `TestResult`s. + """ + + def __init__(self, test_id, short_description=None): + """Construct a `PlaceHolder`. + + :param test_id: The id of the placeholder test. + :param short_description: The short description of the place holder + test. If not provided, the id will be used instead. + """ + self._test_id = test_id + self._short_description = short_description + + def __call__(self, result=None): + return self.run(result=result) + + def __repr__(self): + internal = [self._test_id] + if self._short_description is not None: + internal.append(self._short_description) + return "<%s.%s(%s)>" % ( + self.__class__.__module__, + self.__class__.__name__, + ", ".join(map(repr, internal))) + + def __str__(self): + return self.id() + + def countTestCases(self): + return 1 + + def debug(self): + pass + + def id(self): + return self._test_id + + def run(self, result=None): + if result is None: + result = TestResult() + result.startTest(self) + result.addSuccess(self) + result.stopTest(self) + + def shortDescription(self): + if self._short_description is None: + return self.id() + else: + return self._short_description + + +class ErrorHolder(PlaceHolder): + """A placeholder test that will error out when run.""" + + failureException = None + + def __init__(self, test_id, error, short_description=None): + """Construct an `ErrorHolder`. + + :param test_id: The id of the test. + :param error: The exc info tuple that will be used as the test's error. + :param short_description: An optional short description of the test. + """ + super(ErrorHolder, self).__init__( + test_id, short_description=short_description) + self._error = error + + def __repr__(self): + internal = [self._test_id, self._error] + if self._short_description is not None: + internal.append(self._short_description) + return "<%s.%s(%s)>" % ( + self.__class__.__module__, + self.__class__.__name__, + ", ".join(map(repr, internal))) + + def run(self, result=None): + if result is None: + result = TestResult() + result.startTest(self) + result.addError(self, self._error) + result.stopTest(self) + + # Python 2.4 did not know how to copy functions. if types.FunctionType not in copy._copy_dispatch: copy._copy_dispatch[types.FunctionType] = copy._copy_immutable - def clone_test_with_new_id(test, new_id): """Copy a TestCase, and give the copied test a new id. - + This is only expected to be used on tests that have been constructed but not executed. """ diff --git a/lib/testtools/testtools/testresult/__init__.py b/lib/testtools/testtools/testresult/__init__.py index 2ee3d25293..1f779419d2 100644 --- a/lib/testtools/testtools/testresult/__init__.py +++ b/lib/testtools/testtools/testresult/__init__.py @@ -10,7 +10,7 @@ __all__ = [ 'ThreadsafeForwardingResult', ] -from real import ( +from testtools.testresult.real import ( ExtendedToOriginalDecorator, MultiTestResult, TestResult, diff --git a/lib/testtools/testtools/testresult/real.py b/lib/testtools/testtools/testresult/real.py index 8c8a3edd6e..95f6e8f04c 100644 --- a/lib/testtools/testtools/testresult/real.py +++ b/lib/testtools/testtools/testresult/real.py @@ -13,6 +13,8 @@ __all__ = [ import datetime import unittest +from testtools.compat import _format_exc_info, str_is_unicode, _u + class TestResult(unittest.TestResult): """Subclass of unittest.TestResult extending the protocol for flexability. @@ -105,10 +107,27 @@ class TestResult(unittest.TestResult): """Called when a test was expected to fail, but succeed.""" self.unexpectedSuccesses.append(test) + if str_is_unicode: + # Python 3 and IronPython strings are unicode, use parent class method + _exc_info_to_unicode = unittest.TestResult._exc_info_to_string + else: + # For Python 2, need to decode components of traceback according to + # their source, so can't use traceback.format_exception + # Here follows a little deep magic to copy the existing method and + # replace the formatter with one that returns unicode instead + from types import FunctionType as __F, ModuleType as __M + __f = unittest.TestResult._exc_info_to_string.im_func + __g = dict(__f.func_globals) + __m = __M("__fake_traceback") + __m.format_exception = _format_exc_info + __g["traceback"] = __m + _exc_info_to_unicode = __F(__f.func_code, __g, "_exc_info_to_unicode") + del __F, __M, __f, __g, __m + def _err_details_to_string(self, test, err=None, details=None): """Convert an error in exc_info form or a contents dict to a string.""" if err is not None: - return self._exc_info_to_string(err, test) + return self._exc_info_to_unicode(err, test) return _details_to_str(details) def _now(self): @@ -165,41 +184,43 @@ class MultiTestResult(TestResult): self._results = map(ExtendedToOriginalDecorator, results) def _dispatch(self, message, *args, **kwargs): - for result in self._results: + return tuple( getattr(result, message)(*args, **kwargs) + for result in self._results) def startTest(self, test): - self._dispatch('startTest', test) + return self._dispatch('startTest', test) def stopTest(self, test): - self._dispatch('stopTest', test) + return self._dispatch('stopTest', test) def addError(self, test, error=None, details=None): - self._dispatch('addError', test, error, details=details) + return self._dispatch('addError', test, error, details=details) def addExpectedFailure(self, test, err=None, details=None): - self._dispatch('addExpectedFailure', test, err, details=details) + return self._dispatch( + 'addExpectedFailure', test, err, details=details) def addFailure(self, test, err=None, details=None): - self._dispatch('addFailure', test, err, details=details) + return self._dispatch('addFailure', test, err, details=details) def addSkip(self, test, reason=None, details=None): - self._dispatch('addSkip', test, reason, details=details) + return self._dispatch('addSkip', test, reason, details=details) def addSuccess(self, test, details=None): - self._dispatch('addSuccess', test, details=details) + return self._dispatch('addSuccess', test, details=details) def addUnexpectedSuccess(self, test, details=None): - self._dispatch('addUnexpectedSuccess', test, details=details) + return self._dispatch('addUnexpectedSuccess', test, details=details) def startTestRun(self): - self._dispatch('startTestRun') + return self._dispatch('startTestRun') def stopTestRun(self): - self._dispatch('stopTestRun') + return self._dispatch('stopTestRun') def done(self): - self._dispatch('done') + return self._dispatch('done') class TextTestResult(TestResult): @@ -508,13 +529,23 @@ class ExtendedToOriginalDecorator(object): class _StringException(Exception): """An exception made from an arbitrary string.""" + if not str_is_unicode: + def __init__(self, string): + if type(string) is not unicode: + raise TypeError("_StringException expects unicode, got %r" % + (string,)) + Exception.__init__(self, string) + + def __str__(self): + return self.args[0].encode("utf-8") + + def __unicode__(self): + return self.args[0] + # For 3.0 and above the default __str__ is fine, so we don't define one. + def __hash__(self): return id(self) - def __str__(self): - """Stringify better than 2.x's default behaviour of ascii encoding.""" - return self.args[0] - def __eq__(self, other): try: return self.args == other.args @@ -537,4 +568,4 @@ def _details_to_str(details): if not chars[-1].endswith('\n'): chars.append('\n') chars.append('------------\n') - return ''.join(chars) + return _u('').join(chars) diff --git a/lib/testtools/testtools/tests/__init__.py b/lib/testtools/testtools/tests/__init__.py index 2cceba91e2..5e22000bb4 100644 --- a/lib/testtools/testtools/tests/__init__.py +++ b/lib/testtools/testtools/tests/__init__.py @@ -4,9 +4,11 @@ import unittest from testtools.tests import ( + test_compat, test_content, test_content_type, test_matchers, + test_monkey, test_runtest, test_testtools, test_testresult, @@ -17,9 +19,11 @@ from testtools.tests import ( def test_suite(): suites = [] modules = [ + test_compat, test_content, test_content_type, test_matchers, + test_monkey, test_runtest, test_testresult, test_testsuite, diff --git a/lib/testtools/testtools/tests/test_compat.py b/lib/testtools/testtools/tests/test_compat.py new file mode 100644 index 0000000000..138b286d5d --- /dev/null +++ b/lib/testtools/testtools/tests/test_compat.py @@ -0,0 +1,251 @@ +# Copyright (c) 2010 testtools developers. See LICENSE for details. + +"""Tests for miscellaneous compatibility functions""" + +import linecache +import os +import sys +import tempfile +import traceback + +import testtools + +from testtools.compat import ( + _b, + _detect_encoding, + _get_source_encoding, + _u, + unicode_output_stream, + ) + + +class TestDetectEncoding(testtools.TestCase): + """Test detection of Python source encodings""" + + def _check_encoding(self, expected, lines, possibly_invalid=False): + """Check lines are valid Python and encoding is as expected""" + if not possibly_invalid: + compile(_b("".join(lines)), "<str>", "exec") + encoding = _detect_encoding(lines) + self.assertEqual(expected, encoding, + "Encoding %r expected but got %r from lines %r" % + (expected, encoding, lines)) + + def test_examples_from_pep(self): + """Check the examples given in PEP 263 all work as specified + + See 'Examples' section of <http://www.python.org/dev/peps/pep-0263/> + """ + # With interpreter binary and using Emacs style file encoding comment: + self._check_encoding("latin-1", ( + "#!/usr/bin/python\n", + "# -*- coding: latin-1 -*-\n", + "import os, sys\n")) + self._check_encoding("iso-8859-15", ( + "#!/usr/bin/python\n", + "# -*- coding: iso-8859-15 -*-\n", + "import os, sys\n")) + self._check_encoding("ascii", ( + "#!/usr/bin/python\n", + "# -*- coding: ascii -*-\n", + "import os, sys\n")) + # Without interpreter line, using plain text: + self._check_encoding("utf-8", ( + "# This Python file uses the following encoding: utf-8\n", + "import os, sys\n")) + # Text editors might have different ways of defining the file's + # encoding, e.g. + self._check_encoding("latin-1", ( + "#!/usr/local/bin/python\n", + "# coding: latin-1\n", + "import os, sys\n")) + # Without encoding comment, Python's parser will assume ASCII text: + self._check_encoding("ascii", ( + "#!/usr/local/bin/python\n", + "import os, sys\n")) + # Encoding comments which don't work: + # Missing "coding:" prefix: + self._check_encoding("ascii", ( + "#!/usr/local/bin/python\n", + "# latin-1\n", + "import os, sys\n")) + # Encoding comment not on line 1 or 2: + self._check_encoding("ascii", ( + "#!/usr/local/bin/python\n", + "#\n", + "# -*- coding: latin-1 -*-\n", + "import os, sys\n")) + # Unsupported encoding: + self._check_encoding("ascii", ( + "#!/usr/local/bin/python\n", + "# -*- coding: utf-42 -*-\n", + "import os, sys\n"), + possibly_invalid=True) + + def test_bom(self): + """Test the UTF-8 BOM counts as an encoding declaration""" + self._check_encoding("utf-8", ( + "\xef\xbb\xbfimport sys\n", + )) + self._check_encoding("utf-8", ( + "\xef\xbb\xbf# File encoding: UTF-8\n", + )) + self._check_encoding("utf-8", ( + '\xef\xbb\xbf"""Module docstring\n', + '\xef\xbb\xbfThat should just be a ZWNB"""\n')) + self._check_encoding("latin-1", ( + '"""Is this coding: latin-1 or coding: utf-8 instead?\n', + '\xef\xbb\xbfThose should be latin-1 bytes"""\n')) + self._check_encoding("utf-8", ( + "\xef\xbb\xbf# Is the coding: utf-8 or coding: euc-jp instead?\n", + '"""Module docstring say \xe2\x98\x86"""\n')) + + def test_multiple_coding_comments(self): + """Test only the first of multiple coding declarations counts""" + self._check_encoding("iso-8859-1", ( + "# Is the coding: iso-8859-1\n", + "# Or is it coding: iso-8859-2\n"), + possibly_invalid=True) + self._check_encoding("iso-8859-1", ( + "#!/usr/bin/python\n", + "# Is the coding: iso-8859-1\n", + "# Or is it coding: iso-8859-2\n")) + self._check_encoding("iso-8859-1", ( + "# Is the coding: iso-8859-1 or coding: iso-8859-2\n", + "# Or coding: iso-8859-3 or coding: iso-8859-4\n"), + possibly_invalid=True) + self._check_encoding("iso-8859-2", ( + "# Is the coding iso-8859-1 or coding: iso-8859-2\n", + "# Spot the missing colon above\n")) + + +class TestGetSourceEncoding(testtools.TestCase): + """Test reading and caching the encodings of source files""" + + def setUp(self): + testtools.TestCase.setUp(self) + dir = tempfile.mkdtemp() + self.addCleanup(os.rmdir, dir) + self.filename = os.path.join(dir, self.id().rsplit(".", 1)[1] + ".py") + self._written = False + + def put_source(self, text): + f = open(self.filename, "w") + try: + f.write(text) + finally: + f.close() + if not self._written: + self._written = True + self.addCleanup(os.remove, self.filename) + self.addCleanup(linecache.cache.pop, self.filename, None) + + def test_nonexistant_file_as_ascii(self): + """When file can't be found, the encoding should default to ascii""" + self.assertEquals("ascii", _get_source_encoding(self.filename)) + + def test_encoding_is_cached(self): + """The encoding should stay the same if the cache isn't invalidated""" + self.put_source( + "# coding: iso-8859-13\n" + "import os\n") + self.assertEquals("iso-8859-13", _get_source_encoding(self.filename)) + self.put_source( + "# coding: rot-13\n" + "vzcbeg bf\n") + self.assertEquals("iso-8859-13", _get_source_encoding(self.filename)) + + def test_traceback_rechecks_encoding(self): + """A traceback function checks the cache and resets the encoding""" + self.put_source( + "# coding: iso-8859-8\n" + "import os\n") + self.assertEquals("iso-8859-8", _get_source_encoding(self.filename)) + self.put_source( + "# coding: utf-8\n" + "import os\n") + try: + exec (compile("raise RuntimeError\n", self.filename, "exec")) + except RuntimeError: + traceback.extract_tb(sys.exc_info()[2]) + else: + self.fail("RuntimeError not raised") + self.assertEquals("utf-8", _get_source_encoding(self.filename)) + + +class _FakeOutputStream(object): + """A simple file-like object for testing""" + + def __init__(self): + self.writelog = [] + + def write(self, obj): + self.writelog.append(obj) + + +class TestUnicodeOutputStream(testtools.TestCase): + """Test wrapping output streams so they work with arbitrary unicode""" + + uni = _u("pa\u026a\u03b8\u0259n") + + def setUp(self): + super(TestUnicodeOutputStream, self).setUp() + if sys.platform == "cli": + self.skip("IronPython shouldn't wrap streams to do encoding") + + def test_no_encoding_becomes_ascii(self): + """A stream with no encoding attribute gets ascii/replace strings""" + sout = _FakeOutputStream() + unicode_output_stream(sout).write(self.uni) + self.assertEqual([_b("pa???n")], sout.writelog) + + def test_encoding_as_none_becomes_ascii(self): + """A stream with encoding value of None gets ascii/replace strings""" + sout = _FakeOutputStream() + sout.encoding = None + unicode_output_stream(sout).write(self.uni) + self.assertEqual([_b("pa???n")], sout.writelog) + + def test_bogus_encoding_becomes_ascii(self): + """A stream with a bogus encoding gets ascii/replace strings""" + sout = _FakeOutputStream() + sout.encoding = "bogus" + unicode_output_stream(sout).write(self.uni) + self.assertEqual([_b("pa???n")], sout.writelog) + + def test_partial_encoding_replace(self): + """A string which can be partly encoded correctly should be""" + sout = _FakeOutputStream() + sout.encoding = "iso-8859-7" + unicode_output_stream(sout).write(self.uni) + self.assertEqual([_b("pa?\xe8?n")], sout.writelog) + + def test_unicode_encodings_not_wrapped(self): + """A unicode encoding is left unwrapped as needs no error handler""" + sout = _FakeOutputStream() + sout.encoding = "utf-8" + self.assertIs(unicode_output_stream(sout), sout) + sout = _FakeOutputStream() + sout.encoding = "utf-16-be" + self.assertIs(unicode_output_stream(sout), sout) + + def test_stringio(self): + """A StringIO object should maybe get an ascii native str type""" + try: + from cStringIO import StringIO + newio = False + except ImportError: + from io import StringIO + newio = True + sout = StringIO() + soutwrapper = unicode_output_stream(sout) + if newio: + self.expectFailure("Python 3 StringIO expects text not bytes", + self.assertRaises, TypeError, soutwrapper.write, self.uni) + soutwrapper.write(self.uni) + self.assertEqual("pa???n", sout.getvalue()) + + +def test_suite(): + from unittest import TestLoader + return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_content.py b/lib/testtools/testtools/tests/test_content.py index 1159362036..741256ef7a 100644 --- a/lib/testtools/testtools/tests/test_content.py +++ b/lib/testtools/testtools/tests/test_content.py @@ -1,18 +1,14 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. import unittest +from testtools import TestCase +from testtools.compat import _u from testtools.content import Content, TracebackContent from testtools.content_type import ContentType -from testtools.utils import _u from testtools.tests.helpers import an_exc_info -def test_suite(): - from unittest import TestLoader - return TestLoader().loadTestsFromName(__name__) - - -class TestContent(unittest.TestCase): +class TestContent(TestCase): def test___init___None_errors(self): self.assertRaises(ValueError, Content, None, None) @@ -57,7 +53,7 @@ class TestContent(unittest.TestCase): self.assertEqual([text], list(content.iter_text())) -class TestTracebackContent(unittest.TestCase): +class TestTracebackContent(TestCase): def test___init___None_errors(self): self.assertRaises(ValueError, TracebackContent, None, None) @@ -70,3 +66,8 @@ class TestTracebackContent(unittest.TestCase): result = unittest.TestResult() expected = result._exc_info_to_string(an_exc_info, self) self.assertEqual(expected, ''.join(list(content.iter_text()))) + + +def test_suite(): + from unittest import TestLoader + return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_content_type.py b/lib/testtools/testtools/tests/test_content_type.py index dbefc21dec..d593a14eaf 100644 --- a/lib/testtools/testtools/tests/test_content_type.py +++ b/lib/testtools/testtools/tests/test_content_type.py @@ -1,15 +1,11 @@ # Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. -import unittest -from testtools.content_type import ContentType +from testtools import TestCase +from testtools.matchers import Equals +from testtools.content_type import ContentType, UTF8_TEXT -def test_suite(): - from unittest import TestLoader - return TestLoader().loadTestsFromName(__name__) - - -class TestContentType(unittest.TestCase): +class TestContentType(TestCase): def test___init___None_errors(self): self.assertRaises(ValueError, ContentType, None, None) @@ -23,12 +19,26 @@ class TestContentType(unittest.TestCase): self.assertEqual({}, content_type.parameters) def test___init___with_parameters(self): - content_type = ContentType("foo", "bar", {"quux":"thing"}) - self.assertEqual({"quux":"thing"}, content_type.parameters) + content_type = ContentType("foo", "bar", {"quux": "thing"}) + self.assertEqual({"quux": "thing"}, content_type.parameters) def test___eq__(self): - content_type1 = ContentType("foo", "bar", {"quux":"thing"}) - content_type2 = ContentType("foo", "bar", {"quux":"thing"}) - content_type3 = ContentType("foo", "bar", {"quux":"thing2"}) + content_type1 = ContentType("foo", "bar", {"quux": "thing"}) + content_type2 = ContentType("foo", "bar", {"quux": "thing"}) + content_type3 = ContentType("foo", "bar", {"quux": "thing2"}) self.assertTrue(content_type1.__eq__(content_type2)) self.assertFalse(content_type1.__eq__(content_type3)) + + +class TestBuiltinContentTypes(TestCase): + + def test_plain_text(self): + # The UTF8_TEXT content type represents UTF-8 encoded text/plain. + self.assertThat(UTF8_TEXT.type, Equals('text')) + self.assertThat(UTF8_TEXT.subtype, Equals('plain')) + self.assertThat(UTF8_TEXT.parameters, Equals({'charset': 'utf8'})) + + +def test_suite(): + from unittest import TestLoader + return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_matchers.py b/lib/testtools/testtools/tests/test_matchers.py index 74b1ebc56a..164a6a0c50 100644 --- a/lib/testtools/testtools/tests/test_matchers.py +++ b/lib/testtools/testtools/tests/test_matchers.py @@ -12,14 +12,33 @@ from testtools.matchers import ( Annotate, Equals, DocTestMatches, + Is, + LessThan, MatchesAny, MatchesAll, + Mismatch, Not, NotEquals, ) +# Silence pyflakes. +Matcher -class TestMatchersInterface: + +class TestMismatch(TestCase): + + def test_constructor_arguments(self): + mismatch = Mismatch("some description", {'detail': "things"}) + self.assertEqual("some description", mismatch.describe()) + self.assertEqual({'detail': "things"}, mismatch.get_details()) + + def test_constructor_no_arguments(self): + mismatch = Mismatch() + self.assertRaises(NotImplementedError, mismatch.describe) + self.assertEqual({}, mismatch.get_details()) + + +class TestMatchersInterface(object): def test_matches_match(self): matcher = self.matches_matcher @@ -45,6 +64,15 @@ class TestMatchersInterface: mismatch = matcher.match(matchee) self.assertEqual(difference, mismatch.describe()) + def test_mismatch_details(self): + # The mismatch object must provide get_details, which must return a + # dictionary mapping names to Content objects. + examples = self.describe_examples + for difference, matchee, matcher in examples: + mismatch = matcher.match(matchee) + details = mismatch.get_details() + self.assertEqual(dict(details), details) + class TestDocTestMatchesInterface(TestCase, TestMatchersInterface): @@ -97,6 +125,33 @@ class TestNotEqualsInterface(TestCase, TestMatchersInterface): describe_examples = [("1 == 1", 1, NotEquals(1))] +class TestIsInterface(TestCase, TestMatchersInterface): + + foo = object() + bar = object() + + matches_matcher = Is(foo) + matches_matches = [foo] + matches_mismatches = [bar, 1] + + str_examples = [("Is(2)", Is(2))] + + describe_examples = [("1 is not 2", 2, Is(1))] + + +class TestLessThanInterface(TestCase, TestMatchersInterface): + + matches_matcher = LessThan(4) + matches_matches = [-5, 3] + matches_mismatches = [4, 5, 5000] + + str_examples = [ + ("LessThan(12)", LessThan(12)), + ] + + describe_examples = [('4 is >= 4', 4, LessThan(4))] + + class TestNotInterface(TestCase, TestMatchersInterface): matches_matcher = Not(Equals(1)) diff --git a/lib/testtools/testtools/tests/test_monkey.py b/lib/testtools/testtools/tests/test_monkey.py new file mode 100644 index 0000000000..09388b22f1 --- /dev/null +++ b/lib/testtools/testtools/tests/test_monkey.py @@ -0,0 +1,166 @@ +# Copyright (c) 2010 Twisted Matrix Laboratories. +# See LICENSE for details. + +"""Tests for testtools.monkey.""" + +from testtools import TestCase +from testtools.monkey import MonkeyPatcher, patch + + +class TestObj: + + def __init__(self): + self.foo = 'foo value' + self.bar = 'bar value' + self.baz = 'baz value' + + +class MonkeyPatcherTest(TestCase): + """ + Tests for 'MonkeyPatcher' monkey-patching class. + """ + + def setUp(self): + super(MonkeyPatcherTest, self).setUp() + self.test_object = TestObj() + self.original_object = TestObj() + self.monkey_patcher = MonkeyPatcher() + + def test_empty(self): + # A monkey patcher without patches doesn't change a thing. + self.monkey_patcher.patch() + + # We can't assert that all state is unchanged, but at least we can + # check our test object. + self.assertEquals(self.original_object.foo, self.test_object.foo) + self.assertEquals(self.original_object.bar, self.test_object.bar) + self.assertEquals(self.original_object.baz, self.test_object.baz) + + def test_construct_with_patches(self): + # Constructing a 'MonkeyPatcher' with patches adds all of the given + # patches to the patch list. + patcher = MonkeyPatcher((self.test_object, 'foo', 'haha'), + (self.test_object, 'bar', 'hehe')) + patcher.patch() + self.assertEquals('haha', self.test_object.foo) + self.assertEquals('hehe', self.test_object.bar) + self.assertEquals(self.original_object.baz, self.test_object.baz) + + def test_patch_existing(self): + # Patching an attribute that exists sets it to the value defined in the + # patch. + self.monkey_patcher.add_patch(self.test_object, 'foo', 'haha') + self.monkey_patcher.patch() + self.assertEquals(self.test_object.foo, 'haha') + + def test_patch_non_existing(self): + # Patching a non-existing attribute sets it to the value defined in + # the patch. + self.monkey_patcher.add_patch(self.test_object, 'doesntexist', 'value') + self.monkey_patcher.patch() + self.assertEquals(self.test_object.doesntexist, 'value') + + def test_restore_non_existing(self): + # Restoring a value that didn't exist before the patch deletes the + # value. + self.monkey_patcher.add_patch(self.test_object, 'doesntexist', 'value') + self.monkey_patcher.patch() + self.monkey_patcher.restore() + marker = object() + self.assertIs(marker, getattr(self.test_object, 'doesntexist', marker)) + + def test_patch_already_patched(self): + # Adding a patch for an object and attribute that already have a patch + # overrides the existing patch. + self.monkey_patcher.add_patch(self.test_object, 'foo', 'blah') + self.monkey_patcher.add_patch(self.test_object, 'foo', 'BLAH') + self.monkey_patcher.patch() + self.assertEquals(self.test_object.foo, 'BLAH') + self.monkey_patcher.restore() + self.assertEquals(self.test_object.foo, self.original_object.foo) + + def test_restore_twice_is_a_no_op(self): + # Restoring an already-restored monkey patch is a no-op. + self.monkey_patcher.add_patch(self.test_object, 'foo', 'blah') + self.monkey_patcher.patch() + self.monkey_patcher.restore() + self.assertEquals(self.test_object.foo, self.original_object.foo) + self.monkey_patcher.restore() + self.assertEquals(self.test_object.foo, self.original_object.foo) + + def test_run_with_patches_decoration(self): + # run_with_patches runs the given callable, passing in all arguments + # and keyword arguments, and returns the return value of the callable. + log = [] + + def f(a, b, c=None): + log.append((a, b, c)) + return 'foo' + + result = self.monkey_patcher.run_with_patches(f, 1, 2, c=10) + self.assertEquals('foo', result) + self.assertEquals([(1, 2, 10)], log) + + def test_repeated_run_with_patches(self): + # We can call the same function with run_with_patches more than + # once. All patches apply for each call. + def f(): + return (self.test_object.foo, self.test_object.bar, + self.test_object.baz) + + self.monkey_patcher.add_patch(self.test_object, 'foo', 'haha') + result = self.monkey_patcher.run_with_patches(f) + self.assertEquals( + ('haha', self.original_object.bar, self.original_object.baz), + result) + result = self.monkey_patcher.run_with_patches(f) + self.assertEquals( + ('haha', self.original_object.bar, self.original_object.baz), + result) + + def test_run_with_patches_restores(self): + # run_with_patches restores the original values after the function has + # executed. + self.monkey_patcher.add_patch(self.test_object, 'foo', 'haha') + self.assertEquals(self.original_object.foo, self.test_object.foo) + self.monkey_patcher.run_with_patches(lambda: None) + self.assertEquals(self.original_object.foo, self.test_object.foo) + + def test_run_with_patches_restores_on_exception(self): + # run_with_patches restores the original values even when the function + # raises an exception. + def _(): + self.assertEquals(self.test_object.foo, 'haha') + self.assertEquals(self.test_object.bar, 'blahblah') + raise RuntimeError, "Something went wrong!" + + self.monkey_patcher.add_patch(self.test_object, 'foo', 'haha') + self.monkey_patcher.add_patch(self.test_object, 'bar', 'blahblah') + + self.assertRaises( + RuntimeError, self.monkey_patcher.run_with_patches, _) + self.assertEquals(self.test_object.foo, self.original_object.foo) + self.assertEquals(self.test_object.bar, self.original_object.bar) + + +class TestPatchHelper(TestCase): + + def test_patch_patches(self): + # patch(obj, name, value) sets obj.name to value. + test_object = TestObj() + patch(test_object, 'foo', 42) + self.assertEqual(42, test_object.foo) + + def test_patch_returns_cleanup(self): + # patch(obj, name, value) returns a nullary callable that restores obj + # to its original state when run. + test_object = TestObj() + original = test_object.foo + cleanup = patch(test_object, 'foo', 42) + cleanup() + self.assertEqual(original, test_object.foo) + + +def test_suite(): + from unittest import TestLoader + return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_runtest.py b/lib/testtools/testtools/tests/test_runtest.py index 5c46ad1784..a4c0a728b1 100644 --- a/lib/testtools/testtools/tests/test_runtest.py +++ b/lib/testtools/testtools/tests/test_runtest.py @@ -77,14 +77,12 @@ class TestRunTest(TestCase): e = KeyError('Yo') def raises(): raise e - def log_exc(self, result, err): - log.append((result, err)) - run = RunTest(case, [(KeyError, log_exc)]) + run = RunTest(case, [(KeyError, None)]) run.result = ExtendedTestResult() status = run._run_user(raises) self.assertEqual(run.exception_caught, status) self.assertEqual([], run.result._events) - self.assertEqual(["got it", (run.result, e)], log) + self.assertEqual(["got it"], log) def test__run_user_can_catch_Exception(self): case = self.make_case() @@ -92,14 +90,12 @@ class TestRunTest(TestCase): def raises(): raise e log = [] - def log_exc(self, result, err): - log.append((result, err)) - run = RunTest(case, [(Exception, log_exc)]) + run = RunTest(case, [(Exception, None)]) run.result = ExtendedTestResult() status = run._run_user(raises) self.assertEqual(run.exception_caught, status) self.assertEqual([], run.result._events) - self.assertEqual([(run.result, e)], log) + self.assertEqual([], log) def test__run_user_uncaught_Exception_raised(self): case = self.make_case() diff --git a/lib/testtools/testtools/tests/test_testresult.py b/lib/testtools/testtools/tests/test_testresult.py index df15b91244..1a19440069 100644 --- a/lib/testtools/testtools/tests/test_testresult.py +++ b/lib/testtools/testtools/tests/test_testresult.py @@ -4,14 +4,19 @@ __metaclass__ = type +import codecs import datetime try: - from cStringIO import StringIO + from StringIO import StringIO except ImportError: from io import StringIO import doctest +import os +import shutil import sys +import tempfile import threading +import warnings from testtools import ( ExtendedToOriginalDecorator, @@ -22,9 +27,15 @@ from testtools import ( ThreadsafeForwardingResult, testresult, ) +from testtools.compat import ( + _b, + _get_exception_encoding, + _r, + _u, + str_is_unicode, + ) from testtools.content import Content, ContentType from testtools.matchers import DocTestMatches -from testtools.utils import _u, _b from testtools.tests.helpers import ( LoggingResult, Python26TestResult, @@ -253,8 +264,19 @@ class TestMultiTestResult(TestWithFakeExceptions): self.multiResult.stopTestRun() self.assertResultLogsEqual([('stopTestRun')]) + def test_stopTestRun_returns_results(self): + # `MultiTestResult.stopTestRun` returns a tuple of all of the return + # values the `stopTestRun`s that it forwards to. + class Result(LoggingResult): + def stopTestRun(self): + super(Result, self).stopTestRun() + return 'foo' + multi_result = MultiTestResult(Result([]), Result([])) + result = multi_result.stopTestRun() + self.assertEqual(('foo', 'foo'), result) + -class TestTextTestResult(TestWithFakeExceptions): +class TestTextTestResult(TestCase): """Tests for `TextTestResult`.""" def setUp(self): @@ -377,7 +399,7 @@ Traceback (most recent call last): testMethod() File "...testtools...tests...test_testresult.py", line ..., in error 1/0 -ZeroDivisionError: int... division or modulo by zero +ZeroDivisionError:... divi... by zero... ------------ ====================================================================== FAIL: testtools.tests.test_testresult.Test.failed @@ -578,7 +600,7 @@ class TestExtendedToOriginalResultDecorator( self.make_26_result() self.converter.startTest(self) self.assertEqual([('startTest', self)], self.result._events) - + def test_startTest_py27(self): self.make_27_result() self.converter.startTest(self) @@ -593,7 +615,7 @@ class TestExtendedToOriginalResultDecorator( self.make_26_result() self.converter.startTestRun() self.assertEqual([], self.result._events) - + def test_startTestRun_py27(self): self.make_27_result() self.converter.startTestRun() @@ -608,7 +630,7 @@ class TestExtendedToOriginalResultDecorator( self.make_26_result() self.converter.stopTest(self) self.assertEqual([('stopTest', self)], self.result._events) - + def test_stopTest_py27(self): self.make_27_result() self.converter.stopTest(self) @@ -623,7 +645,7 @@ class TestExtendedToOriginalResultDecorator( self.make_26_result() self.converter.stopTestRun() self.assertEqual([], self.result._events) - + def test_stopTestRun_py27(self): self.make_27_result() self.converter.stopTestRun() @@ -668,7 +690,7 @@ class TestExtendedToOriginalAddError(TestExtendedToOriginalResultDecoratorBase): def test_outcome_Original_py26(self): self.make_26_result() self.check_outcome_exc_info(self.outcome) - + def test_outcome_Original_py27(self): self.make_27_result() self.check_outcome_exc_info(self.outcome) @@ -680,7 +702,7 @@ class TestExtendedToOriginalAddError(TestExtendedToOriginalResultDecoratorBase): def test_outcome_Extended_py26(self): self.make_26_result() self.check_outcome_details_to_exec_info(self.outcome) - + def test_outcome_Extended_py27(self): self.make_27_result() self.check_outcome_details_to_exec_info(self.outcome) @@ -709,11 +731,11 @@ class TestExtendedToOriginalAddExpectedFailure( def test_outcome_Original_py26(self): self.make_26_result() self.check_outcome_exc_info_to_nothing(self.outcome, 'addSuccess') - + def test_outcome_Extended_py26(self): self.make_26_result() self.check_outcome_details_to_nothing(self.outcome, 'addSuccess') - + class TestExtendedToOriginalAddSkip( @@ -724,7 +746,7 @@ class TestExtendedToOriginalAddSkip( def test_outcome_Original_py26(self): self.make_26_result() self.check_outcome_string_nothing(self.outcome, 'addSuccess') - + def test_outcome_Original_py27(self): self.make_27_result() self.check_outcome_string(self.outcome) @@ -736,7 +758,7 @@ class TestExtendedToOriginalAddSkip( def test_outcome_Extended_py26(self): self.make_26_result() self.check_outcome_string_nothing(self.outcome, 'addSuccess') - + def test_outcome_Extended_py27(self): self.make_27_result() self.check_outcome_details_to_string(self.outcome) @@ -760,7 +782,7 @@ class TestExtendedToOriginalAddSuccess( def test_outcome_Original_py26(self): self.make_26_result() self.check_outcome_nothing(self.outcome, self.expected) - + def test_outcome_Original_py27(self): self.make_27_result() self.check_outcome_nothing(self.outcome) @@ -772,7 +794,7 @@ class TestExtendedToOriginalAddSuccess( def test_outcome_Extended_py26(self): self.make_26_result() self.check_outcome_details_to_nothing(self.outcome, self.expected) - + def test_outcome_Extended_py27(self): self.make_27_result() self.check_outcome_details_to_nothing(self.outcome) @@ -800,7 +822,299 @@ class TestExtendedToOriginalResultOtherAttributes( self.make_converter() self.assertEqual(1, self.converter.bar) self.assertEqual(2, self.converter.foo()) - + + +class TestNonAsciiResults(TestCase): + """Test all kinds of tracebacks are cleanly interpreted as unicode + + Currently only uses weak "contains" assertions, would be good to be much + stricter about the expected output. This would add a few failures for the + current release of IronPython for instance, which gets some traceback + lines muddled. + """ + + _sample_texts = ( + _u("pa\u026a\u03b8\u0259n"), # Unicode encodings only + _u("\u5357\u7121"), # In ISO 2022 encodings + _u("\xa7\xa7\xa7"), # In ISO 8859 encodings + ) + # Everything but Jython shows syntax errors on the current character + _error_on_character = os.name != "java" + + def _run(self, stream, test): + """Run the test, the same as in testtools.run but not to stdout""" + result = TextTestResult(stream) + result.startTestRun() + try: + return test.run(result) + finally: + result.stopTestRun() + + def _write_module(self, name, encoding, contents): + """Create Python module on disk with contents in given encoding""" + try: + # Need to pre-check that the coding is valid or codecs.open drops + # the file without closing it which breaks non-refcounted pythons + codecs.lookup(encoding) + except LookupError: + self.skip("Encoding unsupported by implementation: %r" % encoding) + f = codecs.open(os.path.join(self.dir, name + ".py"), "w", encoding) + try: + f.write(contents) + finally: + f.close() + + def _test_external_case(self, testline, coding="ascii", modulelevel="", + suffix=""): + """Create and run a test case in a seperate module""" + self._setup_external_case(testline, coding, modulelevel, suffix) + return self._run_external_case() + + def _setup_external_case(self, testline, coding="ascii", modulelevel="", + suffix=""): + """Create a test case in a seperate module""" + _, prefix, self.modname = self.id().rsplit(".", 2) + self.dir = tempfile.mkdtemp(prefix=prefix, suffix=suffix) + self.addCleanup(shutil.rmtree, self.dir) + self._write_module(self.modname, coding, + # Older Python 2 versions don't see a coding declaration in a + # docstring so it has to be in a comment, but then we can't + # workaround bug: <http://ironpython.codeplex.com/workitem/26940> + "# coding: %s\n" + "import testtools\n" + "%s\n" + "class Test(testtools.TestCase):\n" + " def runTest(self):\n" + " %s\n" % (coding, modulelevel, testline)) + + def _run_external_case(self): + """Run the prepared test case in a seperate module""" + sys.path.insert(0, self.dir) + self.addCleanup(sys.path.remove, self.dir) + module = __import__(self.modname) + self.addCleanup(sys.modules.pop, self.modname) + stream = StringIO() + self._run(stream, module.Test()) + return stream.getvalue() + + def _silence_deprecation_warnings(self): + """Shut up DeprecationWarning for this test only""" + warnings.simplefilter("ignore", DeprecationWarning) + self.addCleanup(warnings.filters.remove, warnings.filters[0]) + + def _get_sample_text(self, encoding="unicode_internal"): + if encoding is None and str_is_unicode: + encoding = "unicode_internal" + for u in self._sample_texts: + try: + b = u.encode(encoding) + if u == b.decode(encoding): + if str_is_unicode: + return u, u + return u, b + except (LookupError, UnicodeError): + pass + self.skip("Could not find a sample text for encoding: %r" % encoding) + + def _as_output(self, text): + return text + + def test_non_ascii_failure_string(self): + """Assertion contents can be non-ascii and should get decoded""" + text, raw = self._get_sample_text(_get_exception_encoding()) + textoutput = self._test_external_case("self.fail(%s)" % _r(raw)) + self.assertIn(self._as_output(text), textoutput) + + def test_non_ascii_failure_string_via_exec(self): + """Assertion via exec can be non-ascii and still gets decoded""" + text, raw = self._get_sample_text(_get_exception_encoding()) + textoutput = self._test_external_case( + testline='exec ("self.fail(%s)")' % _r(raw)) + self.assertIn(self._as_output(text), textoutput) + + def test_control_characters_in_failure_string(self): + """Control characters in assertions should be escaped""" + textoutput = self._test_external_case("self.fail('\\a\\a\\a')") + self.expectFailure("Defense against the beeping horror unimplemented", + self.assertNotIn, self._as_output("\a\a\a"), textoutput) + self.assertIn(self._as_output(_u("\uFFFD\uFFFD\uFFFD")), textoutput) + + def test_os_error(self): + """Locale error messages from the OS shouldn't break anything""" + textoutput = self._test_external_case( + modulelevel="import os", + testline="os.mkdir('/')") + if os.name != "nt" or sys.version_info < (2, 5): + self.assertIn(self._as_output("OSError: "), textoutput) + else: + self.assertIn(self._as_output("WindowsError: "), textoutput) + + def test_assertion_text_shift_jis(self): + """A terminal raw backslash in an encoded string is weird but fine""" + example_text = _u("\u5341") + textoutput = self._test_external_case( + coding="shift_jis", + testline="self.fail('%s')" % example_text) + if str_is_unicode: + output_text = example_text + else: + output_text = example_text.encode("shift_jis").decode( + _get_exception_encoding(), "replace") + self.assertIn(self._as_output("AssertionError: %s" % output_text), + textoutput) + + def test_file_comment_iso2022_jp(self): + """Control character escapes must be preserved if valid encoding""" + example_text, _ = self._get_sample_text("iso2022_jp") + textoutput = self._test_external_case( + coding="iso2022_jp", + testline="self.fail('Simple') # %s" % example_text) + self.assertIn(self._as_output(example_text), textoutput) + + def test_unicode_exception(self): + """Exceptions that can be formated losslessly as unicode should be""" + example_text, _ = self._get_sample_text() + exception_class = ( + "class FancyError(Exception):\n" + # A __unicode__ method does nothing on py3k but the default works + " def __unicode__(self):\n" + " return self.args[0]\n") + textoutput = self._test_external_case( + modulelevel=exception_class, + testline="raise FancyError(%s)" % _r(example_text)) + self.assertIn(self._as_output(example_text), textoutput) + + def test_unprintable_exception(self): + """A totally useless exception instance still prints something""" + exception_class = ( + "class UnprintableError(Exception):\n" + " def __str__(self):\n" + " raise RuntimeError\n" + " def __repr__(self):\n" + " raise RuntimeError\n") + textoutput = self._test_external_case( + modulelevel=exception_class, + testline="raise UnprintableError") + self.assertIn(self._as_output( + "UnprintableError: <unprintable UnprintableError object>\n"), + textoutput) + + def test_string_exception(self): + """Raise a string rather than an exception instance if supported""" + if sys.version_info > (2, 6): + self.skip("No string exceptions in Python 2.6 or later") + elif sys.version_info > (2, 5): + self._silence_deprecation_warnings() + textoutput = self._test_external_case(testline="raise 'plain str'") + self.assertIn(self._as_output("\nplain str\n"), textoutput) + + def test_non_ascii_dirname(self): + """Script paths in the traceback can be non-ascii""" + text, raw = self._get_sample_text(sys.getfilesystemencoding()) + textoutput = self._test_external_case( + # Avoid bug in Python 3 by giving a unicode source encoding rather + # than just ascii which raises a SyntaxError with no other details + coding="utf-8", + testline="self.fail('Simple')", + suffix=raw) + self.assertIn(self._as_output(text), textoutput) + + def test_syntax_error(self): + """Syntax errors should still have fancy special-case formatting""" + textoutput = self._test_external_case("exec ('f(a, b c)')") + self.assertIn(self._as_output( + ' File "<string>", line 1\n' + ' f(a, b c)\n' + + ' ' * self._error_on_character + + ' ^\n' + 'SyntaxError: ' + ), textoutput) + + def test_syntax_error_import_binary(self): + """Importing a binary file shouldn't break SyntaxError formatting""" + if sys.version_info < (2, 5): + # Python 2.4 assumes the file is latin-1 and tells you off + self._silence_deprecation_warnings() + self._setup_external_case("import bad") + f = open(os.path.join(self.dir, "bad.py"), "wb") + try: + f.write(_b("x\x9c\xcb*\xcd\xcb\x06\x00\x04R\x01\xb9")) + finally: + f.close() + textoutput = self._run_external_case() + self.assertIn(self._as_output("\nSyntaxError: "), textoutput) + + def test_syntax_error_line_iso_8859_1(self): + """Syntax error on a latin-1 line shows the line decoded""" + text, raw = self._get_sample_text("iso-8859-1") + textoutput = self._setup_external_case("import bad") + self._write_module("bad", "iso-8859-1", + "# coding: iso-8859-1\n! = 0 # %s\n" % text) + textoutput = self._run_external_case() + self.assertIn(self._as_output(_u( + #'bad.py", line 2\n' + ' ! = 0 # %s\n' + ' ^\n' + 'SyntaxError: ') % + (text,)), textoutput) + + def test_syntax_error_line_iso_8859_5(self): + """Syntax error on a iso-8859-5 line shows the line decoded""" + text, raw = self._get_sample_text("iso-8859-5") + textoutput = self._setup_external_case("import bad") + self._write_module("bad", "iso-8859-5", + "# coding: iso-8859-5\n%% = 0 # %s\n" % text) + textoutput = self._run_external_case() + self.assertIn(self._as_output(_u( + #'bad.py", line 2\n' + ' %% = 0 # %s\n' + + ' ' * self._error_on_character + + ' ^\n' + 'SyntaxError: ') % + (text,)), textoutput) + + def test_syntax_error_line_euc_jp(self): + """Syntax error on a euc_jp line shows the line decoded""" + text, raw = self._get_sample_text("euc_jp") + textoutput = self._setup_external_case("import bad") + self._write_module("bad", "euc_jp", + "# coding: euc_jp\n$ = 0 # %s\n" % text) + textoutput = self._run_external_case() + self.assertIn(self._as_output(_u( + #'bad.py", line 2\n' + ' $ = 0 # %s\n' + + ' ' * self._error_on_character + + ' ^\n' + 'SyntaxError: ') % + (text,)), textoutput) + + def test_syntax_error_line_utf_8(self): + """Syntax error on a utf-8 line shows the line decoded""" + text, raw = self._get_sample_text("utf-8") + textoutput = self._setup_external_case("import bad") + self._write_module("bad", "utf-8", _u("\ufeff^ = 0 # %s\n") % text) + textoutput = self._run_external_case() + self.assertIn(self._as_output(_u( + 'bad.py", line 1\n' + ' ^ = 0 # %s\n' + + ' ' * self._error_on_character + + ' ^\n' + 'SyntaxError: ') % + text), textoutput) + + +class TestNonAsciiResultsWithUnittest(TestNonAsciiResults): + """Test that running under unittest produces clean ascii strings""" + + def _run(self, stream, test): + from unittest import TextTestRunner as _Runner + return _Runner(stream).run(test) + + def _as_output(self, text): + if str_is_unicode: + return text + return text.encode("utf-8") + def test_suite(): from unittest import TestLoader diff --git a/lib/testtools/testtools/tests/test_testtools.py b/lib/testtools/testtools/tests/test_testtools.py index af1fd794c3..9edc5a5176 100644 --- a/lib/testtools/testtools/tests/test_testtools.py +++ b/lib/testtools/testtools/tests/test_testtools.py @@ -1,11 +1,14 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. """Tests for extensions to the base test library.""" +from pprint import pformat import sys import unittest from testtools import ( + ErrorHolder, + PlaceHolder, TestCase, clone_test_with_new_id, content, @@ -26,6 +29,167 @@ from testtools.tests.helpers import ( ) +class TestPlaceHolder(TestCase): + + def makePlaceHolder(self, test_id="foo", short_description=None): + return PlaceHolder(test_id, short_description) + + def test_id_comes_from_constructor(self): + # The id() of a PlaceHolder is whatever you pass into the constructor. + test = PlaceHolder("test id") + self.assertEqual("test id", test.id()) + + def test_shortDescription_is_id(self): + # The shortDescription() of a PlaceHolder is the id, by default. + test = PlaceHolder("test id") + self.assertEqual(test.id(), test.shortDescription()) + + def test_shortDescription_specified(self): + # If a shortDescription is provided to the constructor, then + # shortDescription() returns that instead. + test = PlaceHolder("test id", "description") + self.assertEqual("description", test.shortDescription()) + + def test_repr_just_id(self): + # repr(placeholder) shows you how the object was constructed. + test = PlaceHolder("test id") + self.assertEqual( + "<testtools.testcase.PlaceHolder(%s)>" % repr(test.id()), + repr(test)) + + def test_repr_with_description(self): + # repr(placeholder) shows you how the object was constructed. + test = PlaceHolder("test id", "description") + self.assertEqual( + "<testtools.testcase.PlaceHolder(%r, %r)>" % ( + test.id(), test.shortDescription()), + repr(test)) + + def test_counts_as_one_test(self): + # A placeholder test counts as one test. + test = self.makePlaceHolder() + self.assertEqual(1, test.countTestCases()) + + def test_str_is_id(self): + # str(placeholder) is always the id(). We are not barbarians. + test = self.makePlaceHolder() + self.assertEqual(test.id(), str(test)) + + def test_runs_as_success(self): + # When run, a PlaceHolder test records a success. + test = self.makePlaceHolder() + log = [] + test.run(LoggingResult(log)) + self.assertEqual( + [('startTest', test), ('addSuccess', test), ('stopTest', test)], + log) + + def test_call_is_run(self): + # A PlaceHolder can be called, in which case it behaves like run. + test = self.makePlaceHolder() + run_log = [] + test.run(LoggingResult(run_log)) + call_log = [] + test(LoggingResult(call_log)) + self.assertEqual(run_log, call_log) + + def test_runs_without_result(self): + # A PlaceHolder can be run without a result, in which case there's no + # way to actually get at the result. + self.makePlaceHolder().run() + + def test_debug(self): + # A PlaceHolder can be debugged. + self.makePlaceHolder().debug() + + +class TestErrorHolder(TestCase): + + def makeException(self): + try: + raise RuntimeError("danger danger") + except: + return sys.exc_info() + + def makePlaceHolder(self, test_id="foo", error=None, + short_description=None): + if error is None: + error = self.makeException() + return ErrorHolder(test_id, error, short_description) + + def test_id_comes_from_constructor(self): + # The id() of a PlaceHolder is whatever you pass into the constructor. + test = ErrorHolder("test id", self.makeException()) + self.assertEqual("test id", test.id()) + + def test_shortDescription_is_id(self): + # The shortDescription() of a PlaceHolder is the id, by default. + test = ErrorHolder("test id", self.makeException()) + self.assertEqual(test.id(), test.shortDescription()) + + def test_shortDescription_specified(self): + # If a shortDescription is provided to the constructor, then + # shortDescription() returns that instead. + test = ErrorHolder("test id", self.makeException(), "description") + self.assertEqual("description", test.shortDescription()) + + def test_repr_just_id(self): + # repr(placeholder) shows you how the object was constructed. + error = self.makeException() + test = ErrorHolder("test id", error) + self.assertEqual( + "<testtools.testcase.ErrorHolder(%r, %r)>" % (test.id(), error), + repr(test)) + + def test_repr_with_description(self): + # repr(placeholder) shows you how the object was constructed. + error = self.makeException() + test = ErrorHolder("test id", error, "description") + self.assertEqual( + "<testtools.testcase.ErrorHolder(%r, %r, %r)>" % ( + test.id(), error, test.shortDescription()), + repr(test)) + + def test_counts_as_one_test(self): + # A placeholder test counts as one test. + test = self.makePlaceHolder() + self.assertEqual(1, test.countTestCases()) + + def test_str_is_id(self): + # str(placeholder) is always the id(). We are not barbarians. + test = self.makePlaceHolder() + self.assertEqual(test.id(), str(test)) + + def test_runs_as_error(self): + # When run, a PlaceHolder test records a success. + error = self.makeException() + test = self.makePlaceHolder(error=error) + log = [] + test.run(LoggingResult(log)) + self.assertEqual( + [('startTest', test), + ('addError', test, error), + ('stopTest', test)], log) + + def test_call_is_run(self): + # A PlaceHolder can be called, in which case it behaves like run. + test = self.makePlaceHolder() + run_log = [] + test.run(LoggingResult(run_log)) + call_log = [] + test(LoggingResult(call_log)) + self.assertEqual(run_log, call_log) + + def test_runs_without_result(self): + # A PlaceHolder can be run without a result, in which case there's no + # way to actually get at the result. + self.makePlaceHolder().run() + + def test_debug(self): + # A PlaceHolder can be debugged. + self.makePlaceHolder().debug() + + class TestEquality(TestCase): """Test `TestCase`'s equality implementation.""" @@ -47,16 +211,16 @@ class TestAssertions(TestCase): def test_formatTypes_single(self): # Given a single class, _formatTypes returns the name. - class Foo: + class Foo(object): pass self.assertEqual('Foo', self._formatTypes(Foo)) def test_formatTypes_multiple(self): # Given multiple types, _formatTypes returns the names joined by # commas. - class Foo: + class Foo(object): pass - class Bar: + class Bar(object): pass self.assertEqual('Foo, Bar', self._formatTypes([Foo, Bar])) @@ -164,7 +328,7 @@ class TestAssertions(TestCase): def test_assertIsInstance(self): # assertIsInstance asserts that an object is an instance of a class. - class Foo: + class Foo(object): """Simple class for testing assertIsInstance.""" foo = Foo() @@ -174,10 +338,10 @@ class TestAssertions(TestCase): # assertIsInstance asserts that an object is an instance of one of a # group of classes. - class Foo: + class Foo(object): """Simple class for testing assertIsInstance.""" - class Bar: + class Bar(object): """Another simple class for testing assertIsInstance.""" foo = Foo() @@ -188,7 +352,7 @@ class TestAssertions(TestCase): # assertIsInstance(obj, klass) fails the test when obj is not an # instance of klass. - class Foo: + class Foo(object): """Simple class for testing assertIsInstance.""" self.assertFails( @@ -199,10 +363,10 @@ class TestAssertions(TestCase): # assertIsInstance(obj, (klass1, klass2)) fails the test when obj is # not an instance of klass1 or klass2. - class Foo: + class Foo(object): """Simple class for testing assertIsInstance.""" - class Bar: + class Bar(object): """Another simple class for testing assertIsInstance.""" self.assertFails( @@ -251,20 +415,22 @@ class TestAssertions(TestCase): 'None is None: foo bar', self.assertIsNot, None, None, "foo bar") def test_assertThat_matches_clean(self): - class Matcher: + class Matcher(object): def match(self, foo): return None self.assertThat("foo", Matcher()) def test_assertThat_mismatch_raises_description(self): calls = [] - class Mismatch: + class Mismatch(object): def __init__(self, thing): self.thing = thing def describe(self): calls.append(('describe_diff', self.thing)) return "object is not a thing" - class Matcher: + def get_details(self): + return {} + class Matcher(object): def match(self, thing): calls.append(('match', thing)) return Mismatch(thing) @@ -282,6 +448,35 @@ class TestAssertions(TestCase): ], calls) self.assertFalse(result.wasSuccessful()) + def test_assertEqual_nice_formatting(self): + message = "These things ought not be equal." + a = ['apple', 'banana', 'cherry'] + b = {'Thatcher': 'One who mends roofs of straw', + 'Major': 'A military officer, ranked below colonel', + 'Blair': 'To shout loudly', + 'Brown': 'The colour of healthy human faeces'} + expected_error = '\n'.join( + [message, + 'not equal:', + 'a = %s' % pformat(a), + 'b = %s' % pformat(b), + '']) + self.assertFails(expected_error, self.assertEqual, a, b, message) + self.assertFails(expected_error, self.assertEquals, a, b, message) + self.assertFails(expected_error, self.failUnlessEqual, a, b, message) + + def test_assertEqual_formatting_no_message(self): + a = "cat" + b = "dog" + expected_error = '\n'.join( + ['not equal:', + 'a = %s' % pformat(a), + 'b = %s' % pformat(b), + '']) + self.assertFails(expected_error, self.assertEqual, a, b) + self.assertFails(expected_error, self.assertEquals, a, b) + self.assertFails(expected_error, self.failUnlessEqual, a, b) + class TestAddCleanup(TestCase): """Tests for TestCase.addCleanup.""" @@ -301,6 +496,9 @@ class TestAddCleanup(TestCase): def runTest(self): self._calls.append('runTest') + def brokenTest(self): + raise RuntimeError('Deliberate broken test') + def tearDown(self): self._calls.append('tearDown') TestCase.tearDown(self) @@ -400,13 +598,29 @@ class TestAddCleanup(TestCase): self.assertRaises( KeyboardInterrupt, self.test.run, self.logging_result) - def test_multipleErrorsReported(self): - # Errors from all failing cleanups are reported. + def test_multipleCleanupErrorsReported(self): + # Errors from all failing cleanups are reported as separate backtraces. + self.test.addCleanup(lambda: 1/0) + self.test.addCleanup(lambda: 1/0) + self.logging_result = ExtendedTestResult() + self.test.run(self.logging_result) + self.assertEqual(['startTest', 'addError', 'stopTest'], + [event[0] for event in self.logging_result._events]) + self.assertEqual(set(['traceback', 'traceback-1']), + set(self.logging_result._events[1][2].keys())) + + def test_multipleErrorsCoreAndCleanupReported(self): + # Errors from all failing cleanups are reported, with stopTest, + # startTest inserted. + self.test = TestAddCleanup.LoggingTest('brokenTest') self.test.addCleanup(lambda: 1/0) self.test.addCleanup(lambda: 1/0) + self.logging_result = ExtendedTestResult() self.test.run(self.logging_result) - self.assertErrorLogEqual( - ['startTest', 'addError', 'addError', 'stopTest']) + self.assertEqual(['startTest', 'addError', 'stopTest'], + [event[0] for event in self.logging_result._events]) + self.assertEqual(set(['traceback', 'traceback-1', 'traceback-2']), + set(self.logging_result._events[1][2].keys())) class TestWithDetails(TestCase): @@ -594,6 +808,61 @@ class TestDetailsProvided(TestWithDetails): self.assertDetailsProvided(Case("test"), "addUnexpectedSuccess", ["foo"]) + def test_addDetails_from_Mismatch(self): + content = self.get_content() + class Mismatch(object): + def describe(self): + return "Mismatch" + def get_details(self): + return {"foo": content} + class Matcher(object): + def match(self, thing): + return Mismatch() + def __str__(self): + return "a description" + class Case(TestCase): + def test(self): + self.assertThat("foo", Matcher()) + self.assertDetailsProvided(Case("test"), "addFailure", + ["foo", "traceback"]) + + def test_multiple_addDetails_from_Mismatch(self): + content = self.get_content() + class Mismatch(object): + def describe(self): + return "Mismatch" + def get_details(self): + return {"foo": content, "bar": content} + class Matcher(object): + def match(self, thing): + return Mismatch() + def __str__(self): + return "a description" + class Case(TestCase): + def test(self): + self.assertThat("foo", Matcher()) + self.assertDetailsProvided(Case("test"), "addFailure", + ["bar", "foo", "traceback"]) + + def test_addDetails_with_same_name_as_key_from_get_details(self): + content = self.get_content() + class Mismatch(object): + def describe(self): + return "Mismatch" + def get_details(self): + return {"foo": content} + class Matcher(object): + def match(self, thing): + return Mismatch() + def __str__(self): + return "a description" + class Case(TestCase): + def test(self): + self.addDetail("foo", content) + self.assertThat("foo", Matcher()) + self.assertDetailsProvided(Case("test"), "addFailure", + ["foo", "foo-1", "traceback"]) + class TestSetupTearDown(TestCase): @@ -624,6 +893,9 @@ class TestSkipping(TestCase): def test_skip_causes_skipException(self): self.assertRaises(self.skipException, self.skip, "Skip this test") + def test_can_use_skipTest(self): + self.assertRaises(self.skipException, self.skipTest, "Skip this test") + def test_skip_without_reason_works(self): class Test(TestCase): def test(self): @@ -750,6 +1022,64 @@ class TestOnException(TestCase): self.assertThat(events, Equals([])) +class TestPatchSupport(TestCase): + + class Case(TestCase): + def test(self): + pass + + def test_patch(self): + # TestCase.patch masks obj.attribute with the new value. + self.foo = 'original' + test = self.Case('test') + test.patch(self, 'foo', 'patched') + self.assertEqual('patched', self.foo) + + def test_patch_restored_after_run(self): + # TestCase.patch masks obj.attribute with the new value, but restores + # the original value after the test is finished. + self.foo = 'original' + test = self.Case('test') + test.patch(self, 'foo', 'patched') + test.run() + self.assertEqual('original', self.foo) + + def test_successive_patches_apply(self): + # TestCase.patch can be called multiple times per test. Each time you + # call it, it overrides the original value. + self.foo = 'original' + test = self.Case('test') + test.patch(self, 'foo', 'patched') + test.patch(self, 'foo', 'second') + self.assertEqual('second', self.foo) + + def test_successive_patches_restored_after_run(self): + # TestCase.patch restores the original value, no matter how many times + # it was called. + self.foo = 'original' + test = self.Case('test') + test.patch(self, 'foo', 'patched') + test.patch(self, 'foo', 'second') + test.run() + self.assertEqual('original', self.foo) + + def test_patch_nonexistent_attribute(self): + # TestCase.patch can be used to patch a non-existent attribute. + test = self.Case('test') + test.patch(self, 'doesntexist', 'patched') + self.assertEqual('patched', self.doesntexist) + + def test_restore_nonexistent_attribute(self): + # TestCase.patch can be used to patch a non-existent attribute, after + # the test run, the attribute is then removed from the object. + test = self.Case('test') + test.patch(self, 'doesntexist', 'patched') + test.run() + marker = object() + value = getattr(self, 'doesntexist', marker) + self.assertIs(marker, value) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/testsuite.py b/lib/testtools/testtools/testsuite.py index 26b193799b..fd802621e3 100644 --- a/lib/testtools/testtools/testsuite.py +++ b/lib/testtools/testtools/testsuite.py @@ -5,18 +5,31 @@ __metaclass__ = type __all__ = [ 'ConcurrentTestSuite', + 'iterate_tests', ] try: - import Queue + from Queue import Queue except ImportError: - import queue as Queue + from queue import Queue import threading import unittest import testtools +def iterate_tests(test_suite_or_case): + """Iterate through all of the test cases in 'test_suite_or_case'.""" + try: + suite = iter(test_suite_or_case) + except TypeError: + yield test_suite_or_case + else: + for test in suite: + for subtest in iterate_tests(test): + yield subtest + + class ConcurrentTestSuite(unittest.TestSuite): """A TestSuite whose run() calls out to a concurrency strategy.""" @@ -49,7 +62,7 @@ class ConcurrentTestSuite(unittest.TestSuite): tests = self.make_tests(self) try: threads = {} - queue = Queue.Queue() + queue = Queue() result_semaphore = threading.Semaphore(1) for test in tests: process_result = testtools.ThreadsafeForwardingResult(result, diff --git a/lib/testtools/testtools/utils.py b/lib/testtools/testtools/utils.py index c0845b610c..0f39d8f5b6 100644 --- a/lib/testtools/testtools/utils.py +++ b/lib/testtools/testtools/utils.py @@ -1,39 +1,13 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2010 testtools developers. See LICENSE for details. -"""Utilities for dealing with stuff in unittest.""" +"""Utilities for dealing with stuff in unittest. +Legacy - deprecated - use testtools.testsuite.iterate_tests +""" -import sys +import warnings +warnings.warn("Please import iterate_tests from testtools.testsuite - " + "testtools.utils is deprecated.", DeprecationWarning, stacklevel=2) -__metaclass__ = type -__all__ = [ - 'iterate_tests', - ] +from testtools.testsuite import iterate_tests - -if sys.version_info > (3, 0): - def _u(s): - """Replacement for u'some string' in Python 3.""" - return s - def _b(s): - """A byte literal.""" - return s.encode("latin-1") - advance_iterator = next -else: - def _u(s): - return unicode(s, "latin-1") - def _b(s): - return s - advance_iterator = lambda it: it.next() - - -def iterate_tests(test_suite_or_case): - """Iterate through all of the test cases in 'test_suite_or_case'.""" - try: - suite = iter(test_suite_or_case) - except TypeError: - yield test_suite_or_case - else: - for test in suite: - for subtest in iterate_tests(test): - yield subtest |