3 GAEUnit: Google App Engine Unit Test Framework
7 1. Put gaeunit.py into your application directory. Modify 'app.yaml' by
8 adding the following mapping below the 'handlers:' section:
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 ------------------------------------------------------------------------------
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"
59 __url__
= "http://code.google.com/p/gaeunit"
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
):
83 unknown_args
= [arg
for arg
in self
.request
.arguments()
84 if arg
not in ("format", "package", "name")]
85 if len(unknown_args
) > 0:
87 for arg
in unknown_args
:
88 errors
.append(_log_error("The request parameter '%s' is not valid." % arg
))
90 self
.response
.out
.write(" ".join(errors
))
93 format
= self
.request
.get("format", "html")
96 elif format
== "plain":
99 error
= _log_error("The format '%s' is not valid." % format
)
101 self
.response
.out
.write(error
)
103 def _render_html(self
):
104 suite
, error
= _create_suite(self
.request
)
106 self
.response
.out
.write(_MAIN_PAGE_CONTENT
% _test_suite_to_json(suite
))
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
)
116 self
.response
.out
.write("====================\n" \
117 "GAEUnit Test Results\n" \
118 "====================\n\n")
119 _run_test_suite(runner
, suite
)
122 self
.response
.out
.write(error
)
125 ##############################################################################
127 ##############################################################################
130 class JsonTestResult(unittest
.TestResult
):
132 unittest
.TestResult
.__init
__(self
)
135 def render_to(self
, stream
):
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
)
144 def _render_errors(self
, stream
):
146 self
._render
_error
_list
('errors', self
.errors
, stream
)
148 self
._render
_error
_list
('failures', self
.failures
, stream
)
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
)))
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
)
169 class JsonTestRunner
:
171 self
.result
= JsonTestResult()
172 self
.result
.testNumber
= test
.countTestCases()
173 startTime
= time
.time()
175 stopTime
= time
.time()
176 timeTaken
= stopTime
- startTime
180 class JsonTestRunHandler(webapp
.RequestHandler
):
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
):
193 suite
, error
= _create_suite(self
.request
)
195 self
.response
.out
.write(_test_suite_to_json(suite
))
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
))
219 _load_default_test_modules()
220 suite
.addTest(loader
.loadTestsFromName(test_name
))
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
)))
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
))
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
):
248 if isinstance(test
, unittest
.TestSuite
):
249 _get_tests_from_suite(test
, tests
)
254 def _test_suite_to_json(suite
):
256 _get_tests_from_suite(suite
, tests
)
257 test_tuples
= [(type(test
).__module
__, type(test
).__name
__, test
._testMethodName
) \
260 for test_tuple
in test_tuples
:
261 module_name
, class_name
, method_name
= test_tuple
262 if module_name
not in test_dict
:
265 method_list
.append(method_name
)
266 mod_dict
[class_name
] = method_list
267 test_dict
[module_name
] = mod_dict
269 mod_dict
= test_dict
[module_name
]
270 if class_name
not in mod_dict
:
272 method_list
.append(method_name
)
273 mod_dict
[class_name
] = method_list
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.
291 original_apiproxy
= apiproxy_stub_map
.apiproxy
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
))
301 apiproxy_stub_map
.apiproxy
= original_apiproxy
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
= """
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}
330 <script language="javascript" type="text/javascript">
331 var testsToRun = eval("(" + "%s" + ")"); // JSON-formatted (see _test_suite_to_json)
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");
344 function requestTestRun(moduleName, className, methodName) {
345 var methodSuffix = "";
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) {
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) {
368 var errors = result.details.errors;
369 var failures = result.details.failures;
371 for(var i=0; i<errors.length; i++) {
372 details += '<p><div class="error"><div class="errtitle">ERROR ' +
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 ' +
380 '</div><div class="errdetail"><pre>' +
382 '</pre></div></div></p>';
384 var errorArea = document.getElementById("errorarea");
385 errorArea.innerHTML += details;
387 document.getElementById("errorarea").innerHTML = xmlHttp.responseText;
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).
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++) {
416 var methodName = methods[i];
417 requestTestRun(moduleName, className, methodName);
421 document.getElementById("testtotal").innerHTML = totalTests;
425 <title>GAEUnit: Google App Engine Unit Test Framework</title>
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>
432 <div id="resultarea">
433 <table id="results"><tbody>
434 <tr><td colspan="3"><div id="testindicator"> </div></td</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>
442 <div id="errorarea"></div>
443 <div id="footerarea">
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.
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>
460 ##############################################################################
461 # Script setup and execution
462 ##############################################################################
465 application
= webapp
.WSGIApplication([('/test', MainTestPageHandler
),
466 ('/testrun', JsonTestRunHandler
),
467 ('/testlist', JsonTestListHandler
)],
471 if os
.environ
['SERVER_SOFTWARE'].startswith('Development'):
472 run_wsgi_app(application
)
474 print 'Status: 404 Not Found'
475 print 'Content-Type: text/plain'
480 if __name__
== '__main__':