Motivation for rewriting unittest
Two weeks or so ago, I brought up my unittest redesign on the new testing-in-python mailing list. A number of people were upset that in redesigning unittest, I had rejected nose and py.test; Titus Brown even wrote a few blog posts on the subject, in particular taking me to task for ignoring nose.
I’ll be honest: when I started redesigning unittest, I did ignore nose and py.test. I remembered looking at them a long time ago, when I was first getting frustrated with unittest, casting around for a better, more flexible alternative. py.test has no support for extensions and depends on the rest of the py library, so that’s out. nose has plugins, but my general impression was that it’s just a nice test discovery tool; since that wasn’t what I was looking for, I didn’t care. Thinking that perhaps the project has changed significantly since the last time I looked at it, I took another, closer look at nose’s infrastructure. Verdict: it’s still a nice test discovery tool, but since that’s still not what I’m looking for, I still don’t care.
And now we will have a brief intermezzo, and I will explain exactly why I’m redesigning unittest.
First of all, I didn’t start off with the intention of rewriting the whole module. I began by trying to change the existing design so that it would be easier to compose extensions. So I poked and I tweaked and prodded and twisted unittest until it was unrecognizable, until I was left with something that resembled the old version in name only. That is to say: this didn’t start out as a rewrite — it just ended up that way.
Now, what do I mean when I say “composing extensions”? Yes, unittest as-shipped allows you to extend its functionality by way of subclassing this bit and that bit, but the problem comes when trying to mash two extensions together: you can’t. You can’t put your unittest extensions — say, one that does refcount checking for C extensions or one that writes test results to a database — up on PyPI and have people be able to mix and match to create just the right testing environment for their project.
This all has one major design implications for your testing framework: extensions must operate without knowing anything about what other extensions might be running. The framework has to be designed so that extensions can operate by themselves just as well as they do with 15 others.
nose doesn’t come anywhere close to supporting this.
(Note: the following is based on my best understanding of nose’s codebase and on conversations with others. If I’ve gotten anything wrong, please let me know and I’ll gladly retract it.)
“That’s crap,” you say, “nose has plugins!” Ha. nose plugins don’t come anywhere close to achieving this level of independence. If I want to add a plugin to allow tests to be marked as TODO, there’s no way for this new kind of test-status to make its way into the various reporting plugins. As far as I can tell, just to get TODO tests not to show up as failures in the default console output, I’d have to:
addError()so that it picks up the TODO-ness of the test.
_makeResult()so that it uses my TextTestResult subclass.
runTests()so that it uses my TextTestRunner subclass.
nose.core.run()with a function that uses my TestProgram subclass.Of course, by the time my plugin is running and trying to do all this subclassing/replacing malarkey,
nose.core.run()has already been called, so it’s too late.
By contrast, adding this kind of support to my unittest redesign is trivial. Omitting the TODO() decorator and exception classes (which you’d need for the nose version, too):
class TodoRunner(TestRunner): categories = ['todo pass', 'todo fail'] def handle_exception(self, test, exc_info): exc_type = exc_info if issubclass(exc_type, TodoPassed): self.log_exception('todo pass', test, exc_info) elif issubclass(exc_type, TodoFailed): self.log_exception('todo fail', test, exc_info) else: super(TodoRunner, self).handle_exception(test, exc_info) def was_successful(self): parent_success = super(TodoRunner, self).was_successful() return parent_success and not self.still_todo() def still_todo(self): return self.exceptions['todo pass'] or self.exceptions['todo fail'] def failure_label(self): if self.still_todo(): return 'TODO' return super(TodoRunner, self).failure_label()
With those lines of code, all output extensions — console, database, XML, etc — will automatically recognize TODO tests and treat them as such. No fuss, no muss.
Now, all this isn’t to say that nose is crap. What I said earlier is still true: nose is a good test discovery tool. I even hope to borrow some of its discovery strategies for the new design. What nose is not, however, is an ultra-flexible test environment framework where extensions can be shared easily and openly, and that’s what I’m going for.