diff options
59 files changed, 5255 insertions, 876 deletions
diff --git a/lib/testtools/HACKING b/lib/testtools/HACKING deleted file mode 100644 index e9ece73585..0000000000 --- a/lib/testtools/HACKING +++ /dev/null @@ -1,135 +0,0 @@ -=================================== -Notes for contributing to testtools -=================================== - -Coding style ------------- - -In general, follow PEP 8 <http://www.python.org/dev/peps/pep-0008/>. - -For consistency with the standard library's ``unittest`` module, method names -are generally ``camelCase``. - -testtools supports Python 2.4 and later, so avoid any 2.5-only features like -the ``with`` statement. - - -Copyright assignment --------------------- - -Part of testtools raison d'etre is to provide Python with improvements to the -testing code it ships. For that reason we require all contributions (that are -non-trivial) to meet one of the following rules: - - - be inapplicable for inclusion in Python. - - be able to be included in Python without further contact with the - contributor. - - be copyright assigned to Jonathan M. Lange. - -Please pick one of these and specify it when contributing code to testtools. - - -Licensing ---------- - -All code that is not copyright assigned to Jonathan M. Lange (see Copyright -Assignment above) needs to be licensed under the MIT license that testtools -uses, so that testtools can ship it. - - -Testing -------- - -Please write tests for every feature. This project ought to be a model -example of well-tested Python code! - -Take particular care to make sure the *intent* of each test is clear. - -You can run tests with ``make check``, or by running ``./run-tests`` directly. - - -Source layout -------------- - -The top-level directory contains the ``testtools/`` package directory, and -miscellaneous files like README and setup.py. - -The ``testtools/`` directory is the Python package itself. It is separated -into submodules for internal clarity, but all public APIs should be “promoted” -into the top-level package by importing them in ``testtools/__init__.py``. -Users of testtools should never import a submodule, they are just -implementation details. - -Tests belong in ``testtools/tests/``. - - -Commiting to trunk ------------------- - -Testtools is maintained using bzr, with its trunk at lp:testtools. This gives -every contributor the ability to commit their work to their own branches. -However permission must be granted to allow contributors to commit to the trunk -branch. - -Commit access to trunk is obtained by joining the testtools-devs Launchpad -team. Membership in this team is contingent on obeying the testtools -contribution policy, including assigning copyright of all the work one creates -and places in trunk to Jonathan Lange. - - -Code Review ------------ - -All code must be reviewed before landing on trunk. The process is to create a -branch in launchpad, and submit it for merging to lp:testtools. It will then -be reviewed before it can be merged to trunk. It will be reviewed by someone: - - * not the author - * a committer (member of the testtools-devs team) - -As a special exception, while the testtools committers team is small and prone -to blocking, a merge request from a committer that has not been reviewed after -24 hours may be merged by that committer. When the team is larger this policy -will be revisited. - -Code reviewers should look for the quality of what is being submitted, -including conformance with this HACKING file. - -Changes which all users should be made aware of should be documented in NEWS. - - -NEWS management ---------------- - -The file NEWS is structured as a sorted list of releases. Each release can have -a free form description and more or more sections with bullet point items. -Sections in use today are 'Improvements' and 'Changes'. To ease merging between -branches, the bullet points are kept alphabetically sorted. The release NEXT is -permanently present at the top of the list. - - -Release tasks -------------- - - 1. Choose a version number, say X.Y.Z - 1. Branch from trunk to testtools-X.Y.Z - 1. In testtools-X.Y.Z, ensure __init__ has version X.Y.Z. - 1. Replace NEXT in NEWS with the version number X.Y.Z, adjusting the reST. - 1. Possibly write a blurb into NEWS. - 1. Replace any additional references to NEXT with the version being - released. (should be none). - 1. Commit the changes. - 1. Tag the release, bzr tag testtools-X.Y.Z - 1. Create a source distribution and upload to pypi ('make release'). - 1. Make sure all "Fix committed" bugs are in the 'next' milestone on - Launchpad - 1. Rename the 'next' milestone on Launchpad to 'X.Y.Z' - 1. Create a release on the newly-renamed 'X.Y.Z' milestone - 1. Upload the tarball and asc file to Launchpad - 1. Merge the release branch testtools-X.Y.Z into trunk. Before the commit, - add a NEXT heading to the top of NEWS and bump the version in __init__.py. - Push trunk to Launchpad - 1. If a new series has been created (e.g. 0.10.0), make the series on Launchpad. - 1. Make a new milestone for the *next release*. - 1. During release we rename NEXT to $version. - 1. We call new milestones NEXT. diff --git a/lib/testtools/LICENSE b/lib/testtools/LICENSE index 071d7359d2..42421b0b2d 100644 --- a/lib/testtools/LICENSE +++ b/lib/testtools/LICENSE @@ -1,6 +1,24 @@ -Copyright (c) 2008-2010 Jonathan M. Lange <jml@mumak.net> and the testtools +Copyright (c) 2008-2011 Jonathan M. Lange <jml@mumak.net> and the testtools authors. +The testtools authors are: + * Canonical Ltd + * Twisted Matrix Labs + * Jonathan Lange + * Robert Collins + * Andrew Bennetts + * Benjamin Peterson + * Jamu Kakar + * James Westby + * Martin [gz] + * Michael Hudson-Doyle + * Aaron Bentley + * Christian Kampka + * Gavin Panella + * Martin Pool + +and are collectively referred to as "testtools developers". + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/lib/testtools/MANIFEST.in b/lib/testtools/MANIFEST.in index 6d1bf1170f..92a623b2a1 100644 --- a/lib/testtools/MANIFEST.in +++ b/lib/testtools/MANIFEST.in @@ -6,3 +6,7 @@ include MANUAL include NEWS include README include .bzrignore +graft doc +graft doc/_static +graft doc/_templates +prune doc/_build diff --git a/lib/testtools/MANUAL b/lib/testtools/MANUAL deleted file mode 100644 index 7e7853c7e7..0000000000 --- a/lib/testtools/MANUAL +++ /dev/null @@ -1,349 +0,0 @@ -====== -Manual -====== - -Introduction ------------- - -This document provides overview of the features provided by testtools. Refer -to the API docs (i.e. docstrings) for full details on a particular feature. - -Extensions to TestCase ----------------------- - -Custom exception handling -~~~~~~~~~~~~~~~~~~~~~~~~~ - -testtools provides a way to control how test exceptions are handled. To do -this, add a new exception to self.exception_handlers on a TestCase. For -example:: - - >>> self.exception_handlers.insert(-1, (ExceptionClass, handler)). - -Having done this, if any of setUp, tearDown, or the test method raise -ExceptionClass, handler will be called with the test case, test result and the -raised exception. - -Controlling test execution -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you want to control more than just how exceptions are raised, you can -provide a custom `RunTest` to a TestCase. The `RunTest` object can change -everything about how the test executes. - -To work with `testtools.TestCase`, a `RunTest` must have a factory that takes -a test and an optional list of exception handlers. Instances returned by the -factory must have a `run()` method that takes an optional `TestResult` object. - -The default is `testtools.runtest.RunTest` and calls 'setUp', the test method -and 'tearDown' in the normal, vanilla way that Python's standard unittest -does. - -To specify a `RunTest` for all the tests in a `TestCase` class, do something -like this:: - - class SomeTests(TestCase): - run_tests_with = CustomRunTestFactory - -To specify a `RunTest` for a specific test in a `TestCase` class, do:: - - class SomeTests(TestCase): - @run_test_with(CustomRunTestFactory, extra_arg=42, foo='whatever') - def test_something(self): - pass - -In addition, either of these can be overridden by passing a factory in to the -`TestCase` constructor with the optional 'runTest' argument. - -TestCase.addCleanup -~~~~~~~~~~~~~~~~~~~ - -addCleanup is a robust way to arrange for a cleanup function to be called -before tearDown. This is a powerful and simple alternative to putting cleanup -logic in a try/finally block or tearDown method. e.g.:: - - def test_foo(self): - foo.lock() - self.addCleanup(foo.unlock) - ... - -Cleanups can also report multiple errors, if appropriate by wrapping them in -a testtools.MultipleExceptions object:: - - raise MultipleExceptions(exc_info1, exc_info2) - - -TestCase.addOnException -~~~~~~~~~~~~~~~~~~~~~~~ - -addOnException adds an exception handler that will be called from the test -framework when it detects an exception from your test code. The handler is -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.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. - - -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 -skip a test - for instance when a dependency is missing, or if the test is -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. ``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). - -TestCase.useFixture -~~~~~~~~~~~~~~~~~~~ - -``useFixture(fixture)`` calls setUp on the fixture, schedules a cleanup to -clean it up, and schedules a cleanup to attach all details held by the -fixture to the details dict of the test case. The fixture object should meet -the ``fixtures.Fixture`` protocol (version 0.3.4 or newer). This is useful -for moving code out of setUp and tearDown methods and into composable side -classes. - - -New assertion methods -~~~~~~~~~~~~~~~~~~~~~ - -testtools adds several assertion methods: - - * assertIn - * assertNotIn - * assertIs - * assertIsNot - * assertIsInstance - * assertThat - - -Improved assertRaises -~~~~~~~~~~~~~~~~~~~~~ - -TestCase.assertRaises returns the caught exception. This is useful for -asserting more things about the exception than just the type:: - - error = self.assertRaises(UnauthorisedError, thing.frobnicate) - self.assertEqual('bob', error.username) - self.assertEqual('User bob cannot frobnicate', str(error)) - -Note that this is incompatible with the assertRaises in unittest2/Python2.7. -While we have no immediate plans to change to be compatible consider using the -new assertThat facility instead:: - - self.assertThat( - lambda: thing.frobnicate('foo', 'bar'), - Raises(MatchesException(UnauthorisedError('bob'))) - -There is also a convenience function to handle this common case:: - - self.assertThat( - lambda: thing.frobnicate('foo', 'bar'), - raises(UnauthorisedError('bob'))) - - -TestCase.assertThat -~~~~~~~~~~~~~~~~~~~ - -assertThat is a clean way to write complex assertions without tying them to -the TestCase inheritance hierarchy (and thus making them easier to reuse). - -assertThat takes an object to be matched, and a matcher, and fails if the -matcher does not match the matchee. - -See pydoc testtools.Matcher for the protocol that matchers need to implement. - -testtools includes some matchers in testtools.matchers. -python -c 'import testtools.matchers; print testtools.matchers.__all__' will -list those matchers. - -An example using the DocTestMatches matcher which uses doctests example -matching logic:: - - def test_foo(self): - self.assertThat([1,2,3,4], DocTestMatches('[1, 2, 3, 4]')) - - -Creation methods -~~~~~~~~~~~~~~~~ - -testtools.TestCase implements creation methods called ``getUniqueString`` and -``getUniqueInteger``. See pages 419-423 of *xUnit Test Patterns* by Meszaros -for a detailed discussion of creation methods. - - -Test renaming -~~~~~~~~~~~~~ - -``testtools.clone_test_with_new_id`` is a function to copy a test case -instance to one with a new name. This is helpful for implementing test -parameterization. - - -Extensions to TestResult ------------------------- - -TestResult.addSkip -~~~~~~~~~~~~~~~~~~ - -This method is called on result objects when a test skips. The -``testtools.TestResult`` class records skips in its ``skip_reasons`` instance -dict. The can be reported on in much the same way as succesful tests. - - -TestResult.time -~~~~~~~~~~~~~~~ - -This method controls the time used by a TestResult, permitting accurate -timing of test results gathered on different machines or in different threads. -See pydoc testtools.TestResult.time for more details. - - -ThreadsafeForwardingResult -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -A TestResult which forwards activity to another test result, but synchronises -on a semaphore to ensure that all the activity for a single test arrives in a -batch. This allows simple TestResults which do not expect concurrent test -reporting to be fed the activity from multiple test threads, or processes. - -Note that when you provide multiple errors for a single test, the target sees -each error as a distinct complete test. - - -TextTestResult -~~~~~~~~~~~~~~ - -A TestResult that provides a text UI very similar to the Python standard -library UI. Key differences are that its supports the extended outcomes and -details API, and is completely encapsulated into the result object, permitting -it to be used without a 'TestRunner' object. Not all the Python 2.7 outcomes -are displayed (yet). It is also a 'quiet' result with no dots or verbose mode. -These limitations will be corrected soon. - - -Test Doubles -~~~~~~~~~~~~ - -In testtools.testresult.doubles there are three test doubles that testtools -uses for its own testing: Python26TestResult, Python27TestResult, -ExtendedTestResult. These TestResult objects implement a single variation of -the TestResult API each, and log activity to a list self._events. These are -made available for the convenience of people writing their own extensions. - - -startTestRun and stopTestRun -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Python 2.7 added hooks 'startTestRun' and 'stopTestRun' which are called -before and after the entire test run. 'stopTestRun' is particularly useful for -test results that wish to produce summary output. - -testtools.TestResult provides empty startTestRun and stopTestRun methods, and -the default testtools runner will call these methods appropriately. - - -Extensions to TestSuite ------------------------ - -ConcurrentTestSuite -~~~~~~~~~~~~~~~~~~~ - -A TestSuite for parallel testing. This is used in conjuction with a helper that -runs a single suite in some parallel fashion (for instance, forking, handing -off to a subprocess, to a compute cloud, or simple threads). -ConcurrentTestSuite uses the helper to get a number of separate runnable -objects with a run(result), runs them all in threads using the -ThreadsafeForwardingResult to coalesce their activity. - - -Running tests -------------- - -testtools provides a convenient way to run a test suite using the testtools -result object: python -m testtools.run testspec [testspec...]. - -To run tests with Python 2.4, you'll have to do something like: - python2.4 /path/to/testtools/run.py 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 - - -Twisted support ---------------- - -Support for running Twisted tests is very experimental right now. You -shouldn't really do it. However, if you are going to, here are some tips for -converting your Trial tests into testtools tests. - - * Use the AsynchronousDeferredRunTest runner - * Make sure to upcall to setUp and tearDown - * Don't use setUpClass or tearDownClass - * Don't expect setting .todo, .timeout or .skip attributes to do anything - * flushLoggedErrors is not there for you. Sorry. - * assertFailure is not there for you. Even more sorry. - - -General helpers ---------------- - -Lots of the time we would like to conditionally import modules. testtools -needs to do this itself, and graciously extends the ability to its users. - -Instead of:: - - try: - from twisted.internet import defer - except ImportError: - defer = None - -You can do:: - - defer = try_import('twisted.internet.defer') - - -Instead of:: - - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - -You can do:: - - StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) diff --git a/lib/testtools/Makefile b/lib/testtools/Makefile index c36fbd8012..b3e40ecddf 100644 --- a/lib/testtools/Makefile +++ b/lib/testtools/Makefile @@ -12,7 +12,7 @@ TAGS: ${SOURCES} tags: ${SOURCES} ctags -R testtools/ -clean: +clean: clean-sphinx rm -f TAGS tags find testtools -name "*.pyc" -exec rm '{}' \; @@ -22,14 +22,35 @@ prerelease: release: ./setup.py sdist upload --sign + $(PYTHON) scripts/_lp_release.py snapshot: prerelease ./setup.py sdist +### Documentation ### + apidocs: - pydoctor --make-html --add-package testtools \ + # pydoctor emits deprecation warnings under Ubuntu 10.10 LTS + PYTHONWARNINGS='ignore::DeprecationWarning' \ + pydoctor --make-html --add-package testtools \ --docformat=restructuredtext --project-name=testtools \ --project-url=https://launchpad.net/testtools +doc/news.rst: + ln -s ../NEWS doc/news.rst + +docs: doc/news.rst docs-sphinx + rm doc/news.rst + +docs-sphinx: html-sphinx + +# Clean out generated documentation +clean-sphinx: + cd doc && make clean + +# Build the html docs using Sphinx. +html-sphinx: + cd doc && make html -.PHONY: check clean prerelease release apidocs +.PHONY: apidocs docs-sphinx clean-sphinx html-sphinx docs +.PHONY: check clean prerelease release diff --git a/lib/testtools/NEWS b/lib/testtools/NEWS index 4d2a74430f..6588b8d438 100644 --- a/lib/testtools/NEWS +++ b/lib/testtools/NEWS @@ -1,12 +1,157 @@ testtools NEWS ++++++++++++++ +Changes and improvements to testtools_, grouped by release. + NEXT ~~~~ Changes ------- +* ``AfterPreproccessing`` renamed to ``AfterPreprocessing``, which is a more + correct spelling. Old name preserved for backwards compatibility, but is + now deprecated. Please stop using it. + (Jonathan Lange, #813460) + +* ``gather_details`` takes two dicts, rather than two detailed objects. + (Jonathan Lange, #801027) + +* ``MatchesRegex`` mismatch now says "<value> does not match /<regex>/" rather + than "<regex> did not match <value>". The regular expression contains fewer + backslashes too. (Jonathan Lange, #818079) + +* Tests that run with ``AsynchronousDeferredRunTest`` now have the ``reactor`` + attribute set to the running reactor. (Jonathan Lange, #720749) + +Improvements +------------ + +* All public matchers are now in ``testtools.matchers.__all__``. + (Jonathan Lange, #784859) + +* assertThat output is much less verbose, displaying only what the mismatch + tells us to display. Old-style verbose output can be had by passing + ``verbose=True`` to assertThat. (Jonathan Lange, #675323, #593190) + +* assertThat accepts a message which will be used to annotate the matcher. This + can be given as a third parameter or as a keyword parameter. (Robert Collins) + +* Automated the Launchpad part of the release process. + (Jonathan Lange, #623486) + +* Correctly display non-ASCII unicode output on terminals that claim to have a + unicode encoding. (Martin [gz], #804122) + +* ``DocTestMatches`` correctly handles unicode output from examples, rather + than raising an error. (Martin [gz], #764170) + +* ``ErrorHolder`` and ``PlaceHolder`` added to docs. (Jonathan Lange, #816597) + +* ``ExpectedException`` now matches any exception of the given type by + default, and also allows specifying a ``Matcher`` rather than a mere regular + expression. (Jonathan Lange, #791889) + +* ``FixtureSuite`` added, allows test suites to run with a given fixture. + (Jonathan Lange) + +* Hide testtools's own stack frames when displaying tracebacks, making it + easier for test authors to focus on their errors. + (Jonathan Lange, Martin [gz], #788974) + +* Less boilerplate displayed in test failures and errors. + (Jonathan Lange, #660852) + +* ``MatchesException`` now allows you to match exceptions against any matcher, + rather than just regular expressions. (Jonathan Lange, #791889) + +* ``MatchesException`` now permits a tuple of types rather than a single type + (when using the type matching mode). (Robert Collins) + +* ``MatchesStructure.byEquality`` added to make the common case of matching + many attributes by equality much easier. ``MatchesStructure.byMatcher`` + added in case folk want to match by things other than equality. + (Jonathan Lange) + +* New convenience assertions, ``assertIsNone`` and ``assertIsNotNone``. + (Christian Kampka) + +* New matchers: + + * ``AllMatch`` matches many values against a single matcher. + (Jonathan Lange, #615108) + + * ``Contains``. (Robert Collins) + + * ``GreaterThan``. (Christian Kampka) + +* New helper, ``safe_hasattr`` added. (Jonathan Lange) + +* ``reraise`` added to ``testtools.compat``. (Jonathan Lange) + + +0.9.11 +~~~~~~ + +This release brings consistent use of super for better compatibility with +multiple inheritance, fixed Python3 support, improvements in fixture and mather +outputs and a compat helper for testing libraries that deal with bytestrings. + +Changes +------- + +* ``TestCase`` now uses super to call base ``unittest.TestCase`` constructor, + ``setUp`` and ``tearDown``. (Tim Cole, #771508) + +* If, when calling ``useFixture`` an error occurs during fixture set up, we + still attempt to gather details from the fixture. (Gavin Panella) + + +Improvements +------------ + +* Additional compat helper for ``BytesIO`` for libraries that build on + testtools and are working on Python 3 porting. (Robert Collins) + +* Corrected documentation for ``MatchesStructure`` in the test authors + document. (Jonathan Lange) + +* ``LessThan`` error message now says something that is logically correct. + (Gavin Panella, #762008) + +* Multiple details from a single fixture are now kept separate, rather than + being mooshed together. (Gavin Panella, #788182) + +* Python 3 support now back in action. (Martin [gz], #688729) + +* ``try_import`` and ``try_imports`` have a callback that is called whenever + they fail to import a module. (Martin Pool) + + +0.9.10 +~~~~~~ + +The last release of testtools could not be easy_installed. This is considered +severe enough for a re-release. + +Improvements +------------ + +* Include ``doc/`` in the source distribution, making testtools installable + from PyPI again (Tres Seaver, #757439) + + +0.9.9 +~~~~~ + +Many, many new matchers, vastly expanded documentation, stacks of bug fixes, +better unittest2 integration. If you've ever wanted to try out testtools but +been afraid to do so, this is the release to try. + + +Changes +------- + * The timestamps generated by ``TestResult`` objects when no timing data has been received are now datetime-with-timezone, which allows them to be sensibly serialised and transported. (Robert Collins, #692297) @@ -14,8 +159,56 @@ Changes Improvements ------------ +* ``AnnotatedMismatch`` now correctly returns details. + (Jonathan Lange, #724691) + +* distutils integration for the testtools test runner. Can now use it for + 'python setup.py test'. (Christian Kampka, #693773) + +* ``EndsWith`` and ``KeysEqual`` now in testtools.matchers.__all__. + (Jonathan Lange, #692158) + +* ``MatchesException`` extended to support a regular expression check against + the str() of a raised exception. (Jonathan Lange) + * ``MultiTestResult`` now forwards the ``time`` API. (Robert Collins, #692294) +* ``MultiTestResult`` now documented in the manual. (Jonathan Lange, #661116) + +* New content helpers ``content_from_file``, ``content_from_stream`` and + ``attach_file`` make it easier to attach file-like objects to a + test. (Jonathan Lange, Robert Collins, #694126) + +* New ``ExpectedException`` context manager to help write tests against things + that are expected to raise exceptions. (Aaron Bentley) + +* New matchers: + + * ``MatchesListwise`` matches an iterable of matchers against an iterable + of values. (Michael Hudson-Doyle) + + * ``MatchesRegex`` matches a string against a regular expression. + (Michael Hudson-Doyle) + + * ``MatchesStructure`` matches attributes of an object against given + matchers. (Michael Hudson-Doyle) + + * ``AfterPreproccessing`` matches values against a matcher after passing them + through a callable. (Michael Hudson-Doyle) + + * ``MatchesSetwise`` matches an iterable of matchers against an iterable of + values, without regard to order. (Michael Hudson-Doyle) + +* ``setup.py`` can now build a snapshot when Bazaar is installed but the tree + is not a Bazaar tree. (Jelmer Vernooij) + +* Support for running tests using distutils (Christian Kampka, #726539) + +* Vastly improved and extended documentation. (Jonathan Lange) + +* Use unittest2 exception classes if available. (Jelmer Vernooij) + + 0.9.8 ~~~~~ @@ -55,6 +248,16 @@ Changes Improvements ------------ +* New matchers: + + * ``EndsWith`` which complements the existing ``StartsWith`` matcher. + (Jonathan Lange, #669165) + + * ``MatchesException`` matches an exception class and parameters. (Robert + Collins) + + * ``KeysEqual`` matches a dictionary with particular keys. (Jonathan Lange) + * ``assertIsInstance`` supports a custom error message to be supplied, which is necessary when using ``assertDictEqual`` on Python 2.7 with a ``testtools.TestCase`` base class. (Jelmer Vernooij) @@ -68,22 +271,14 @@ Improvements * Fix the runTest parameter of TestCase to actually work, rather than raising a TypeError. (Jonathan Lange, #657760) -* New matcher ``EndsWith`` added to complement the existing ``StartsWith`` - matcher. (Jonathan Lange, #669165) - * Non-release snapshots of testtools will now work with buildout. (Jonathan Lange, #613734) * Malformed SyntaxErrors no longer blow up the test suite. (Martin [gz]) -* ``MatchesException`` added to the ``testtools.matchers`` module - matches - an exception class and parameters. (Robert Collins) - * ``MismatchesAll.describe`` no longer appends a trailing newline. (Michael Hudson-Doyle, #686790) -* New ``KeysEqual`` matcher. (Jonathan Lange) - * New helpers for conditionally importing modules, ``try_import`` and ``try_imports``. (Jonathan Lange) @@ -466,3 +661,6 @@ Improvements a test success will now be reported. Previously an error was reported but production experience has shown that this is too disruptive for projects that are using skips: they cannot get a clean run on down-level result objects. + + +.. _testtools: http://pypi.python.org/pypi/testtools diff --git a/lib/testtools/README b/lib/testtools/README index 83120f01e4..78397de85b 100644 --- a/lib/testtools/README +++ b/lib/testtools/README @@ -8,15 +8,24 @@ framework. These extensions have been derived from years of experience with unit testing in Python and come from many different sources. + +Documentation +------------- + +If you would like to learn more about testtools, consult our documentation in +the 'doc/' directory. You might like to start at 'doc/overview.rst' or +'doc/for-test-authors.rst'. + + Licensing --------- This project is distributed under the MIT license and copyright is owned by -Jonathan M. Lange. See LICENSE for details. +Jonathan M. Lange and the testtools authors. 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. +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. Required Dependencies @@ -24,6 +33,7 @@ Required Dependencies * Python 2.4+ or 3.0+ + Optional Dependencies --------------------- @@ -70,3 +80,7 @@ Thanks * James Westby * Martin [gz] * Michael Hudson-Doyle + * Aaron Bentley + * Christian Kampka + * Gavin Panella + * Martin Pool diff --git a/lib/testtools/doc/Makefile b/lib/testtools/doc/Makefile new file mode 100644 index 0000000000..b5d07af57f --- /dev/null +++ b/lib/testtools/doc/Makefile @@ -0,0 +1,89 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/testtools.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/testtools.qhc" + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/lib/testtools/doc/_static/placeholder.txt b/lib/testtools/doc/_static/placeholder.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/lib/testtools/doc/_static/placeholder.txt diff --git a/lib/testtools/doc/_templates/placeholder.txt b/lib/testtools/doc/_templates/placeholder.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/lib/testtools/doc/_templates/placeholder.txt diff --git a/lib/testtools/doc/conf.py b/lib/testtools/doc/conf.py new file mode 100644 index 0000000000..de5fdd4224 --- /dev/null +++ b/lib/testtools/doc/conf.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +# +# testtools documentation build configuration file, created by +# sphinx-quickstart on Sun Nov 28 13:45:40 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.append(os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'testtools' +copyright = u'2010, The testtools authors' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = 'VERSION' +# The full version, including alpha/beta/rc tags. +release = 'VERSION' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of documents that shouldn't be included in the build. +#unused_docs = [] + +# List of directories, relative to source directory, that shouldn't be searched +# for source files. +exclude_trees = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_use_modindex = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'testtoolsdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'testtools.tex', u'testtools Documentation', + u'The testtools authors', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_use_modindex = True diff --git a/lib/testtools/doc/for-framework-folk.rst b/lib/testtools/doc/for-framework-folk.rst new file mode 100644 index 0000000000..a4b20f64ca --- /dev/null +++ b/lib/testtools/doc/for-framework-folk.rst @@ -0,0 +1,219 @@ +============================ +testtools for framework folk +============================ + +Introduction +============ + +In addition to having many features :doc:`for test authors +<for-test-authors>`, testtools also has many bits and pieces that are useful +for folk who write testing frameworks. + +If you are the author of a test runner, are working on a very large +unit-tested project, are trying to get one testing framework to play nicely +with another or are hacking away at getting your test suite to run in parallel +over a heterogenous cluster of machines, this guide is for you. + +This manual is a summary. You can get details by consulting the `testtools +API docs`_. + + +Extensions to TestCase +====================== + +Custom exception handling +------------------------- + +testtools provides a way to control how test exceptions are handled. To do +this, add a new exception to ``self.exception_handlers`` on a +``testtools.TestCase``. For example:: + + >>> self.exception_handlers.insert(-1, (ExceptionClass, handler)). + +Having done this, if any of ``setUp``, ``tearDown``, or the test method raise +``ExceptionClass``, ``handler`` will be called with the test case, test result +and the raised exception. + +Use this if you want to add a new kind of test result, that is, if you think +that ``addError``, ``addFailure`` and so forth are not enough for your needs. + + +Controlling test execution +-------------------------- + +If you want to control more than just how exceptions are raised, you can +provide a custom ``RunTest`` to a ``TestCase``. The ``RunTest`` object can +change everything about how the test executes. + +To work with ``testtools.TestCase``, a ``RunTest`` must have a factory that +takes a test and an optional list of exception handlers. Instances returned +by the factory must have a ``run()`` method that takes an optional ``TestResult`` +object. + +The default is ``testtools.runtest.RunTest``, which calls ``setUp``, the test +method, ``tearDown`` and clean ups (see :ref:`addCleanup`) in the normal, vanilla +way that Python's standard unittest_ does. + +To specify a ``RunTest`` for all the tests in a ``TestCase`` class, do something +like this:: + + class SomeTests(TestCase): + run_tests_with = CustomRunTestFactory + +To specify a ``RunTest`` for a specific test in a ``TestCase`` class, do:: + + class SomeTests(TestCase): + @run_test_with(CustomRunTestFactory, extra_arg=42, foo='whatever') + def test_something(self): + pass + +In addition, either of these can be overridden by passing a factory in to the +``TestCase`` constructor with the optional ``runTest`` argument. + + +Test renaming +------------- + +``testtools.clone_test_with_new_id`` is a function to copy a test case +instance to one with a new name. This is helpful for implementing test +parameterization. + + +Test placeholders +================= + +Sometimes, it's useful to be able to add things to a test suite that are not +actually tests. For example, you might wish to represents import failures +that occur during test discovery as tests, so that your test result object +doesn't have to do special work to handle them nicely. + +testtools provides two such objects, called "placeholders": ``PlaceHolder`` +and ``ErrorHolder``. ``PlaceHolder`` takes a test id and an optional +description. When it's run, it succeeds. ``ErrorHolder`` takes a test id, +and error and an optional short description. When it's run, it reports that +error. + +These placeholders are best used to log events that occur outside the test +suite proper, but are still very relevant to its results. + +e.g.:: + + >>> suite = TestSuite() + >>> suite.add(PlaceHolder('I record an event')) + >>> suite.run(TextTestResult(verbose=True)) + I record an event [OK] + + +Extensions to TestResult +======================== + +TestResult.addSkip +------------------ + +This method is called on result objects when a test skips. The +``testtools.TestResult`` class records skips in its ``skip_reasons`` instance +dict. The can be reported on in much the same way as succesful tests. + + +TestResult.time +--------------- + +This method controls the time used by a ``TestResult``, permitting accurate +timing of test results gathered on different machines or in different threads. +See pydoc testtools.TestResult.time for more details. + + +ThreadsafeForwardingResult +-------------------------- + +A ``TestResult`` which forwards activity to another test result, but synchronises +on a semaphore to ensure that all the activity for a single test arrives in a +batch. This allows simple TestResults which do not expect concurrent test +reporting to be fed the activity from multiple test threads, or processes. + +Note that when you provide multiple errors for a single test, the target sees +each error as a distinct complete test. + + +MultiTestResult +--------------- + +A test result that dispatches its events to many test results. Use this +to combine multiple different test result objects into one test result object +that can be passed to ``TestCase.run()`` or similar. For example:: + + a = TestResult() + b = TestResult() + combined = MultiTestResult(a, b) + combined.startTestRun() # Calls a.startTestRun() and b.startTestRun() + +Each of the methods on ``MultiTestResult`` will return a tuple of whatever the +component test results return. + + +TextTestResult +-------------- + +A ``TestResult`` that provides a text UI very similar to the Python standard +library UI. Key differences are that its supports the extended outcomes and +details API, and is completely encapsulated into the result object, permitting +it to be used without a 'TestRunner' object. Not all the Python 2.7 outcomes +are displayed (yet). It is also a 'quiet' result with no dots or verbose mode. +These limitations will be corrected soon. + + +ExtendedToOriginalDecorator +--------------------------- + +Adapts legacy ``TestResult`` objects, such as those found in older Pythons, to +meet the testtools ``TestResult`` API. + + +Test Doubles +------------ + +In testtools.testresult.doubles there are three test doubles that testtools +uses for its own testing: ``Python26TestResult``, ``Python27TestResult``, +``ExtendedTestResult``. These TestResult objects implement a single variation of +the TestResult API each, and log activity to a list ``self._events``. These are +made available for the convenience of people writing their own extensions. + + +startTestRun and stopTestRun +---------------------------- + +Python 2.7 added hooks ``startTestRun`` and ``stopTestRun`` which are called +before and after the entire test run. 'stopTestRun' is particularly useful for +test results that wish to produce summary output. + +``testtools.TestResult`` provides default ``startTestRun`` and ``stopTestRun`` +methods, and he default testtools runner will call these methods +appropriately. + +The ``startTestRun`` method will reset any errors, failures and so forth on +the result, making the result object look as if no tests have been run. + + +Extensions to TestSuite +======================= + +ConcurrentTestSuite +------------------- + +A TestSuite for parallel testing. This is used in conjuction with a helper that +runs a single suite in some parallel fashion (for instance, forking, handing +off to a subprocess, to a compute cloud, or simple threads). +ConcurrentTestSuite uses the helper to get a number of separate runnable +objects with a run(result), runs them all in threads using the +ThreadsafeForwardingResult to coalesce their activity. + +FixtureSuite +------------ + +A test suite that sets up a fixture_ before running any tests, and then tears +it down after all of the tests are run. The fixture is *not* made available to +any of the tests. + +.. _`testtools API docs`: http://mumak.net/testtools/apidocs/ +.. _unittest: http://docs.python.org/library/unittest.html +.. _fixture: http://pypi.python.org/pypi/fixtures diff --git a/lib/testtools/doc/for-test-authors.rst b/lib/testtools/doc/for-test-authors.rst new file mode 100644 index 0000000000..eec98b14f8 --- /dev/null +++ b/lib/testtools/doc/for-test-authors.rst @@ -0,0 +1,1196 @@ +========================== +testtools for test authors +========================== + +If you are writing tests for a Python project and you (rather wisely) want to +use testtools to do so, this is the manual for you. + +We assume that you already know Python and that you know something about +automated testing already. + +If you are a test author of an unusually large or unusually unusual test +suite, you might be interested in :doc:`for-framework-folk`. + +You might also be interested in the `testtools API docs`_. + + +Introduction +============ + +testtools is a set of extensions to Python's standard unittest module. +Writing tests with testtools is very much like writing tests with standard +Python, or with Twisted's "trial_", or nose_, except a little bit easier and +more enjoyable. + +Below, we'll try to give some examples of how to use testtools in its most +basic way, as well as a sort of feature-by-feature breakdown of the cool bits +that you could easily miss. + + +The basics +========== + +Here's what a basic testtools unit tests look like:: + + from testtools import TestCase + from myproject import silly + + class TestSillySquare(TestCase): + """Tests for silly square function.""" + + def test_square(self): + # 'square' takes a number and multiplies it by itself. + result = silly.square(7) + self.assertEqual(result, 49) + + def test_square_bad_input(self): + # 'square' raises a TypeError if it's given bad input, say a + # string. + self.assertRaises(TypeError, silly.square, "orange") + + +Here you have a class that inherits from ``testtools.TestCase`` and bundles +together a bunch of related tests. The tests themselves are methods on that +class that begin with ``test_``. + +Running your tests +------------------ + +You can run these tests in many ways. testtools provides a very basic +mechanism for doing so:: + + $ python -m testtools.run exampletest + Tests running... + Ran 2 tests in 0.000s + + OK + +where 'exampletest' is a module that contains unit tests. By default, +``testtools.run`` will *not* recursively search the module or package for unit +tests. To do this, you will need to either have the discover_ module +installed or have Python 2.7 or later, and then run:: + + $ python -m testtools.run discover packagecontainingtests + +For more information see the Python 2.7 unittest documentation, or:: + + python -m testtools.run --help + +As your testing needs grow and evolve, you will probably want to use a more +sophisticated test runner. There are many of these for Python, and almost all +of them will happily run testtools tests. In particular: + +* testrepository_ +* Trial_ +* nose_ +* unittest2_ +* `zope.testrunner`_ (aka zope.testing) + +From now on, we'll assume that you know how to run your tests. + +Running test with Distutils +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are using Distutils_ to build your Python project, you can use the testtools +Distutils_ command to integrate testtools into your Distutils_ workflow:: + + from distutils.core import setup + from testtools import TestCommand + setup(name='foo', + version='1.0', + py_modules=['foo'], + cmdclass={'test': TestCommand} + ) + +You can then run:: + + $ python setup.py test -m exampletest + Tests running... + Ran 2 tests in 0.000s + + OK + +For more information about the capabilities of the `TestCommand` command see:: + + $ python setup.py test --help + +You can use the `setup configuration`_ to specify the default behavior of the +`TestCommand` command. + +Assertions +========== + +The core of automated testing is making assertions about the way things are, +and getting a nice, helpful, informative error message when things are not as +they ought to be. + +All of the assertions that you can find in Python standard unittest_ can be +found in testtools (remember, testtools extends unittest). testtools changes +the behaviour of some of those assertions slightly and adds some new +assertions that you will almost certainly find useful. + + +Improved assertRaises +--------------------- + +``TestCase.assertRaises`` returns the caught exception. This is useful for +asserting more things about the exception than just the type:: + + def test_square_bad_input(self): + # 'square' raises a TypeError if it's given bad input, say a + # string. + e = self.assertRaises(TypeError, silly.square, "orange") + self.assertEqual("orange", e.bad_value) + self.assertEqual("Cannot square 'orange', not a number.", str(e)) + +Note that this is incompatible with the ``assertRaises`` in unittest2 and +Python2.7. + + +ExpectedException +----------------- + +If you are using a version of Python that supports the ``with`` context +manager syntax, you might prefer to use that syntax to ensure that code raises +particular errors. ``ExpectedException`` does just that. For example:: + + def test_square_root_bad_input_2(self): + # 'square' raises a TypeError if it's given bad input. + with ExpectedException(TypeError, "Cannot square.*"): + silly.square('orange') + +The first argument to ``ExpectedException`` is the type of exception you +expect to see raised. The second argument is optional, and can be either a +regular expression or a matcher. If it is a regular expression, the ``str()`` +of the raised exception must match the regular expression. If it is a matcher, +then the raised exception object must match it. + + +assertIn, assertNotIn +--------------------- + +These two assertions check whether a value is in a sequence and whether a +value is not in a sequence. They are "assert" versions of the ``in`` and +``not in`` operators. For example:: + + def test_assert_in_example(self): + self.assertIn('a', 'cat') + self.assertNotIn('o', 'cat') + self.assertIn(5, list_of_primes_under_ten) + self.assertNotIn(12, list_of_primes_under_ten) + + +assertIs, assertIsNot +--------------------- + +These two assertions check whether values are identical to one another. This +is sometimes useful when you want to test something more strict than mere +equality. For example:: + + def test_assert_is_example(self): + foo = [None] + foo_alias = foo + bar = [None] + self.assertIs(foo, foo_alias) + self.assertIsNot(foo, bar) + self.assertEqual(foo, bar) # They are equal, but not identical + + +assertIsInstance +---------------- + +As much as we love duck-typing and polymorphism, sometimes you need to check +whether or not a value is of a given type. This method does that. For +example:: + + def test_assert_is_instance_example(self): + now = datetime.now() + self.assertIsInstance(now, datetime) + +Note that there is no ``assertIsNotInstance`` in testtools currently. + + +expectFailure +------------- + +Sometimes it's useful to write tests that fail. For example, you might want +to turn a bug report into a unit test, but you don't know how to fix the bug +yet. Or perhaps you want to document a known, temporary deficiency in a +dependency. + +testtools gives you the ``TestCase.expectFailure`` to help with this. You use +it to say that you expect this assertion to fail. When the test runs and the +assertion fails, testtools will report it as an "expected failure". + +Here's an example:: + + def test_expect_failure_example(self): + self.expectFailure( + "cats should be dogs", self.assertEqual, 'cats', 'dogs') + +As long as 'cats' is not equal to 'dogs', the test will be reported as an +expected failure. + +If ever by some miracle 'cats' becomes 'dogs', then testtools will report an +"unexpected success". Unlike standard unittest, testtools treats this as +something that fails the test suite, like an error or a failure. + + +Matchers +======== + +The built-in assertion methods are very useful, they are the bread and butter +of writing tests. However, soon enough you will probably want to write your +own assertions. Perhaps there are domain specific things that you want to +check (e.g. assert that two widgets are aligned parallel to the flux grid), or +perhaps you want to check something that could almost but not quite be found +in some other standard library (e.g. assert that two paths point to the same +file). + +When you are in such situations, you could either make a base class for your +project that inherits from ``testtools.TestCase`` and make sure that all of +your tests derive from that, *or* you could use the testtools ``Matcher`` +system. + + +Using Matchers +-------------- + +Here's a really basic example using stock matchers found in testtools:: + + import testtools + from testtools.matchers import Equals + + class TestSquare(TestCase): + def test_square(self): + result = square(7) + self.assertThat(result, Equals(49)) + +The line ``self.assertThat(result, Equals(49))`` is equivalent to +``self.assertEqual(result, 49)`` and means "assert that ``result`` equals 49". +The difference is that ``assertThat`` is a more general method that takes some +kind of observed value (in this case, ``result``) and any matcher object +(here, ``Equals(49)``). + +The matcher object could be absolutely anything that implements the Matcher +protocol. This means that you can make more complex matchers by combining +existing ones:: + + def test_square_silly(self): + result = square(7) + self.assertThat(result, Not(Equals(50))) + +Which is roughly equivalent to:: + + def test_square_silly(self): + result = square(7) + self.assertNotEqual(result, 50) + + +Stock matchers +-------------- + +testtools comes with many matchers built in. They can all be found in and +imported from the ``testtools.matchers`` module. + +Equals +~~~~~~ + +Matches if two items are equal. For example:: + + def test_equals_example(self): + self.assertThat([42], Equals([42])) + + +Is +~~~ + +Matches if two items are identical. For example:: + + def test_is_example(self): + foo = object() + self.assertThat(foo, Is(foo)) + + +IsInstance +~~~~~~~~~~ + +Adapts isinstance() to use as a matcher. For example:: + + def test_isinstance_example(self): + class MyClass:pass + self.assertThat(MyClass(), IsInstance(MyClass)) + self.assertThat(MyClass(), IsInstance(MyClass, str)) + + +The raises helper +~~~~~~~~~~~~~~~~~ + +Matches if a callable raises a particular type of exception. For example:: + + def test_raises_example(self): + self.assertThat(lambda: 1/0, raises(ZeroDivisionError)) + +This is actually a convenience function that combines two other matchers: +Raises_ and MatchesException_. + + +DocTestMatches +~~~~~~~~~~~~~~ + +Matches a string as if it were the output of a doctest_ example. Very useful +for making assertions about large chunks of text. For example:: + + import doctest + + def test_doctest_example(self): + output = "Colorless green ideas" + self.assertThat( + output, + DocTestMatches("Colorless ... ideas", doctest.ELLIPSIS)) + +We highly recommend using the following flags:: + + doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF + + +GreaterThan +~~~~~~~~~~~ + +Matches if the given thing is greater than the thing in the matcher. For +example:: + + def test_greater_than_example(self): + self.assertThat(3, GreaterThan(2)) + + +LessThan +~~~~~~~~ + +Matches if the given thing is less than the thing in the matcher. For +example:: + + def test_less_than_example(self): + self.assertThat(2, LessThan(3)) + + +StartsWith, EndsWith +~~~~~~~~~~~~~~~~~~~~ + +These matchers check to see if a string starts with or ends with a particular +substring. For example:: + + def test_starts_and_ends_with_example(self): + self.assertThat('underground', StartsWith('und')) + self.assertThat('underground', EndsWith('und')) + + +Contains +~~~~~~~~ + +This matcher checks to see if the given thing contains the thing in the +matcher. For example:: + + def test_contains_example(self): + self.assertThat('abc', Contains('b')) + + +MatchesException +~~~~~~~~~~~~~~~~ + +Matches an exc_info tuple if the exception is of the correct type. For +example:: + + def test_matches_exception_example(self): + try: + raise RuntimeError('foo') + except RuntimeError: + exc_info = sys.exc_info() + self.assertThat(exc_info, MatchesException(RuntimeError)) + self.assertThat(exc_info, MatchesException(RuntimeError('bar')) + +Most of the time, you will want to uses `The raises helper`_ instead. + + +NotEquals +~~~~~~~~~ + +Matches if something is not equal to something else. Note that this is subtly +different to ``Not(Equals(x))``. ``NotEquals(x)`` will match if ``y != x``, +``Not(Equals(x))`` will match if ``not y == x``. + +You only need to worry about this distinction if you are testing code that +relies on badly written overloaded equality operators. + + +KeysEqual +~~~~~~~~~ + +Matches if the keys of one dict are equal to the keys of another dict. For +example:: + + def test_keys_equal(self): + x = {'a': 1, 'b': 2} + y = {'a': 2, 'b': 3} + self.assertThat(a, KeysEqual(b)) + + +MatchesRegex +~~~~~~~~~~~~ + +Matches a string against a regular expression, which is a wonderful thing to +be able to do, if you think about it:: + + def test_matches_regex_example(self): + self.assertThat('foo', MatchesRegex('fo+')) + + +Combining matchers +------------------ + +One great thing about matchers is that you can readily combine existing +matchers to get variations on their behaviour or to quickly build more complex +assertions. + +Below are a few of the combining matchers that come with testtools. + + +Not +~~~ + +Negates another matcher. For example:: + + def test_not_example(self): + self.assertThat([42], Not(Equals("potato"))) + self.assertThat([42], Not(Is([42]))) + +If you find yourself using ``Not`` frequently, you may wish to create a custom +matcher for it. For example:: + + IsNot = lambda x: Not(Is(x)) + + def test_not_example_2(self): + self.assertThat([42], IsNot([42])) + + +Annotate +~~~~~~~~ + +Used to add custom notes to a matcher. For example:: + + def test_annotate_example(self): + result = 43 + self.assertThat( + result, Annotate("Not the answer to the Question!", Equals(42)) + +Since the annotation is only ever displayed when there is a mismatch +(e.g. when ``result`` does not equal 42), it's a good idea to phrase the note +negatively, so that it describes what a mismatch actually means. + +As with Not_, you may wish to create a custom matcher that describes a +common operation. For example:: + + PoliticallyEquals = lambda x: Annotate("Death to the aristos!", Equals(x)) + + def test_annotate_example_2(self): + self.assertThat("orange", PoliticallyEquals("yellow")) + +You can have assertThat perform the annotation for you as a convenience:: + + def test_annotate_example_3(self): + self.assertThat("orange", Equals("yellow"), "Death to the aristos!") + + +AfterPreprocessing +~~~~~~~~~~~~~~~~~~ + +Used to make a matcher that applies a function to the matched object before +matching. This can be used to aid in creating trivial matchers as functions, for +example:: + + def test_after_preprocessing_example(self): + def HasFileContent(content): + def _read(path): + return open(path).read() + return AfterPreprocessing(_read, Equals(content)) + self.assertThat('/tmp/foo.txt', PathHasFileContent("Hello world!")) + + +MatchesAll +~~~~~~~~~~ + +Combines many matchers to make a new matcher. The new matcher will only match +things that match every single one of the component matchers. + +It's much easier to understand in Python than in English:: + + def test_matches_all_example(self): + has_und_at_both_ends = MatchesAll(StartsWith("und"), EndsWith("und")) + # This will succeed. + self.assertThat("underground", has_und_at_both_ends) + # This will fail. + self.assertThat("found", has_und_at_both_ends) + # So will this. + self.assertThat("undead", has_und_at_both_ends) + +At this point some people ask themselves, "why bother doing this at all? why +not just have two separate assertions?". It's a good question. + +The first reason is that when a ``MatchesAll`` gets a mismatch, the error will +include information about all of the bits that mismatched. When you have two +separate assertions, as below:: + + def test_two_separate_assertions(self): + self.assertThat("foo", StartsWith("und")) + self.assertThat("foo", EndsWith("und")) + +Then you get absolutely no information from the second assertion if the first +assertion fails. Tests are largely there to help you debug code, so having +more information in error messages is a big help. + +The second reason is that it is sometimes useful to give a name to a set of +matchers. ``has_und_at_both_ends`` is a bit contrived, of course, but it is +clear. + + +MatchesAny +~~~~~~~~~~ + +Like MatchesAll_, ``MatchesAny`` combines many matchers to make a new +matcher. The difference is that the new matchers will match a thing if it +matches *any* of the component matchers. + +For example:: + + def test_matches_any_example(self): + self.assertThat(42, MatchesAny(Equals(5), Not(Equals(6)))) + + +AllMatch +~~~~~~~~ + +Matches many values against a single matcher. Can be used to make sure that +many things all meet the same condition:: + + def test_all_match_example(self): + self.assertThat([2, 3, 5, 7], AllMatch(LessThan(10))) + +If the match fails, then all of the values that fail to match will be included +in the error message. + +In some ways, this is the converse of MatchesAll_. + + +MatchesListwise +~~~~~~~~~~~~~~~ + +Where ``MatchesAny`` and ``MatchesAll`` combine many matchers to match a +single value, ``MatchesListwise`` combines many matches to match many values. + +For example:: + + def test_matches_listwise_example(self): + self.assertThat( + [1, 2, 3], MatchesListwise(map(Equals, [1, 2, 3]))) + +This is useful for writing custom, domain-specific matchers. + + +MatchesSetwise +~~~~~~~~~~~~~~ + +Combines many matchers to match many values, without regard to their order. + +Here's an example:: + + def test_matches_setwise_example(self): + self.assertThat( + [1, 2, 3], MatchesSetwise(Equals(2), Equals(3), Equals(1))) + +Much like ``MatchesListwise``, best used for writing custom, domain-specific +matchers. + + +MatchesStructure +~~~~~~~~~~~~~~~~ + +Creates a matcher that matches certain attributes of an object against a +pre-defined set of matchers. + +It's much easier to understand in Python than in English:: + + def test_matches_structure_example(self): + foo = Foo() + foo.a = 1 + foo.b = 2 + matcher = MatchesStructure(a=Equals(1), b=Equals(2)) + self.assertThat(foo, matcher) + +Since all of the matchers used were ``Equals``, we could also write this using +the ``byEquality`` helper:: + + def test_matches_structure_example(self): + foo = Foo() + foo.a = 1 + foo.b = 2 + matcher = MatchesStructure.byEquality(a=1, b=2) + self.assertThat(foo, matcher) + +``MatchesStructure.fromExample`` takes an object and a list of attributes and +creates a ``MatchesStructure`` matcher where each attribute of the matched +object must equal each attribute of the example object. For example:: + + matcher = MatchesStructure.fromExample(foo, 'a', 'b') + +is exactly equivalent to ``matcher`` in the previous example. + + +Raises +~~~~~~ + +Takes whatever the callable raises as an exc_info tuple and matches it against +whatever matcher it was given. For example, if you want to assert that a +callable raises an exception of a given type:: + + def test_raises_example(self): + self.assertThat( + lambda: 1/0, Raises(MatchesException(ZeroDivisionError))) + +Although note that this could also be written as:: + + def test_raises_example_convenient(self): + self.assertThat(lambda: 1/0, raises(ZeroDivisionError)) + +See also MatchesException_ and `the raises helper`_ + + +Writing your own matchers +------------------------- + +Combining matchers is fun and can get you a very long way indeed, but +sometimes you will have to write your own. Here's how. + +You need to make two closely-linked objects: a ``Matcher`` and a +``Mismatch``. The ``Matcher`` knows how to actually make the comparison, and +the ``Mismatch`` knows how to describe a failure to match. + +Here's an example matcher:: + + class IsDivisibleBy(object): + """Match if a number is divisible by another number.""" + def __init__(self, divider): + self.divider = divider + def __str__(self): + return 'IsDivisibleBy(%s)' % (self.divider,) + def match(self, actual): + remainder = actual % self.divider + if remainder != 0: + return IsDivisibleByMismatch(actual, self.divider, remainder) + else: + return None + +The matcher has a constructor that takes parameters that describe what you +actually *expect*, in this case a number that other numbers ought to be +divisible by. It has a ``__str__`` method, the result of which is displayed +on failure by ``assertThat`` and a ``match`` method that does the actual +matching. + +``match`` takes something to match against, here ``actual``, and decides +whether or not it matches. If it does match, then ``match`` must return +``None``. If it does *not* match, then ``match`` must return a ``Mismatch`` +object. ``assertThat`` will call ``match`` and then fail the test if it +returns a non-None value. For example:: + + def test_is_divisible_by_example(self): + # This succeeds, since IsDivisibleBy(5).match(10) returns None. + self.assertThat(10, IsDivisbleBy(5)) + # This fails, since IsDivisibleBy(7).match(10) returns a mismatch. + self.assertThat(10, IsDivisbleBy(7)) + +The mismatch is responsible for what sort of error message the failing test +generates. Here's an example mismatch:: + + class IsDivisibleByMismatch(object): + def __init__(self, number, divider, remainder): + self.number = number + self.divider = divider + self.remainder = remainder + + def describe(self): + return "%s is not divisible by %s, %s remains" % ( + self.number, self.divider, self.remainder) + + def get_details(self): + return {} + +The mismatch takes information about the mismatch, and provides a ``describe`` +method that assembles all of that into a nice error message for end users. +You can use the ``get_details`` method to provide extra, arbitrary data with +the mismatch (e.g. the contents of a log file). Most of the time it's fine to +just return an empty dict. You can read more about Details_ elsewhere in this +document. + +Sometimes you don't need to create a custom mismatch class. In particular, if +you don't care *when* the description is calculated, then you can just do that +in the Matcher itself like this:: + + def match(self, actual): + remainder = actual % self.divider + if remainder != 0: + return Mismatch( + "%s is not divisible by %s, %s remains" % ( + actual, self.divider, remainder)) + else: + return None + + +Details +======= + +As we may have mentioned once or twice already, one of the great benefits of +automated tests is that they help find, isolate and debug errors in your +system. + +Frequently however, the information provided by a mere assertion failure is +not enough. It's often useful to have other information: the contents of log +files; what queries were run; benchmark timing information; what state certain +subsystem components are in and so forth. + +testtools calls all of these things "details" and provides a single, powerful +mechanism for including this information in your test run. + +Here's an example of how to add them:: + + from testtools import TestCase + from testtools.content import text_content + + class TestSomething(TestCase): + + def test_thingy(self): + self.addDetail('arbitrary-color-name', text_content("blue")) + 1 / 0 # Gratuitous error! + +A detail an arbitrary piece of content given a name that's unique within the +test. Here the name is ``arbitrary-color-name`` and the content is +``text_content("blue")``. The name can be any text string, and the content +can be any ``testtools.content.Content`` object. + +When the test runs, testtools will show you something like this:: + + ====================================================================== + ERROR: exampletest.TestSomething.test_thingy + ---------------------------------------------------------------------- + arbitrary-color-name: {{{blue}}} + + Traceback (most recent call last): + File "exampletest.py", line 8, in test_thingy + 1 / 0 # Gratuitous error! + ZeroDivisionError: integer division or modulo by zero + ------------ + Ran 1 test in 0.030s + +As you can see, the detail is included as an attachment, here saying +that our arbitrary-color-name is "blue". + + +Content +------- + +For the actual content of details, testtools uses its own MIME-based Content +object. This allows you to attach any information that you could possibly +conceive of to a test, and allows testtools to use or serialize that +information. + +The basic ``testtools.content.Content`` object is constructed from a +``testtools.content.ContentType`` and a nullary callable that must return an +iterator of chunks of bytes that the content is made from. + +So, to make a Content object that is just a simple string of text, you can +do:: + + from testtools.content import Content + from testtools.content_type import ContentType + + text = Content(ContentType('text', 'plain'), lambda: ["some text"]) + +Because adding small bits of text content is very common, there's also a +convenience method:: + + text = text_content("some text") + +To make content out of an image stored on disk, you could do something like:: + + image = Content(ContentType('image', 'png'), lambda: open('foo.png').read()) + +Or you could use the convenience function:: + + image = content_from_file('foo.png', ContentType('image', 'png')) + +The ``lambda`` helps make sure that the file is opened and the actual bytes +read only when they are needed – by default, when the test is finished. This +means that tests can construct and add Content objects freely without worrying +too much about how they affect run time. + + +A realistic example +------------------- + +A very common use of details is to add a log file to failing tests. Say your +project has a server represented by a class ``SomeServer`` that you can start +up and shut down in tests, but runs in another process. You want to test +interaction with that server, and whenever the interaction fails, you want to +see the client-side error *and* the logs from the server-side. Here's how you +might do it:: + + from testtools import TestCase + from testtools.content import attach_file, Content + from testtools.content_type import UTF8_TEXT + + from myproject import SomeServer + + class SomeTestCase(TestCase): + + def setUp(self): + super(SomeTestCase, self).setUp() + self.server = SomeServer() + self.server.start_up() + self.addCleanup(self.server.shut_down) + self.addCleanup(attach_file, self.server.logfile, self) + + def attach_log_file(self): + self.addDetail( + 'log-file', + Content(UTF8_TEXT, + lambda: open(self.server.logfile, 'r').readlines())) + + def test_a_thing(self): + self.assertEqual("cool", self.server.temperature) + +This test will attach the log file of ``SomeServer`` to each test that is +run. testtools will only display the log file for failing tests, so it's not +such a big deal. + +If the act of adding at detail is expensive, you might want to use +addOnException_ so that you only do it when a test actually raises an +exception. + + +Controlling test execution +========================== + +.. _addCleanup: + +addCleanup +---------- + +``TestCase.addCleanup`` is a robust way to arrange for a clean up function to +be called before ``tearDown``. This is a powerful and simple alternative to +putting clean up logic in a try/finally block or ``tearDown`` method. For +example:: + + def test_foo(self): + foo.lock() + self.addCleanup(foo.unlock) + ... + +This is particularly useful if you have some sort of factory in your test:: + + def make_locked_foo(self): + foo = Foo() + foo.lock() + self.addCleanup(foo.unlock) + return foo + + def test_frotz_a_foo(self): + foo = self.make_locked_foo() + foo.frotz() + self.assertEqual(foo.frotz_count, 1) + +Any extra arguments or keyword arguments passed to ``addCleanup`` are passed +to the callable at cleanup time. + +Cleanups can also report multiple errors, if appropriate by wrapping them in +a ``testtools.MultipleExceptions`` object:: + + raise MultipleExceptions(exc_info1, exc_info2) + + +Fixtures +-------- + +Tests often depend on a system being set up in a certain way, or having +certain resources available to them. Perhaps a test needs a connection to the +database or access to a running external server. + +One common way of doing this is to do:: + + class SomeTest(TestCase): + def setUp(self): + super(SomeTest, self).setUp() + self.server = Server() + self.server.setUp() + self.addCleanup(self.server.tearDown) + +testtools provides a more convenient, declarative way to do the same thing:: + + class SomeTest(TestCase): + def setUp(self): + super(SomeTest, self).setUp() + self.server = self.useFixture(Server()) + +``useFixture(fixture)`` calls ``setUp`` on the fixture, schedules a clean up +to clean it up, and schedules a clean up to attach all details_ held by the +fixture to the test case. The fixture object must meet the +``fixtures.Fixture`` protocol (version 0.3.4 or newer, see fixtures_). + +If you have anything beyond the most simple test set up, we recommend that +you put this set up into a ``Fixture`` class. Once there, the fixture can be +easily re-used by other tests and can be combined with other fixtures to make +more complex resources. + + +Skipping tests +-------------- + +Many reasons exist to skip a test: a dependency might be missing; a test might +be too expensive and thus should not berun while on battery power; or perhaps +the test is testing an incomplete feature. + +``TestCase.skipTest`` is a simple way to have a test stop running and be +reported as a skipped test, rather than a success, error or failure. For +example:: + + def test_make_symlink(self): + symlink = getattr(os, 'symlink', None) + if symlink is None: + self.skipTest("No symlink support") + symlink(whatever, something_else) + +Using ``skipTest`` means that you can make decisions about what tests to run +as late as possible, and close to the actual tests. Without it, you might be +forced to use convoluted logic during test loading, which is a bit of a mess. + + +Legacy skip support +~~~~~~~~~~~~~~~~~~~ + +If you are using this feature when running your test suite with a legacy +``TestResult`` object that is missing the ``addSkip`` method, then the +``addError`` method will be invoked instead. If you are using a test result +from testtools, you do not have to worry about this. + +In older versions of testtools, ``skipTest`` was known as ``skip``. Since +Python 2.7 added ``skipTest`` support, the ``skip`` name is now deprecated. +No warning is emitted yet – some time in the future we may do so. + + +addOnException +-------------- + +Sometimes, you might wish to do something only when a test fails. Perhaps you +need to run expensive diagnostic routines or some such. +``TestCase.addOnException`` allows you to easily do just this. For example:: + + class SomeTest(TestCase): + def setUp(self): + super(SomeTest, self).setUp() + self.server = self.useFixture(SomeServer()) + self.addOnException(self.attach_server_diagnostics) + + def attach_server_diagnostics(self, exc_info): + self.server.prep_for_diagnostics() # Expensive! + self.addDetail('server-diagnostics', self.server.get_diagnostics) + + def test_a_thing(self): + self.assertEqual('cheese', 'chalk') + +In this example, ``attach_server_diagnostics`` will only be called when a test +fails. It is given the exc_info tuple of the error raised by the test, just +in case it is needed. + + +Twisted support +--------------- + +testtools provides *highly experimental* support for running Twisted tests – +tests that return a Deferred_ and rely on the Twisted reactor. You should not +use this feature right now. We reserve the right to change the API and +behaviour without telling you first. + +However, if you are going to, here's how you do it:: + + from testtools import TestCase + from testtools.deferredruntest import AsynchronousDeferredRunTest + + class MyTwistedTests(TestCase): + + run_tests_with = AsynchronousDeferredRunTest + + def test_foo(self): + # ... + return d + +In particular, note that you do *not* have to use a special base ``TestCase`` +in order to run Twisted tests. + +You can also run individual tests within a test case class using the Twisted +test runner:: + + class MyTestsSomeOfWhichAreTwisted(TestCase): + + def test_normal(self): + pass + + @run_test_with(AsynchronousDeferredRunTest) + def test_twisted(self): + # ... + return d + +Here are some tips for converting your Trial tests into testtools tests. + +* Use the ``AsynchronousDeferredRunTest`` runner +* Make sure to upcall to ``setUp`` and ``tearDown`` +* Don't use ``setUpClass`` or ``tearDownClass`` +* Don't expect setting .todo, .timeout or .skip attributes to do anything +* ``flushLoggedErrors`` is ``testtools.deferredruntest.flush_logged_errors`` +* ``assertFailure`` is ``testtools.deferredruntest.assert_fails_with`` +* Trial spins the reactor a couple of times before cleaning it up, + ``AsynchronousDeferredRunTest`` does not. If you rely on this behavior, use + ``AsynchronousDeferredRunTestForBrokenTwisted``. + + +Test helpers +============ + +testtools comes with a few little things that make it a little bit easier to +write tests. + + +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. + + +Creation methods +---------------- + +Often when writing unit tests, you want to create an object that is a +completely normal instance of its type. You don't want there to be anything +special about its properties, because you are testing generic behaviour rather +than specific conditions. + +A lot of the time, test authors do this by making up silly strings and numbers +and passing them to constructors (e.g. 42, 'foo', "bar" etc), and that's +fine. However, sometimes it's useful to be able to create arbitrary objects +at will, without having to make up silly sample data. + +To help with this, ``testtools.TestCase`` implements creation methods called +``getUniqueString`` and ``getUniqueInteger``. They return strings and +integers that are unique within the context of the test that can be used to +assemble more complex objects. Here's a basic example where +``getUniqueString`` is used instead of saying "foo" or "bar" or whatever:: + + class SomeTest(TestCase): + + def test_full_name(self): + first_name = self.getUniqueString() + last_name = self.getUniqueString() + p = Person(first_name, last_name) + self.assertEqual(p.full_name, "%s %s" % (first_name, last_name)) + + +And here's how it could be used to make a complicated test:: + + class TestCoupleLogic(TestCase): + + def make_arbitrary_person(self): + return Person(self.getUniqueString(), self.getUniqueString()) + + def test_get_invitation(self): + a = self.make_arbitrary_person() + b = self.make_arbitrary_person() + couple = Couple(a, b) + event_name = self.getUniqueString() + invitation = couple.get_invitation(event_name) + self.assertEqual( + invitation, + "We invite %s and %s to %s" % ( + a.full_name, b.full_name, event_name)) + +Essentially, creation methods like these are a way of reducing the number of +assumptions in your tests and communicating to test readers that the exact +details of certain variables don't actually matter. + +See pages 419-423 of `xUnit Test Patterns`_ by Gerard Meszaros for a detailed +discussion of creation methods. + + +General helpers +=============== + +Conditional imports +------------------- + +Lots of the time we would like to conditionally import modules. testtools +needs to do this itself, and graciously extends the ability to its users. + +Instead of:: + + try: + from twisted.internet import defer + except ImportError: + defer = None + +You can do:: + + defer = try_import('twisted.internet.defer') + + +Instead of:: + + try: + from StringIO import StringIO + except ImportError: + from io import StringIO + +You can do:: + + StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) + + +Safe attribute testing +---------------------- + +``hasattr`` is broken_ on many versions of Python. testtools provides +``safe_hasattr``, which can be used to safely test whether an object has a +particular attribute. + + +.. _testrepository: https://launchpad.net/testrepository +.. _Trial: http://twistedmatrix.com/documents/current/core/howto/testing.html +.. _nose: http://somethingaboutorange.com/mrl/projects/nose/ +.. _unittest2: http://pypi.python.org/pypi/unittest2 +.. _zope.testrunner: http://pypi.python.org/pypi/zope.testrunner/ +.. _xUnit test patterns: http://xunitpatterns.com/ +.. _fixtures: http://pypi.python.org/pypi/fixtures +.. _unittest: http://docs.python.org/library/unittest.html +.. _doctest: http://docs.python.org/library/doctest.html +.. _Deferred: http://twistedmatrix.com/documents/current/core/howto/defer.html +.. _discover: http://pypi.python.org/pypi/discover +.. _`testtools API docs`: http://mumak.net/testtools/apidocs/ +.. _Distutils: http://docs.python.org/library/distutils.html +.. _`setup configuration`: http://docs.python.org/distutils/configfile.html +.. _broken: http://chipaca.com/post/3210673069/hasattr-17-less-harmful diff --git a/lib/testtools/doc/hacking.rst b/lib/testtools/doc/hacking.rst new file mode 100644 index 0000000000..b9f5ff22c6 --- /dev/null +++ b/lib/testtools/doc/hacking.rst @@ -0,0 +1,154 @@ +========================= +Contributing to testtools +========================= + +Coding style +------------ + +In general, follow `PEP 8`_ except where consistency with the standard +library's unittest_ module would suggest otherwise. + +testtools supports Python 2.4 and later, including Python 3, so avoid any +2.5-only features like the ``with`` statement. + + +Copyright assignment +-------------------- + +Part of testtools raison d'etre is to provide Python with improvements to the +testing code it ships. For that reason we require all contributions (that are +non-trivial) to meet one of the following rules: + +* be inapplicable for inclusion in Python. +* be able to be included in Python without further contact with the contributor. +* be copyright assigned to Jonathan M. Lange. + +Please pick one of these and specify it when contributing code to testtools. + + +Licensing +--------- + +All code that is not copyright assigned to Jonathan M. Lange (see Copyright +Assignment above) needs to be licensed under the `MIT license`_ that testtools +uses, so that testtools can ship it. + + +Testing +------- + +Please write tests for every feature. This project ought to be a model +example of well-tested Python code! + +Take particular care to make sure the *intent* of each test is clear. + +You can run tests with ``make check``. + +By default, testtools hides many levels of its own stack when running tests. +This is for the convenience of users, who do not care about how, say, assert +methods are implemented. However, when writing tests for testtools itself, it +is often useful to see all levels of the stack. To do this, add +``run_tests_with = FullStackRunTest`` to the top of a test's class definition. + + +Documentation +------------- + +Documents are written using the Sphinx_ variant of reStructuredText_. All +public methods, functions, classes and modules must have API documentation. +When changing code, be sure to check the API documentation to see if it could +be improved. Before submitting changes to trunk, look over them and see if +the manuals ought to be updated. + + +Source layout +------------- + +The top-level directory contains the ``testtools/`` package directory, and +miscellaneous files like ``README`` and ``setup.py``. + +The ``testtools/`` directory is the Python package itself. It is separated +into submodules for internal clarity, but all public APIs should be “promoted” +into the top-level package by importing them in ``testtools/__init__.py``. +Users of testtools should never import a submodule in order to use a stable +API. Unstable APIs like ``testtools.matchers`` and +``testtools.deferredruntest`` should be exported as submodules. + +Tests belong in ``testtools/tests/``. + + +Committing to trunk +------------------- + +Testtools is maintained using bzr, with its trunk at lp:testtools. This gives +every contributor the ability to commit their work to their own branches. +However permission must be granted to allow contributors to commit to the trunk +branch. + +Commit access to trunk is obtained by joining the testtools-committers +Launchpad team. Membership in this team is contingent on obeying the testtools +contribution policy, see `Copyright Assignment`_ above. + + +Code Review +----------- + +All code must be reviewed before landing on trunk. The process is to create a +branch in launchpad, and submit it for merging to lp:testtools. It will then +be reviewed before it can be merged to trunk. It will be reviewed by someone: + +* not the author +* a committer (member of the `~testtools-committers`_ team) + +As a special exception, while the testtools committers team is small and prone +to blocking, a merge request from a committer that has not been reviewed after +24 hours may be merged by that committer. When the team is larger this policy +will be revisited. + +Code reviewers should look for the quality of what is being submitted, +including conformance with this HACKING file. + +Changes which all users should be made aware of should be documented in NEWS. + + +NEWS management +--------------- + +The file NEWS is structured as a sorted list of releases. Each release can have +a free form description and more or more sections with bullet point items. +Sections in use today are 'Improvements' and 'Changes'. To ease merging between +branches, the bullet points are kept alphabetically sorted. The release NEXT is +permanently present at the top of the list. + + +Release tasks +------------- + +#. Choose a version number, say X.Y.Z +#. Branch from trunk to testtools-X.Y.Z +#. In testtools-X.Y.Z, ensure __init__ has version ``(X, Y, Z, 'final', 0)`` +#. Replace NEXT in NEWS with the version number X.Y.Z, adjusting the reST. +#. Possibly write a blurb into NEWS. +#. Replace any additional references to NEXT with the version being + released. (There should be none other than the ones in these release tasks + which should not be replaced). +#. Commit the changes. +#. Tag the release, bzr tag testtools-X.Y.Z +#. Run 'make release', this: + #. Creates a source distribution and uploads to PyPI + #. Ensures all Fix Committed bugs are in the release milestone + #. Makes a release on Launchpad and uploads the tarball + #. Marks all the Fix Committed bugs as Fix Released + #. Creates a new milestone +#. Merge the release branch testtools-X.Y.Z into trunk. Before the commit, + add a NEXT heading to the top of NEWS and bump the version in __init__.py. + Push trunk to Launchpad +#. If a new series has been created (e.g. 0.10.0), make the series on Launchpad. + +.. _PEP 8: http://www.python.org/dev/peps/pep-0008/ +.. _unittest: http://docs.python.org/library/unittest.html +.. _~testtools-dev: https://launchpad.net/~testtools-dev +.. _MIT license: http://www.opensource.org/licenses/mit-license.php +.. _Sphinx: http://sphinx.pocoo.org/ +.. _restructuredtext: http://docutils.sourceforge.net/rst.html + diff --git a/lib/testtools/doc/index.rst b/lib/testtools/doc/index.rst new file mode 100644 index 0000000000..4687cebb62 --- /dev/null +++ b/lib/testtools/doc/index.rst @@ -0,0 +1,33 @@ +.. testtools documentation master file, created by + sphinx-quickstart on Sun Nov 28 13:45:40 2010. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +testtools: tasteful testing for Python +====================================== + +testtools is a set of extensions to the Python standard library's unit testing +framework. These extensions have been derived from many years of experience +with unit testing in Python and come from many different sources. testtools +also ports recent unittest changes all the way back to Python 2.4. + + +Contents: + +.. toctree:: + :maxdepth: 1 + + overview + for-test-authors + for-framework-folk + hacking + Changes to testtools <news> + API reference documentation <http://mumak.net/testtools/apidocs/> + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/lib/testtools/doc/make.bat b/lib/testtools/doc/make.bat new file mode 100644 index 0000000000..f8c1fd520a --- /dev/null +++ b/lib/testtools/doc/make.bat @@ -0,0 +1,113 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +set SPHINXBUILD=sphinx-build +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^<target^>` where ^<target^> is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\testtools.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\testtools.ghc + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/lib/testtools/doc/overview.rst b/lib/testtools/doc/overview.rst new file mode 100644 index 0000000000..e43265fd1e --- /dev/null +++ b/lib/testtools/doc/overview.rst @@ -0,0 +1,96 @@ +====================================== +testtools: tasteful testing for Python +====================================== + +testtools is a set of extensions to the Python standard library's unit testing +framework. These extensions have been derived from many years of experience +with unit testing in Python and come from many different sources. testtools +also ports recent unittest changes all the way back to Python 2.4. + +What better way to start than with a contrived code snippet?:: + + from testtools import TestCase + from testtools.content import Content + from testtools.content_type import UTF8_TEXT + from testtools.matchers import Equals + + from myproject import SillySquareServer + + class TestSillySquareServer(TestCase): + + def setUp(self): + super(TestSillySquare, self).setUp() + self.server = self.useFixture(SillySquareServer()) + self.addCleanup(self.attach_log_file) + + def attach_log_file(self): + self.addDetail( + 'log-file', + Content(UTF8_TEXT + lambda: open(self.server.logfile, 'r').readlines())) + + def test_server_is_cool(self): + self.assertThat(self.server.temperature, Equals("cool")) + + def test_square(self): + self.assertThat(self.server.silly_square_of(7), Equals(49)) + + +Why use testtools? +================== + +Better assertion methods +------------------------ + +The standard assertion methods that come with unittest aren't as helpful as +they could be, and there aren't quite enough of them. testtools adds +``assertIn``, ``assertIs``, ``assertIsInstance`` and their negatives. + + +Matchers: better than assertion methods +--------------------------------------- + +Of course, in any serious project you want to be able to have assertions that +are specific to that project and the particular problem that it is addressing. +Rather than forcing you to define your own assertion methods and maintain your +own inheritance hierarchy of ``TestCase`` classes, testtools lets you write +your own "matchers", custom predicates that can be plugged into a unit test:: + + def test_response_has_bold(self): + # The response has bold text. + response = self.server.getResponse() + self.assertThat(response, HTMLContains(Tag('bold', 'b'))) + + +More debugging info, when you need it +-------------------------------------- + +testtools makes it easy to add arbitrary data to your test result. If you +want to know what's in a log file when a test fails, or what the load was on +the computer when a test started, or what files were open, you can add that +information with ``TestCase.addDetail``, and it will appear in the test +results if that test fails. + + +Extend unittest, but stay compatible and re-usable +-------------------------------------------------- + +testtools goes to great lengths to allow serious test authors and test +*framework* authors to do whatever they like with their tests and their +extensions while staying compatible with the standard library's unittest. + +testtools has completely parametrized how exceptions raised in tests are +mapped to ``TestResult`` methods and how tests are actually executed (ever +wanted ``tearDown`` to be called regardless of whether ``setUp`` succeeds?) + +It also provides many simple but handy utilities, like the ability to clone a +test, a ``MultiTestResult`` object that lets many result objects get the +results from one test suite, adapters to bring legacy ``TestResult`` objects +into our new golden age. + + +Cross-Python compatibility +-------------------------- + +testtools gives you the very latest in unit testing technology in a way that +will work with Python 2.4, 2.5, 2.6, 2.7 and 3.1. diff --git a/lib/testtools/scripts/README b/lib/testtools/scripts/README new file mode 100644 index 0000000000..648f105705 --- /dev/null +++ b/lib/testtools/scripts/README @@ -0,0 +1,3 @@ +These are scripts to help with building, maintaining and releasing testtools. + +There is little here for anyone except a testtools contributor. diff --git a/lib/testtools/scripts/_lp_release.py b/lib/testtools/scripts/_lp_release.py new file mode 100644 index 0000000000..20afd0199e --- /dev/null +++ b/lib/testtools/scripts/_lp_release.py @@ -0,0 +1,230 @@ +#!/usr/bin/python + +"""Release testtools on Launchpad. + +Steps: + 1. Make sure all "Fix committed" bugs are assigned to 'next' + 2. Rename 'next' to the new version + 3. Release the milestone + 4. Upload the tarball + 5. Create a new 'next' milestone + 6. Mark all "Fix committed" bugs in the milestone as "Fix released" + +Assumes that NEWS is in the parent directory, that the release sections are +underlined with '~' and the subsections are underlined with '-'. + +Assumes that this file is in the 'scripts' directory a testtools tree that has +already had a tarball built and uploaded with 'python setup.py sdist upload +--sign'. +""" + +from datetime import datetime, timedelta, tzinfo +import logging +import os +import sys + +from launchpadlib.launchpad import Launchpad +from launchpadlib import uris + + +APP_NAME = 'testtools-lp-release' +CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache') +SERVICE_ROOT = uris.LPNET_SERVICE_ROOT + +FIX_COMMITTED = u"Fix Committed" +FIX_RELEASED = u"Fix Released" + +# Launchpad file type for a tarball upload. +CODE_RELEASE_TARBALL = 'Code Release Tarball' + +PROJECT_NAME = 'testtools' +NEXT_MILESTONE_NAME = 'next' + + +class _UTC(tzinfo): + """UTC""" + + def utcoffset(self, dt): + return timedelta(0) + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return timedelta(0) + +UTC = _UTC() + + +def configure_logging(): + level = logging.INFO + log = logging.getLogger(APP_NAME) + log.setLevel(level) + handler = logging.StreamHandler() + handler.setLevel(level) + formatter = logging.Formatter("%(levelname)s: %(message)s") + handler.setFormatter(formatter) + log.addHandler(handler) + return log +LOG = configure_logging() + + +def get_path(relpath): + """Get the absolute path for something relative to this file.""" + return os.path.abspath( + os.path.join( + os.path.dirname(os.path.dirname(__file__)), relpath)) + + +def assign_fix_committed_to_next(testtools, next_milestone): + """Find all 'Fix Committed' and make sure they are in 'next'.""" + fixed_bugs = list(testtools.searchTasks(status=FIX_COMMITTED)) + for task in fixed_bugs: + LOG.debug("%s" % (task.title,)) + if task.milestone != next_milestone: + task.milestone = next_milestone + LOG.info("Re-assigning %s" % (task.title,)) + task.lp_save() + + +def rename_milestone(next_milestone, new_name): + """Rename 'next_milestone' to 'new_name'.""" + LOG.info("Renaming %s to %s" % (next_milestone.name, new_name)) + next_milestone.name = new_name + next_milestone.lp_save() + + +def get_release_notes_and_changelog(news_path): + release_notes = [] + changelog = [] + state = None + last_line = None + + def is_heading_marker(line, marker_char): + return line and line == marker_char * len(line) + + LOG.debug("Loading NEWS from %s" % (news_path,)) + with open(news_path, 'r') as news: + for line in news: + line = line.strip() + if state is None: + if is_heading_marker(line, '~'): + milestone_name = last_line + state = 'release-notes' + else: + last_line = line + elif state == 'title': + # The line after the title is a heading marker line, so we + # ignore it and change state. That which follows are the + # release notes. + state = 'release-notes' + elif state == 'release-notes': + if is_heading_marker(line, '-'): + state = 'changelog' + # Last line in the release notes is actually the first + # line of the changelog. + changelog = [release_notes.pop(), line] + else: + release_notes.append(line) + elif state == 'changelog': + if is_heading_marker(line, '~'): + # Last line in changelog is actually the first line of the + # next section. + changelog.pop() + break + else: + changelog.append(line) + else: + raise ValueError("Couldn't parse NEWS") + + release_notes = '\n'.join(release_notes).strip() + '\n' + changelog = '\n'.join(changelog).strip() + '\n' + return milestone_name, release_notes, changelog + + +def release_milestone(milestone, release_notes, changelog): + date_released = datetime.now(tz=UTC) + LOG.info( + "Releasing milestone: %s, date %s" % (milestone.name, date_released)) + release = milestone.createProductRelease( + date_released=date_released, + changelog=changelog, + release_notes=release_notes, + ) + milestone.is_active = False + milestone.lp_save() + return release + + +def create_milestone(series, name): + """Create a new milestone in the same series as 'release_milestone'.""" + LOG.info("Creating milestone %s in series %s" % (name, series.name)) + return series.newMilestone(name=name) + + +def close_fixed_bugs(milestone): + tasks = list(milestone.searchTasks()) + for task in tasks: + LOG.debug("Found %s" % (task.title,)) + if task.status == FIX_COMMITTED: + LOG.info("Closing %s" % (task.title,)) + task.status = FIX_RELEASED + else: + LOG.warning( + "Bug not fixed, removing from milestone: %s" % (task.title,)) + task.milestone = None + task.lp_save() + + +def upload_tarball(release, tarball_path): + with open(tarball_path) as tarball: + tarball_content = tarball.read() + sig_path = tarball_path + '.asc' + with open(sig_path) as sig: + sig_content = sig.read() + tarball_name = os.path.basename(tarball_path) + LOG.info("Uploading tarball: %s" % (tarball_path,)) + release.add_file( + file_type=CODE_RELEASE_TARBALL, + file_content=tarball_content, filename=tarball_name, + signature_content=sig_content, + signature_filename=sig_path, + content_type="application/x-gzip; charset=binary") + + +def release_project(launchpad, project_name, next_milestone_name): + testtools = launchpad.projects[project_name] + next_milestone = testtools.getMilestone(name=next_milestone_name) + release_name, release_notes, changelog = get_release_notes_and_changelog( + get_path('NEWS')) + LOG.info("Releasing %s %s" % (project_name, release_name)) + # Since reversing these operations is hard, and inspecting errors from + # Launchpad is also difficult, do some looking before leaping. + errors = [] + tarball_path = get_path('dist/%s-%s.tar.gz' % (project_name, release_name,)) + if not os.path.isfile(tarball_path): + errors.append("%s does not exist" % (tarball_path,)) + if not os.path.isfile(tarball_path + '.asc'): + errors.append("%s does not exist" % (tarball_path + '.asc',)) + if testtools.getMilestone(name=release_name): + errors.append("Milestone %s exists on %s" % (release_name, project_name)) + if errors: + for error in errors: + LOG.error(error) + return 1 + assign_fix_committed_to_next(testtools, next_milestone) + rename_milestone(next_milestone, release_name) + release = release_milestone(next_milestone, release_notes, changelog) + upload_tarball(release, tarball_path) + create_milestone(next_milestone.series_target, next_milestone_name) + close_fixed_bugs(next_milestone) + return 0 + + +def main(args): + launchpad = Launchpad.login_with(APP_NAME, SERVICE_ROOT, CACHE_DIR) + return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME) + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/lib/testtools/scripts/all-pythons b/lib/testtools/scripts/all-pythons new file mode 100755 index 0000000000..aecc9495a6 --- /dev/null +++ b/lib/testtools/scripts/all-pythons @@ -0,0 +1,90 @@ +#!/usr/bin/python + +"""Run the testtools test suite for all supported Pythons. + +Prints output as a subunit test suite. If anything goes to stderr, that is +treated as a test error. If a Python is not available, then it is skipped. +""" + +from datetime import datetime +import os +import subprocess +import sys + +import subunit +from subunit import ( + iso8601, + _make_stream_binary, + TestProtocolClient, + TestProtocolServer, + ) +from testtools import ( + PlaceHolder, + TestCase, + ) +from testtools.compat import BytesIO +from testtools.content import text_content + + +ROOT = os.path.dirname(os.path.dirname(__file__)) + + +def run_for_python(version, result): + # XXX: This could probably be broken up and put into subunit. + python = 'python%s' % (version,) + # XXX: Correct API, but subunit doesn't support it. :( + # result.tags(set(python), set()) + result.time(now()) + test = PlaceHolder(''.join(c for c in python if c != '.')) + process = subprocess.Popen( + '%s -c pass' % (python,), shell=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + process.communicate() + + if process.returncode: + result.startTest(test) + result.addSkip(test, reason='%s not available' % (python,)) + result.stopTest(test) + return + + env = os.environ.copy() + if env.get('PYTHONPATH', None): + env['PYTHONPATH'] = os.pathsep.join([ROOT, env['PYTHONPATH']]) + else: + env['PYTHONPATH'] = ROOT + result.time(now()) + protocol = TestProtocolServer(result) + subunit_path = os.path.join(os.path.dirname(subunit.__file__), 'run.py') + cmd = [ + python, + '-W', 'ignore:Module testtools was already imported', + subunit_path, 'testtools.tests.test_suite'] + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + _make_stream_binary(process.stdout) + _make_stream_binary(process.stderr) + # XXX: This buffers everything. Bad for memory, bad for getting progress + # on jenkins. + output, error = process.communicate() + protocol.readFrom(BytesIO(output)) + if error: + result.startTest(test) + result.addError(test, details={ + 'stderr': text_content(error), + }) + result.stopTest(test) + result.time(now()) + # XXX: Correct API, but subunit doesn't support it. :( + #result.tags(set(), set(python)) + + +def now(): + return datetime.utcnow().replace(tzinfo=iso8601.Utc()) + + + +if __name__ == '__main__': + sys.path.append(ROOT) + result = TestProtocolClient(sys.stdout) + for version in '2.4 2.5 2.6 2.7 3.0 3.1 3.2'.split(): + run_for_python(version, result) diff --git a/lib/testtools/scripts/update-rtfd b/lib/testtools/scripts/update-rtfd new file mode 100755 index 0000000000..92a19daaa6 --- /dev/null +++ b/lib/testtools/scripts/update-rtfd @@ -0,0 +1,11 @@ +#!/usr/bin/python + +from StringIO import StringIO +from urllib2 import urlopen + + +WEB_HOOK = 'http://readthedocs.org/build/588' + + +if __name__ == '__main__': + urlopen(WEB_HOOK, data=' ') diff --git a/lib/testtools/setup.cfg b/lib/testtools/setup.cfg new file mode 100644 index 0000000000..9f95adde2b --- /dev/null +++ b/lib/testtools/setup.cfg @@ -0,0 +1,4 @@ +[test] +test_module = testtools.tests +buffer=1 +catch=1 diff --git a/lib/testtools/setup.py b/lib/testtools/setup.py index 59e5804f05..d07c8f2935 100755 --- a/lib/testtools/setup.py +++ b/lib/testtools/setup.py @@ -9,9 +9,14 @@ import testtools def get_revno(): + import bzrlib.errors import bzrlib.workingtree - t = bzrlib.workingtree.WorkingTree.open_containing(__file__)[0] - return t.branch.revno() + try: + t = bzrlib.workingtree.WorkingTree.open_containing(__file__)[0] + except (bzrlib.errors.NotBranchError, bzrlib.errors.NoWorkingTree): + return None + else: + return t.branch.revno() def get_version_from_pkg_info(): @@ -39,6 +44,8 @@ def get_version(): if pkg_info_version: return pkg_info_version revno = get_revno() + if revno is None: + return "snapshot" if phase == 'alpha': # No idea what the next version will be return 'next-r%s' % revno @@ -48,7 +55,8 @@ def get_version(): def get_long_description(): - manual_path = os.path.join(os.path.dirname(__file__), 'MANUAL') + manual_path = os.path.join( + os.path.dirname(__file__), 'doc/overview.rst') return open(manual_path).read() @@ -61,4 +69,5 @@ setup(name='testtools', long_description=get_long_description(), version=get_version(), classifiers=["License :: OSI Approved :: MIT License"], - packages=['testtools', 'testtools.testresult', 'testtools.tests']) + packages=['testtools', 'testtools.testresult', 'testtools.tests'], + cmdclass={'test': testtools.TestCommand}) diff --git a/lib/testtools/testtools/__init__.py b/lib/testtools/testtools/__init__.py index 48fa335694..2a2f4d65e0 100644 --- a/lib/testtools/testtools/__init__.py +++ b/lib/testtools/testtools/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2008, 2009, 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Extensions to the standard Python unittest library.""" @@ -6,13 +6,16 @@ __all__ = [ 'clone_test_with_new_id', 'ConcurrentTestSuite', 'ErrorHolder', + 'ExpectedException', 'ExtendedToOriginalDecorator', + 'FixtureSuite', 'iterate_tests', 'MultipleExceptions', 'MultiTestResult', 'PlaceHolder', 'run_test_with', 'TestCase', + 'TestCommand', 'TestResult', 'TextTestResult', 'RunTest', @@ -31,12 +34,16 @@ from testtools.helpers import ( from testtools.matchers import ( Matcher, ) +# Shut up, pyflakes. We are importing for documentation, not for namespacing. +Matcher + from testtools.runtest import ( MultipleExceptions, RunTest, ) from testtools.testcase import ( ErrorHolder, + ExpectedException, PlaceHolder, TestCase, clone_test_with_new_id, @@ -54,8 +61,12 @@ from testtools.testresult import ( ) from testtools.testsuite import ( ConcurrentTestSuite, + FixtureSuite, iterate_tests, ) +from testtools.distutilscmd import ( + TestCommand, +) # same format as sys.version_info: "A tuple containing the five components of # the version number: major, minor, micro, releaselevel, and serial. All @@ -69,4 +80,4 @@ from testtools.testsuite import ( # If the releaselevel is 'final', then the tarball will be major.minor.micro. # Otherwise it is major.minor.micro~$(revno). -__version__ = (0, 9, 9, 'dev', 0) +__version__ = (0, 9, 12, 'dev', 0) diff --git a/lib/testtools/testtools/_compat2x.py b/lib/testtools/testtools/_compat2x.py new file mode 100644 index 0000000000..2b25c13e08 --- /dev/null +++ b/lib/testtools/testtools/_compat2x.py @@ -0,0 +1,17 @@ +# Copyright (c) 2011 testtools developers. See LICENSE for details. + +"""Compatibility helpers that are valid syntax in Python 2.x. + +Only add things here if they *only* work in Python 2.x or are Python 2 +alternatives to things that *only* work in Python 3.x. +""" + +__all__ = [ + 'reraise', + ] + + +def reraise(exc_class, exc_obj, exc_tb, _marker=object()): + """Re-raise an exception received from sys.exc_info() or similar.""" + raise exc_class, exc_obj, exc_tb + diff --git a/lib/testtools/testtools/_compat3x.py b/lib/testtools/testtools/_compat3x.py new file mode 100644 index 0000000000..f3d569662d --- /dev/null +++ b/lib/testtools/testtools/_compat3x.py @@ -0,0 +1,17 @@ +# Copyright (c) 2011 testtools developers. See LICENSE for details. + +"""Compatibility helpers that are valid syntax in Python 3.x. + +Only add things here if they *only* work in Python 3.x or are Python 3 +alternatives to things that *only* work in Python 2.x. +""" + +__all__ = [ + 'reraise', + ] + + +def reraise(exc_class, exc_obj, exc_tb, _marker=object()): + """Re-raise an exception received from sys.exc_info() or similar.""" + raise exc_class(*exc_obj.args).with_traceback(exc_tb) + diff --git a/lib/testtools/testtools/_spinner.py b/lib/testtools/testtools/_spinner.py index 98b51a6565..baf455a5f9 100644 --- a/lib/testtools/testtools/_spinner.py +++ b/lib/testtools/testtools/_spinner.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010 testtools developers. See LICENSE for details. """Evil reactor-spinning logic for running Twisted tests. @@ -91,9 +91,9 @@ def trap_unhandled_errors(function, *args, **kwargs): If 'function' raises, then don't bother doing any unhandled error jiggery-pokery, since something horrible has probably happened anyway. - :return: A tuple of '(result, error)', where 'result' is the value returned - by 'function' and 'error' is a list of `defer.DebugInfo` objects that - have unhandled errors in Deferreds. + :return: A tuple of '(result, error)', where 'result' is the value + returned by 'function' and 'error' is a list of 'defer.DebugInfo' + objects that have unhandled errors in Deferreds. """ real_DebugInfo = defer.DebugInfo debug_infos = [] @@ -215,9 +215,9 @@ class Spinner(object): """Clean up any junk in the reactor. Will always iterate the reactor a number of times equal to - `_OBLIGATORY_REACTOR_ITERATIONS`. This is to work around bugs in - various Twisted APIs where a Deferred fires but still leaves work - (e.g. cancelling a call, actually closing a connection) for the + ``Spinner._OBLIGATORY_REACTOR_ITERATIONS``. This is to work around + bugs in various Twisted APIs where a Deferred fires but still leaves + work (e.g. cancelling a call, actually closing a connection) for the reactor to do. """ for i in range(self._OBLIGATORY_REACTOR_ITERATIONS): @@ -269,10 +269,10 @@ class Spinner(object): the Deferred fires and its chain completes or until the timeout is reached -- whichever comes first. - :raise TimeoutError: If 'timeout' is reached before the `Deferred` + :raise TimeoutError: If 'timeout' is reached before the Deferred returned by 'function' has completed its callback chain. :raise NoResultError: If the reactor is somehow interrupted before - the `Deferred` returned by 'function' has completed its callback + the Deferred returned by 'function' has completed its callback chain. :raise StaleJunkError: If there's junk in the spinner from a previous run. diff --git a/lib/testtools/testtools/compat.py b/lib/testtools/testtools/compat.py index ecbfb42d9a..c8a641be23 100644 --- a/lib/testtools/testtools/compat.py +++ b/lib/testtools/testtools/compat.py @@ -1,7 +1,22 @@ -# Copyright (c) 2008-2010 testtools developers. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Compatibility support for python 2 and 3.""" +__metaclass__ = type +__all__ = [ + '_b', + '_u', + 'advance_iterator', + 'all', + 'BytesIO', + 'classtypes', + 'isbaseexception', + 'istext', + 'str_is_unicode', + 'StringIO', + 'reraise', + 'unicode_output_stream', + ] import codecs import linecache @@ -11,14 +26,18 @@ import re import sys import traceback -__metaclass__ = type -__all__ = [ - '_b', - '_u', - 'advance_iterator', - 'str_is_unicode', - 'unicode_output_stream', - ] +from testtools.helpers import try_imports + +BytesIO = try_imports(['StringIO.StringIO', 'io.BytesIO']) +StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) + +try: + from testtools import _compat2x as _compat + _compat +except SyntaxError: + from testtools import _compat3x as _compat + +reraise = _compat.reraise __u_doc = """A function version of the 'u' prefix. @@ -114,7 +133,9 @@ def unicode_output_stream(stream): 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): + return stream + return writer(stream) if sys.version_info > (3, 0): # Python 3 doesn't seem to make this easy, handle a common case try: diff --git a/lib/testtools/testtools/content.py b/lib/testtools/testtools/content.py index 86df09fc6e..2c6ed9f586 100644 --- a/lib/testtools/testtools/content.py +++ b/lib/testtools/testtools/content.py @@ -1,17 +1,44 @@ -# Copyright (c) 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """Content - a MIME-like Content object.""" +__all__ = [ + 'attach_file', + 'Content', + 'content_from_file', + 'content_from_stream', + 'text_content', + 'TracebackContent', + ] + import codecs +import os +from testtools import try_import from testtools.compat import _b from testtools.content_type import ContentType, UTF8_TEXT from testtools.testresult import TestResult +functools = try_import('functools') _join_b = _b("").join +DEFAULT_CHUNK_SIZE = 4096 + + +def _iter_chunks(stream, chunk_size): + """Read 'stream' in chunks of 'chunk_size'. + + :param stream: A file-like object to read from. + :param chunk_size: The size of each read from 'stream'. + """ + chunk = stream.read(chunk_size) + while chunk: + yield chunk + chunk = stream.read(chunk_size) + + class Content(object): """A MIME-like Content object. @@ -100,3 +127,112 @@ def text_content(text): This is useful for adding details which are short strings. """ return Content(UTF8_TEXT, lambda: [text.encode('utf8')]) + + + +def maybe_wrap(wrapper, func): + """Merge metadata for func into wrapper if functools is present.""" + if functools is not None: + wrapper = functools.update_wrapper(wrapper, func) + return wrapper + + +def content_from_file(path, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE, + buffer_now=False): + """Create a `Content` object from a file on disk. + + Note that unless 'read_now' is explicitly passed in as True, the file + will only be read from when ``iter_bytes`` is called. + + :param path: The path to the file to be used as content. + :param content_type: The type of content. If not specified, defaults + to UTF8-encoded text/plain. + :param chunk_size: The size of chunks to read from the file. + Defaults to `DEFAULT_CHUNK_SIZE`. + :param buffer_now: If True, read the file from disk now and keep it in + memory. Otherwise, only read when the content is serialized. + """ + if content_type is None: + content_type = UTF8_TEXT + def reader(): + # This should be try:finally:, but python2.4 makes that hard. When + # We drop older python support we can make this use a context manager + # for maximum simplicity. + stream = open(path, 'rb') + for chunk in _iter_chunks(stream, chunk_size): + yield chunk + stream.close() + return content_from_reader(reader, content_type, buffer_now) + + +def content_from_stream(stream, content_type=None, + chunk_size=DEFAULT_CHUNK_SIZE, buffer_now=False): + """Create a `Content` object from a file-like stream. + + Note that the stream will only be read from when ``iter_bytes`` is + called. + + :param stream: A file-like object to read the content from. The stream + is not closed by this function or the content object it returns. + :param content_type: The type of content. If not specified, defaults + to UTF8-encoded text/plain. + :param chunk_size: The size of chunks to read from the file. + Defaults to `DEFAULT_CHUNK_SIZE`. + :param buffer_now: If True, reads from the stream right now. Otherwise, + only reads when the content is serialized. Defaults to False. + """ + if content_type is None: + content_type = UTF8_TEXT + reader = lambda: _iter_chunks(stream, chunk_size) + return content_from_reader(reader, content_type, buffer_now) + + +def content_from_reader(reader, content_type, buffer_now): + """Create a Content object that will obtain the content from reader. + + :param reader: A callback to read the content. Should return an iterable of + bytestrings. + :param content_type: The content type to create. + :param buffer_now: If True the reader is evaluated immediately and + buffered. + """ + if content_type is None: + content_type = UTF8_TEXT + if buffer_now: + contents = list(reader()) + reader = lambda: contents + return Content(content_type, reader) + + +def attach_file(detailed, path, name=None, content_type=None, + chunk_size=DEFAULT_CHUNK_SIZE, buffer_now=True): + """Attach a file to this test as a detail. + + This is a convenience method wrapping around `addDetail`. + + Note that unless 'read_now' is explicitly passed in as True, the file + *must* exist when the test result is called with the results of this + test, after the test has been torn down. + + :param detailed: An object with details + :param path: The path to the file to attach. + :param name: The name to give to the detail for the attached file. + :param content_type: The content type of the file. If not provided, + defaults to UTF8-encoded text/plain. + :param chunk_size: The size of chunks to read from the file. Defaults + to something sensible. + :param buffer_now: If False the file content is read when the content + object is evaluated rather than when attach_file is called. + Note that this may be after any cleanups that obj_with_details has, so + if the file is a temporary file disabling buffer_now may cause the file + to be read after it is deleted. To handle those cases, using + attach_file as a cleanup is recommended because it guarantees a + sequence for when the attach_file call is made:: + + detailed.addCleanup(attach_file, 'foo.txt', detailed) + """ + if name is None: + name = os.path.basename(path) + content_object = content_from_file( + path, content_type, chunk_size, buffer_now) + detailed.addDetail(name, content_object) diff --git a/lib/testtools/testtools/content_type.py b/lib/testtools/testtools/content_type.py index a936506e48..82c301b38d 100644 --- a/lib/testtools/testtools/content_type.py +++ b/lib/testtools/testtools/content_type.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """ContentType - a MIME Content Type.""" @@ -27,7 +27,13 @@ class ContentType(object): return self.__dict__ == other.__dict__ def __repr__(self): - return "%s/%s params=%s" % (self.type, self.subtype, self.parameters) + if self.parameters: + params = '; ' + params += ', '.join( + '%s="%s"' % (k, v) for k, v in self.parameters.items()) + else: + params = '' + return "%s/%s%s" % (self.type, self.subtype, params) UTF8_TEXT = ContentType('text', 'plain', {'charset': 'utf8'}) diff --git a/lib/testtools/testtools/deferredruntest.py b/lib/testtools/testtools/deferredruntest.py index 50153bee4f..b8bfaaaa39 100644 --- a/lib/testtools/testtools/deferredruntest.py +++ b/lib/testtools/testtools/deferredruntest.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010 testtools developers. See LICENSE for details. """Individual test case execution for tests that return Deferreds. @@ -15,7 +15,7 @@ __all__ = [ import sys -from testtools import try_imports +from testtools.compat import StringIO from testtools.content import ( Content, text_content, @@ -34,8 +34,6 @@ from twisted.internet import defer from twisted.python import log from twisted.trial.unittest import _LogObserver -StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) - class _DeferredRunTest(RunTest): """Base for tests that return Deferreds.""" @@ -95,10 +93,10 @@ class AsynchronousDeferredRunTest(_DeferredRunTest): debug=False): """Construct an `AsynchronousDeferredRunTest`. - :param case: The `testtools.TestCase` to run. + :param case: The `TestCase` to run. :param handlers: A list of exception handlers (ExceptionType, handler) where 'handler' is a callable that takes a `TestCase`, a - `TestResult` and the exception raised. + ``testtools.TestResult`` and the exception raised. :param reactor: The Twisted reactor to use. If not given, we use the default reactor. :param timeout: The maximum time allowed for running a test. The @@ -217,6 +215,7 @@ class AsynchronousDeferredRunTest(_DeferredRunTest): def _run_core(self): # Add an observer to trap all logged errors. + self.case.reactor = self._reactor error_observer = _log_observer full_log = StringIO() full_observer = log.FileLogObserver(full_log) @@ -289,11 +288,12 @@ def assert_fails_with(d, *exc_types, **kwargs): peril; expect the API to change. :param d: A Deferred that is expected to fail. - :param *exc_types: The exception types that the Deferred is expected to + :param exc_types: The exception types that the Deferred is expected to fail with. :param failureException: An optional keyword argument. If provided, will - raise that exception instead of `testtools.TestCase.failureException`. - :return: A Deferred that will fail with an `AssertionError` if 'd' does + raise that exception instead of + ``testtools.TestCase.failureException``. + :return: A Deferred that will fail with an ``AssertionError`` if 'd' does not fail with one of the exception types. """ failureException = kwargs.pop('failureException', None) diff --git a/lib/testtools/testtools/distutilscmd.py b/lib/testtools/testtools/distutilscmd.py new file mode 100644 index 0000000000..91e14ca504 --- /dev/null +++ b/lib/testtools/testtools/distutilscmd.py @@ -0,0 +1,62 @@ +# Copyright (c) 2010-2011 testtools developers . See LICENSE for details. + +"""Extensions to the standard Python unittest library.""" + +import sys + +from distutils.core import Command +from distutils.errors import DistutilsOptionError + +from testtools.run import TestProgram, TestToolsTestRunner + + +class TestCommand(Command): + """Command to run unit tests with testtools""" + + description = "run unit tests with testtools" + + user_options = [ + ('catch', 'c', "Catch ctrl-C and display results so far"), + ('buffer', 'b', "Buffer stdout and stderr during tests"), + ('failfast', 'f', "Stop on first fail or error"), + ('test-module=','m', "Run 'test_suite' in specified module"), + ('test-suite=','s', + "Test suite to run (e.g. 'some_module.test_suite')") + ] + + def __init__(self, dist): + Command.__init__(self, dist) + self.runner = TestToolsTestRunner(sys.stdout) + + + def initialize_options(self): + self.test_suite = None + self.test_module = None + self.catch = None + self.buffer = None + self.failfast = None + + def finalize_options(self): + if self.test_suite is None: + if self.test_module is None: + raise DistutilsOptionError( + "You must specify a module or a suite to run tests from") + else: + self.test_suite = self.test_module+".test_suite" + elif self.test_module: + raise DistutilsOptionError( + "You may specify a module or a suite, but not both") + self.test_args = [self.test_suite] + if self.verbose: + self.test_args.insert(0, '--verbose') + if self.buffer: + self.test_args.insert(0, '--buffer') + if self.catch: + self.test_args.insert(0, '--catch') + if self.failfast: + self.test_args.insert(0, '--failfast') + + def run(self): + self.program = TestProgram( + argv=self.test_args, testRunner=self.runner, stdout=sys.stdout, + exit=False) diff --git a/lib/testtools/testtools/helpers.py b/lib/testtools/testtools/helpers.py index 0f489c73f6..dbf66719ed 100644 --- a/lib/testtools/testtools/helpers.py +++ b/lib/testtools/testtools/helpers.py @@ -1,64 +1,87 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010-2011 testtools developers. See LICENSE for details. __all__ = [ + 'safe_hasattr', 'try_import', 'try_imports', ] +import sys -def try_import(name, alternative=None): - """Attempt to import `name`. If it fails, return `alternative`. + +def try_import(name, alternative=None, error_callback=None): + """Attempt to import ``name``. If it fails, return ``alternative``. When supporting multiple versions of Python or optional dependencies, it is useful to be able to try to import a module. - :param name: The name of the object to import, e.g. 'os.path' or - 'os.path.join'. + :param name: The name of the object to import, e.g. ``os.path`` or + ``os.path.join``. :param alternative: The value to return if no module can be imported. Defaults to None. + :param error_callback: If non-None, a callable that is passed the ImportError + when the module cannot be loaded. """ module_segments = name.split('.') + last_error = None while module_segments: module_name = '.'.join(module_segments) try: module = __import__(module_name) except ImportError: + last_error = sys.exc_info()[1] module_segments.pop() continue else: break else: + if last_error is not None and error_callback is not None: + error_callback(last_error) return alternative nonexistent = object() for segment in name.split('.')[1:]: module = getattr(module, segment, nonexistent) if module is nonexistent: + if last_error is not None and error_callback is not None: + error_callback(last_error) return alternative return module _RAISE_EXCEPTION = object() -def try_imports(module_names, alternative=_RAISE_EXCEPTION): +def try_imports(module_names, alternative=_RAISE_EXCEPTION, error_callback=None): """Attempt to import modules. - Tries to import the first module in `module_names`. If it can be + Tries to import the first module in ``module_names``. If it can be imported, we return it. If not, we go on to the second module and try that. The process continues until we run out of modules to try. If none of the modules can be imported, either raise an exception or return the - provided `alternative` value. + provided ``alternative`` value. :param module_names: A sequence of module names to try to import. :param alternative: The value to return if no module can be imported. If unspecified, we raise an ImportError. + :param error_callback: If None, called with the ImportError for *each* + module that fails to load. :raises ImportError: If none of the modules can be imported and no alternative value was specified. """ module_names = list(module_names) for module_name in module_names: - module = try_import(module_name) + module = try_import(module_name, error_callback=error_callback) if module: return module if alternative is _RAISE_EXCEPTION: raise ImportError( "Could not import any of: %s" % ', '.join(module_names)) return alternative + + +def safe_hasattr(obj, attr, _marker=object()): + """Does 'obj' have an attribute 'attr'? + + Use this rather than built-in hasattr, as the built-in swallows exceptions + in some versions of Python and behaves unpredictably with respect to + properties. + """ + return getattr(obj, attr, _marker) is not _marker diff --git a/lib/testtools/testtools/matchers.py b/lib/testtools/testtools/matchers.py index 06b348c6d9..6ee33f0fd8 100644 --- a/lib/testtools/testtools/matchers.py +++ b/lib/testtools/testtools/matchers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """Matchers, a way to express complex assertions outside the testcase. @@ -12,14 +12,25 @@ $ python -c 'import testtools.matchers; print testtools.matchers.__all__' __metaclass__ = type __all__ = [ + 'AfterPreprocessing', + 'AllMatch', 'Annotate', + 'Contains', 'DocTestMatches', + 'EndsWith', 'Equals', + 'GreaterThan', 'Is', + 'IsInstance', + 'KeysEqual', 'LessThan', 'MatchesAll', 'MatchesAny', 'MatchesException', + 'MatchesListwise', + 'MatchesRegex', + 'MatchesSetwise', + 'MatchesStructure', 'NotEquals', 'Not', 'Raises', @@ -30,9 +41,16 @@ __all__ = [ import doctest import operator from pprint import pformat +import re import sys +import types -from testtools.compat import classtypes, _error_repr, isbaseexception +from testtools.compat import ( + classtypes, + _error_repr, + isbaseexception, + istext, + ) class Matcher(object): @@ -113,6 +131,69 @@ class Mismatch(object): id(self), self.__dict__) +class MismatchDecorator(object): + """Decorate a ``Mismatch``. + + Forwards all messages to the original mismatch object. Probably the best + way to use this is inherit from this class and then provide your own + custom decoration logic. + """ + + def __init__(self, original): + """Construct a `MismatchDecorator`. + + :param original: A `Mismatch` object to decorate. + """ + self.original = original + + def __repr__(self): + return '<testtools.matchers.MismatchDecorator(%r)>' % (self.original,) + + def describe(self): + return self.original.describe() + + def get_details(self): + return self.original.get_details() + + +class _NonManglingOutputChecker(doctest.OutputChecker): + """Doctest checker that works with unicode rather than mangling strings + + This is needed because current Python versions have tried to fix string + encoding related problems, but regressed the default behaviour with unicode + inputs in the process. + + In Python 2.6 and 2.7 `OutputChecker.output_difference` is was changed to + return a bytestring encoded as per `sys.stdout.encoding`, or utf-8 if that + can't be determined. Worse, that encoding process happens in the innocent + looking `_indent` global function. Because the `DocTestMismatch.describe` + result may well not be destined for printing to stdout, this is no good + for us. To get a unicode return as before, the method is monkey patched if + `doctest._encoding` exists. + + Python 3 has a different problem. For some reason both inputs are encoded + to ascii with 'backslashreplace', making an escaped string matches its + unescaped form. Overriding the offending `OutputChecker._toAscii` method + is sufficient to revert this. + """ + + def _toAscii(self, s): + """Return `s` unchanged rather than mangling it to ascii""" + return s + + # Only do this overriding hackery if doctest has a broken _input function + if getattr(doctest, "_encoding", None) is not None: + from types import FunctionType as __F + __f = doctest.OutputChecker.output_difference.im_func + __g = dict(__f.func_globals) + def _indent(s, indent=4, _pattern=re.compile("^(?!$)", re.MULTILINE)): + """Prepend non-empty lines in `s` with `indent` number of spaces""" + return _pattern.sub(indent*" ", s) + __g["_indent"] = _indent + output_difference = __F(__f.func_code, __g, "output_difference") + del __F, __f, __g, _indent + + class DocTestMatches(object): """See if a string matches a doctest example.""" @@ -127,7 +208,7 @@ class DocTestMatches(object): example += '\n' self.want = example # required variable name by doctest. self.flags = flags - self._checker = doctest.OutputChecker() + self._checker = _NonManglingOutputChecker() def __str__(self): if self.flags: @@ -137,7 +218,7 @@ class DocTestMatches(object): return 'DocTestMatches(%r%s)' % (self.want, flagstr) def _with_nl(self, actual): - result = str(actual) + result = self.want.__class__(actual) if not result.endswith('\n'): result += '\n' return result @@ -163,14 +244,28 @@ class DocTestMismatch(Mismatch): return self.matcher._describe_difference(self.with_nl) +class DoesNotContain(Mismatch): + + def __init__(self, matchee, needle): + """Create a DoesNotContain Mismatch. + + :param matchee: the object that did not contain needle. + :param needle: the needle that 'matchee' was expected to contain. + """ + self.matchee = matchee + self.needle = needle + + def describe(self): + return "%r not in %r" % (self.needle, self.matchee) + + class DoesNotStartWith(Mismatch): def __init__(self, matchee, expected): """Create a DoesNotStartWith Mismatch. :param matchee: the string that did not match. - :param expected: the string that `matchee` was expected to start - with. + :param expected: the string that 'matchee' was expected to start with. """ self.matchee = matchee self.expected = expected @@ -186,7 +281,7 @@ class DoesNotEndWith(Mismatch): """Create a DoesNotEndWith Mismatch. :param matchee: the string that did not match. - :param expected: the string that `matchee` was expected to end with. + :param expected: the string that 'matchee' was expected to end with. """ self.matchee = matchee self.expected = expected @@ -222,13 +317,20 @@ class _BinaryMismatch(Mismatch): self._mismatch_string = mismatch_string self.other = other + def _format(self, thing): + # Blocks of text with newlines are formatted as triple-quote + # strings. Everything else is pretty-printed. + if istext(thing) and '\n' in thing: + return '"""\\\n%s"""' % (thing,) + return pformat(thing) + def describe(self): left = repr(self.expected) right = repr(self.other) if len(left) + len(right) > 70: return "%s:\nreference = %s\nactual = %s\n" % ( - self._mismatch_string, pformat(self.expected), - pformat(self.other)) + self._mismatch_string, self._format(self.expected), + self._format(self.other)) else: return "%s %s %s" % (left, self._mismatch_string,right) @@ -243,8 +345,8 @@ class Equals(_BinaryComparison): 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. + In most cases, this is equivalent to ``Not(Equals(foo))``. The difference + only matters when testing ``__ne__`` implementations. """ comparator = operator.ne @@ -258,11 +360,54 @@ class Is(_BinaryComparison): mismatch_string = 'is not' +class IsInstance(object): + """Matcher that wraps isinstance.""" + + def __init__(self, *types): + self.types = tuple(types) + + def __str__(self): + return "%s(%s)" % (self.__class__.__name__, + ', '.join(type.__name__ for type in self.types)) + + def match(self, other): + if isinstance(other, self.types): + return None + return NotAnInstance(other, self.types) + + +class NotAnInstance(Mismatch): + + def __init__(self, matchee, types): + """Create a NotAnInstance Mismatch. + + :param matchee: the thing which is not an instance of any of types. + :param types: A tuple of the types which were expected. + """ + self.matchee = matchee + self.types = types + + def describe(self): + if len(self.types) == 1: + typestr = self.types[0].__name__ + else: + typestr = 'any of (%s)' % ', '.join(type.__name__ for type in + self.types) + return "'%s' is not an instance of %s" % (self.matchee, typestr) + + class LessThan(_BinaryComparison): """Matches if the item is less than the matchers reference object.""" comparator = operator.__lt__ - mismatch_string = 'is >=' + mismatch_string = 'is not >' + + +class GreaterThan(_BinaryComparison): + """Matches if the item is greater than the matchers reference object.""" + + comparator = operator.__gt__ + mismatch_string = 'is not <' class MatchesAny(object): @@ -351,17 +496,26 @@ class MatchedUnexpectedly(Mismatch): class MatchesException(Matcher): """Match an exc_info tuple against an exception instance or type.""" - def __init__(self, exception): + def __init__(self, exception, value_re=None): """Create a MatchesException that will match exc_info's for exception. :param exception: Either an exception instance or type. If an instance is given, the type and arguments of the exception are checked. If a type is given only the type of the exception is - checked. + checked. If a tuple is given, then as with isinstance, any of the + types in the tuple matching is sufficient to match. + :param value_re: If 'exception' is a type, and the matchee exception + is of the right type, then match against this. If value_re is a + string, then assume value_re is a regular expression and match + the str() of the exception against it. Otherwise, assume value_re + is a matcher, and match the exception against it. """ Matcher.__init__(self) self.expected = exception - self._is_instance = type(self.expected) not in classtypes() + if istext(value_re): + value_re = AfterPreproccessing(str, MatchesRegex(value_re), False) + self.value_re = value_re + self._is_instance = type(self.expected) not in classtypes() + (tuple,) def match(self, other): if type(other) != tuple: @@ -371,9 +525,12 @@ class MatchesException(Matcher): expected_class = expected_class.__class__ if not issubclass(other[0], expected_class): return Mismatch('%r is not a %r' % (other[0], expected_class)) - if self._is_instance and other[1].args != self.expected.args: - return Mismatch('%s has different arguments to %s.' % ( - _error_repr(other[1]), _error_repr(self.expected))) + if self._is_instance: + if other[1].args != self.expected.args: + return Mismatch('%s has different arguments to %s.' % ( + _error_repr(other[1]), _error_repr(self.expected))) + elif self.value_re is not None: + return self.value_re.match(other[1]) def __str__(self): if self._is_instance: @@ -381,6 +538,29 @@ class MatchesException(Matcher): return "MatchesException(%s)" % repr(self.expected) +class Contains(Matcher): + """Checks whether something is container in another thing.""" + + def __init__(self, needle): + """Create a Contains Matcher. + + :param needle: the thing that needs to be contained by matchees. + """ + self.needle = needle + + def __str__(self): + return "Contains(%r)" % (self.needle,) + + def match(self, matchee): + try: + if self.needle not in matchee: + return DoesNotContain(matchee, self.needle) + except TypeError: + # e.g. 1 in 2 will raise TypeError + return DoesNotContain(matchee, self.needle) + return None + + class StartsWith(Matcher): """Checks whether one string starts with another.""" @@ -425,7 +605,7 @@ class KeysEqual(Matcher): def __init__(self, *expected): """Create a `KeysEqual` Matcher. - :param *expected: The keys the dict is expected to have. If a dict, + :param expected: The keys the dict is expected to have. If a dict, then we use the keys of that dict, if a collection, we assume it is a collection of expected keys. """ @@ -457,6 +637,13 @@ class Annotate(object): self.annotation = annotation self.matcher = matcher + @classmethod + def if_message(cls, annotation, matcher): + """Annotate ``matcher`` only if ``annotation`` is non-empty.""" + if not annotation: + return matcher + return cls(annotation, matcher) + def __str__(self): return 'Annotate(%r, %s)' % (self.annotation, self.matcher) @@ -466,15 +653,16 @@ class Annotate(object): return AnnotatedMismatch(self.annotation, mismatch) -class AnnotatedMismatch(Mismatch): +class AnnotatedMismatch(MismatchDecorator): """A mismatch annotated with a descriptive string.""" def __init__(self, annotation, mismatch): + super(AnnotatedMismatch, self).__init__(mismatch) self.annotation = annotation self.mismatch = mismatch def describe(self): - return '%s: %s' % (self.mismatch.describe(), self.annotation) + return '%s: %s' % (self.original.describe(), self.annotation) class Raises(Matcher): @@ -502,16 +690,19 @@ class Raises(Matcher): # Catch all exceptions: Raises() should be able to match a # KeyboardInterrupt or SystemExit. except: + exc_info = sys.exc_info() if self.exception_matcher: - mismatch = self.exception_matcher.match(sys.exc_info()) + mismatch = self.exception_matcher.match(exc_info) if not mismatch: + del exc_info return else: mismatch = None # The exception did not match, or no explicit matching logic was # performed. If the exception is a non-user exception (that is, not # a subclass of Exception on Python 2.5+) then propogate it. - if isbaseexception(sys.exc_info()[1]): + if isbaseexception(exc_info[1]): + del exc_info raise return mismatch @@ -523,8 +714,292 @@ def raises(exception): """Make a matcher that checks that a callable raises an exception. This is a convenience function, exactly equivalent to:: + return Raises(MatchesException(exception)) See `Raises` and `MatchesException` for more information. """ return Raises(MatchesException(exception)) + + +class MatchesListwise(object): + """Matches if each matcher matches the corresponding value. + + More easily explained by example than in words: + + >>> MatchesListwise([Equals(1)]).match([1]) + >>> MatchesListwise([Equals(1), Equals(2)]).match([1, 2]) + >>> print (MatchesListwise([Equals(1), Equals(2)]).match([2, 1]).describe()) + Differences: [ + 1 != 2 + 2 != 1 + ] + """ + + def __init__(self, matchers): + self.matchers = matchers + + def match(self, values): + mismatches = [] + length_mismatch = Annotate( + "Length mismatch", Equals(len(self.matchers))).match(len(values)) + if length_mismatch: + mismatches.append(length_mismatch) + for matcher, value in zip(self.matchers, values): + mismatch = matcher.match(value) + if mismatch: + mismatches.append(mismatch) + if mismatches: + return MismatchesAll(mismatches) + + +class MatchesStructure(object): + """Matcher that matches an object structurally. + + 'Structurally' here means that attributes of the object being matched are + compared against given matchers. + + `fromExample` allows the creation of a matcher from a prototype object and + then modified versions can be created with `update`. + + `byEquality` creates a matcher in much the same way as the constructor, + except that the matcher for each of the attributes is assumed to be + `Equals`. + + `byMatcher` creates a similar matcher to `byEquality`, but you get to pick + the matcher, rather than just using `Equals`. + """ + + def __init__(self, **kwargs): + """Construct a `MatchesStructure`. + + :param kwargs: A mapping of attributes to matchers. + """ + self.kws = kwargs + + @classmethod + def byEquality(cls, **kwargs): + """Matches an object where the attributes equal the keyword values. + + Similar to the constructor, except that the matcher is assumed to be + Equals. + """ + return cls.byMatcher(Equals, **kwargs) + + @classmethod + def byMatcher(cls, matcher, **kwargs): + """Matches an object where the attributes match the keyword values. + + Similar to the constructor, except that the provided matcher is used + to match all of the values. + """ + return cls( + **dict((name, matcher(value)) for name, value in kwargs.items())) + + @classmethod + def fromExample(cls, example, *attributes): + kwargs = {} + for attr in attributes: + kwargs[attr] = Equals(getattr(example, attr)) + return cls(**kwargs) + + def update(self, **kws): + new_kws = self.kws.copy() + for attr, matcher in kws.items(): + if matcher is None: + new_kws.pop(attr, None) + else: + new_kws[attr] = matcher + return type(self)(**new_kws) + + def __str__(self): + kws = [] + for attr, matcher in sorted(self.kws.items()): + kws.append("%s=%s" % (attr, matcher)) + return "%s(%s)" % (self.__class__.__name__, ', '.join(kws)) + + def match(self, value): + matchers = [] + values = [] + for attr, matcher in sorted(self.kws.items()): + matchers.append(Annotate(attr, matcher)) + values.append(getattr(value, attr)) + return MatchesListwise(matchers).match(values) + + +class MatchesRegex(object): + """Matches if the matchee is matched by a regular expression.""" + + def __init__(self, pattern, flags=0): + self.pattern = pattern + self.flags = flags + + def __str__(self): + args = ['%r' % self.pattern] + flag_arg = [] + # dir() sorts the attributes for us, so we don't need to do it again. + for flag in dir(re): + if len(flag) == 1: + if self.flags & getattr(re, flag): + flag_arg.append('re.%s' % flag) + if flag_arg: + args.append('|'.join(flag_arg)) + return '%s(%s)' % (self.__class__.__name__, ', '.join(args)) + + def match(self, value): + if not re.match(self.pattern, value, self.flags): + return Mismatch("%r does not match /%s/" % ( + value, self.pattern)) + + +class MatchesSetwise(object): + """Matches if all the matchers match elements of the value being matched. + + That is, each element in the 'observed' set must match exactly one matcher + from the set of matchers, with no matchers left over. + + The difference compared to `MatchesListwise` is that the order of the + matchings does not matter. + """ + + def __init__(self, *matchers): + self.matchers = matchers + + def match(self, observed): + remaining_matchers = set(self.matchers) + not_matched = [] + for value in observed: + for matcher in remaining_matchers: + if matcher.match(value) is None: + remaining_matchers.remove(matcher) + break + else: + not_matched.append(value) + if not_matched or remaining_matchers: + remaining_matchers = list(remaining_matchers) + # There are various cases that all should be reported somewhat + # differently. + + # There are two trivial cases: + # 1) There are just some matchers left over. + # 2) There are just some values left over. + + # Then there are three more interesting cases: + # 3) There are the same number of matchers and values left over. + # 4) There are more matchers left over than values. + # 5) There are more values left over than matchers. + + if len(not_matched) == 0: + if len(remaining_matchers) > 1: + msg = "There were %s matchers left over: " % ( + len(remaining_matchers),) + else: + msg = "There was 1 matcher left over: " + msg += ', '.join(map(str, remaining_matchers)) + return Mismatch(msg) + elif len(remaining_matchers) == 0: + if len(not_matched) > 1: + return Mismatch( + "There were %s values left over: %s" % ( + len(not_matched), not_matched)) + else: + return Mismatch( + "There was 1 value left over: %s" % ( + not_matched, )) + else: + common_length = min(len(remaining_matchers), len(not_matched)) + if common_length == 0: + raise AssertionError("common_length can't be 0 here") + if common_length > 1: + msg = "There were %s mismatches" % (common_length,) + else: + msg = "There was 1 mismatch" + if len(remaining_matchers) > len(not_matched): + extra_matchers = remaining_matchers[common_length:] + msg += " and %s extra matcher" % (len(extra_matchers), ) + if len(extra_matchers) > 1: + msg += "s" + msg += ': ' + ', '.join(map(str, extra_matchers)) + elif len(not_matched) > len(remaining_matchers): + extra_values = not_matched[common_length:] + msg += " and %s extra value" % (len(extra_values), ) + if len(extra_values) > 1: + msg += "s" + msg += ': ' + str(extra_values) + return Annotate( + msg, MatchesListwise(remaining_matchers[:common_length]) + ).match(not_matched[:common_length]) + + +class AfterPreprocessing(object): + """Matches if the value matches after passing through a function. + + This can be used to aid in creating trivial matchers as functions, for + example:: + + def PathHasFileContent(content): + def _read(path): + return open(path).read() + return AfterPreprocessing(_read, Equals(content)) + """ + + def __init__(self, preprocessor, matcher, annotate=True): + """Create an AfterPreprocessing matcher. + + :param preprocessor: A function called with the matchee before + matching. + :param matcher: What to match the preprocessed matchee against. + :param annotate: Whether or not to annotate the matcher with + something explaining how we transformed the matchee. Defaults + to True. + """ + self.preprocessor = preprocessor + self.matcher = matcher + self.annotate = annotate + + def _str_preprocessor(self): + if isinstance(self.preprocessor, types.FunctionType): + return '<function %s>' % self.preprocessor.__name__ + return str(self.preprocessor) + + def __str__(self): + return "AfterPreprocessing(%s, %s)" % ( + self._str_preprocessor(), self.matcher) + + def match(self, value): + after = self.preprocessor(value) + if self.annotate: + matcher = Annotate( + "after %s on %r" % (self._str_preprocessor(), value), + self.matcher) + else: + matcher = self.matcher + return matcher.match(after) + +# This is the old, deprecated. spelling of the name, kept for backwards +# compatibility. +AfterPreproccessing = AfterPreprocessing + + +class AllMatch(object): + """Matches if all provided values match the given matcher.""" + + def __init__(self, matcher): + self.matcher = matcher + + def __str__(self): + return 'AllMatch(%s)' % (self.matcher,) + + def match(self, values): + mismatches = [] + for value in values: + mismatch = self.matcher.match(value) + if mismatch: + mismatches.append(mismatch) + if mismatches: + return MismatchesAll(mismatches) + + +# Signal that this is part of the testing framework, and that code from this +# should not normally appear in tracebacks. +__unittest = True diff --git a/lib/testtools/testtools/monkey.py b/lib/testtools/testtools/monkey.py index bb24764cb7..ba0ac8fd8b 100644 --- a/lib/testtools/testtools/monkey.py +++ b/lib/testtools/testtools/monkey.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010 testtools developers. See LICENSE for details. """Helpers for monkey-patching Python code.""" @@ -22,7 +22,7 @@ class MonkeyPatcher(object): def __init__(self, *patches): """Construct a `MonkeyPatcher`. - :param *patches: The patches to apply, each should be (obj, name, + :param patches: The patches to apply, each should be (obj, name, new_value). Providing patches here is equivalent to calling `add_patch`. """ diff --git a/lib/testtools/testtools/run.py b/lib/testtools/testtools/run.py index 272992cd05..72011c74ca 100755 --- a/lib/testtools/testtools/run.py +++ b/lib/testtools/testtools/run.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009 testtools developers. See LICENSE for details. """python -m testtools.run testspec [testspec...] @@ -158,12 +158,12 @@ class TestProgram(object): # OptimisingTestSuite.add, but with a standard protocol). # This is needed because the load_tests hook allows arbitrary # suites, even if that is rarely used. - source = file(self.load_list, 'rb') + source = open(self.load_list, 'rb') try: lines = source.readlines() finally: source.close() - test_ids = set(line.strip() for line in lines) + test_ids = set(line.strip().decode('utf-8') for line in lines) filtered = unittest.TestSuite() for test in iterate_tests(self.test): if test.id() in test_ids: diff --git a/lib/testtools/testtools/runtest.py b/lib/testtools/testtools/runtest.py index eb5801a4c6..507ad87c27 100644 --- a/lib/testtools/testtools/runtest.py +++ b/lib/testtools/testtools/runtest.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2010 testtools developers. See LICENSE for details. """Individual test case execution.""" @@ -35,7 +35,7 @@ class RunTest(object): :ivar handlers: A list of (ExceptionClass, handler_function) for exceptions that should be caught if raised from the user code. Exceptions that are caught are checked against this list in - first to last order. There is a catch-all of `Exception` at the end + first to last order. There is a catch-all of 'Exception' at the end of the list, so to add a new exception to the list, insert it at the front (which ensures that it will be checked before any existing base classes in the list. If you add multiple exceptions some of which are @@ -145,7 +145,7 @@ class RunTest(object): See the docstring for addCleanup for more information. :return: None if all cleanups ran without error, - `self.exception_caught` if there was an error. + ``exception_caught`` if there was an error. """ failing = False while self.case._cleanups: @@ -162,7 +162,7 @@ class RunTest(object): Exceptions are processed by `_got_user_exception`. - :return: Either whatever 'fn' returns or `self.exception_caught` if + :return: Either whatever 'fn' returns or ``exception_caught`` if 'fn' raised an exception. """ try: @@ -181,8 +181,8 @@ class RunTest(object): :param exc_info: A sys.exc_info() tuple for the user error. :param tb_label: An optional string label for the error. If not specified, will default to 'traceback'. - :return: `exception_caught` if we catch one of the exceptions that - have handlers in `self.handlers`, otherwise raise the error. + :return: 'exception_caught' if we catch one of the exceptions that + have handlers in 'handlers', otherwise raise the error. """ if exc_info[0] is MultipleExceptions: for sub_exc_info in exc_info[1].args: @@ -198,3 +198,8 @@ class RunTest(object): self._exceptions.append(e) return self.exception_caught raise e + + +# Signal that this is part of the testing framework, and that code from this +# should not normally appear in tracebacks. +__unittest = True diff --git a/lib/testtools/testtools/testcase.py b/lib/testtools/testtools/testcase.py index 804684adb8..9370b29e57 100644 --- a/lib/testtools/testtools/testcase.py +++ b/lib/testtools/testtools/testcase.py @@ -1,10 +1,12 @@ -# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Test case related stuff.""" __metaclass__ = type __all__ = [ 'clone_test_with_new_id', + 'ExpectedException', + 'gather_details', 'run_test_with', 'skip', 'skipIf', @@ -22,10 +24,20 @@ from testtools import ( content, try_import, ) -from testtools.compat import advance_iterator +from testtools.compat import ( + advance_iterator, + reraise, + ) from testtools.matchers import ( Annotate, + Contains, Equals, + MatchesAll, + MatchesException, + Is, + IsInstance, + Not, + Raises, ) from testtools.monkey import patch from testtools.runtest import RunTest @@ -35,6 +47,7 @@ wraps = try_import('functools.wraps') class TestSkipped(Exception): """Raised within TestCase.run() when a test is skipped.""" +testSkipped = try_import('unittest2.case.SkipTest', TestSkipped) TestSkipped = try_import('unittest.case.SkipTest', TestSkipped) @@ -45,6 +58,8 @@ class _UnexpectedSuccess(Exception): module. """ _UnexpectedSuccess = try_import( + 'unittest2.case._UnexpectedSuccess', _UnexpectedSuccess) +_UnexpectedSuccess = try_import( 'unittest.case._UnexpectedSuccess', _UnexpectedSuccess) class _ExpectedFailure(Exception): @@ -54,30 +69,33 @@ class _ExpectedFailure(Exception): module. """ _ExpectedFailure = try_import( + 'unittest2.case._ExpectedFailure', _ExpectedFailure) +_ExpectedFailure = try_import( 'unittest.case._ExpectedFailure', _ExpectedFailure) def run_test_with(test_runner, **kwargs): - """Decorate a test as using a specific `RunTest`. + """Decorate a test as using a specific ``RunTest``. + + e.g.:: - e.g. @run_test_with(CustomRunner, timeout=42) def test_foo(self): self.assertTrue(True) The returned decorator works by setting an attribute on the decorated - function. `TestCase.__init__` looks for this attribute when deciding - on a `RunTest` factory. If you wish to use multiple decorators on a test - method, then you must either make this one the top-most decorator, or - you must write your decorators so that they update the wrapping function - with the attributes of the wrapped function. The latter is recommended - style anyway. `functools.wraps`, `functools.wrapper` and - `twisted.python.util.mergeFunctionMetadata` can help you do this. - - :param test_runner: A `RunTest` factory that takes a test case and an - optional list of exception handlers. See `RunTest`. - :param **kwargs: Keyword arguments to pass on as extra arguments to - `test_runner`. + function. `TestCase.__init__` looks for this attribute when deciding on a + ``RunTest`` factory. If you wish to use multiple decorators on a test + method, then you must either make this one the top-most decorator, or you + must write your decorators so that they update the wrapping function with + the attributes of the wrapped function. The latter is recommended style + anyway. ``functools.wraps``, ``functools.wrapper`` and + ``twisted.python.util.mergeFunctionMetadata`` can help you do this. + + :param test_runner: A ``RunTest`` factory that takes a test case and an + optional list of exception handlers. See ``RunTest``. + :param kwargs: Keyword arguments to pass on as extra arguments to + 'test_runner'. :return: A decorator to be used for marking a test as needing a special runner. """ @@ -91,14 +109,45 @@ def run_test_with(test_runner, **kwargs): return decorator +def _copy_content(content_object): + """Make a copy of the given content object. + + The content within `content_object` is iterated and saved. This is useful + when the source of the content is volatile, a log file in a temporary + directory for example. + + :param content_object: A `content.Content` instance. + :return: A `content.Content` instance with the same mime-type as + `content_object` and a non-volatile copy of its content. + """ + content_bytes = list(content_object.iter_bytes()) + content_callback = lambda: content_bytes + return content.Content(content_object.content_type, content_callback) + + +def gather_details(source_dict, target_dict): + """Merge the details from `source_dict` into `target_dict`. + + :param source_dict: A dictionary of details will be gathered. + :param target_dict: A dictionary into which details will be gathered. + """ + for name, content_object in source_dict.items(): + new_name = name + disambiguator = itertools.count(1) + while new_name in target_dict: + new_name = '%s-%d' % (name, advance_iterator(disambiguator)) + name = new_name + target_dict[name] = _copy_content(content_object) + + class TestCase(unittest.TestCase): """Extensions to the basic TestCase. :ivar exception_handlers: Exceptions to catch from setUp, runTest and tearDown. This list is able to be modified at any time and consists of (exception_class, handler(case, result, exception_value)) pairs. - :cvar run_tests_with: A factory to make the `RunTest` to run tests with. - Defaults to `RunTest`. The factory is expected to take a test case + :cvar run_tests_with: A factory to make the ``RunTest`` to run tests with. + Defaults to ``RunTest``. The factory is expected to take a test case and an optional list of exception handlers. """ @@ -110,13 +159,13 @@ class TestCase(unittest.TestCase): """Construct a TestCase. :param testMethod: The name of the method to run. - :param runTest: Optional class to use to execute the test. If not - supplied `testtools.runtest.RunTest` is used. The instance to be - used is created when run() is invoked, so will be fresh each time. - Overrides `run_tests_with` if given. + :keyword runTest: Optional class to use to execute the test. If not + supplied ``RunTest`` is used. The instance to be used is created + when run() is invoked, so will be fresh each time. Overrides + ``TestCase.run_tests_with`` if given. """ runTest = kwargs.pop('runTest', None) - unittest.TestCase.__init__(self, *args, **kwargs) + super(TestCase, self).__init__(*args, **kwargs) self._cleanups = [] self._unique_id_gen = itertools.count(1) # Generators to ensure unique traceback ids. Maps traceback label to @@ -263,16 +312,31 @@ class TestCase(unittest.TestCase): :param message: An optional message to include in the error. """ matcher = Equals(expected) - if message: - matcher = Annotate(message, matcher) - self.assertThat(observed, matcher) + self.assertThat(observed, matcher, message) failUnlessEqual = assertEquals = assertEqual def assertIn(self, needle, haystack): """Assert that needle is in haystack.""" - self.assertTrue( - needle in haystack, '%r not in %r' % (needle, haystack)) + self.assertThat(haystack, Contains(needle)) + + def assertIsNone(self, observed, message=''): + """Assert that 'observed' is equal to None. + + :param observed: The observed value. + :param message: An optional message describing the error. + """ + matcher = Is(None) + self.assertThat(observed, matcher, message) + + def assertIsNotNone(self, observed, message=''): + """Assert that 'observed' is not equal to None. + + :param observed: The observed value. + :param message: An optional message describing the error. + """ + matcher = Not(Is(None)) + self.assertThat(observed, matcher, message) def assertIs(self, expected, observed, message=''): """Assert that 'expected' is 'observed'. @@ -281,30 +345,25 @@ class TestCase(unittest.TestCase): :param observed: The observed value. :param message: An optional message describing the error. """ - if message: - message = ': ' + message - self.assertTrue( - expected is observed, - '%r is not %r%s' % (expected, observed, message)) + matcher = Is(expected) + self.assertThat(observed, matcher, message) def assertIsNot(self, expected, observed, message=''): """Assert that 'expected' is not 'observed'.""" - if message: - message = ': ' + message - self.assertTrue( - expected is not observed, - '%r is %r%s' % (expected, observed, message)) + matcher = Not(Is(expected)) + self.assertThat(observed, matcher, message) def assertNotIn(self, needle, haystack): """Assert that needle is not in haystack.""" - self.assertTrue( - needle not in haystack, '%r in %r' % (needle, haystack)) + matcher = Not(Contains(needle)) + self.assertThat(haystack, matcher) def assertIsInstance(self, obj, klass, msg=None): - if msg is None: - msg = '%r is not an instance of %s' % ( - obj, self._formatTypes(klass)) - self.assertTrue(isinstance(obj, klass), msg) + if isinstance(klass, tuple): + matcher = IsInstance(*klass) + else: + matcher = IsInstance(klass) + self.assertThat(obj, matcher, msg) def assertRaises(self, excClass, callableObj, *args, **kwargs): """Fail unless an exception of class excClass is thrown @@ -314,22 +373,29 @@ class TestCase(unittest.TestCase): deemed to have suffered an error, exactly as for an unexpected exception. """ - try: - ret = callableObj(*args, **kwargs) - except excClass: - return sys.exc_info()[1] - else: - excName = self._formatTypes(excClass) - self.fail("%s not raised, %r returned instead." % (excName, ret)) + class ReRaiseOtherTypes(object): + def match(self, matchee): + if not issubclass(matchee[0], excClass): + reraise(*matchee) + class CaptureMatchee(object): + def match(self, matchee): + self.matchee = matchee[1] + capture = CaptureMatchee() + matcher = Raises(MatchesAll(ReRaiseOtherTypes(), + MatchesException(excClass), capture)) + + self.assertThat(lambda: callableObj(*args, **kwargs), matcher) + return capture.matchee failUnlessRaises = assertRaises - def assertThat(self, matchee, matcher): + def assertThat(self, matchee, matcher, message='', verbose=False): """Assert that matchee is matched by matcher. :param matchee: An object to match with matcher. :param matcher: An object meeting the testtools.Matcher protocol. :raises self.failureException: When matcher does not match thing. """ + matcher = Annotate.if_message(message, matcher) mismatch = matcher.match(matchee) if not mismatch: return @@ -341,8 +407,13 @@ class TestCase(unittest.TestCase): 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())) + if verbose: + message = ( + 'Match failed. Matchee: "%s"\nMatcher: %s\nDifference: %s\n' + % (matchee, matcher, mismatch.describe())) + else: + message = mismatch.describe() + self.fail(message) def defaultTestResult(self): return TestResult() @@ -363,6 +434,7 @@ class TestCase(unittest.TestCase): be removed. This separation preserves the original intent of the test while it is in the expectFailure mode. """ + # TODO: implement with matchers. self._add_reason(reason) try: predicate(*args, **kwargs) @@ -507,31 +579,23 @@ class TestCase(unittest.TestCase): :return: The fixture, after setting it up and scheduling a cleanup for it. """ - fixture.setUp() - self.addCleanup(fixture.cleanUp) - self.addCleanup(self._gather_details, fixture.getDetails) - return fixture - - def _gather_details(self, getDetails): - """Merge the details from getDetails() into self.getDetails().""" - details = getDetails() - my_details = self.getDetails() - for name, content_object in details.items(): - new_name = name - disambiguator = itertools.count(1) - while new_name in my_details: - new_name = '%s-%d' % (name, advance_iterator(disambiguator)) - name = new_name - content_bytes = list(content_object.iter_bytes()) - content_callback = lambda:content_bytes - self.addDetail(name, - content.Content(content_object.content_type, content_callback)) + try: + fixture.setUp() + except: + gather_details(fixture.getDetails(), self.getDetails()) + raise + else: + self.addCleanup(fixture.cleanUp) + self.addCleanup( + gather_details, fixture.getDetails(), self.getDetails()) + return fixture def setUp(self): - unittest.TestCase.setUp(self) + super(TestCase, self).setUp() self.__setup_called = True def tearDown(self): + super(TestCase, self).tearDown() unittest.TestCase.tearDown(self) self.__teardown_called = True @@ -539,8 +603,8 @@ class TestCase(unittest.TestCase): 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. + `PlaceHolder` implements much of the same interface as TestCase and is + particularly suitable for being added to TestResults. """ def __init__(self, test_id, short_description=None): @@ -630,7 +694,7 @@ if types.FunctionType not in copy._copy_dispatch: def clone_test_with_new_id(test, new_id): - """Copy a TestCase, and give the copied test a 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. @@ -675,3 +739,49 @@ def skipUnless(condition, reason): def _id(obj): return obj return _id + + +class ExpectedException: + """A context manager to handle expected exceptions. + + In Python 2.5 or later:: + + def test_foo(self): + with ExpectedException(ValueError, 'fo.*'): + raise ValueError('foo') + + will pass. If the raised exception has a type other than the specified + type, it will be re-raised. If it has a 'str()' that does not match the + given regular expression, an AssertionError will be raised. If no + exception is raised, an AssertionError will be raised. + """ + + def __init__(self, exc_type, value_re=None): + """Construct an `ExpectedException`. + + :param exc_type: The type of exception to expect. + :param value_re: A regular expression to match against the + 'str()' of the raised exception. + """ + self.exc_type = exc_type + self.value_re = value_re + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is None: + raise AssertionError('%s not raised.' % self.exc_type.__name__) + if exc_type != self.exc_type: + return False + if self.value_re: + matcher = MatchesException(self.exc_type, self.value_re) + mismatch = matcher.match((exc_type, exc_value, traceback)) + if mismatch: + raise AssertionError(mismatch.describe()) + return True + + +# Signal that this is part of the testing framework, and that code from this +# should not normally appear in tracebacks. +__unittest = True diff --git a/lib/testtools/testtools/testresult/__init__.py b/lib/testtools/testtools/testresult/__init__.py index 1f779419d2..19f88bc8a3 100644 --- a/lib/testtools/testtools/testresult/__init__.py +++ b/lib/testtools/testtools/testresult/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009 testtools developers. See LICENSE for details. """Test result objects.""" diff --git a/lib/testtools/testtools/testresult/doubles.py b/lib/testtools/testtools/testresult/doubles.py index 7e4a2c9b41..9af5b364ff 100644 --- a/lib/testtools/testtools/testresult/doubles.py +++ b/lib/testtools/testtools/testresult/doubles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2010 testtools developers. See LICENSE for details. """Doubles of test result objects, useful for testing unittest code.""" diff --git a/lib/testtools/testtools/testresult/real.py b/lib/testtools/testtools/testresult/real.py index b521251f46..eb548dfa2c 100644 --- a/lib/testtools/testtools/testresult/real.py +++ b/lib/testtools/testtools/testresult/real.py @@ -1,4 +1,4 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008 testtools developers. See LICENSE for details. """Test results and related things.""" @@ -56,7 +56,7 @@ class TestResult(unittest.TestResult): def __init__(self): # startTestRun resets all attributes, and older clients don't know to # call startTestRun, so it is called once here. - # Because subclasses may reasonably not expect this, we call the + # Because subclasses may reasonably not expect this, we call the # specific version we want to run. TestResult.startTestRun(self) @@ -158,7 +158,7 @@ class TestResult(unittest.TestResult): """Convert an error in exc_info form or a contents dict to a string.""" if err is not None: return self._exc_info_to_unicode(err, test) - return _details_to_str(details) + return _details_to_str(details, special='traceback') def _now(self): """Return the current 'test time'. @@ -175,8 +175,9 @@ class TestResult(unittest.TestResult): def startTestRun(self): """Called before a test run starts. - New in python 2.7. The testtools version resets the result to a - pristine condition ready for use in another test run. + New in Python 2.7. The testtools version resets the result to a + pristine condition ready for use in another test run. Note that this + is different from Python 2.7's startTestRun, which does nothing. """ super(TestResult, self).__init__() self.skip_reasons = {} @@ -309,7 +310,7 @@ class TextTestResult(TestResult): self.stream.write( "%sUNEXPECTED SUCCESS: %s\n%s" % ( self.sep1, test.id(), self.sep2)) - self.stream.write("Ran %d test%s in %.3fs\n\n" % + self.stream.write("\nRan %d test%s in %.3fs\n" % (self.testsRun, plural, self._delta_to_float(stop - self.__start))) if self.wasSuccessful(): @@ -519,8 +520,10 @@ class ExtendedToOriginalDecorator(object): def _details_to_exc_info(self, details): """Convert a details dict to an exc_info tuple.""" - return (_StringException, - _StringException(_details_to_str(details)), None) + return ( + _StringException, + _StringException(_details_to_str(details, special='traceback')), + None) def done(self): try: @@ -602,19 +605,54 @@ class _StringException(Exception): return False -def _details_to_str(details): - """Convert a details dict to a string.""" - chars = [] +def _format_text_attachment(name, text): + if '\n' in text: + return "%s: {{{\n%s\n}}}\n" % (name, text) + return "%s: {{{%s}}}" % (name, text) + + +def _details_to_str(details, special=None): + """Convert a details dict to a string. + + :param details: A dictionary mapping short names to ``Content`` objects. + :param special: If specified, an attachment that should have special + attention drawn to it. The primary attachment. Normally it's the + traceback that caused the test to fail. + :return: A formatted string that can be included in text test results. + """ + empty_attachments = [] + binary_attachments = [] + text_attachments = [] + special_content = None # sorted is for testing, may want to remove that and use a dict # subclass with defined order for items instead. for key, content in sorted(details.items()): if content.content_type.type != 'text': - chars.append('Binary content: %s\n' % key) + binary_attachments.append((key, content.content_type)) + continue + text = _u('').join(content.iter_text()).strip() + if not text: + empty_attachments.append(key) + continue + # We want the 'special' attachment to be at the bottom. + if key == special: + special_content = '%s\n' % (text,) continue - chars.append('Text attachment: %s\n' % key) - chars.append('------------\n') - chars.extend(content.iter_text()) - if not chars[-1].endswith('\n'): - chars.append('\n') - chars.append('------------\n') - return _u('').join(chars) + text_attachments.append(_format_text_attachment(key, text)) + if text_attachments and not text_attachments[-1].endswith('\n'): + text_attachments.append('') + if special_content: + text_attachments.append(special_content) + lines = [] + if binary_attachments: + lines.append('Binary content:\n') + for name, content_type in binary_attachments: + lines.append(' %s (%s)\n' % (name, content_type)) + if empty_attachments: + lines.append('Empty attachments:\n') + for name in empty_attachments: + lines.append(' %s\n' % (name,)) + if (binary_attachments or empty_attachments) and text_attachments: + lines.append('\n') + lines.append('\n'.join(text_attachments)) + return _u('').join(lines) diff --git a/lib/testtools/testtools/tests/__init__.py b/lib/testtools/testtools/tests/__init__.py index ac3c218de9..1b1aa38a1f 100644 --- a/lib/testtools/testtools/tests/__init__.py +++ b/lib/testtools/testtools/tests/__init__.py @@ -2,7 +2,7 @@ # See README for copyright and licensing details. -import unittest +from unittest import TestSuite def test_suite(): @@ -11,6 +11,7 @@ def test_suite(): test_content, test_content_type, test_deferredruntest, + test_distutilscmd, test_fixturesupport, test_helpers, test_matchers, @@ -18,7 +19,7 @@ def test_suite(): test_run, test_runtest, test_spinner, - test_testtools, + test_testcase, test_testresult, test_testsuite, ) @@ -27,15 +28,17 @@ def test_suite(): test_content, test_content_type, test_deferredruntest, + test_distutilscmd, test_fixturesupport, test_helpers, test_matchers, test_monkey, test_run, + test_runtest, test_spinner, + test_testcase, test_testresult, test_testsuite, - test_testtools, ] - suites = map(lambda x:x.test_suite(), modules) - return unittest.TestSuite(suites) + suites = map(lambda x: x.test_suite(), modules) + return TestSuite(suites) diff --git a/lib/testtools/testtools/tests/helpers.py b/lib/testtools/testtools/tests/helpers.py index 5f3187db29..660cfecb72 100644 --- a/lib/testtools/testtools/tests/helpers.py +++ b/lib/testtools/testtools/tests/helpers.py @@ -1,15 +1,19 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Helpers for tests.""" -import sys - -__metaclass__ = type __all__ = [ 'LoggingResult', ] +import sys + from testtools import TestResult +from testtools.helpers import ( + safe_hasattr, + try_import, + ) +from testtools import runtest # GZ 2010-08-12: Don't do this, pointlessly creates an exc_info cycle @@ -67,6 +71,41 @@ class LoggingResult(TestResult): self._events.append(('time', a_datetime)) super(LoggingResult, self).time(a_datetime) -# Note, the following three classes are different to LoggingResult by -# being fully defined exact matches rather than supersets. -from testtools.testresult.doubles import * + +def is_stack_hidden(): + return safe_hasattr(runtest, '__unittest') + + +def hide_testtools_stack(should_hide=True): + modules = [ + 'testtools.matchers', + 'testtools.runtest', + 'testtools.testcase', + ] + result = is_stack_hidden() + for module_name in modules: + module = try_import(module_name) + if should_hide: + setattr(module, '__unittest', True) + else: + try: + delattr(module, '__unittest') + except AttributeError: + # Attribute already doesn't exist. Our work here is done. + pass + return result + + +def run_with_stack_hidden(should_hide, f, *args, **kwargs): + old_should_hide = hide_testtools_stack(should_hide) + try: + return f(*args, **kwargs) + finally: + hide_testtools_stack(old_should_hide) + + + +class FullStackRunTest(runtest.RunTest): + + def _run_user(self, fn, *args, **kwargs): + return run_with_stack_hidden(False, fn, *args, **kwargs) diff --git a/lib/testtools/testtools/tests/test_compat.py b/lib/testtools/testtools/tests/test_compat.py index 856953896a..a33c071aaa 100644 --- a/lib/testtools/testtools/tests/test_compat.py +++ b/lib/testtools/testtools/tests/test_compat.py @@ -15,6 +15,7 @@ from testtools.compat import ( _detect_encoding, _get_source_encoding, _u, + str_is_unicode, unicode_output_stream, ) from testtools.matchers import ( @@ -225,14 +226,23 @@ class TestUnicodeOutputStream(testtools.TestCase): 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""" + @testtools.skipIf(str_is_unicode, "Tests behaviour when str is not unicode") + def test_unicode_encodings_wrapped_when_str_is_not_unicode(self): + """A unicode encoding is wrapped but needs no error handler""" sout = _FakeOutputStream() sout.encoding = "utf-8" - self.assertIs(unicode_output_stream(sout), sout) + uout = unicode_output_stream(sout) + self.assertEqual(uout.errors, "strict") + uout.write(self.uni) + self.assertEqual([_b("pa\xc9\xaa\xce\xb8\xc9\x99n")], sout.writelog) + + @testtools.skipIf(not str_is_unicode, "Tests behaviour when str is unicode") + def test_unicode_encodings_not_wrapped_when_str_is_unicode(self): + # No wrapping needed if native str type is unicode sout = _FakeOutputStream() - sout.encoding = "utf-16-be" - self.assertIs(unicode_output_stream(sout), sout) + sout.encoding = "utf-8" + uout = unicode_output_stream(sout) + self.assertIs(uout, sout) def test_stringio(self): """A StringIO object should maybe get an ascii native str type""" diff --git a/lib/testtools/testtools/tests/test_content.py b/lib/testtools/testtools/tests/test_content.py index eaf50c7f37..14f400f04e 100644 --- a/lib/testtools/testtools/tests/test_content.py +++ b/lib/testtools/testtools/tests/test_content.py @@ -1,11 +1,33 @@ -# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. +import os +import tempfile import unittest + from testtools import TestCase -from testtools.compat import _b, _u -from testtools.content import Content, TracebackContent, text_content -from testtools.content_type import ContentType, UTF8_TEXT -from testtools.matchers import MatchesException, Raises +from testtools.compat import ( + _b, + _u, + StringIO, + ) +from testtools.content import ( + attach_file, + Content, + content_from_file, + content_from_stream, + TracebackContent, + text_content, + ) +from testtools.content_type import ( + ContentType, + UTF8_TEXT, + ) +from testtools.matchers import ( + Equals, + MatchesException, + Raises, + raises, + ) from testtools.tests.helpers import an_exc_info @@ -15,10 +37,11 @@ raises_value_error = Raises(MatchesException(ValueError)) class TestContent(TestCase): def test___init___None_errors(self): - self.assertThat(lambda:Content(None, None), raises_value_error) - self.assertThat(lambda:Content(None, lambda: ["traceback"]), - raises_value_error) - self.assertThat(lambda:Content(ContentType("text", "traceback"), None), + self.assertThat(lambda: Content(None, None), raises_value_error) + self.assertThat( + lambda: Content(None, lambda: ["traceback"]), raises_value_error) + self.assertThat( + lambda: Content(ContentType("text", "traceback"), None), raises_value_error) def test___init___sets_ivars(self): @@ -64,12 +87,68 @@ class TestContent(TestCase): content = Content(content_type, lambda: [iso_version]) self.assertEqual([text], list(content.iter_text())) + def test_from_file(self): + fd, path = tempfile.mkstemp() + self.addCleanup(os.remove, path) + os.write(fd, _b('some data')) + os.close(fd) + content = content_from_file(path, UTF8_TEXT, chunk_size=2) + self.assertThat( + list(content.iter_bytes()), + Equals([_b('so'), _b('me'), _b(' d'), _b('at'), _b('a')])) + + def test_from_nonexistent_file(self): + directory = tempfile.mkdtemp() + nonexistent = os.path.join(directory, 'nonexistent-file') + content = content_from_file(nonexistent) + self.assertThat(content.iter_bytes, raises(IOError)) + + def test_from_file_default_type(self): + content = content_from_file('/nonexistent/path') + self.assertThat(content.content_type, Equals(UTF8_TEXT)) + + def test_from_file_eager_loading(self): + fd, path = tempfile.mkstemp() + os.write(fd, _b('some data')) + os.close(fd) + content = content_from_file(path, UTF8_TEXT, buffer_now=True) + os.remove(path) + self.assertThat( + ''.join(content.iter_text()), Equals('some data')) + + def test_from_stream(self): + data = StringIO('some data') + content = content_from_stream(data, UTF8_TEXT, chunk_size=2) + self.assertThat( + list(content.iter_bytes()), Equals(['so', 'me', ' d', 'at', 'a'])) + + def test_from_stream_default_type(self): + data = StringIO('some data') + content = content_from_stream(data) + self.assertThat(content.content_type, Equals(UTF8_TEXT)) + + def test_from_stream_eager_loading(self): + fd, path = tempfile.mkstemp() + self.addCleanup(os.remove, path) + os.write(fd, _b('some data')) + stream = open(path, 'rb') + content = content_from_stream(stream, UTF8_TEXT, buffer_now=True) + os.write(fd, _b('more data')) + os.close(fd) + self.assertThat( + ''.join(content.iter_text()), Equals('some data')) + + def test_from_text(self): + data = _u("some data") + expected = Content(UTF8_TEXT, lambda: [data.encode('utf8')]) + self.assertEqual(expected, text_content(data)) + class TestTracebackContent(TestCase): def test___init___None_errors(self): - self.assertThat(lambda:TracebackContent(None, None), - raises_value_error) + self.assertThat( + lambda: TracebackContent(None, None), raises_value_error) def test___init___sets_ivars(self): content = TracebackContent(an_exc_info, self) @@ -81,12 +160,66 @@ class TestTracebackContent(TestCase): self.assertEqual(expected, ''.join(list(content.iter_text()))) -class TestBytesContent(TestCase): - - def test_bytes(self): - data = _u("some data") - expected = Content(UTF8_TEXT, lambda: [data.encode('utf8')]) - self.assertEqual(expected, text_content(data)) +class TestAttachFile(TestCase): + + def make_file(self, data): + # GZ 2011-04-21: This helper could be useful for methods above trying + # to use mkstemp, but should handle write failures and + # always close the fd. There must be a better way. + fd, path = tempfile.mkstemp() + self.addCleanup(os.remove, path) + os.write(fd, _b(data)) + os.close(fd) + return path + + def test_simple(self): + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + data = 'some data' + path = self.make_file(data) + my_content = text_content(data) + attach_file(test, path, name='foo') + self.assertEqual({'foo': my_content}, test.getDetails()) + + def test_optional_name(self): + # If no name is provided, attach_file just uses the base name of the + # file. + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + path = self.make_file('some data') + base_path = os.path.basename(path) + attach_file(test, path) + self.assertEqual([base_path], list(test.getDetails())) + + def test_lazy_read(self): + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + path = self.make_file('some data') + attach_file(test, path, name='foo', buffer_now=False) + content = test.getDetails()['foo'] + content_file = open(path, 'w') + content_file.write('new data') + content_file.close() + self.assertEqual(''.join(content.iter_text()), 'new data') + + def test_eager_read_by_default(self): + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + path = self.make_file('some data') + attach_file(test, path, name='foo') + content = test.getDetails()['foo'] + content_file = open(path, 'w') + content_file.write('new data') + content_file.close() + self.assertEqual(''.join(content.iter_text()), 'some data') def test_suite(): diff --git a/lib/testtools/testtools/tests/test_content_type.py b/lib/testtools/testtools/tests/test_content_type.py index 52f4afac05..9d8c0f6f7a 100644 --- a/lib/testtools/testtools/tests/test_content_type.py +++ b/lib/testtools/testtools/tests/test_content_type.py @@ -1,4 +1,4 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008 testtools developers. See LICENSE for details. from testtools import TestCase from testtools.matchers import Equals, MatchesException, Raises @@ -31,6 +31,16 @@ class TestContentType(TestCase): self.assertTrue(content_type1.__eq__(content_type2)) self.assertFalse(content_type1.__eq__(content_type3)) + def test_basic_repr(self): + content_type = ContentType('text', 'plain') + self.assertThat(repr(content_type), Equals('text/plain')) + + def test_extended_repr(self): + content_type = ContentType( + 'text', 'plain', {'foo': 'bar', 'baz': 'qux'}) + self.assertThat( + repr(content_type), Equals('text/plain; foo="bar", baz="qux"')) + class TestBuiltinContentTypes(TestCase): diff --git a/lib/testtools/testtools/tests/test_deferredruntest.py b/lib/testtools/testtools/tests/test_deferredruntest.py index 04614df77f..ab0fd87890 100644 --- a/lib/testtools/testtools/tests/test_deferredruntest.py +++ b/lib/testtools/testtools/tests/test_deferredruntest.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010-2011 testtools developers. See LICENSE for details. """Tests for the DeferredRunTest single test execution logic.""" @@ -8,12 +8,12 @@ import signal from testtools import ( skipIf, TestCase, + TestResult, ) from testtools.content import ( text_content, ) from testtools.helpers import try_import -from testtools.tests.helpers import ExtendedTestResult from testtools.matchers import ( Equals, KeysEqual, @@ -21,6 +21,7 @@ from testtools.matchers import ( Raises, ) from testtools.runtest import RunTest +from testtools.testresult.doubles import ExtendedTestResult from testtools.tests.test_spinner import NeedsTwistedTestCase assert_fails_with = try_import('testtools.deferredruntest.assert_fails_with') @@ -335,6 +336,20 @@ class TestAsynchronousDeferredRunTest(NeedsTwistedTestCase): error = result._events[1][2] self.assertThat(error, KeysEqual('traceback', 'twisted-log')) + def test_exports_reactor(self): + # The reactor is set as an attribute on the test case. + reactor = self.make_reactor() + timeout = self.make_timeout() + class SomeCase(TestCase): + def test_cruft(self): + self.assertIs(reactor, self.reactor) + test = SomeCase('test_cruft') + runner = self.make_runner(test, timeout) + result = TestResult() + runner.run(result) + self.assertEqual([], result.errors) + self.assertEqual([], result.failures) + def test_unhandled_error_from_deferred(self): # If there's a Deferred with an unhandled error, the test fails. Each # unhandled error is reported with a separate traceback. diff --git a/lib/testtools/testtools/tests/test_distutilscmd.py b/lib/testtools/testtools/tests/test_distutilscmd.py new file mode 100644 index 0000000000..c485a473d3 --- /dev/null +++ b/lib/testtools/testtools/tests/test_distutilscmd.py @@ -0,0 +1,98 @@ +# Copyright (c) 2010-2011 Testtools authors. See LICENSE for details. + +"""Tests for the distutils test command logic.""" + +from distutils.dist import Distribution + +from testtools.compat import ( + _b, + BytesIO, + ) +from testtools.helpers import try_import +fixtures = try_import('fixtures') + +import testtools +from testtools import TestCase +from testtools.distutilscmd import TestCommand +from testtools.matchers import MatchesRegex + + +if fixtures: + class SampleTestFixture(fixtures.Fixture): + """Creates testtools.runexample temporarily.""" + + def __init__(self): + self.package = fixtures.PythonPackage( + 'runexample', [('__init__.py', _b(""" +from testtools import TestCase + +class TestFoo(TestCase): + def test_bar(self): + pass + def test_quux(self): + pass +def test_suite(): + from unittest import TestLoader + return TestLoader().loadTestsFromName(__name__) +"""))]) + + def setUp(self): + super(SampleTestFixture, self).setUp() + self.useFixture(self.package) + testtools.__path__.append(self.package.base) + self.addCleanup(testtools.__path__.remove, self.package.base) + + +class TestCommandTest(TestCase): + + def setUp(self): + super(TestCommandTest, self).setUp() + if fixtures is None: + self.skipTest("Need fixtures") + + def test_test_module(self): + self.useFixture(SampleTestFixture()) + stream = BytesIO() + dist = Distribution() + dist.script_name = 'setup.py' + dist.script_args = ['test'] + dist.cmdclass = {'test': TestCommand} + dist.command_options = { + 'test': {'test_module': ('command line', 'testtools.runexample')}} + cmd = dist.reinitialize_command('test') + cmd.runner.stdout = stream + dist.run_command('test') + self.assertThat( + stream.getvalue(), + MatchesRegex(_b("""Tests running... + +Ran 2 tests in \\d.\\d\\d\\ds +OK +"""))) + + def test_test_suite(self): + self.useFixture(SampleTestFixture()) + stream = BytesIO() + dist = Distribution() + dist.script_name = 'setup.py' + dist.script_args = ['test'] + dist.cmdclass = {'test': TestCommand} + dist.command_options = { + 'test': { + 'test_suite': ( + 'command line', 'testtools.runexample.test_suite')}} + cmd = dist.reinitialize_command('test') + cmd.runner.stdout = stream + dist.run_command('test') + self.assertThat( + stream.getvalue(), + MatchesRegex(_b("""Tests running... + +Ran 2 tests in \\d.\\d\\d\\ds +OK +"""))) + + +def test_suite(): + from unittest import TestLoader + return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_fixturesupport.py b/lib/testtools/testtools/tests/test_fixturesupport.py index ebdd0373e2..ae6f2ec86e 100644 --- a/lib/testtools/testtools/tests/test_fixturesupport.py +++ b/lib/testtools/testtools/tests/test_fixturesupport.py @@ -1,3 +1,5 @@ +# Copyright (c) 2010-2011 testtools developers. See LICENSE for details. + import unittest from testtools import ( @@ -5,8 +7,9 @@ from testtools import ( content, content_type, ) +from testtools.compat import _b, _u from testtools.helpers import try_import -from testtools.tests.helpers import ( +from testtools.testresult.doubles import ( ExtendedTestResult, ) @@ -50,7 +53,7 @@ class TestFixtureSupport(TestCase): def setUp(self): fixtures.Fixture.setUp(self) self.addCleanup(delattr, self, 'content') - self.content = ['content available until cleanUp'] + self.content = [_b('content available until cleanUp')] self.addDetail('content', content.Content(content_type.UTF8_TEXT, self.get_content)) def get_content(self): @@ -61,16 +64,53 @@ class TestFixtureSupport(TestCase): self.useFixture(fixture) # Add a colliding detail (both should show up) self.addDetail('content', - content.Content(content_type.UTF8_TEXT, lambda:['foo'])) + content.Content(content_type.UTF8_TEXT, lambda:[_b('foo')])) result = ExtendedTestResult() SimpleTest('test_foo').run(result) self.assertEqual('addSuccess', result._events[-2][0]) details = result._events[-2][2] self.assertEqual(['content', 'content-1'], sorted(details.keys())) - self.assertEqual('foo', ''.join(details['content'].iter_text())) + self.assertEqual('foo', _u('').join(details['content'].iter_text())) self.assertEqual('content available until cleanUp', ''.join(details['content-1'].iter_text())) + def test_useFixture_multiple_details_captured(self): + class DetailsFixture(fixtures.Fixture): + def setUp(self): + fixtures.Fixture.setUp(self) + self.addDetail('aaa', content.text_content("foo")) + self.addDetail('bbb', content.text_content("bar")) + fixture = DetailsFixture() + class SimpleTest(TestCase): + def test_foo(self): + self.useFixture(fixture) + result = ExtendedTestResult() + SimpleTest('test_foo').run(result) + self.assertEqual('addSuccess', result._events[-2][0]) + details = result._events[-2][2] + self.assertEqual(['aaa', 'bbb'], sorted(details)) + self.assertEqual('foo', ''.join(details['aaa'].iter_text())) + self.assertEqual('bar', ''.join(details['bbb'].iter_text())) + + def test_useFixture_details_captured_from_setUp(self): + # Details added during fixture set-up are gathered even if setUp() + # fails with an exception. + class BrokenFixture(fixtures.Fixture): + def setUp(self): + fixtures.Fixture.setUp(self) + self.addDetail('content', content.text_content("foobar")) + raise Exception() + fixture = BrokenFixture() + class SimpleTest(TestCase): + def test_foo(self): + self.useFixture(fixture) + result = ExtendedTestResult() + SimpleTest('test_foo').run(result) + self.assertEqual('addError', result._events[-2][0]) + details = result._events[-2][2] + self.assertEqual(['content', 'traceback'], sorted(details)) + self.assertEqual('foobar', ''.join(details['content'].iter_text())) + def test_suite(): from unittest import TestLoader diff --git a/lib/testtools/testtools/tests/test_helpers.py b/lib/testtools/testtools/tests/test_helpers.py index f1894a4613..55de34b7e7 100644 --- a/lib/testtools/testtools/tests/test_helpers.py +++ b/lib/testtools/testtools/tests/test_helpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010-2011 testtools developers. See LICENSE for details. from testtools import TestCase from testtools.helpers import ( @@ -6,9 +6,76 @@ from testtools.helpers import ( try_imports, ) from testtools.matchers import ( + AllMatch, + AfterPreprocessing, Equals, Is, + Not, ) +from testtools.tests.helpers import ( + FullStackRunTest, + hide_testtools_stack, + is_stack_hidden, + safe_hasattr, + ) + + +def check_error_callback(test, function, arg, expected_error_count, + expect_result): + """General test template for error_callback argument. + + :param test: Test case instance. + :param function: Either try_import or try_imports. + :param arg: Name or names to import. + :param expected_error_count: Expected number of calls to the callback. + :param expect_result: Boolean for whether a module should + ultimately be returned or not. + """ + cb_calls = [] + def cb(e): + test.assertIsInstance(e, ImportError) + cb_calls.append(e) + try: + result = function(arg, error_callback=cb) + except ImportError: + test.assertFalse(expect_result) + else: + if expect_result: + test.assertThat(result, Not(Is(None))) + else: + test.assertThat(result, Is(None)) + test.assertEquals(len(cb_calls), expected_error_count) + + +class TestSafeHasattr(TestCase): + + def test_attribute_not_there(self): + class Foo(object): + pass + self.assertEqual(False, safe_hasattr(Foo(), 'anything')) + + def test_attribute_there(self): + class Foo(object): + pass + foo = Foo() + foo.attribute = None + self.assertEqual(True, safe_hasattr(foo, 'attribute')) + + def test_property_there(self): + class Foo(object): + @property + def attribute(self): + return None + foo = Foo() + self.assertEqual(True, safe_hasattr(foo, 'attribute')) + + def test_property_raises(self): + class Foo(object): + @property + def attribute(self): + 1/0 + foo = Foo() + self.assertRaises(ZeroDivisionError, safe_hasattr, foo, 'attribute') class TestTryImport(TestCase): @@ -52,6 +119,19 @@ class TestTryImport(TestCase): import os self.assertThat(result, Is(os.path.join)) + def test_error_callback(self): + # the error callback is called on failures. + check_error_callback(self, try_import, 'doesntexist', 1, False) + + def test_error_callback_missing_module_member(self): + # the error callback is called on failures to find an object + # inside an existing module. + check_error_callback(self, try_import, 'os.nonexistent', 1, False) + + def test_error_callback_not_on_success(self): + # the error callback is not called on success. + check_error_callback(self, try_import, 'os.path', 0, True) + class TestTryImports(TestCase): @@ -100,6 +180,60 @@ class TestTryImports(TestCase): import os self.assertThat(result, Is(os.path)) + def test_error_callback(self): + # One error for every class that doesn't exist. + check_error_callback(self, try_imports, + ['os.doesntexist', 'os.notthiseither'], + 2, False) + check_error_callback(self, try_imports, + ['os.doesntexist', 'os.notthiseither', 'os'], + 2, True) + check_error_callback(self, try_imports, + ['os.path'], + 0, True) + + +import testtools.matchers +import testtools.runtest +import testtools.testcase + + +def StackHidden(is_hidden): + return AllMatch( + AfterPreprocessing( + lambda module: safe_hasattr(module, '__unittest'), + Equals(is_hidden))) + + +class TestStackHiding(TestCase): + + modules = [ + testtools.matchers, + testtools.runtest, + testtools.testcase, + ] + + run_tests_with = FullStackRunTest + + def setUp(self): + super(TestStackHiding, self).setUp() + self.addCleanup(hide_testtools_stack, is_stack_hidden()) + + def test_shown_during_testtools_testsuite(self): + self.assertThat(self.modules, StackHidden(False)) + + def test_is_stack_hidden_consistent_true(self): + hide_testtools_stack(True) + self.assertEqual(True, is_stack_hidden()) + + def test_is_stack_hidden_consistent_false(self): + hide_testtools_stack(False) + self.assertEqual(False, is_stack_hidden()) + + def test_show_stack(self): + hide_testtools_stack(False) + self.assertThat(self.modules, StackHidden(False)) + def test_suite(): from unittest import TestLoader diff --git a/lib/testtools/testtools/tests/test_matchers.py b/lib/testtools/testtools/tests/test_matchers.py index bbcd87eff8..feca41a4e6 100644 --- a/lib/testtools/testtools/tests/test_matchers.py +++ b/lib/testtools/testtools/tests/test_matchers.py @@ -1,16 +1,25 @@ -# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Tests for matchers.""" import doctest +import re import sys from testtools import ( Matcher, # check that Matcher is exposed at the top level for docs. TestCase, ) +from testtools.compat import ( + StringIO, + _u, + ) from testtools.matchers import ( + AfterPreprocessing, + AllMatch, Annotate, + AnnotatedMismatch, + Contains, Equals, DocTestMatches, DoesNotEndWith, @@ -18,17 +27,25 @@ from testtools.matchers import ( EndsWith, KeysEqual, Is, + IsInstance, LessThan, + GreaterThan, MatchesAny, MatchesAll, MatchesException, + MatchesListwise, + MatchesRegex, + MatchesSetwise, + MatchesStructure, Mismatch, + MismatchDecorator, Not, NotEquals, Raises, raises, StartsWith, ) +from testtools.tests.helpers import FullStackRunTest # Silence pyflakes. Matcher @@ -36,6 +53,8 @@ Matcher class TestMismatch(TestCase): + run_tests_with = FullStackRunTest + def test_constructor_arguments(self): mismatch = Mismatch("some description", {'detail': "things"}) self.assertEqual("some description", mismatch.describe()) @@ -50,6 +69,8 @@ class TestMismatch(TestCase): class TestMatchersInterface(object): + run_tests_with = FullStackRunTest + def test_matches_match(self): matcher = self.matches_matcher matches = self.matches_matches @@ -100,8 +121,26 @@ class TestDocTestMatchesInterface(TestCase, TestMatchersInterface): DocTestMatches("Ran 1 tests in ...s", doctest.ELLIPSIS))] +class TestDocTestMatchesInterfaceUnicode(TestCase, TestMatchersInterface): + + matches_matcher = DocTestMatches(_u("\xa7..."), doctest.ELLIPSIS) + matches_matches = [_u("\xa7"), _u("\xa7 more\n")] + matches_mismatches = ["\\xa7", _u("more \xa7"), _u("\n\xa7")] + + str_examples = [("DocTestMatches(%r)" % (_u("\xa7\n"),), + DocTestMatches(_u("\xa7"))), + ] + + describe_examples = [( + _u("Expected:\n \xa7\nGot:\n a\n"), + "a", + DocTestMatches(_u("\xa7"), doctest.ELLIPSIS))] + + class TestDocTestMatchesSpecific(TestCase): + run_tests_with = FullStackRunTest + def test___init__simple(self): matcher = DocTestMatches("foo") self.assertEqual("foo\n", matcher.want) @@ -149,6 +188,26 @@ class TestIsInterface(TestCase, TestMatchersInterface): describe_examples = [("1 is not 2", 2, Is(1))] +class TestIsInstanceInterface(TestCase, TestMatchersInterface): + + class Foo:pass + + matches_matcher = IsInstance(Foo) + matches_matches = [Foo()] + matches_mismatches = [object(), 1, Foo] + + str_examples = [ + ("IsInstance(str)", IsInstance(str)), + ("IsInstance(str, int)", IsInstance(str, int)), + ] + + describe_examples = [ + ("'foo' is not an instance of int", 'foo', IsInstance(int)), + ("'foo' is not an instance of any of (int, type)", 'foo', + IsInstance(int, type)), + ] + + class TestLessThanInterface(TestCase, TestMatchersInterface): matches_matcher = LessThan(4) @@ -159,7 +218,40 @@ class TestLessThanInterface(TestCase, TestMatchersInterface): ("LessThan(12)", LessThan(12)), ] - describe_examples = [('4 is >= 4', 4, LessThan(4))] + describe_examples = [ + ('4 is not > 5', 5, LessThan(4)), + ('4 is not > 4', 4, LessThan(4)), + ] + + +class TestGreaterThanInterface(TestCase, TestMatchersInterface): + + matches_matcher = GreaterThan(4) + matches_matches = [5, 8] + matches_mismatches = [-2, 0, 4] + + str_examples = [ + ("GreaterThan(12)", GreaterThan(12)), + ] + + describe_examples = [ + ('5 is not < 4', 4, GreaterThan(5)), + ('4 is not < 4', 4, GreaterThan(4)), + ] + + +class TestContainsInterface(TestCase, TestMatchersInterface): + + matches_matcher = Contains('foo') + matches_matches = ['foo', 'afoo', 'fooa'] + matches_mismatches = ['f', 'fo', 'oo', 'faoo', 'foao'] + + str_examples = [ + ("Contains(1)", Contains(1)), + ("Contains('foo')", Contains('foo')), + ] + + describe_examples = [("1 not in 2", 2, Contains(1))] def make_error(type, *args, **kwargs): @@ -212,6 +304,45 @@ class TestMatchesExceptionTypeInterface(TestCase, TestMatchersInterface): ] +class TestMatchesExceptionTypeReInterface(TestCase, TestMatchersInterface): + + matches_matcher = MatchesException(ValueError, 'fo.') + error_foo = make_error(ValueError, 'foo') + error_sub = make_error(UnicodeError, 'foo') + error_bar = make_error(ValueError, 'bar') + matches_matches = [error_foo, error_sub] + matches_mismatches = [error_bar] + + str_examples = [ + ("MatchesException(%r)" % Exception, + MatchesException(Exception, 'fo.')) + ] + describe_examples = [ + ("'bar' does not match /fo./", + error_bar, MatchesException(ValueError, "fo.")), + ] + + +class TestMatchesExceptionTypeMatcherInterface(TestCase, TestMatchersInterface): + + matches_matcher = MatchesException( + ValueError, AfterPreprocessing(str, Equals('foo'))) + error_foo = make_error(ValueError, 'foo') + error_sub = make_error(UnicodeError, 'foo') + error_bar = make_error(ValueError, 'bar') + matches_matches = [error_foo, error_sub] + matches_mismatches = [error_bar] + + str_examples = [ + ("MatchesException(%r)" % Exception, + MatchesException(Exception, Equals('foo'))) + ] + describe_examples = [ + ("5 != %r" % (error_bar[1],), + error_bar, MatchesException(ValueError, Equals(5))), + ] + + class TestNotInterface(TestCase, TestMatchersInterface): matches_matcher = Not(Equals(1)) @@ -303,6 +434,33 @@ class TestAnnotate(TestCase, TestMatchersInterface): describe_examples = [("1 != 2: foo", 2, Annotate('foo', Equals(1)))] + def test_if_message_no_message(self): + # Annotate.if_message returns the given matcher if there is no + # message. + matcher = Equals(1) + not_annotated = Annotate.if_message('', matcher) + self.assertIs(matcher, not_annotated) + + def test_if_message_given_message(self): + # Annotate.if_message returns an annotated version of the matcher if a + # message is provided. + matcher = Equals(1) + expected = Annotate('foo', matcher) + annotated = Annotate.if_message('foo', matcher) + self.assertThat( + annotated, + MatchesStructure.fromExample(expected, 'annotation', 'matcher')) + + +class TestAnnotatedMismatch(TestCase): + + run_tests_with = FullStackRunTest + + def test_forwards_details(self): + x = Mismatch('description', {'foo': 'bar'}) + annotated = AnnotatedMismatch("annotation", x) + self.assertEqual(x.get_details(), annotated.get_details()) + class TestRaisesInterface(TestCase, TestMatchersInterface): @@ -339,6 +497,8 @@ class TestRaisesExceptionMatcherInterface(TestCase, TestMatchersInterface): class TestRaisesBaseTypes(TestCase): + run_tests_with = FullStackRunTest + def raiser(self): raise KeyboardInterrupt('foo') @@ -372,6 +532,8 @@ class TestRaisesBaseTypes(TestCase): class TestRaisesConvenience(TestCase): + run_tests_with = FullStackRunTest + def test_exc_type(self): self.assertThat(lambda: 1/0, raises(ZeroDivisionError)) @@ -384,6 +546,8 @@ class TestRaisesConvenience(TestCase): class DoesNotStartWithTests(TestCase): + run_tests_with = FullStackRunTest + def test_describe(self): mismatch = DoesNotStartWith("fo", "bo") self.assertEqual("'fo' does not start with 'bo'.", mismatch.describe()) @@ -391,6 +555,8 @@ class DoesNotStartWithTests(TestCase): class StartsWithTests(TestCase): + run_tests_with = FullStackRunTest + def test_str(self): matcher = StartsWith("bar") self.assertEqual("Starts with 'bar'.", str(matcher)) @@ -416,6 +582,8 @@ class StartsWithTests(TestCase): class DoesNotEndWithTests(TestCase): + run_tests_with = FullStackRunTest + def test_describe(self): mismatch = DoesNotEndWith("fo", "bo") self.assertEqual("'fo' does not end with 'bo'.", mismatch.describe()) @@ -423,6 +591,8 @@ class DoesNotEndWithTests(TestCase): class EndsWithTests(TestCase): + run_tests_with = FullStackRunTest + def test_str(self): matcher = EndsWith("bar") self.assertEqual("Ends with 'bar'.", str(matcher)) @@ -446,6 +616,259 @@ class EndsWithTests(TestCase): self.assertEqual("bar", mismatch.expected) +def run_doctest(obj, name): + p = doctest.DocTestParser() + t = p.get_doctest( + obj.__doc__, sys.modules[obj.__module__].__dict__, name, '', 0) + r = doctest.DocTestRunner() + output = StringIO() + r.run(t, out=output.write) + return r.failures, output.getvalue() + + +class TestMatchesListwise(TestCase): + + run_tests_with = FullStackRunTest + + def test_docstring(self): + failure_count, output = run_doctest( + MatchesListwise, "MatchesListwise") + if failure_count: + self.fail("Doctest failed with %s" % output) + + +class TestMatchesStructure(TestCase, TestMatchersInterface): + + class SimpleClass: + def __init__(self, x, y): + self.x = x + self.y = y + + matches_matcher = MatchesStructure(x=Equals(1), y=Equals(2)) + matches_matches = [SimpleClass(1, 2)] + matches_mismatches = [ + SimpleClass(2, 2), + SimpleClass(1, 1), + SimpleClass(3, 3), + ] + + str_examples = [ + ("MatchesStructure(x=Equals(1))", MatchesStructure(x=Equals(1))), + ("MatchesStructure(y=Equals(2))", MatchesStructure(y=Equals(2))), + ("MatchesStructure(x=Equals(1), y=Equals(2))", + MatchesStructure(x=Equals(1), y=Equals(2))), + ] + + describe_examples = [ + ("""\ +Differences: [ +3 != 1: x +]""", SimpleClass(1, 2), MatchesStructure(x=Equals(3), y=Equals(2))), + ("""\ +Differences: [ +3 != 2: y +]""", SimpleClass(1, 2), MatchesStructure(x=Equals(1), y=Equals(3))), + ("""\ +Differences: [ +0 != 1: x +0 != 2: y +]""", SimpleClass(1, 2), MatchesStructure(x=Equals(0), y=Equals(0))), + ] + + def test_fromExample(self): + self.assertThat( + self.SimpleClass(1, 2), + MatchesStructure.fromExample(self.SimpleClass(1, 3), 'x')) + + def test_byEquality(self): + self.assertThat( + self.SimpleClass(1, 2), + MatchesStructure.byEquality(x=1)) + + def test_withStructure(self): + self.assertThat( + self.SimpleClass(1, 2), + MatchesStructure.byMatcher(LessThan, x=2)) + + def test_update(self): + self.assertThat( + self.SimpleClass(1, 2), + MatchesStructure(x=NotEquals(1)).update(x=Equals(1))) + + def test_update_none(self): + self.assertThat( + self.SimpleClass(1, 2), + MatchesStructure(x=Equals(1), z=NotEquals(42)).update( + z=None)) + + +class TestMatchesRegex(TestCase, TestMatchersInterface): + + matches_matcher = MatchesRegex('a|b') + matches_matches = ['a', 'b'] + matches_mismatches = ['c'] + + str_examples = [ + ("MatchesRegex('a|b')", MatchesRegex('a|b')), + ("MatchesRegex('a|b', re.M)", MatchesRegex('a|b', re.M)), + ("MatchesRegex('a|b', re.I|re.M)", MatchesRegex('a|b', re.I|re.M)), + ] + + describe_examples = [ + ("'c' does not match /a|b/", 'c', MatchesRegex('a|b')), + ("'c' does not match /a\d/", 'c', MatchesRegex(r'a\d')), + ] + + +class TestMatchesSetwise(TestCase): + + run_tests_with = FullStackRunTest + + def assertMismatchWithDescriptionMatching(self, value, matcher, + description_matcher): + mismatch = matcher.match(value) + if mismatch is None: + self.fail("%s matched %s" % (matcher, value)) + actual_description = mismatch.describe() + self.assertThat( + actual_description, + Annotate( + "%s matching %s" % (matcher, value), + description_matcher)) + + def test_matches(self): + self.assertIs( + None, MatchesSetwise(Equals(1), Equals(2)).match([2, 1])) + + def test_mismatches(self): + self.assertMismatchWithDescriptionMatching( + [2, 3], MatchesSetwise(Equals(1), Equals(2)), + MatchesRegex('.*There was 1 mismatch$', re.S)) + + def test_too_many_matchers(self): + self.assertMismatchWithDescriptionMatching( + [2, 3], MatchesSetwise(Equals(1), Equals(2), Equals(3)), + Equals('There was 1 matcher left over: Equals(1)')) + + def test_too_many_values(self): + self.assertMismatchWithDescriptionMatching( + [1, 2, 3], MatchesSetwise(Equals(1), Equals(2)), + Equals('There was 1 value left over: [3]')) + + def test_two_too_many_matchers(self): + self.assertMismatchWithDescriptionMatching( + [3], MatchesSetwise(Equals(1), Equals(2), Equals(3)), + MatchesRegex( + 'There were 2 matchers left over: Equals\([12]\), ' + 'Equals\([12]\)')) + + def test_two_too_many_values(self): + self.assertMismatchWithDescriptionMatching( + [1, 2, 3, 4], MatchesSetwise(Equals(1), Equals(2)), + MatchesRegex( + 'There were 2 values left over: \[[34], [34]\]')) + + def test_mismatch_and_too_many_matchers(self): + self.assertMismatchWithDescriptionMatching( + [2, 3], MatchesSetwise(Equals(0), Equals(1), Equals(2)), + MatchesRegex( + '.*There was 1 mismatch and 1 extra matcher: Equals\([01]\)', + re.S)) + + def test_mismatch_and_too_many_values(self): + self.assertMismatchWithDescriptionMatching( + [2, 3, 4], MatchesSetwise(Equals(1), Equals(2)), + MatchesRegex( + '.*There was 1 mismatch and 1 extra value: \[[34]\]', + re.S)) + + def test_mismatch_and_two_too_many_matchers(self): + self.assertMismatchWithDescriptionMatching( + [3, 4], MatchesSetwise( + Equals(0), Equals(1), Equals(2), Equals(3)), + MatchesRegex( + '.*There was 1 mismatch and 2 extra matchers: ' + 'Equals\([012]\), Equals\([012]\)', re.S)) + + def test_mismatch_and_two_too_many_values(self): + self.assertMismatchWithDescriptionMatching( + [2, 3, 4, 5], MatchesSetwise(Equals(1), Equals(2)), + MatchesRegex( + '.*There was 1 mismatch and 2 extra values: \[[145], [145]\]', + re.S)) + + +class TestAfterPreprocessing(TestCase, TestMatchersInterface): + + def parity(x): + return x % 2 + + matches_matcher = AfterPreprocessing(parity, Equals(1)) + matches_matches = [3, 5] + matches_mismatches = [2] + + str_examples = [ + ("AfterPreprocessing(<function parity>, Equals(1))", + AfterPreprocessing(parity, Equals(1))), + ] + + describe_examples = [ + ("1 != 0: after <function parity> on 2", 2, + AfterPreprocessing(parity, Equals(1))), + ("1 != 0", 2, + AfterPreprocessing(parity, Equals(1), annotate=False)), + ] + + +class TestMismatchDecorator(TestCase): + + run_tests_with = FullStackRunTest + + def test_forwards_description(self): + x = Mismatch("description", {'foo': 'bar'}) + decorated = MismatchDecorator(x) + self.assertEqual(x.describe(), decorated.describe()) + + def test_forwards_details(self): + x = Mismatch("description", {'foo': 'bar'}) + decorated = MismatchDecorator(x) + self.assertEqual(x.get_details(), decorated.get_details()) + + def test_repr(self): + x = Mismatch("description", {'foo': 'bar'}) + decorated = MismatchDecorator(x) + self.assertEqual( + '<testtools.matchers.MismatchDecorator(%r)>' % (x,), + repr(decorated)) + + +class TestAllMatch(TestCase, TestMatchersInterface): + + matches_matcher = AllMatch(LessThan(10)) + matches_matches = [ + [9, 9, 9], + (9, 9), + iter([9, 9, 9, 9, 9]), + ] + matches_mismatches = [ + [11, 9, 9], + iter([9, 12, 9, 11]), + ] + + str_examples = [ + ("AllMatch(LessThan(12))", AllMatch(LessThan(12))), + ] + + describe_examples = [ + ('Differences: [\n' + '10 is not > 11\n' + '10 is not > 10\n' + ']', + [11, 9, 10], + AllMatch(LessThan(10))), + ] + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_run.py b/lib/testtools/testtools/tests/test_run.py index 8f88fb62ec..d2974f6373 100644 --- a/lib/testtools/testtools/tests/test_run.py +++ b/lib/testtools/testtools/tests/test_run.py @@ -1,10 +1,13 @@ -# Copyright (c) 2010 Testtools authors. See LICENSE for details. +# Copyright (c) 2010 testtools developers. See LICENSE for details. """Tests for the test runner logic.""" -from testtools.helpers import try_import, try_imports +from testtools.compat import ( + _b, + StringIO, + ) +from testtools.helpers import try_import fixtures = try_import('fixtures') -StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) import testtools from testtools import TestCase, run @@ -16,7 +19,7 @@ if fixtures: def __init__(self): self.package = fixtures.PythonPackage( - 'runexample', [('__init__.py', """ + 'runexample', [('__init__.py', _b(""" from testtools import TestCase class TestFoo(TestCase): @@ -27,7 +30,7 @@ class TestFoo(TestCase): def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) -""")]) +"""))]) def setUp(self): super(SampleTestFixture, self).setUp() @@ -41,7 +44,7 @@ class TestRun(TestCase): def test_run_list(self): if fixtures is None: self.skipTest("Need fixtures") - package = self.useFixture(SampleTestFixture()) + self.useFixture(SampleTestFixture()) out = StringIO() run.main(['prog', '-l', 'testtools.runexample.test_suite'], out) self.assertEqual("""testtools.runexample.TestFoo.test_bar @@ -51,7 +54,7 @@ testtools.runexample.TestFoo.test_quux def test_run_load_list(self): if fixtures is None: self.skipTest("Need fixtures") - package = self.useFixture(SampleTestFixture()) + self.useFixture(SampleTestFixture()) out = StringIO() # We load two tests - one that exists and one that doesn't, and we # should get the one that exists and neither the one that doesn't nor @@ -60,10 +63,10 @@ testtools.runexample.TestFoo.test_quux tempname = tempdir.path + '/tests.list' f = open(tempname, 'wb') try: - f.write(""" + f.write(_b(""" testtools.runexample.TestFoo.test_bar testtools.runexample.missingtest -""") +""")) finally: f.close() run.main(['prog', '-l', '--load-list', tempname, @@ -71,6 +74,7 @@ testtools.runexample.missingtest self.assertEqual("""testtools.runexample.TestFoo.test_bar """, out.getvalue()) + 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 02863ac6fd..afbb8baf39 100644 --- a/lib/testtools/testtools/tests/test_runtest.py +++ b/lib/testtools/testtools/tests/test_runtest.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """Tests for the RunTest single test execution logic.""" @@ -10,11 +10,14 @@ from testtools import ( TestResult, ) from testtools.matchers import MatchesException, Is, Raises -from testtools.tests.helpers import ExtendedTestResult +from testtools.testresult.doubles import ExtendedTestResult +from testtools.tests.helpers import FullStackRunTest class TestRunTest(TestCase): + run_tests_with = FullStackRunTest + def make_case(self): class Case(TestCase): def test(self): diff --git a/lib/testtools/testtools/tests/test_spinner.py b/lib/testtools/testtools/tests/test_spinner.py index 5c6139d0e9..3d677bd754 100644 --- a/lib/testtools/testtools/tests/test_spinner.py +++ b/lib/testtools/testtools/tests/test_spinner.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2010 testtools developers. See LICENSE for details. """Tests for the evil Twisted reactor-spinning we do.""" diff --git a/lib/testtools/testtools/tests/test_testtools.py b/lib/testtools/testtools/tests/test_testcase.py index 2e722e919d..03457310a7 100644 --- a/lib/testtools/testtools/tests/test_testtools.py +++ b/lib/testtools/testtools/tests/test_testcase.py @@ -1,7 +1,8 @@ -# Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Tests for extensions to the base test library.""" +from doctest import ELLIPSIS from pprint import pformat import sys import unittest @@ -18,22 +19,36 @@ from testtools import ( skipUnless, testcase, ) +from testtools.compat import _b from testtools.matchers import ( + Annotate, + DocTestMatches, Equals, MatchesException, Raises, ) -from testtools.tests.helpers import ( - an_exc_info, - LoggingResult, +from testtools.testresult.doubles import ( Python26TestResult, Python27TestResult, ExtendedTestResult, ) +from testtools.tests.helpers import ( + an_exc_info, + FullStackRunTest, + LoggingResult, + ) +try: + exec('from __future__ import with_statement') +except SyntaxError: + pass +else: + from testtools.tests.test_with_with import * class TestPlaceHolder(TestCase): + run_test_with = FullStackRunTest + def makePlaceHolder(self, test_id="foo", short_description=None): return PlaceHolder(test_id, short_description) @@ -108,6 +123,8 @@ class TestPlaceHolder(TestCase): class TestErrorHolder(TestCase): + run_test_with = FullStackRunTest + def makeException(self): try: raise RuntimeError("danger danger") @@ -194,7 +211,9 @@ class TestErrorHolder(TestCase): class TestEquality(TestCase): - """Test `TestCase`'s equality implementation.""" + """Test ``TestCase``'s equality implementation.""" + + run_test_with = FullStackRunTest def test_identicalIsEqual(self): # TestCase's are equal if they are identical. @@ -209,6 +228,8 @@ class TestEquality(TestCase): class TestAssertions(TestCase): """Test assertions in TestCase.""" + run_test_with = FullStackRunTest + def raiseError(self, exceptionFactory, *args, **kwargs): raise exceptionFactory(*args, **kwargs) @@ -235,16 +256,8 @@ class TestAssertions(TestCase): # assertRaises raises self.failureException when it's passed a # callable that raises no error. ret = ('orange', 42) - try: - self.assertRaises(RuntimeError, lambda: ret) - except self.failureException: - # We expected assertRaises to raise this exception. - e = sys.exc_info()[1] - self.assertEqual( - '%s not raised, %r returned instead.' - % (self._formatTypes(RuntimeError), ret), str(e)) - else: - self.fail('Expected assertRaises to fail, but it did not.') + self.assertFails("<function <lambda> at ...> returned ('orange', 42)", + self.assertRaises, RuntimeError, lambda: ret) def test_assertRaises_fails_when_different_error_raised(self): # assertRaises re-raises an exception that it didn't expect. @@ -289,15 +302,14 @@ class TestAssertions(TestCase): failure = self.assertRaises( self.failureException, self.assertRaises, expectedExceptions, lambda: None) - self.assertEqual( - '%s not raised, None returned instead.' - % self._formatTypes(expectedExceptions), str(failure)) + self.assertFails('<function <lambda> at ...> returned None', + self.assertRaises, expectedExceptions, lambda: None) def assertFails(self, message, function, *args, **kwargs): """Assert that function raises a failure with the given message.""" failure = self.assertRaises( self.failureException, function, *args, **kwargs) - self.assertEqual(message, str(failure)) + self.assertThat(failure, DocTestMatches(message, ELLIPSIS)) def test_assertIn_success(self): # assertIn(needle, haystack) asserts that 'needle' is in 'haystack'. @@ -322,9 +334,10 @@ class TestAssertions(TestCase): def test_assertNotIn_failure(self): # assertNotIn(needle, haystack) fails the test when 'needle' is in # 'haystack'. - self.assertFails('3 in [1, 2, 3]', self.assertNotIn, 3, [1, 2, 3]) + self.assertFails('[1, 2, 3] matches Contains(3)', self.assertNotIn, + 3, [1, 2, 3]) self.assertFails( - '%r in %r' % ('foo', 'foo bar baz'), + "'foo bar baz' matches Contains('foo')", self.assertNotIn, 'foo', 'foo bar baz') def test_assertIsInstance(self): @@ -358,7 +371,7 @@ class TestAssertions(TestCase): """Simple class for testing assertIsInstance.""" self.assertFails( - '42 is not an instance of %s' % self._formatTypes(Foo), + "'42' is not an instance of %s" % self._formatTypes(Foo), self.assertIsInstance, 42, Foo) def test_assertIsInstance_failure_multiple_classes(self): @@ -372,12 +385,13 @@ class TestAssertions(TestCase): """Another simple class for testing assertIsInstance.""" self.assertFails( - '42 is not an instance of %s' % self._formatTypes([Foo, Bar]), + "'42' is not an instance of any of (%s)" % self._formatTypes([Foo, Bar]), self.assertIsInstance, 42, (Foo, Bar)) def test_assertIsInstance_overridden_message(self): # assertIsInstance(obj, klass, msg) permits a custom message. - self.assertFails("foo", self.assertIsInstance, 42, str, "foo") + self.assertFails("'42' is not an instance of str: foo", + self.assertIsInstance, 42, str, "foo") def test_assertIs(self): # assertIs asserts that an object is identical to another object. @@ -409,16 +423,17 @@ class TestAssertions(TestCase): def test_assertIsNot_fails(self): # assertIsNot raises assertion errors if one object is identical to # another. - self.assertFails('None is None', self.assertIsNot, None, None) + self.assertFails('None matches Is(None)', self.assertIsNot, None, None) some_list = [42] self.assertFails( - '[42] is [42]', self.assertIsNot, some_list, some_list) + '[42] matches Is([42])', self.assertIsNot, some_list, some_list) def test_assertIsNot_fails_with_message(self): # assertIsNot raises assertion errors if one object is identical to # another, and includes a user-supplied message if it's provided. self.assertFails( - 'None is None: foo bar', self.assertIsNot, None, None, "foo bar") + 'None matches Is(None): foo bar', self.assertIsNot, None, None, + "foo bar") def test_assertThat_matches_clean(self): class Matcher(object): @@ -450,10 +465,35 @@ class TestAssertions(TestCase): self.assertEqual([ ('match', "foo"), ('describe_diff', "foo"), - ('__str__',), ], calls) self.assertFalse(result.wasSuccessful()) + def test_assertThat_output(self): + matchee = 'foo' + matcher = Equals('bar') + expected = matcher.match(matchee).describe() + self.assertFails(expected, self.assertThat, matchee, matcher) + + def test_assertThat_message_is_annotated(self): + matchee = 'foo' + matcher = Equals('bar') + expected = Annotate('woo', matcher).match(matchee).describe() + self.assertFails(expected, self.assertThat, matchee, matcher, 'woo') + + def test_assertThat_verbose_output(self): + matchee = 'foo' + matcher = Equals('bar') + expected = ( + 'Match failed. Matchee: "%s"\n' + 'Matcher: %s\n' + 'Difference: %s\n' % ( + matchee, + matcher, + matcher.match(matchee).describe(), + )) + self.assertFails( + expected, self.assertThat, matchee, matcher, verbose=True) + def test_assertEqual_nice_formatting(self): message = "These things ought not be equal." a = ['apple', 'banana', 'cherry'] @@ -461,20 +501,11 @@ class TestAssertions(TestCase): '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), - '']) expected_error = '\n'.join([ - 'Match failed. Matchee: "%r"' % b, - 'Matcher: Annotate(%r, Equals(%r))' % (message, a), - 'Difference: !=:', + '!=:', 'reference = %s' % pformat(a), 'actual = %s' % pformat(b), ': ' + message, - '' ]) self.assertFails(expected_error, self.assertEqual, a, b, message) self.assertFails(expected_error, self.assertEquals, a, b, message) @@ -483,20 +514,30 @@ class TestAssertions(TestCase): def test_assertEqual_formatting_no_message(self): a = "cat" b = "dog" - expected_error = '\n'.join([ - 'Match failed. Matchee: "dog"', - 'Matcher: Equals(\'cat\')', - 'Difference: \'cat\' != \'dog\'', - '' - ]) + expected_error = "'cat' != 'dog'" self.assertFails(expected_error, self.assertEqual, a, b) self.assertFails(expected_error, self.assertEquals, a, b) self.assertFails(expected_error, self.failUnlessEqual, a, b) + def test_assertIsNone(self): + self.assertIsNone(None) + + expected_error = 'None is not 0' + self.assertFails(expected_error, self.assertIsNone, 0) + + def test_assertIsNotNone(self): + self.assertIsNotNone(0) + self.assertIsNotNone("0") + + expected_error = 'None matches Is(None)' + self.assertFails(expected_error, self.assertIsNotNone, None) + class TestAddCleanup(TestCase): """Tests for TestCase.addCleanup.""" + run_test_with = FullStackRunTest + class LoggingTest(TestCase): """A test that logs calls to setUp, runTest and tearDown.""" @@ -662,6 +703,8 @@ class TestAddCleanup(TestCase): class TestWithDetails(TestCase): + run_test_with = FullStackRunTest + def assertDetailsProvided(self, case, expected_outcome, expected_keys): """Assert that when case is run, details are provided to the result. @@ -688,12 +731,14 @@ class TestWithDetails(TestCase): def get_content(self): return content.Content( - content.ContentType("text", "foo"), lambda: ['foo']) + content.ContentType("text", "foo"), lambda: [_b('foo')]) class TestExpectedFailure(TestWithDetails): """Tests for expected failures and unexpected successess.""" + run_test_with = FullStackRunTest + def make_unexpected_case(self): class Case(TestCase): def test(self): @@ -757,6 +802,8 @@ class TestExpectedFailure(TestWithDetails): class TestUniqueFactories(TestCase): """Tests for getUniqueString and getUniqueInteger.""" + run_test_with = FullStackRunTest + def test_getUniqueInteger(self): # getUniqueInteger returns an integer that increments each time you # call it. @@ -785,6 +832,8 @@ class TestUniqueFactories(TestCase): class TestCloneTestWithNewId(TestCase): """Tests for clone_test_with_new_id.""" + run_test_with = FullStackRunTest + def test_clone_test_with_new_id(self): class FooTestCase(TestCase): def test_foo(self): @@ -812,6 +861,8 @@ class TestCloneTestWithNewId(TestCase): class TestDetailsProvided(TestWithDetails): + run_test_with = FullStackRunTest + def test_addDetail(self): mycontent = self.get_content() self.addDetail("foo", mycontent) @@ -915,6 +966,8 @@ class TestDetailsProvided(TestWithDetails): class TestSetupTearDown(TestCase): + run_test_with = FullStackRunTest + def test_setUpNotCalled(self): class DoesnotcallsetUp(TestCase): def setUp(self): @@ -939,6 +992,8 @@ class TestSetupTearDown(TestCase): class TestSkipping(TestCase): """Tests for skipping of tests functionality.""" + run_test_with = FullStackRunTest + def test_skip_causes_skipException(self): self.assertThat(lambda:self.skip("Skip this test"), Raises(MatchesException(self.skipException))) @@ -1040,6 +1095,8 @@ class TestSkipping(TestCase): class TestOnException(TestCase): + run_test_with = FullStackRunTest + def test_default_works(self): events = [] class Case(TestCase): @@ -1074,6 +1131,8 @@ class TestOnException(TestCase): class TestPatchSupport(TestCase): + run_test_with = FullStackRunTest + class Case(TestCase): def test(self): pass @@ -1130,6 +1189,39 @@ class TestPatchSupport(TestCase): self.assertIs(marker, value) +class TestTestCaseSuper(TestCase): + + run_test_with = FullStackRunTest + + def test_setup_uses_super(self): + class OtherBaseCase(unittest.TestCase): + setup_called = False + def setUp(self): + self.setup_called = True + super(OtherBaseCase, self).setUp() + class OurCase(TestCase, OtherBaseCase): + def runTest(self): + pass + test = OurCase() + test.setUp() + test.tearDown() + self.assertTrue(test.setup_called) + + def test_teardown_uses_super(self): + class OtherBaseCase(unittest.TestCase): + teardown_called = False + def tearDown(self): + self.teardown_called = True + super(OtherBaseCase, self).tearDown() + class OurCase(TestCase, OtherBaseCase): + def runTest(self): + pass + test = OurCase() + test.setUp() + test.tearDown() + self.assertTrue(test.teardown_called) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_testresult.py b/lib/testtools/testtools/tests/test_testresult.py index 57c3293c09..a241788bea 100644 --- a/lib/testtools/testtools/tests/test_testresult.py +++ b/lib/testtools/testtools/tests/test_testresult.py @@ -1,4 +1,4 @@ -# Copyright (c) 2008 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. """Test TestResults and related things.""" @@ -22,7 +22,6 @@ from testtools import ( TextTestResult, ThreadsafeForwardingResult, testresult, - try_imports, ) from testtools.compat import ( _b, @@ -30,24 +29,70 @@ from testtools.compat import ( _r, _u, str_is_unicode, + StringIO, + ) +from testtools.content import ( + Content, + content_from_stream, + text_content, ) -from testtools.content import Content from testtools.content_type import ContentType, UTF8_TEXT from testtools.matchers import ( DocTestMatches, + Equals, MatchesException, Raises, ) from testtools.tests.helpers import ( + an_exc_info, + FullStackRunTest, LoggingResult, + run_with_stack_hidden, + ) +from testtools.testresult.doubles import ( Python26TestResult, Python27TestResult, ExtendedTestResult, - an_exc_info ) -from testtools.testresult.real import utc +from testtools.testresult.real import ( + _details_to_str, + utc, + ) + + +def make_erroring_test(): + class Test(TestCase): + def error(self): + 1/0 + return Test("error") + + +def make_failing_test(): + class Test(TestCase): + def failed(self): + self.fail("yo!") + return Test("failed") + + +def make_unexpectedly_successful_test(): + class Test(TestCase): + def succeeded(self): + self.expectFailure("yo!", lambda: None) + return Test("succeeded") + -StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) +def make_test(): + class Test(TestCase): + def test(self): + pass + return Test("test") + + +def make_exception_info(exceptionFactory, *args, **kwargs): + try: + raise exceptionFactory(*args, **kwargs) + except: + return sys.exc_info() class Python26Contract(object): @@ -190,7 +235,7 @@ class FallbackContract(DetailsContract): class StartTestRunContract(FallbackContract): """Defines the contract for testtools policy choices. - + That is things which are not simply extensions to unittest but choices we have made differently. """ @@ -222,24 +267,32 @@ class StartTestRunContract(FallbackContract): class TestTestResultContract(TestCase, StartTestRunContract): + run_test_with = FullStackRunTest + def makeResult(self): return TestResult() class TestMultiTestResultContract(TestCase, StartTestRunContract): + run_test_with = FullStackRunTest + def makeResult(self): return MultiTestResult(TestResult(), TestResult()) class TestTextTestResultContract(TestCase, StartTestRunContract): + run_test_with = FullStackRunTest + def makeResult(self): return TextTestResult(StringIO()) class TestThreadSafeForwardingResultContract(TestCase, StartTestRunContract): + run_test_with = FullStackRunTest + def makeResult(self): result_semaphore = threading.Semaphore(1) target = TestResult() @@ -277,7 +330,9 @@ class TestAdaptedPython27TestResultContract(TestCase, DetailsContract): class TestTestResult(TestCase): - """Tests for `TestResult`.""" + """Tests for 'TestResult'.""" + + run_tests_with = FullStackRunTest def makeResult(self): """Make an arbitrary result for testing.""" @@ -328,21 +383,45 @@ class TestTestResult(TestCase): result.time(now) self.assertEqual(now, result._now()) - -class TestWithFakeExceptions(TestCase): - - def makeExceptionInfo(self, exceptionFactory, *args, **kwargs): - try: - raise exceptionFactory(*args, **kwargs) - except: - return sys.exc_info() + def test_traceback_formatting_without_stack_hidden(self): + # During the testtools test run, we show our levels of the stack, + # because we want to be able to use our test suite to debug our own + # code. + result = self.makeResult() + test = make_erroring_test() + test.run(result) + self.assertThat( + result.errors[0][1], + DocTestMatches( + 'Traceback (most recent call last):\n' + ' File "...testtools...runtest.py", line ..., in _run_user\n' + ' return fn(*args, **kwargs)\n' + ' File "...testtools...testcase.py", line ..., in _run_test_method\n' + ' return self._get_test_method()()\n' + ' File "...testtools...tests...test_testresult.py", line ..., in error\n' + ' 1/0\n' + 'ZeroDivisionError: ...\n', + doctest.ELLIPSIS | doctest.REPORT_UDIFF)) + + def test_traceback_formatting_with_stack_hidden(self): + result = self.makeResult() + test = make_erroring_test() + run_with_stack_hidden(True, test.run, result) + self.assertThat( + result.errors[0][1], + DocTestMatches( + 'Traceback (most recent call last):\n' + ' File "...testtools...tests...test_testresult.py", line ..., in error\n' + ' 1/0\n' + 'ZeroDivisionError: ...\n', + doctest.ELLIPSIS)) -class TestMultiTestResult(TestWithFakeExceptions): - """Tests for `MultiTestResult`.""" +class TestMultiTestResult(TestCase): + """Tests for 'MultiTestResult'.""" def setUp(self): - TestWithFakeExceptions.setUp(self) + super(TestMultiTestResult, self).setUp() self.result1 = LoggingResult([]) self.result2 = LoggingResult([]) self.multiResult = MultiTestResult(self.result1, self.result2) @@ -391,14 +470,14 @@ class TestMultiTestResult(TestWithFakeExceptions): def test_addFailure(self): # Calling `addFailure` on a `MultiTestResult` calls `addFailure` on # all its `TestResult`s. - exc_info = self.makeExceptionInfo(AssertionError, 'failure') + exc_info = make_exception_info(AssertionError, 'failure') self.multiResult.addFailure(self, exc_info) self.assertResultLogsEqual([('addFailure', self, exc_info)]) def test_addError(self): # Calling `addError` on a `MultiTestResult` calls `addError` on all # its `TestResult`s. - exc_info = self.makeExceptionInfo(RuntimeError, 'error') + exc_info = make_exception_info(RuntimeError, 'error') self.multiResult.addError(self, exc_info) self.assertResultLogsEqual([('addError', self, exc_info)]) @@ -432,36 +511,12 @@ class TestMultiTestResult(TestWithFakeExceptions): class TestTextTestResult(TestCase): - """Tests for `TextTestResult`.""" + """Tests for 'TextTestResult'.""" def setUp(self): super(TestTextTestResult, self).setUp() self.result = TextTestResult(StringIO()) - def make_erroring_test(self): - class Test(TestCase): - def error(self): - 1/0 - return Test("error") - - def make_failing_test(self): - class Test(TestCase): - def failed(self): - self.fail("yo!") - return Test("failed") - - def make_unexpectedly_successful_test(self): - class Test(TestCase): - def succeeded(self): - self.expectFailure("yo!", lambda: None) - return Test("succeeded") - - def make_test(self): - class Test(TestCase): - def test(self): - pass - return Test("test") - def getvalue(self): return self.result.stream.getvalue() @@ -477,7 +532,7 @@ class TestTextTestResult(TestCase): self.assertEqual("Tests running...\n", self.getvalue()) def test_stopTestRun_count_many(self): - test = self.make_test() + test = make_test() self.result.startTestRun() self.result.startTest(test) self.result.stopTest(test) @@ -486,27 +541,27 @@ class TestTextTestResult(TestCase): self.result.stream = StringIO() self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("Ran 2 tests in ...s\n...", doctest.ELLIPSIS)) + DocTestMatches("\nRan 2 tests in ...s\n...", doctest.ELLIPSIS)) def test_stopTestRun_count_single(self): - test = self.make_test() + test = make_test() self.result.startTestRun() self.result.startTest(test) self.result.stopTest(test) self.reset_output() self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("Ran 1 test in ...s\n\nOK\n", doctest.ELLIPSIS)) + DocTestMatches("\nRan 1 test in ...s\nOK\n", doctest.ELLIPSIS)) def test_stopTestRun_count_zero(self): self.result.startTestRun() self.reset_output() self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("Ran 0 tests in ...s\n\nOK\n", doctest.ELLIPSIS)) + DocTestMatches("\nRan 0 tests in ...s\nOK\n", doctest.ELLIPSIS)) def test_stopTestRun_current_time(self): - test = self.make_test() + test = make_test() now = datetime.datetime.now(utc) self.result.time(now) self.result.startTestRun() @@ -523,79 +578,67 @@ class TestTextTestResult(TestCase): self.result.startTestRun() self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("...\n\nOK\n", doctest.ELLIPSIS)) + DocTestMatches("...\nOK\n", doctest.ELLIPSIS)) def test_stopTestRun_not_successful_failure(self): - test = self.make_failing_test() + test = make_failing_test() self.result.startTestRun() test.run(self.result) self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("...\n\nFAILED (failures=1)\n", doctest.ELLIPSIS)) + DocTestMatches("...\nFAILED (failures=1)\n", doctest.ELLIPSIS)) def test_stopTestRun_not_successful_error(self): - test = self.make_erroring_test() + test = make_erroring_test() self.result.startTestRun() test.run(self.result) self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("...\n\nFAILED (failures=1)\n", doctest.ELLIPSIS)) + DocTestMatches("...\nFAILED (failures=1)\n", doctest.ELLIPSIS)) def test_stopTestRun_not_successful_unexpected_success(self): - test = self.make_unexpectedly_successful_test() + test = make_unexpectedly_successful_test() self.result.startTestRun() test.run(self.result) self.result.stopTestRun() self.assertThat(self.getvalue(), - DocTestMatches("...\n\nFAILED (failures=1)\n", doctest.ELLIPSIS)) + DocTestMatches("...\nFAILED (failures=1)\n", doctest.ELLIPSIS)) def test_stopTestRun_shows_details(self): - self.result.startTestRun() - self.make_erroring_test().run(self.result) - self.make_unexpectedly_successful_test().run(self.result) - self.make_failing_test().run(self.result) - self.reset_output() - self.result.stopTestRun() + def run_tests(): + self.result.startTestRun() + make_erroring_test().run(self.result) + make_unexpectedly_successful_test().run(self.result) + make_failing_test().run(self.result) + self.reset_output() + self.result.stopTestRun() + run_with_stack_hidden(True, run_tests) self.assertThat(self.getvalue(), DocTestMatches("""...====================================================================== ERROR: testtools.tests.test_testresult.Test.error ---------------------------------------------------------------------- -Text attachment: traceback ------------- Traceback (most recent call last): - File "...testtools...runtest.py", line ..., in _run_user... - return fn(*args, **kwargs) - File "...testtools...testcase.py", line ..., in _run_test_method - return self._get_test_method()() File "...testtools...tests...test_testresult.py", line ..., in error 1/0 ZeroDivisionError:... divi... by zero... ------------- ====================================================================== FAIL: testtools.tests.test_testresult.Test.failed ---------------------------------------------------------------------- -Text attachment: traceback ------------- Traceback (most recent call last): - File "...testtools...runtest.py", line ..., in _run_user... - return fn(*args, **kwargs) - File "...testtools...testcase.py", line ..., in _run_test_method - return self._get_test_method()() File "...testtools...tests...test_testresult.py", line ..., in failed self.fail("yo!") AssertionError: yo! ------------- ====================================================================== UNEXPECTED SUCCESS: testtools.tests.test_testresult.Test.succeeded ---------------------------------------------------------------------- ...""", doctest.ELLIPSIS | doctest.REPORT_NDIFF)) -class TestThreadSafeForwardingResult(TestWithFakeExceptions): +class TestThreadSafeForwardingResult(TestCase): """Tests for `TestThreadSafeForwardingResult`.""" def setUp(self): - TestWithFakeExceptions.setUp(self) + super(TestThreadSafeForwardingResult, self).setUp() self.result_semaphore = threading.Semaphore(1) self.target = LoggingResult([]) self.result1 = ThreadsafeForwardingResult(self.target, @@ -624,14 +667,14 @@ class TestThreadSafeForwardingResult(TestWithFakeExceptions): def test_forwarding_methods(self): # error, failure, skip and success are forwarded in batches. - exc_info1 = self.makeExceptionInfo(RuntimeError, 'error') + exc_info1 = make_exception_info(RuntimeError, 'error') starttime1 = datetime.datetime.utcfromtimestamp(1.489) endtime1 = datetime.datetime.utcfromtimestamp(51.476) self.result1.time(starttime1) self.result1.startTest(self) self.result1.time(endtime1) self.result1.addError(self, exc_info1) - exc_info2 = self.makeExceptionInfo(AssertionError, 'failure') + exc_info2 = make_exception_info(AssertionError, 'failure') starttime2 = datetime.datetime.utcfromtimestamp(2.489) endtime2 = datetime.datetime.utcfromtimestamp(3.476) self.result1.time(starttime2) @@ -708,10 +751,19 @@ class TestExtendedToOriginalResultDecoratorBase(TestCase): details = {'text 1': Content(ContentType('text', 'plain'), text1), 'text 2': Content(ContentType('text', 'strange'), text2), 'bin 1': Content(ContentType('application', 'binary'), bin1)} - return (details, "Binary content: bin 1\n" - "Text attachment: text 1\n------------\n1\n2\n" - "------------\nText attachment: text 2\n------------\n" - "3\n4\n------------\n") + return (details, + ("Binary content:\n" + " bin 1 (application/binary)\n" + "\n" + "text 1: {{{\n" + "1\n" + "2\n" + "}}}\n" + "\n" + "text 2: {{{\n" + "3\n" + "4\n" + "}}}\n")) def check_outcome_details_to_exec_info(self, outcome, expected=None): """Call an outcome with a details dict to be made into exc_info.""" @@ -1369,6 +1421,87 @@ class TestNonAsciiResultsWithUnittest(TestNonAsciiResults): return text.encode("utf-8") +class TestDetailsToStr(TestCase): + + def test_no_details(self): + string = _details_to_str({}) + self.assertThat(string, Equals('')) + + def test_binary_content(self): + content = content_from_stream( + StringIO('foo'), content_type=ContentType('image', 'jpeg')) + string = _details_to_str({'attachment': content}) + self.assertThat( + string, Equals("""\ +Binary content: + attachment (image/jpeg) +""")) + + def test_single_line_content(self): + content = text_content('foo') + string = _details_to_str({'attachment': content}) + self.assertThat(string, Equals('attachment: {{{foo}}}\n')) + + def test_multi_line_text_content(self): + content = text_content('foo\nbar\nbaz') + string = _details_to_str({'attachment': content}) + self.assertThat(string, Equals('attachment: {{{\nfoo\nbar\nbaz\n}}}\n')) + + def test_special_text_content(self): + content = text_content('foo') + string = _details_to_str({'attachment': content}, special='attachment') + self.assertThat(string, Equals('foo\n')) + + def test_multiple_text_content(self): + string = _details_to_str( + {'attachment': text_content('foo\nfoo'), + 'attachment-1': text_content('bar\nbar')}) + self.assertThat( + string, Equals('attachment: {{{\n' + 'foo\n' + 'foo\n' + '}}}\n' + '\n' + 'attachment-1: {{{\n' + 'bar\n' + 'bar\n' + '}}}\n')) + + def test_empty_attachment(self): + string = _details_to_str({'attachment': text_content('')}) + self.assertThat( + string, Equals("""\ +Empty attachments: + attachment +""")) + + def test_lots_of_different_attachments(self): + jpg = lambda x: content_from_stream( + StringIO(x), ContentType('image', 'jpeg')) + attachments = { + 'attachment': text_content('foo'), + 'attachment-1': text_content('traceback'), + 'attachment-2': jpg('pic1'), + 'attachment-3': text_content('bar'), + 'attachment-4': text_content(''), + 'attachment-5': jpg('pic2'), + } + string = _details_to_str(attachments, special='attachment-1') + self.assertThat( + string, Equals("""\ +Binary content: + attachment-2 (image/jpeg) + attachment-5 (image/jpeg) +Empty attachments: + attachment-4 + +attachment: {{{foo}}} +attachment-3: {{{bar}}} + +traceback +""")) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_testsuite.py b/lib/testtools/testtools/tests/test_testsuite.py index eeb8fd2811..05647577cd 100644 --- a/lib/testtools/testtools/tests/test_testsuite.py +++ b/lib/testtools/testtools/tests/test_testsuite.py @@ -1,10 +1,9 @@ -# Copyright (c) 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """Test ConcurrentTestSuite and related things.""" __metaclass__ = type -import datetime import unittest from testtools import ( @@ -12,11 +11,12 @@ from testtools import ( iterate_tests, TestCase, ) -from testtools.matchers import ( - Equals, - ) +from testtools.helpers import try_import +from testtools.testsuite import FixtureSuite from testtools.tests.helpers import LoggingResult +FunctionFixture = try_import('fixtures.FunctionFixture') + class TestConcurrentTestSuiteRun(TestCase): @@ -48,6 +48,28 @@ class TestConcurrentTestSuiteRun(TestCase): return tests[0], tests[1] +class TestFixtureSuite(TestCase): + + def setUp(self): + super(TestFixtureSuite, self).setUp() + if FunctionFixture is None: + self.skip("Need fixtures") + + def test_fixture_suite(self): + log = [] + class Sample(TestCase): + def test_one(self): + log.append(1) + def test_two(self): + log.append(2) + fixture = FunctionFixture( + lambda: log.append('setUp'), + lambda fixture: log.append('tearDown')) + suite = FixtureSuite(fixture, [Sample('test_one'), Sample('test_two')]) + suite.run(LoggingResult([])) + self.assertEqual(['setUp', 1, 2, 'tearDown'], log) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/lib/testtools/testtools/tests/test_with_with.py b/lib/testtools/testtools/tests/test_with_with.py new file mode 100644 index 0000000000..e06adeb181 --- /dev/null +++ b/lib/testtools/testtools/tests/test_with_with.py @@ -0,0 +1,73 @@ +# Copyright (c) 2011 testtools developers. See LICENSE for details. + +from __future__ import with_statement + +import sys + +from testtools import ( + ExpectedException, + TestCase, + ) +from testtools.matchers import ( + AfterPreprocessing, + Equals, + ) + + +class TestExpectedException(TestCase): + """Test the ExpectedException context manager.""" + + def test_pass_on_raise(self): + with ExpectedException(ValueError, 'tes.'): + raise ValueError('test') + + def test_pass_on_raise_matcher(self): + with ExpectedException( + ValueError, AfterPreprocessing(str, Equals('test'))): + raise ValueError('test') + + def test_raise_on_text_mismatch(self): + try: + with ExpectedException(ValueError, 'tes.'): + raise ValueError('mismatch') + except AssertionError: + e = sys.exc_info()[1] + self.assertEqual("'mismatch' does not match /tes./", str(e)) + else: + self.fail('AssertionError not raised.') + + def test_raise_on_general_mismatch(self): + matcher = AfterPreprocessing(str, Equals('test')) + value_error = ValueError('mismatch') + try: + with ExpectedException(ValueError, matcher): + raise value_error + except AssertionError: + e = sys.exc_info()[1] + self.assertEqual(matcher.match(value_error).describe(), str(e)) + else: + self.fail('AssertionError not raised.') + + def test_raise_on_error_mismatch(self): + try: + with ExpectedException(TypeError, 'tes.'): + raise ValueError('mismatch') + except ValueError: + e = sys.exc_info()[1] + self.assertEqual('mismatch', str(e)) + else: + self.fail('ValueError not raised.') + + def test_raise_if_no_exception(self): + try: + with ExpectedException(TypeError, 'tes.'): + pass + except AssertionError: + e = sys.exc_info()[1] + self.assertEqual('TypeError not raised.', str(e)) + else: + self.fail('AssertionError not raised.') + + def test_pass_on_raise_any_message(self): + with ExpectedException(ValueError): + raise ValueError('whatever') diff --git a/lib/testtools/testtools/testsuite.py b/lib/testtools/testtools/testsuite.py index fd802621e3..18de8b89e1 100644 --- a/lib/testtools/testtools/testsuite.py +++ b/lib/testtools/testtools/testsuite.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009 Jonathan M. Lange. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """Test suites and related things.""" @@ -8,10 +8,10 @@ __all__ = [ 'iterate_tests', ] -try: - from Queue import Queue -except ImportError: - from queue import Queue +from testtools.helpers import try_imports + +Queue = try_imports(['Queue.Queue', 'queue.Queue']) + import threading import unittest @@ -85,3 +85,17 @@ class ConcurrentTestSuite(unittest.TestSuite): test.run(process_result) finally: queue.put(test) + + +class FixtureSuite(unittest.TestSuite): + + def __init__(self, fixture, tests): + super(FixtureSuite, self).__init__(tests) + self._fixture = fixture + + def run(self, result): + self._fixture.setUp() + try: + super(FixtureSuite, self).run(result) + finally: + self._fixture.cleanUp() |