.. highlight:: javascript Testing in OpenERP Web ====================== Javascript Unit Testing ----------------------- OpenERP Web 7.0 includes means to unit-test both the core code of OpenERP Web and your own javascript modules. On the javascript side, unit-testing is based on QUnit_ with a number of helpers and extensions for better integration with OpenERP. To see what the runner looks like, find (or start) an OpenERP server with the web client enabled, and navigate to ``/web/tests`` e.g. `on OpenERP's CI `_. This will show the runner selector, which lists all modules with javascript unit tests, and allows starting any of them (or all javascript tests in all modules at once). .. image:: ./images/runner.png :align: center Clicking any runner button will launch the corresponding tests in the bundled QUnit_ runner: .. image:: ./images/tests.png :align: center Writing a test case ------------------- The first step is to list the test file(s). This is done through the ``test`` key of the openerp manifest, by adding javascript files to it (next to the usual YAML files, if any): .. code-block:: python { 'name': "Demonstration of web/javascript tests", 'category': 'Hidden', 'depends': ['web'], 'test': ['static/test/demo.js'], } and to create the corresponding test file(s) .. note:: Test files which do not exist will be ignored, if all test files of a module are ignored (can not be found), the test runner will consider that the module has no javascript tests. After that, refreshing the runner selector will display the new module and allow running all of its (0 so far) tests: .. image:: ./images/runner2.png :align: center The next step is to create a test case:: openerp.testing.section('basic section', function (test) { test('my first test', function () { ok(false, "this test has run"); }); }); All testing helpers and structures live in the ``openerp.testing`` module. OpenERP tests live in a :js:func:`~openerp.testing.section`, which is itself part of a module. The first argument to a section is the name of the section, the second one is the section body. :js:func:`test `, provided by the :js:func:`~openerp.testing.section` to the callback, is used to register a given test case which will be run whenever the test runner actually does its job. OpenERP Web test case use standard `QUnit assertions`_ within them. Launching the test runner at this point will run the test and display the corresponding assertion message, with red colors indicating the test failed: .. image:: ./images/tests2.png :align: center Fixing the test (by replacing ``false`` to ``true`` in the assertion) will make it pass: .. image:: ./images/tests3.png :align: center Assertions ---------- As noted above, OpenERP Web's tests use `qunit assertions`_. They are available globally (so they can just be called without references to anything). The following list is available: .. js:function:: ok(state[, message]) checks that ``state`` is truthy (in the javascript sense) .. js:function:: strictEqual(actual, expected[, message]) checks that the actual (produced by a method being tested) and expected values are identical (roughly equivalent to ``ok(actual === expected, message)``) .. js:function:: notStrictEqual(actual, expected[, message]) checks that the actual and expected values are *not* identical (roughly equivalent to ``ok(actual !== expected, message)``) .. js:function:: deepEqual(actual, expected[, message]) deep comparison between actual and expected: recurse into containers (objects and arrays) to ensure that they have the same keys/number of elements, and the values match. .. js:function:: notDeepEqual(actual, expected[, message]) inverse operation to :js:func:`deepEqual` .. js:function:: throws(block[, expected][, message]) checks that, when called, the ``block`` throws an error. Optionally validates that error against ``expected``. :param Function block: :param expected: if a regexp, checks that the thrown error's message matches the regular expression. If an error type, checks that the thrown error is of that type. :type expected: Error | RegExp .. js:function:: equal(actual, expected[, message]) checks that ``actual`` and ``expected`` are loosely equal, using the ``==`` operator and its coercion rules. .. js:function:: notEqual(actual, expected[, message]) inverse operation to :js:func:`equal` Getting an OpenERP instance --------------------------- The OpenERP instance is the base through which most OpenERP Web modules behaviors (functions, objects, …) are accessed. As a result, the test framework automatically builds one, and loads the module being tested and all of its dependencies inside it. This new instance is provided as the first positional parameter to your test cases. Let's observe by adding javascript code (not test code) to the test module: .. code-block:: python { 'name': "Demonstration of web/javascript tests", 'category': 'Hidden', 'depends': ['web'], 'js': ['static/src/js/demo.js'], 'test': ['static/test/demo.js'], } :: // src/js/demo.js openerp.web_tests_demo = function (instance) { instance.web_tests_demo = { value_true: true, SomeType: instance.web.Class.extend({ init: function (value) { this.value = value; } }) }; }; and then adding a new test case, which simply checks that the ``instance`` contains all the expected stuff we created in the module:: // test/demo.js test('module content', function (instance) { ok(instance.web_tests_demo.value_true, "should have a true value"); var type_instance = new instance.web_tests_demo.SomeType(42); strictEqual(type_instance.value, 42, "should have provided value"); }); DOM Scratchpad -------------- As in the wider client, arbitrarily accessing document content is strongly discouraged during tests. But DOM access is still needed to e.g. fully initialize :js:class:`widgets <~openerp.web.Widget>` before testing them. Thus, a test case gets a DOM scratchpad as its second positional parameter, in a jQuery instance. That scratchpad is fully cleaned up before each test, and as long as it doesn't do anything outside the scratchpad your code can do whatever it wants:: // test/demo.js test('DOM content', function (instance, $scratchpad) { $scratchpad.html('
ok
'); ok($scratchpad.find('span').hasClass('foo'), "should have provided class"); }); test('clean scratchpad', function (instance, $scratchpad) { ok(!$scratchpad.children().length, "should have no content"); ok(!$scratchpad.text(), "should have no text"); }); .. note:: The top-level element of the scratchpad is not cleaned up, test cases can add text or DOM children but shoud not alter ``$scratchpad`` itself. Loading templates ----------------- To avoid the corresponding processing costs, by default templates are not loaded into QWeb. If you need to render e.g. widgets making use of QWeb templates, you can request their loading through the :js:attr:`~TestOptions.templates` option to the :js:func:`test case function `. This will automatically load all relevant templates in the instance's qweb before running the test case: .. code-block:: python { 'name': "Demonstration of web/javascript tests", 'category': 'Hidden', 'depends': ['web'], 'js': ['static/src/js/demo.js'], 'test': ['static/test/demo.js'], 'qweb': ['static/src/xml/demo.xml'], } .. code-block:: xml

