update to address some API deprecations
[gae-samples.git] / overheard / gaeunit.py
blob44ff542f05dd2d17b9c2a02f99a4a34c512d05b4
1 #!/usr/bin/env python
2 '''
3 GAEUnit: Google App Engine Unit Test Framework
5 Usage:
7 1. Put gaeunit.py into your application directory. Modify 'app.yaml' by
8 adding the following mapping below the 'handlers:' section:
10 - url: /test.*
11 script: gaeunit.py
13 2. Write your own test cases by extending unittest.TestCase.
15 3. Launch the development web server. To run all tests, point your browser to:
17 http://localhost:8080/test (Modify the port if necessary.)
19 For plain text output add '?format=plain' to the above URL.
20 See README.TXT for information on how to run specific tests.
22 4. The results are displayed as the tests are run.
24 Visit http://code.google.com/p/gaeunit for more information and updates.
26 ------------------------------------------------------------------------------
27 Copyright (c) 2008, George Lei and Steven R. Farley. All rights reserved.
29 Distributed under the following BSD license:
31 Redistribution and use in source and binary forms, with or without
32 modification, are permitted provided that the following conditions are met:
34 * Redistributions of source code must retain the above copyright notice,
35 this list of conditions and the following disclaimer.
37 * Redistributions in binary form must reproduce the above copyright notice,
38 this list of conditions and the following disclaimer in the documentation
39 and/or other materials provided with the distribution.
41 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
42 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
43 IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
44 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
45 FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
46 DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
47 SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
48 CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
49 OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
50 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
51 ------------------------------------------------------------------------------
52 '''
54 __author__ = "George Lei and Steven R. Farley"
55 __email__ = "George.Z.Lei@Gmail.com"
56 __version__ = "#Revision: 1.2.2 $"[11:-2]
57 __copyright__= "Copyright (c) 2008, George Lei and Steven R. Farley"
58 __license__ = "BSD"
59 __url__ = "http://code.google.com/p/gaeunit"
61 import sys
62 import os
63 import unittest
64 import StringIO
65 import time
66 import re
67 import logging
68 from google.appengine.ext import webapp
69 from google.appengine.api import apiproxy_stub_map
70 from google.appengine.api import datastore_file_stub
71 from google.appengine.ext.webapp.util import run_wsgi_app
73 _DEFAULT_TEST_DIR = 'test'
76 ##############################################################################
77 # Main request handler
78 ##############################################################################
81 class MainTestPageHandler(webapp.RequestHandler):
82 def get(self):
83 unknown_args = [arg for arg in self.request.arguments()
84 if arg not in ("format", "package", "name")]
85 if len(unknown_args) > 0:
86 errors = []
87 for arg in unknown_args:
88 errors.append(_log_error("The request parameter '%s' is not valid." % arg))
89 self.error(404)
90 self.response.out.write(" ".join(errors))
91 return
93 format = self.request.get("format", "html")
94 if format == "html":
95 self._render_html()
96 elif format == "plain":
97 self._render_plain()
98 else:
99 error = _log_error("The format '%s' is not valid." % format)
100 self.error(404)
101 self.response.out.write(error)
103 def _render_html(self):
104 suite, error = _create_suite(self.request)
105 if not error:
106 self.response.out.write(_MAIN_PAGE_CONTENT % _test_suite_to_json(suite))
107 else:
108 self.error(404)
109 self.response.out.write(error)
111 def _render_plain(self):
112 self.response.headers["Content-Type"] = "text/plain"
113 runner = unittest.TextTestRunner(self.response.out)
114 suite, error = _create_suite(self.request)
115 if not error:
116 self.response.out.write("====================\n" \
117 "GAEUnit Test Results\n" \
118 "====================\n\n")
119 _run_test_suite(runner, suite)
120 else:
121 self.error(404)
122 self.response.out.write(error)
125 ##############################################################################
126 # JSON test classes
127 ##############################################################################
130 class JsonTestResult(unittest.TestResult):
131 def __init__(self):
132 unittest.TestResult.__init__(self)
133 self.testNumber = 0
135 def render_to(self, stream):
136 stream.write('{')
137 stream.write('"runs":"%d", "total":"%d", "errors":"%d", "failures":"%d",' % \
138 (self.testsRun, self.testNumber,
139 len(self.errors), len(self.failures)))
140 stream.write('"details":')
141 self._render_errors(stream)
142 stream.write('}')
144 def _render_errors(self, stream):
145 stream.write('{')
146 self._render_error_list('errors', self.errors, stream)
147 stream.write(',')
148 self._render_error_list('failures', self.failures, stream)
149 stream.write('}')
151 def _render_error_list(self, flavour, errors, stream):
152 stream.write('"%s":[' % flavour)
153 for test, err in errors:
154 stream.write('{"desc":"%s", "detail":"%s"},' %
155 (self._description(test), self._escape(err)))
156 if len(errors):
157 stream.seek(-1, 2)
158 stream.write("]")
160 def _description(self, test):
161 return test.shortDescription() or str(test)
163 def _escape(self, s):
164 newstr = re.sub('"', '"', s)
165 newstr = re.sub('\n', '<br/>', newstr)
166 return newstr
169 class JsonTestRunner:
170 def run(self, test):
171 self.result = JsonTestResult()
172 self.result.testNumber = test.countTestCases()
173 startTime = time.time()
174 test(self.result)
175 stopTime = time.time()
176 timeTaken = stopTime - startTime
177 return self.result
180 class JsonTestRunHandler(webapp.RequestHandler):
181 def get(self):
182 test_name = self.request.get("name")
183 _load_default_test_modules()
184 suite = unittest.defaultTestLoader.loadTestsFromName(test_name)
185 runner = JsonTestRunner()
186 _run_test_suite(runner, suite)
187 runner.result.render_to(self.response.out)
190 # This is not used by the HTML page, but it may be useful for other client test runners.
191 class JsonTestListHandler(webapp.RequestHandler):
192 def get(self):
193 suite, error = _create_suite(self.request)
194 if not error:
195 self.response.out.write(_test_suite_to_json(suite))
196 else:
197 self.error(404)
198 self.response.out.write(error)
201 ##############################################################################
202 # Module helper functions
203 ##############################################################################
206 def _create_suite(request):
207 package_name = request.get("package")
208 test_name = request.get("name")
210 loader = unittest.defaultTestLoader
211 suite = unittest.TestSuite()
213 if not package_name and not test_name:
214 modules = _load_default_test_modules()
215 for module in modules:
216 suite.addTest(loader.loadTestsFromModule(module))
217 elif test_name:
218 try:
219 _load_default_test_modules()
220 suite.addTest(loader.loadTestsFromName(test_name))
221 except:
222 pass
223 elif package_name:
224 try:
225 package = reload(__import__(package_name))
226 module_names = package.__all__
227 for module_name in module_names:
228 suite.addTest(loader.loadTestsFromName('%s.%s' % (package_name, module_name)))
229 except:
230 pass
231 if suite.countTestCases() == 0:
232 error = _log_error("'%s' is not found or does not contain any tests." % \
233 (test_name or package_name))
234 else:
235 error = None
236 return (suite, error)
239 def _load_default_test_modules():
240 if not _DEFAULT_TEST_DIR in sys.path:
241 sys.path.append(_DEFAULT_TEST_DIR)
242 module_names = [mf[0:-3] for mf in os.listdir(_DEFAULT_TEST_DIR) if mf.endswith(".py")]
243 return [reload(__import__(name)) for name in module_names]
246 def _get_tests_from_suite(suite, tests):
247 for test in suite:
248 if isinstance(test, unittest.TestSuite):
249 _get_tests_from_suite(test, tests)
250 else:
251 tests.append(test)
254 def _test_suite_to_json(suite):
255 tests = []
256 _get_tests_from_suite(suite, tests)
257 test_tuples = [(type(test).__module__, type(test).__name__, test._testMethodName) \
258 for test in tests]
259 test_dict = {}
260 for test_tuple in test_tuples:
261 module_name, class_name, method_name = test_tuple
262 if module_name not in test_dict:
263 mod_dict = {}
264 method_list = []
265 method_list.append(method_name)
266 mod_dict[class_name] = method_list
267 test_dict[module_name] = mod_dict
268 else:
269 mod_dict = test_dict[module_name]
270 if class_name not in mod_dict:
271 method_list = []
272 method_list.append(method_name)
273 mod_dict[class_name] = method_list
274 else:
275 method_list = mod_dict[class_name]
276 method_list.append(method_name)
278 # Python's dictionary and list string representations happen to match JSON formatting.
279 return str(test_dict)
282 def _run_test_suite(runner, suite):
283 """Run the test suite.
285 Preserve the current development apiproxy, create a new apiproxy and
286 replace the datastore with a temporary one that will be used for this
287 test suite, run the test suite, and restore the development apiproxy.
288 This isolates the test datastore from the development datastore.
290 """
291 original_apiproxy = apiproxy_stub_map.apiproxy
292 try:
293 apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
294 temp_stub = datastore_file_stub.DatastoreFileStub('GAEUnitDataStore', None, None)
295 apiproxy_stub_map.apiproxy.RegisterStub('datastore', temp_stub)
296 # Allow the other services to be used as-is for tests.
297 for name in ['user', 'urlfetch', 'mail', 'memcache', 'images']:
298 apiproxy_stub_map.apiproxy.RegisterStub(name, original_apiproxy.GetStub(name))
299 runner.run(suite)
300 finally:
301 apiproxy_stub_map.apiproxy = original_apiproxy
304 def _log_error(s):
305 logging.warn(s)
306 return s
309 ################################################
310 # Browser HTML, CSS, and Javascript
311 ################################################
314 # This string uses Python string formatting, so be sure to escape percents as %%.
315 _MAIN_PAGE_CONTENT = """
316 <html>
317 <head>
318 <style>
319 body {font-family:arial,sans-serif; text-align:center}
320 #title {font-family:"Times New Roman","Times Roman",TimesNR,times,serif; font-size:28px; font-weight:bold; text-align:center}
321 #version {font-size:87%%; text-align:center;}
322 #weblink {font-style:italic; text-align:center; padding-top:7px; padding-bottom:7px}
323 #results {padding-top:20px; margin:0pt auto; text-align:center; font-weight:bold}
324 #testindicator {width:750px; height:16px; border-style:solid; border-width:2px 1px 1px 2px; background-color:#f8f8f8;}
325 #footerarea {text-align:center; font-size:83%%; padding-top:25px}
326 #errorarea {padding-top:25px}
327 .error {border-color: #c3d9ff; border-style: solid; border-width: 2px 1px 2px 1px; width:750px; padding:1px; margin:0pt auto; text-align:left}
328 .errtitle {background-color:#c3d9ff; font-weight:bold}
329 </style>
330 <script language="javascript" type="text/javascript">
331 var testsToRun = eval("(" + "%s" + ")"); // JSON-formatted (see _test_suite_to_json)
332 var totalRuns = 0;
333 var totalErrors = 0;
334 var totalFailures = 0;
336 function newXmlHttp() {
337 try { return new XMLHttpRequest(); } catch(e) {}
338 try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e) {}
339 try { return new ActiveXObject("Microsoft.XMLHTTP"); } catch (e) {}
340 alert("XMLHttpRequest not supported");
341 return null;
344 function requestTestRun(moduleName, className, methodName) {
345 var methodSuffix = "";
346 if (methodName) {
347 methodSuffix = "." + methodName;
349 var xmlHttp = newXmlHttp();
350 xmlHttp.open("GET", "/testrun?name=" + moduleName + "." + className + methodSuffix, true);
351 xmlHttp.onreadystatechange = function() {
352 if (xmlHttp.readyState != 4) {
353 return;
355 if (xmlHttp.status == 200) {
356 var result = eval("(" + xmlHttp.responseText + ")");
357 totalRuns += parseInt(result.runs);
358 totalErrors += parseInt(result.errors);
359 totalFailures += parseInt(result.failures);
360 document.getElementById("testran").innerHTML = totalRuns;
361 document.getElementById("testerror").innerHTML = totalErrors;
362 document.getElementById("testfailure").innerHTML = totalFailures;
363 if (totalErrors == 0 && totalFailures == 0) {
364 testSucceed();
365 } else {
366 testFailed();
368 var errors = result.details.errors;
369 var failures = result.details.failures;
370 var details = "";
371 for(var i=0; i<errors.length; i++) {
372 details += '<p><div class="error"><div class="errtitle">ERROR ' +
373 errors[i].desc +
374 '</div><div class="errdetail"><pre>'+errors[i].detail +
375 '</pre></div></div></p>';
377 for(var i=0; i<failures.length; i++) {
378 details += '<p><div class="error"><div class="errtitle">FAILURE ' +
379 failures[i].desc +
380 '</div><div class="errdetail"><pre>' +
381 failures[i].detail +
382 '</pre></div></div></p>';
384 var errorArea = document.getElementById("errorarea");
385 errorArea.innerHTML += details;
386 } else {
387 document.getElementById("errorarea").innerHTML = xmlHttp.responseText;
388 testFailed();
391 xmlHttp.send(null);
394 function testFailed() {
395 document.getElementById("testindicator").style.backgroundColor="red";
398 function testSucceed() {
399 document.getElementById("testindicator").style.backgroundColor="green";
402 function runTests() {
403 // Run each test asynchronously (concurrently).
404 var totalTests = 0;
405 for (var moduleName in testsToRun) {
406 var classes = testsToRun[moduleName];
407 for (var className in classes) {
408 // TODO: Optimize for the case where tests are run by class so we don't
409 // have to always execute each method separately. This should be
410 // possible when we have a UI that allows the user to select tests
411 // by module, class, and method.
412 //requestTestRun(moduleName, className);
413 methods = classes[className];
414 for (var i = 0; i < methods.length; i++) {
415 totalTests += 1;
416 var methodName = methods[i];
417 requestTestRun(moduleName, className, methodName);
421 document.getElementById("testtotal").innerHTML = totalTests;
424 </script>
425 <title>GAEUnit: Google App Engine Unit Test Framework</title>
426 </head>
427 <body onload="runTests()">
428 <div id="headerarea">
429 <div id="title">GAEUnit: Google App Engine Unit Test Framework</div>
430 <div id="version">Version 1.2.4</div>
431 </div>
432 <div id="resultarea">
433 <table id="results"><tbody>
434 <tr><td colspan="3"><div id="testindicator"> </div></td</tr>
435 <tr>
436 <td>Runs: <span id="testran">0</span>/<span id="testtotal">0</span></td>
437 <td>Errors: <span id="testerror">0</span></td>
438 <td>Failures: <span id="testfailure">0</span></td>
439 </tr>
440 </tbody></table>
441 </div>
442 <div id="errorarea"></div>
443 <div id="footerarea">
444 <div id="weblink">
446 Please visit the <a href="http://code.google.com/p/gaeunit">project home page</a>
447 for the latest version or to report problems.
448 </p>
450 Copyright 2008 <a href="mailto:George.Z.Lei@Gmail.com">George Lei</a>
451 and <a href="mailto:srfarley@gmail.com>Steven R. Farley</a>
452 </p>
453 </div>
454 </div>
455 </body>
456 </html>
460 ##############################################################################
461 # Script setup and execution
462 ##############################################################################
465 application = webapp.WSGIApplication([('/test', MainTestPageHandler),
466 ('/testrun', JsonTestRunHandler),
467 ('/testlist', JsonTestListHandler)],
468 debug=True)
470 def main():
471 if os.environ['SERVER_SOFTWARE'].startswith('Development'):
472 run_wsgi_app(application)
473 else:
474 print 'Status: 404 Not Found'
475 print 'Content-Type: text/plain'
476 print ''
477 print 'Not Found'
478 return
480 if __name__ == '__main__':
481 main()