From f201393fa4136263656b7be40ec31c625c7f2728 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 09:43:24 +0100 Subject: [PATCH 1/7] [IMP] reformat Menu bzr revid: xmo@openerp.com-20110321084324-0b3it36uhxk44lc8 --- addons/base/controllers/main.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/addons/base/controllers/main.py b/addons/base/controllers/main.py index 4b223bdeb9e..bc16076272e 100644 --- a/addons/base/controllers/main.py +++ b/addons/base/controllers/main.py @@ -118,27 +118,26 @@ class Menu(openerpweb.Controller): @openerpweb.jsonrequest def load(self, req): - m = req.session.model('ir.ui.menu') + Menus = req.session.model('ir.ui.menu') # menus are loaded fully unlike a regular tree view, cause there are # less than 512 items - menu_ids = m.search([]) - menu_items = m.read(menu_ids, ['name', 'sequence', 'parent_id']) + menu_ids = Menus.search([]) + menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id']) menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, '']} menu_items.append(menu_root) + # make a tree using parent_id - for i in menu_items: - i['children'] = [] - d = dict([(i["id"], i) for i in menu_items]) - for i in menu_items: - if not i['parent_id']: - pid = False - else: - pid = i['parent_id'][0] - if pid in d: - d[pid]['children'].append(i) + menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items) + for menu_item in menu_items: + if not menu_item['parent_id']: continue + parent = menu_item['parent_id'][0] + if parent in menu_items_map: + menu_items_map[parent].set_default('children', []).append(menu_item) + # sort by sequence a tree using parent_id - for i in menu_items: - i['children'].sort(key=lambda x:x["sequence"]) + for menu_item in menu_items: + menu_item.set_default('children', []).sort( + key=lambda x:x["sequence"]) return {'data': menu_root} From 3d02b3f8045f8f217e54a073bde98bdee4b1e606 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 11:47:35 +0100 Subject: [PATCH 2/7] [ADD] tests for base.controllers.main.Menu.load * Make model factory pluggable in OpenERPSession (so it's possible to mock the models handler) * Split load between the JSON-RPC handler and the actual logic * Add some docstring to Menu.do_load bzr revid: xmo@openerp.com-20110321104735-9hmrsyoccueya9fh --- addons/base/controllers/main.py | 17 +++++- addons/base/tests/__init__.py | 1 + addons/base/tests/test_menu.py | 99 +++++++++++++++++++++++++++++++++ openerpweb/openerpweb.py | 6 +- 4 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 addons/base/tests/__init__.py create mode 100644 addons/base/tests/test_menu.py diff --git a/addons/base/controllers/main.py b/addons/base/controllers/main.py index bc16076272e..6c91a3b1633 100644 --- a/addons/base/controllers/main.py +++ b/addons/base/controllers/main.py @@ -118,6 +118,16 @@ class Menu(openerpweb.Controller): @openerpweb.jsonrequest def load(self, req): + return {'data': self.do_load(req)} + + def do_load(self, req): + """ Loads all menu items (all applications and their sub-menus). + + :param req: A request object, with an OpenERP session attribute + :type req: < session -> OpenERPSession > + :return: the menu root + :rtype: dict('children': menu_nodes) + """ Menus = req.session.model('ir.ui.menu') # menus are loaded fully unlike a regular tree view, cause there are # less than 512 items @@ -132,14 +142,15 @@ class Menu(openerpweb.Controller): if not menu_item['parent_id']: continue parent = menu_item['parent_id'][0] if parent in menu_items_map: - menu_items_map[parent].set_default('children', []).append(menu_item) + menu_items_map[parent].setdefault( + 'children', []).append(menu_item) # sort by sequence a tree using parent_id for menu_item in menu_items: - menu_item.set_default('children', []).sort( + menu_item.setdefault('children', []).sort( key=lambda x:x["sequence"]) - return {'data': menu_root} + return menu_root @openerpweb.jsonrequest def action(self, req, menu_id): diff --git a/addons/base/tests/__init__.py b/addons/base/tests/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/addons/base/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/addons/base/tests/test_menu.py b/addons/base/tests/test_menu.py new file mode 100644 index 00000000000..1d1e49b6c97 --- /dev/null +++ b/addons/base/tests/test_menu.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +import mock +import unittest2 +import base.controllers.main +import openerpweb.openerpweb + +class Placeholder(object): + def __init__(self, **kwargs): + for k, v in kwargs.iteritems(): + setattr(self, k, v) + +class LoadTest(unittest2.TestCase): + def setUp(self): + self.menu = base.controllers.main.Menu() + self.menus_mock = mock.Mock() + self.request = Placeholder( + session=openerpweb.openerpweb.OpenERPSession( + model_factory=lambda _session, _name: self.menus_mock)) + + def tearDown(self): + del self.request + del self.menus_mock + del self.menu + + def test_empty(self): + self.menus_mock.search = mock.Mock(return_value=[]) + self.menus_mock.read = mock.Mock(return_value=[]) + + root = self.menu.do_load(self.request) + + self.menus_mock.search.assert_called_with([]) + self.menus_mock.read.assert_called_with( + [], ['name', 'sequence', 'parent_id']) + + self.assertListEqual( + root['children'], + []) + + def test_applications_sort(self): + self.menus_mock.search = mock.Mock(return_value=[1, 2, 3]) + self.menus_mock.read = mock.Mock(return_value=[ + {'id': 2, 'sequence': 3, 'parent_id': [False, '']}, + {'id': 3, 'sequence': 2, 'parent_id': [False, '']}, + {'id': 1, 'sequence': 1, 'parent_id': [False, '']}, + ]) + + root = self.menu.do_load(self.request) + + self.menus_mock.read.assert_called_with( + [1, 2, 3], ['name', 'sequence', 'parent_id']) + + self.assertEqual( + root['children'], + [{ + 'id': 1, 'sequence': 1, + 'parent_id': [False, ''], 'children': [] + }, { + 'id': 3, 'sequence': 2, + 'parent_id': [False, ''], 'children': [] + }, { + 'id': 2, 'sequence': 3, + 'parent_id': [False, ''], 'children': [] + }]) + + def test_deep(self): + self.menus_mock.search = mock.Mock(return_value=[1, 2, 3, 4]) + self.menus_mock.read = mock.Mock(return_value=[ + {'id': 1, 'sequence': 1, 'parent_id': [False, '']}, + {'id': 2, 'sequence': 2, 'parent_id': [1, '']}, + {'id': 3, 'sequence': 1, 'parent_id': [2, '']}, + {'id': 4, 'sequence': 2, 'parent_id': [2, '']}, + ]) + + root = self.menu.do_load(self.request) + + self.assertEqual( + root['children'], + [{ + 'id': 1, + 'sequence': 1, + 'parent_id': [False, ''], + 'children': [{ + 'id': 2, + 'sequence': 2, + 'parent_id': [1, ''], + 'children': [{ + 'id': 3, + 'sequence': 1, + 'parent_id': [2, ''], + 'children': [] + }, { + 'id': 4, + 'sequence': 2, + 'parent_id': [2, ''], + 'children': [] + }] + }] + }] + ) diff --git a/openerpweb/openerpweb.py b/openerpweb/openerpweb.py index 404616273fb..f966501f72c 100644 --- a/openerpweb/openerpweb.py +++ b/openerpweb/openerpweb.py @@ -39,13 +39,15 @@ class OpenERPModel(object): class OpenERPSession(object): - def __init__(self, server='127.0.0.1', port=8069): + def __init__(self, server='127.0.0.1', port=8069, + model_factory=OpenERPModel): self._server = server self._port = port self._db = False self._uid = False self._login = False self._password = False + self.model_factory = model_factory def proxy(self, service): s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service), timeout=5) @@ -69,7 +71,7 @@ class OpenERPSession(object): return r def model(self, model): - return OpenERPModel(self, model) + return self.model_factory(self, model) #---------------------------------------------------------- # OpenERP Web RequestHandler From 3d77482d5e21f9c72819dea8df834425d6fc678f Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 12:51:37 +0100 Subject: [PATCH 3/7] [ADD] unittest2 and mock as tests_require dependencies bzr revid: xmo@openerp.com-20110321115137-m8bafe1ixkfps3t7 --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 439fb77bcfd..8d55ed02eef 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,10 @@ setup( "simplejson >= 2.0.9", "python-dateutil >= 1.4.1", ], + tests_require=[ + 'unittest2', + 'mock', + ], zip_safe=False, packages=[ 'addons', From 6f471d8946f87e59923196465bff83a3b9518e8a Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 13:13:40 +0100 Subject: [PATCH 4/7] [ADD] documentation on testing the Python side of addons bzr revid: xmo@openerp.com-20110321121340-tq0s9p28g6cwg9op --- doc/source/addon-structure.txt | 9 +++ doc/source/addons.rst | 135 ++++++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 doc/source/addon-structure.txt diff --git a/doc/source/addon-structure.txt b/doc/source/addon-structure.txt new file mode 100644 index 00000000000..8e2824bc950 --- /dev/null +++ b/doc/source/addon-structure.txt @@ -0,0 +1,9 @@ + + +-- __openerp__.py + +-- controllers/ + +-- static/ + +-- openerp/ + +-- css/ + +-- img/ + +-- js/ + +-- tests/ diff --git a/doc/source/addons.rst b/doc/source/addons.rst index ad46dd2f6fe..e24cd8e6957 100644 --- a/doc/source/addons.rst +++ b/doc/source/addons.rst @@ -1,7 +1,89 @@ Developing OpenERP Web Addons ============================= -* Structure of an addon +Structure +--------- + +.. literalinclude:: addon-structure.txt + +``__openerp__.py`` + The addon's descriptor, contains the following information: + + ``name: str`` + The addon name, in plain, readable english + ``version: str`` + The addon version, following `Semantic Versioning`_ rules + ``depends: [str]`` + A list of addons this addon needs to work correctly. ``base`` is + an implied dependency if the list is empty. + ``css: [str]`` + An ordered list of CSS files this addon provides and needs. The + file paths are relative to the addon's root. Because the Web + Client *may* perform concatenations and other various + optimizations on CSS files, the order is important. + ``js: [str]`` + An ordered list of Javascript files this addon provides and needs + (including dependencies files). As with CSS files, the order is + important as the Web Client *may* perform contatenations and + minimizations of files. + ``active: bool`` + Whether this addon should be enabled by default any time it is + found, or whether it will be enabled through other means (on a + by-need or by-installation basis for instance). + +``controllers/`` + All of the Python controllers and JSON-RPC endpoints. + +``static/`` + The static files directory, may be served via a separate web server. + + The third-party dependencies should be bundled in it (each in their + own directory). + +``static/openerp/`` + Sub-tree for all the addon's own static files. + +``static/openerp/{css,js,img}`` + Location for (respectively) the addon's static CSS files, its JS + files and its various image resources. + +``tests/`` + The directories in which all tests for the addon are located. + +Testing +------- + +Python +++++++ + +OpenERP Web uses unittest2_ for its testing needs. We selected +unittest2 rather than unittest_ for the following reasons: + +* autodiscovery_ (similar to nose, via the ``unit2`` + CLI utility) and `pluggable test discovery`_. + +* `new and improved assertions`_ (with improvements in type-specific + inequality reportings) including `pluggable custom types equality + assertions`_ + +* neveral new APIs, most notably `assertRaises context manager`_, + `cleanup function registration`_, `test skipping`_ and `class- and + module-level setup and teardown`_ + +* finally, unittest2 is a backport of Python 3's unittest. We might as + well get used to it. + +To run tests on addons (from the root directory of OpenERP Web) is as +simple as typing ``PYTHONPATH=. unit2 discover -s addons`` [#]_. To +test an addon which does not live in the ``addons`` directory, simply +replace ``addons`` by the directory in which your own addon lives. + +.. note:: unittest2 is entirely compatible with nose_ (or the + other way around). If you want to use nose as your test + runner (due to its addons for instance) you can simply install it + and run ``nosetests addons`` instead of the ``unit2`` command, + the result should be exactly the same. + * Addons lifecycle (loading, execution, events, ...) * Python-side @@ -16,3 +98,54 @@ Developing OpenERP Web Addons * Javascript public APIs * QWeb templates description? * OpenERP Web modules (from OpenERP modules) + +.. [#] the ``-s`` parameter tells ``unit2`` to start trying to + find tests in the provided directory (here we're testing + addons). However a side-effect of that is to set the + ``PYTHONPATH`` there as well, so it will fail to find (and + import) ``openerpweb``. + + The ``-t`` parameter lets us set the ``PYTHONPATH`` + independently, but it doesn't accept multiple values and here + we really want to have both ``.`` and ``addons`` on the + ``PYTHONPATH``. + + The solution is to set the ``PYTHONPATH`` to ``.`` on start, + and the ``start-directory`` to ``addons``. This results in a + correct ``PYTHONPATH`` within ``unit2``. + +.. _unittest: + http://docs.python.org/library/unittest.html + +.. _unittest2: + http://www.voidspace.org.uk/python/articles/unittest2.shtml + +.. _autodiscovery: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#test-discovery + +.. _pluggable test discovery: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#load-tests + +.. _new and improved assertions: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#new-assert-methods + +.. _pluggable custom types equality assertions: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#add-new-type-specific-functions + +.. _assertRaises context manager: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#assertraises + +.. _cleanup function registration: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#cleanup-functions-with-addcleanup + +.. _test skipping: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#test-skipping + +.. _class- and module-level setup and teardown: + http://www.voidspace.org.uk/python/articles/unittest2.shtml#class-and-module-level-fixtures + +.. _Semantic Versioning: + http://semver.org/ + +.. _nose: + http://somethingaboutorange.com/mrl/projects/nose/1.0.0/ From 20200587eef7f222df432cb8512ca798dcf8a3d9 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 13:13:50 +0100 Subject: [PATCH 5/7] [ADD] unittest2.collector as a setuptools test_suite, though it doesn't go search the addons (yet?) it'll be useful when we start adding tests to the core itself bzr revid: xmo@openerp.com-20110321121350-a6fx646gbfhks9f1 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8d55ed02eef..da0dd56a294 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,7 @@ setup( 'unittest2', 'mock', ], + test_suite = 'unittest2.collector', zip_safe=False, packages=[ 'addons', From 698875cc27aa41dd8c211a6463108447dfdf5007 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 13:14:47 +0100 Subject: [PATCH 6/7] [ADD] basic test to openerpweb (to test the behavior of OpenERPModels) bzr revid: xmo@openerp.com-20110321121447-c81cg3n3a97nx15h --- openerpweb/tests/__init__.py | 1 + openerpweb/tests/test_model.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 openerpweb/tests/__init__.py create mode 100644 openerpweb/tests/test_model.py diff --git a/openerpweb/tests/__init__.py b/openerpweb/tests/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/openerpweb/tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/openerpweb/tests/test_model.py b/openerpweb/tests/test_model.py new file mode 100644 index 00000000000..1d0d058b092 --- /dev/null +++ b/openerpweb/tests/test_model.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +import mock +import unittest2 +import openerpweb.openerpweb + +class OpenERPModelTest(unittest2.TestCase): + def test_rpc_call(self): + session = mock.Mock(['execute']) + Model = openerpweb.openerpweb.OpenERPModel( + session, 'a.b') + + Model.search([('field', 'op', 'value')], {'key': 'value'}) + session.execute.assert_called_once_with( + 'a.b', 'search', [('field', 'op', 'value')], {'key': 'value'}) + + session.execute.reset_mock() + + Model.read([42]) + session.execute.assert_called_once_with( + 'a.b', 'read', [42]) From 562de2d9a93aadc817d9544be22761487b77bb9f Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 21 Mar 2011 13:23:28 +0100 Subject: [PATCH 7/7] [ADD] documentation on running tests in OpenERP Web core bzr revid: xmo@openerp.com-20110321122328-u1u55gbf3rfks2xv --- doc/source/addons.rst | 2 ++ doc/source/development.rst | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/doc/source/addons.rst b/doc/source/addons.rst index e24cd8e6957..976054bc47e 100644 --- a/doc/source/addons.rst +++ b/doc/source/addons.rst @@ -50,6 +50,8 @@ Structure ``tests/`` The directories in which all tests for the addon are located. +.. _addons-testing: + Testing ------- diff --git a/doc/source/development.rst b/doc/source/development.rst index aebf57c1086..a2be0ec2eef 100644 --- a/doc/source/development.rst +++ b/doc/source/development.rst @@ -6,4 +6,25 @@ Contributing to OpenERP Web * QWeb code documentation/description * Documentation of the OpenERP APIs and choices taken based on that? * Style guide and coding conventions (PEP8? More) -* Test frameworks for Python and JS? +* Test frameworks in JS? + +Testing +------- + +Python +++++++ + +Testing for the OpenERP Web core is similar to :ref:`testing addons +`: the tests live in ``openerpweb.tests``, unittest2_ +is the testing framework and tests can be run via either unittest2 +(``unit2 discover``) or via nose_ (``nosetests``). + +Tests for the OpenERP Web core can also be run using ``setup.py +test``. + + +.. _unittest2: + http://www.voidspace.org.uk/python/articles/unittest2.shtml + +.. _nose: + http://somethingaboutorange.com/mrl/projects/nose/1.0.0/