:: // test/demo.js test('templates', {templates: true}, function (instance) { var s = instance.web.qweb.render('DemoTemplate'); var texts = $(s).find('p').map(function () { return $(this).text(); }).get(); deepEqual(texts, ['0', '1', '2', '3', '4']); }); Asynchronous cases ------------------ The test case examples so far are all synchronous, they execute from the first to the last line and once the last line has executed the test is done. But the web client is full of :doc:`asynchronous code `, and thus test cases need to be async-aware. This is done by returning a :js:class:`deferred ` from the case callback:: // test/demo.js test('asynchronous', { asserts: 1 }, function () { var d = $.Deferred(); setTimeout(function () { ok(true); d.resolve(); }, 100); return d; }); This example also uses the :js:class:`options parameter ` to specify the number of assertions the case should expect, if less or more assertions are specified the case will count as failed. Asynchronous test cases *must* specify the number of assertions they will run. This allows more easily catching situations where e.g. the test architecture was not warned about asynchronous operations. .. note:: Asynchronous test cases also have a 2 seconds timeout: if the test does not finish within 2 seconds, it will be considered failed. This pretty much always means the test will not resolve. This timeout *only* applies to the test itself, not to the setup and teardown processes. .. note:: If the returned deferred is rejected, the test will be failed unless :js:attr:`~TestOptions.fail_on_rejection` is set to ``false``. RPC --- An important subset of asynchronous test cases is test cases which need to perform (and chain, to an extent) RPC calls. .. note:: Because they are a subset of asynchronous cases, RPC cases must also provide a valid :js:attr:`assertions count `. By default, test cases will fail when trying to perform an RPC call. The ability to perform RPC calls must be explicitly requested by a test case (or its containing test suite) through :js:attr:`~TestOptions.rpc`, and can be one of two modes: ``mock`` or ``rpc``. Mock RPC ++++++++ The preferred (and fastest from a setup and execution time point of view) way to do RPC during tests is to mock the RPC calls: while setting up the test case, provide what the RPC responses "should" be, and only test the code between the "user" (the test itself) and the RPC call, before the call is effectively done. To do this, set the :js:attr:`rpc option ` to ``mock``. This will add a third parameter to the test case callback: .. js:function:: mock(rpc_spec, handler) Can be used in two different ways depending on the shape of the first parameter: * If it matches the pattern ``model:method`` (if it contains a colon, essentially) the call will set up the mocking of an RPC call straight to the OpenERP server (through XMLRPC) as performed via e.g. :js:func:`openerp.web.Model.call`. In that case, ``handler`` should be a function taking two arguments ``args`` and ``kwargs``, matching the corresponding arguments on the server side and should simply return the value as if it were returned by the Python XMLRPC handler:: test('XML-RPC', {rpc: 'mock', asserts: 3}, function (instance, $s, mock) { // set up mocking mock('people.famous:name_search', function (args, kwargs) { strictEqual(kwargs.name, 'bob'); return [ [1, "Microsoft Bob"], [2, "Bob the Builder"], [3, "Silent Bob"] ]; }); // actual test code return new instance.web.Model('people.famous') .call('name_search', {name: 'bob'}).then(function (result) { strictEqual(result.length, 3, "shoud return 3 people"); strictEqual(result[0][1], "Microsoft Bob", "the most famous bob should be Microsoft Bob"); }); }); * Otherwise, if it matches an absolute path (e.g. ``/a/b/c``) it will mock a JSON-RPC call to a web client controller, such as ``/web/webclient/translations``. In that case, the handler takes a single ``params`` argument holding all of the parameters provided over JSON-RPC. As previously, the handler should simply return the result value as if returned by the original JSON-RPC handler:: test('JSON-RPC', {rpc: 'mock', asserts: 3, templates: true}, function (instance, $s, mock) { var fetched_dbs = false, fetched_langs = false; mock('/web/database/get_list', function () { fetched_dbs = true; return ['foo', 'bar', 'baz']; }); mock('/web/session/get_lang_list', function () { fetched_langs = true; return [['vo_IS', 'Hopelandic / Vonlenska']]; }); // widget needs that or it blows up instance.webclient = {toggle_bars: openerp.testing.noop}; var dbm = new instance.web.DatabaseManager({}); return dbm.appendTo($s).then(function () { ok(fetched_dbs, "should have fetched databases"); ok(fetched_langs, "should have fetched languages"); deepEqual(dbm.db_list, ['foo', 'bar', 'baz']); }); }); .. note:: Mock handlers can contain assertions, these assertions should be part of the assertions count (and if multiple calls are made to a handler containing assertions, it multiplies the effective number of assertions). .. _testing-rpc-rpc: Actual RPC ++++++++++ A more realistic (but significantly slower and more expensive) way to perform RPC calls is to perform actual calls to an actually running OpenERP server. To do this, set the :js:attr:`rpc option <~TestOptions.rpc>` to ``rpc``, it will not provide any new parameter but will enable actual RPC, and the automatic creation and destruction of databases (from a specified source) around tests. First, create a basic model we can test stuff with: .. code-block:: javascript from openerp.osv import orm, fields class TestObject(orm.Model): _name = 'web_tests_demo.model' _columns = { 'name': fields.char("Name", required=True), 'thing': fields.char("Thing"), 'other': fields.char("Other", required=True) } _defaults = { 'other': "bob" } then the actual test:: test('actual RPC', {rpc: 'rpc', asserts: 4}, function (instance) { var Model = new instance.web.Model('web_tests_demo.model'); return Model.call('create', [{name: "Bob"}]) .then(function (id) { return Model.call('read', [[id]]); }).then(function (records) { strictEqual(records.length, 1); var record = records[0]; strictEqual(record.name, "Bob"); strictEqual(record.thing, false); // default value strictEqual(record.other, 'bob'); }); }); This test looks like a "mock" RPC test but for the lack of mock response (and the different ``rpc`` type), however it has further ranging consequences in that it will copy an existing database to a new one, run the test in full on that temporary database and destroy the database, to simulate an isolated and transactional context and avoid affecting other tests. One of the consequences is that it takes a *long* time to run (5~10s, most of that time being spent waiting for a database duplication). Furthermore, as the test needs to clone a database, it also has to ask which database to clone, the database/super-admin password and the password of the ``admin`` user (in order to authenticate as said user). As a result, the first time the test runner encounters an ``rpc: "rpc"`` test configuration it will produce the following prompt: .. image:: ./images/db-query.png :align: center and stop the testing process until the necessary information has been provided. The prompt will only appear once per test run, all tests will use the same "source" database. .. note:: The handling of that information is currently rather brittle and unchecked, incorrect values will likely crash the runner. .. note:: The runner does not currently store this information (for any longer than a test run that is), the prompt will have to be filled every time. Testing API ----------- .. js:function:: openerp.testing.section(name[, options], body) A test section, serves as shared namespace for related tests (for constants or values to only set up once). The ``body`` function should contain the tests themselves. Note that the order in which tests are run is essentially undefined, do *not* rely on it. :param String name: :param TestOptions options: :param body: :type body: Function<:js:func:`~openerp.testing.case`, void> .. js:function:: openerp.testing.case(name[, options], callback) Registers a test case callback in the test runner, the callback will only be run once the runner is started (or maybe not at all, if the test is filtered out). :param String name: :param TestOptions options: :param callback: :type callback: Function> .. js:class:: TestOptions the various options which can be passed to :js:func:`~openerp.testing.section` or :js:func:`~openerp.testing.case`. Except for :js:attr:`~TestOptions.setup` and :js:attr:`~TestOptions.teardown`, an option on :js:func:`~openerp.testing.case` will overwrite the corresponding option on :js:func:`~openerp.testing.section` so e.g. :js:attr:`~TestOptions.rpc` can be set for a :js:func:`~openerp.testing.section` and then differently set for some :js:func:`~openerp.testing.case` of that :js:func:`~openerp.testing.section` .. js:attribute:: TestOptions.asserts An integer, the number of assertions which should run during a normal execution of the test. Mandatory for asynchronous tests. .. js:attribute:: TestOptions.setup Test case setup, run right before each test case. A section's :js:func:`~TestOptions.setup` is run before the case's own, if both are specified. .. js:attribute:: TestOptions.teardown Test case teardown, a case's :js:func:`~TestOptions.teardown` is run before the corresponding section if both are present. .. js:attribute:: TestOptions.fail_on_rejection If the test is asynchronous and its resulting promise is rejected, fail the test. Defaults to ``true``, set to ``false`` to not fail the test in case of rejection:: // test/demo.js test('unfail rejection', { asserts: 1, fail_on_rejection: false }, function () { var d = $.Deferred(); setTimeout(function () { ok(true); d.reject(); }, 100); return d; }); .. js:attribute:: TestOptions.rpc RPC method to use during tests, one of ``"mock"`` or ``"rpc"``. Any other value will disable RPC for the test (if they were enabled by the suite for instance). .. js:attribute:: TestOptions.templates Whether the current module (and its dependencies)'s templates should be loaded into QWeb before starting the test. A boolean, ``false`` by default. The test runner can also use two global configuration values set directly on the ``window`` object: * ``oe_all_dependencies`` is an ``Array`` of all modules with a web component, ordered by dependency (for a module ``A`` with dependencies ``A'``, any module of ``A'`` must come before ``A`` in the array) * ``oe_db_info`` is an object with 3 keys ``source``, ``supadmin`` and ``password``. It is used to pre-configure :ref:`actual RPC ` tests, to avoid a prompt being displayed (especially for headless situations). Running through Python ---------------------- The web client includes the means to run these tests on the command-line (or in a CI system), but while actually running it is pretty simple the setup of the pre-requisite parts has some complexities. 1. Install unittest2_ and QUnitSuite_ in your Python environment. Both can trivially be installed via `pip `_ or `easy_install `_. The former is the unit-testing framework used by OpenERP, the latter is an adapter module to run qunit_ test suites and convert their result into something unittest2_ can understand and report. 2. Install PhantomJS_. It is a headless browser which allows automating running and testing web pages. QUnitSuite_ uses it to actually run the qunit_ test suite. The PhantomJS_ website provides pre-built binaries for some platforms, and your OS's package management probably provides it as well. If you're building PhantomJS_ from source, I recommend preparing for some knitting time as it's not exactly fast (it needs to compile both `Qt `_ and `Webkit `_, both being pretty big projects). .. note:: Because PhantomJS_ is webkit-based, it will not be able to test if Firefox, Opera or Internet Explorer can correctly run the test suite (and it is only an approximation for Safari and Chrome). It is therefore recommended to *also* run the test suites in actual browsers once in a while. .. note:: The version of PhantomJS_ this was build through is 1.7, previous versions *should* work but are not actually supported (and tend to just segfault when something goes wrong in PhantomJS_ itself so they're a pain to debug). 3. Set up :ref:`OpenERP Command `, which will be used to actually run the tests: running the qunit_ test suite requires a running server, so at this point OpenERP Server isn't able to do it on its own during the building/testing process. 4. Install a new database with all relevant modules (all modules with a web component at least), then restart the server .. note:: For some tests, a source database needs to be duplicated. This operation requires that there be no connection to the database being duplicated, but OpenERP doesn't currently break existing/outstanding connections, so restarting the server is the simplest way to ensure everything is in the right state. 5. Launch ``oe run-tests -d $DATABASE -mweb`` with the correct addons-path specified (and replacing ``$DATABASE`` by the source database you created above) .. note:: If you leave out ``-mweb``, the runner will attempt to run all the tests in all the modules, which may or may not work. If everything went correctly, you should now see a list of tests with (hopefully) ``ok`` next to their names, closing with a report of the number of tests run and the time it took: .. literalinclude:: test-report.txt :language: text Congratulation, you have just performed a successful "offline" run of the OpenERP Web test suite. .. note:: Note that this runs all the Python tests for the ``web`` module, but all the web tests for all of OpenERP. This can be surprising. .. _qunit: http://qunitjs.com/ .. _qunit assertions: http://api.qunitjs.com/category/assert/ .. _unittest2: http://pypi.python.org/pypi/unittest2 .. _QUnitSuite: http://pypi.python.org/pypi/QUnitSuite/ .. _PhantomJS: http://phantomjs.org/