Skip to content

Instantly share code, notes, and snippets.

@jml
Last active February 11, 2016 17:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jml/801103efdf65ee9922fe to your computer and use it in GitHub Desktop.
Save jml/801103efdf65ee9922fe to your computer and use it in GitHub Desktop.
Class decorator instead of base TestCase

Some notes on a different approach to xUnit style tests. I don't necessarily want to implement it—I don't quite have the motive or opportunity—but I think it interesting enough to share.

Instead of telling users to subclass testtools.TestCase, we would tell them to decorate. Tests would look like:

@test_cases()
class MyTests(object):
    def setUp(self):
        # no upcall
        whatever()
    def test_foo(self):
        assert 1 == 1
    def tearDown(self):
        destroy_things()

@test_cases() would adapt the suite to an object that implements only the bare bones of TestCase: run(), id(), countTestCases(), etc1.

The actual test classes (MyTests) would have absolutely _nothing inserted into their namespace: no assertions; no fixtures; no details; no methods to upcall. Maybe we'd have an explicit interface that they would have to conform to, perhaps implemented by a purely abstract base class.

@test_cases() would only be responsible for the strategy that translates user code into a runnable TestCase. That is, it would know about setUp and tearDown, and it would know how to translate raised exceptions into various calls on a TestResult2.

We could do this, we have the technology.

We might even want to go further, and start with something that adapts functions to runnable test cases and then build the richer strategy in terms of that. That could be quite fun.

If we wanted to provide users with the benefits of details and assertions, we could build something on top of the basic @test_cases that passes a fixture and a detailed into each method, e.g.:

@enriched_test_cases(fixtures=True, details=True)
class MyTests(object):
    def setUp(self, fixture, details):
        fixture.useFixture(TempDir())
    def test_something(self, fixture, details):
        # ...

I think this part needs a lot of experimentation, but I think those experiments should be done building on top of the minimal one. My hunch is that it will lead to simpler, more composable code.

We wouldn't need to provide assertions. assert_that and expect_that just need to take a detailed parameter in order to have full functionality.

Under this regime, RunTest as interface would melt away. The awkward constructor interface would be replaced by whatever parameters @test_cases provides and the run() interface would no longer be needed. Much of the code of RunTest would be moved into the adapter that @test_cases uses, but maybe the hierarchy of method calls could be flattened, since the adapter wouldn't be designed to be inherited from.

Importantly, the relationship would be unidirectional: @test_cases calls to the actual test case; the test case doesn't know about @test_cases at all.

DeferredRunTest and friends would likewise become @deferred_test_cases or something. They wouldn't be obligated to share code with the synchronous version, but we'd be free to factor out common code.

Good acceptance tests for this would be whether something like @flaky decorator support <https://github.com/ClusterHQ/flocker/blob/master/flocker/testtools/_flaky.py#L129> could be written using only public methods but still work with distinct underlying runners, whether Hypothesis works, whether we could build on this approach to implement something like testscenarios, and whether we could move all of the code in testtools.TestCase out into multiple smaller components.

Other quick thoughts:

  • We'd have to do some dancing around discovery. I looked briefly at this and it looks possible.
  • I have no idea how this would work with backwards compatibility. That's partly why I haven't tried seriously to write this as code.
  • I think this would be easier to do with explicit interfaces
  • Benefits for this plan are mainly implementation-side. End-user benefit is that they have less magic in their tests and a perhaps a vague feeling of being more lightweight. Possibly that a compositional approach makes it easier to build cool stuff for their tests.

Just putting it out there. Thoughts & questions welcome, but please try to be gentle.


  1. https://github.com/testing-cabal/testtools/pull/178/files#diff-4372c1c31a1c37a51a147d74f52e157fR30

  2. In my ideal world, it would translate them to a single value (perhaps an algebraic data type) that then gets translated into calls on TestResult

@jml
Copy link
Author

jml commented Feb 8, 2016

ITestCaseStrategy documents what RunTest currently expects of a TestCase implementation. The relationship is mostly unidirectional, but it has some non-obvious interactions:

  • TestCase constructs RunTest
  • TestCase.run calls RunTest.run
  • RunTest.run calls methods on TestCase
  • Methods on TestCase call TestCase.expectThat
  • expectThat sets force_failure on TestCase
  • RunTest checks for force_failure at the end of a test run

@rbtcollins
Copy link

https://rbtcollins.wordpress.com/2010/05/10/maintainable-pyunit-test-suites/ and https://rbtcollins.wordpress.com/2010/09/18/maintainable-pyunit-test-suites-fixtures/ are relevant bits of thinking here.

From a de novo perspective, I think having the three interfaces not mangled together would be great.

However there's decades of prior art now in the Python space, so we'd need to be super careful to be clean and clear and comprehensible. Its also getting pretty close to what py.test does, and perhaps adopting py.test would be the thing to do? Apparently it is less evil than it was :- though I don't entirely believe that :)

I don't believe it can be done backwards compatibly, but I may be being overly pessimistic - its not a reason not to try.

I'd like though, if we try, to not mutate what we have: do an expand-contract within the testtools codebase, and where we can share, we share; where we cannot, we don't, and eventually we delete all the bits that ended up unsharable / old style. (e.g. in 10 years).

I haven't done a line by line grok of your text yet, just gone off of the basic abstract.

@jml
Copy link
Author

jml commented Feb 11, 2016

Thanks. I agree regarding the logistics: expand; share; eventually delete. I'm not sure I have the heart for it.

py.test is still intensely magical, but I am seriously thinking of giving in and going with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment