Introduction
Writing unit and integration tests with an asycronous framework like Twisted can be quite challenging. This page presents some code and references to get you started.
The basic idea is that of test driven development; namely that writing the code and its test should be done at the same time. However, when the code is Twisted, it gets more complicated and testing is tricker. This attempts to explain how to write tests for protocols, services, factories and clients using the facilites in Trial and Twisted.
References
- Test-driven development with Twisted is a good starting point. Read it closely, and learn the StringIO null transport method. See also this used-to-work-code for an example I wrote using StringIO.
- Networking with Twisted is a large presentation we'll be using. Pages 60 and 61; more on this below.
- This wiki page talks about components, protocols, adapters, services and applications. These are Twisted concepts explained in the finger tutorial.
- Trial API documentation
- Buildbot docs on Trial
Requirements and imports
For most of these tests, these imports are required:
1 2 3 4 5 6 7 8 9 | from twisted.trial import unittest from twisted.internet import reactor from twisted.internet.defer import inlineCallbacks, DeferredQueue import logging from ooici.protocol import ApplicationProtocol, Message from magnet.preactor import Preactor from magnet.protocol import ClientCreator as MCC |
Testing a protocol
When I first wrote the attribute store, I factored the code into protocol and factory. The protocol was actually protocol plus service, so the StringIO tests worked well. You could do something nice and compact like
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | class ASProtoTestCase(unittest.TestCase): def setUp(self): logging.basicConfig(level=logging.ERROR, \ format='%(asctime)s %(levelname)s [%(funcName)s] %(message)s') factory = ASFactory() self.proto = factory.buildProtocol(('127.0.0.1', 0)) self.tr = proto_helpers.StringTransport() self.proto.makeConnection(self.tr) def tearDown(self): self.tr.loseConnection() def _test(self, op, key, value, expectedRC=200): # Clear any old data in the pipe self.tr.clear() # Push into fake-net and see if the answer is as expected self.proto.dataReceived('%s %s %s\r\n' % (op, key, value)) # First chunk should be http return code rc = int(self.tr.value().split()[0]) self.assertEquals(rc, expectedRC) def test_write(self): return self._test('write', uuid.uuid4(), uuid.uuid4()) |
and have a single-line test. However, once you separate protocol from service, this fails because your protocol will reference
1 | self.factory.function |
which is undefined until instantiated via the factory. However, if you have a simpler program where the protocol does the work, please see this code.
Testing a protocol+service+factory
In this case, we instantiate a server object, and replace its sendLine method, with the effect that we exercise the factory, service and protocol, but not the transport or layers below it. (e.g. Magnet, AMQP)
Here's the code from the presentation:
1 2 3 4 5 6 7 8 9 10 | from twisted.internet import unittest class ServerTestCase(unittest.TestCase): def testServer(self): l = []; f = PresenceFactory() p = f.buildProtocol(None) p.sendLine = l.append p.connectionMade() p.lineReceived('ISONLINE foo') self.assertEquals(l[-1], 'ERROR') |
The main trick here is to instantiate the factory (which invokes the component registry, service and protocol) then manually invoke the steps in a real connection.
The other cute trick is this line:
1 | p.sendLine = l.append |
This reaches into the object and replaces its sendLine method with a hook to append our local array. So when we send it data, it replies straight into our local array!
You can see this used here, in the test_server_only method:
1 2 3 4 5 6 7 8 9 10 11 12 | l = [] logging.debug('Creating server') preactor = yield Preactor() serv = AttributeStoreService() factory = IASFactory(serv) p = factory.buildProtocol(None) p.sendLine = l.append p.connectionMade() # Start of read/write tests p.lineReceived('read trial:%s' % uuid.uuid4()) self.assertEquals(l[-1], '404 Key not found') |
Testing just the factory
In this case, I'm using the factory to maintain persistent data between connections (though I should perhaps add a service and do it there!), so I want to instantiate the factory sans protocol, connection or transport:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class PSTest(unittest.TestCase): def setUp(self): logging.basicConfig(level=logging.INFO, \ format='%(asctime)s %(levelname)s [%(funcName)s] %(message)s') self.f = PSFactory() self.f._init_listeners() def tearDown(self): pass def test_sub_once(self): self.f.set_listener('http://.+?', 'meee') rc = self.f.get_listeners('http://foo.bar.com/') self.failUnless(len(rc) > 0) self.failUnlessEqual(rc[0], 'meee') |
Works fine. No Preactor or reactor calls.
Testing client and server together
This is from the next page, where he shows how to create the server and its client, and then interact via the client API. Note that this adds the transport and client code, making it a full integration test. Here's a snippet from my code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | logging.debug('Creating server') preactor = yield Preactor() serv = AttributeStoreService() factory = IASFactory(serv) preactor.listenMS(routingKey, factory) logging.debug('Creating client') # Now create a client to match cc = MCC(reactor, preactor, ASClient) asc = yield cc.connectMS(routingKey) # Ready to operate the system now # Start with a known-404 key ans = yield asc.read('trial:%s' % uuid.uuid4()) reply = yield ans rc, body = parseMsg(reply) self.failUnlessEqual(rc, 404) |
Here, you're creating the client and server as you would normally, running both in the reactor. See the test_client_server routine linked above for more tests.
Note that, as of 9/17/09, you still have to call preactor.stop() at the end of the test. This may change as Magnet evolves.
On-disk organization
Before 9/18/09, I was putting a test directory in the top level of the module. After reading the buildbot docs, I learned that it should be underneath the code directory. For the attribute store, here's how it looks:
base dir/attribute_store/test/
That way, you can invoke the tests like a namespace:
cd base_dir trial attribute_store.test
which will automatically run all the tests.
Skipping tests
Sometimes you want to temporarily skip a failing test, perhaps because you know the code is broken and the errors are driving you mad. Here's how:
1 2 3 4 | from twisted.trial import unittest def test_fail(self): raise unittest.SkipTest('Broken right now due to Foo.bar') |
this has the benefit of noting the skip in the test report, so that it's not forgotten:
=============================================================================== [SKIPPED]: attribute_store.test.test_service.ServiceTestCase.test_service known-broken ------------------------------------------------------------------------------- Ran 5 tests in 0.607s PASSED (skips=1, successes=4)
Setting timeouts
By default, Twisted will wait 120 seconds for a test to complete. This is often too long for most code, and too short for big tests. To change it, add a call in your setUp method:
1 2 | def setUp(self): self.timeout = 60 |
Parameter is seconds.
Waiting for results
In Twisted, calling sleep brings the reactor to a halt, but sometimes you need to wait for an external event or process to complete inside of a test. If you look at the test_service_with_dq test in the attribute store, you'll see the use of DeferredQueue for this purpose. The components' sendLine method is replaced by the queues' put method, and then you yield on get. Very cute. Here's the code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | serv = AttributeStoreService() yield serv._connect() factory = IASFactory(serv) proto = factory.buildProtocol(None) # Output should get dropped into DQ... dq = DeferredQueue() proto.sendLine = dq.put key = uuid.uuid4() value = 'baz' proto.lineReceived('write trial:%s %s' % (key, value)) logging.debug('Waiting for output...') msg = yield dq.get() logging.debug(msg) rc, body = self.parseMsg(msg) self.failUnlessEqual(rc, 200) |
Integration with buildbot
Once you've got working tests, the next step is to invoke them via our buildbot system. This is something to be determined; right now you have to edit the buildbot configuration directly. If you place your tests as explained above, we should be able to auto-invoke them via a single
trial {packagename}.test
and this will handle the addition or removal of tests without changing buildbot.