Articles > Writing tests in Odoo

Writing tests in Odoo

Organizing, Writing, and Running Automated Tests

Written by
Holden Rehg
Posted on
February 2, 2019

Introduction

Testing with Odoo, in any version, is not 100% straight forward. There are some different tricks and requirements that aren’t always made clear.

So I wanted to write up everything that my team and I at Blue Stingray have learned about Odoo testing over the last few years. We’ve luckily been able to learn a ton.

This is not an explanation on what to test. There are plenty of great articles and books out there for what should you be testing, and maybe I’ll write up a summary of those one day. But for now, this is strictly an explanation on how to organize, write, and run some example tests in Odoo.

I will try to thoroughly cover:

  • Setting up a module for tests
  • Writing a test within the Odoo framework boundaries
  • Running your Odoo / module test suite

Requirements

What do you need?

  • A running instance of Odoo.
  • A custom Odoo module (I’ll provide a sample).

I will be referencing Odoo 12, but Odoo 9 to 12 have pretty similar testing frameworks. There were no huge changes between recent versions.

Also, you may want to check out a Simple Introduction To Docker Development In Odoo since I will be referencing some Docker commands.

Module Structure

Let’s create a sample module. For reference, all sample code is in a repository at https://github.com/holdenrehg/sample_test_module.

test_module
├── doc
├── helpers
├── models
├── security
├── static
├── tests
├── __manifest__.py
├── __init__.py
├── init.xml
└── readme.md
            

I’m creating a sample module called test_module which will contain all of my sample tests and code. You will see quite a few directories above, many just “standards” introduced by Odoo in general, but the most important directory will be tests.

The Tests Directory

test_module/tests/
├── unit
│   ├── test_tweeter_helper.py
│   ├── test_twitter_tweet_model.py
│   ├── test_string_helper.py
│   ├── test_twitter_settings_model.py
│   └── __init__.py
└── __init__.py
            

You can organize your tests directory however you would like, but I often will try to break up different types of tests. In the example above you can see that we have tests/unit for all of our unit tests. Maybe if we kept building this out, we would introduce tests/integration, tests/security, etc.

Some Sample Models and Helpers

Also inside of our module, we have 2 models and 2 helpers to use as examples for writing some tests.

Again, this is just convention to have a models folder and a helpers folder for Odoo modules, but as long as your __init__ files are properly importing files you can organize your module however you would like.

test_module/helpers
├── string.py
├── tweeter.py
└── __init__.py
test_module/models/
├── twitter
│   ├── tweet.py
│   ├── settings.py
│   └── __init__.py
└── __init__.py
            

Module Files

Let’s dig into the contents of some of these sample files that I’ve written for you. I’m not quite going to get into the tests folder until the section below.

__manifest__.py

Because we are using 12.0 we require a manifest file. If we were looking at 9.0 and back we may have an __openerp__.py file in the root of our module, but the general contents will be the same.

# -*- coding: utf-8 -*-
# noinspection PyStatementEffect
{
    'name': 'Sample Testing Module',
    'category': 'Testing',
    'version': '12.0.0',
    'author': 'Holden Rehg',

    # |-------------------------------------------------------------------------
    # | Dependencies
    # |-------------------------------------------------------------------------
    # |
    # | References of all modules that this module depends on. If this module
    # | is ever installed or upgrade, it will automatically install any
    # | dependencies as well.
    # |

    'depends': ['web'],

    # |-------------------------------------------------------------------------
    # | Data References
    # |-------------------------------------------------------------------------
    # |
    # | References to all XML data that this module relies on. These XML files
    # | are automatically pulled into the system and processed.
    # |

    'data': [

        # Security Records...
        'security/ir.rule.csv',
        'security/ir.model.access.csv',

        # General/Data Records...
        'init.xml',
    ],

    # |-------------------------------------------------------------------------
    # | Demo Data
    # |-------------------------------------------------------------------------
    # |
    # | A reference to demo data
    # |

    'demo': [],

    # |-------------------------------------------------------------------------
    # | Is Installable
    # |-------------------------------------------------------------------------
    # |
    # | Gives the user the option to look at Local Modules and install, upgrade
    # | or uninstall. This seems to be used by most developers as a switch for
    # | modules that are either active / inactive.
    # |

    'installable': True,

    # |-------------------------------------------------------------------------
    # | Auto Install
    # |-------------------------------------------------------------------------
    # |
    # | Lets Odoo know if this module should be automatically installed when
    # | the server is started.
    # |

    'auto_install': False,
}
            

A Couple Sample Models

We have an extremely creative, and definitely unique (no one has used Twitter for their development tutorial before, right?) set of models to mess around with.

The first is a Twitter settings model. This is going to be a class that stores a username and a password. These would be credentials for accessing Twitter accounts if this module was expanding out to a functioning module.

# -*- coding: utf-8 -*-
from odoo import _, api, fields, models


class TwitterSettings(models.Model):
    _name = 'twitter.settings'

    username = fields.Char(string='Username')
    password = fields.Char(string='Password')
    connected = fields.Boolean(string='Connected', default=False)

    def connect(self):
        """
        Attempt to connect to Twitter.
        """
        self.ensure_one()
        self.update({'connected': True, })
            

The second is a Tweet model. This is a data model that contains the description of the tweet, and constraints for the tweet. For example, you cannot generate a tweet that is longer than 140 character.

This gives us a clear restriction where we can write some tests to ensure that restriction is being enforced by the system.

# -*- coding: utf-8 -*-
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class Tweet(models.Model):
    _name = 'twitter.tweet'
    _rec_name = 'description'

    description = fields.Text(string='Description', size=140, required=True)

    @api.constrains('description')
    def constrain_description(self):
        """
        Raises a ValidationError if tweets are longer than 140 characters.
        """
        if self and len(self.description) > 140:
            raise ValidationError('Tweets cannot be longer than 140 characters.')
            

A Couple Sample Helpers

Now we need 2 helpers. We have the Twitter data models, but we need an easy way to make a tweet and an easy way to truncate off characters down.

So that leaves us with a string.py helper and a tweeter.py helper for our Tweeter class.

# -*- coding: utf-8 -*-

def limit(str, size):
    """
    Truncate a string down to a certain size limit.
    In the case where the string is less than size it will
    return the string as is.
    :param str: The string to truncate.
    :param size: The max number of characters the string can be.
    :return: The size limited string.
    """
    if len(str) <= size:
        return str
    return str[0:size]
            
# -*- coding: utf-8 -*-

class Tweeter():
    def __init__(self, settings):
        """
        Initialize the Tweeter object.
        """
        self.settings = settings

    def tweet(self, description):
        """
        Create a tweet.
        :param description: The content of the tweet.
        :return: The generated tweet.
        """
        return self.settings.env['twitter.tweet'].create({
            'description': description, })
            

Writing A Test — Requirements

Alright, we got a sweet Twitter module built out now.

How do we write a test for it? First, there are some requirements specific to the Odoo testing framework that we must adhere to. You’ll see how these apply in the sample tests I’ve written, but here’s what you need to think about:

  • All tests must be included in tests/__init__.py
  • All tests must extend an Odoo test case class
  • All tests must be put inside of the tests directory in your module
  • All test files must start with test_<what_your_testing>.py
  • All test methods must start with def test_<what_your_testing>(self):

All Tests Must Be Included In tests/__init__.py

Even if they are nested deeper. So in the case of our sample module, we have 4 tests:

  • tests/unit/test_string_helper.py
  • tests/unit/test_tweeter_helper.py
  • tests/unit/test_twitter_settings_model.py
  • tests/unit/test_twitter_tweet_model.py

If you look at the tests/__init__.py file then you will see the following:

# -*- coding: utf-8 -*-
from .unit import test_string_helper
from .unit import test_tweeter_helper
from .unit import test_twitter_settings_model
from .unit import test_twitter_tweet_model
            

Make sure to always include your test file in this __init__.py file for the system to recognize the test.

Extend An Odoo Test Class

There are some pre-existing test classes for you to work with, but you need to extend one of them.

odoo.tests.common.TransactionCase <--- Most common
odoo.tests.common.SingleTransactionCase
odoo.tests.common.HttpCase
odoo.tests.common.SavepointCase
            

So your tests classes will look something like the following:

# -*- coding: utf-8 -*-
from odoo.tests import TransactionCase

class MyTest(TransactionCase):
    ...
            

Put Your Tests In The Tests Folder

You can get a little bit creative with your module organization, except when it comes to things like tests, make sure they are in the tests folder.

Otherwise, the testing framework will ignore them.

test_module/tests/
├── unit
│   ├── test_tweeter_helper.py
│   ├── test_twitter_tweet_model.py
│   ├── test_string_helper.py
│   ├── test_twitter_settings_model.py
│   └── __init__.py
└── __init__.py
            

All Test Files Must Start With test_

As you are making your tests (and putting them in the tests folder), make sure that all of your test files are prefixed with test_ .

There’s a reason why, in my example, that my test files are named things like test_tweeter_helper.py or test_string_helper.py .

All Test Methods Must Start With test_

I haven’t actually shown you a full test class or method yet, but when you add methods to your test class (which is inside of the tests folder and inside of a file prepended with tests_<filename>.py) then you also have to prefix it with test_ .

I know it’s feeling a little overkill, but this is the last test_ you will need.

# -*- coding: utf-8 -*-
from odoo.tests import TransactionCase
class MyTest(TransactionCase):
    def test_should_evaluate_true(self):  <----
        ...
            

Writing A Test — Examples

Now you know those 5 requirements above for actually writing a test.

Here’s what one looks like:


# -*- coding: utf-8 -*-
from odoo.tests import TransactionCase
from odoo.addons.test_module.helpers import string


class StringHelperTest(TransactionCase):
    def test_string_should_truncate_when_greater(self):
        self.assertEqual(len(string.limit('A short string...', size=5)), 5)

    def test_string_should_do_nothing_when_same_size(self):
        sample_str = 'This is my sample string.'
        sample_str_len = len(sample_str)
        self.assertEqual(len(string.limit(sample_str, sample_str_len)), sample_str_len)

    def test_string_should_do_nothing_when_less_than(self):
        sample_str = 'Another cool sample string!'
        sample_str_len = len(sample_str)
        self.assertEqual(len(string.limit(sample_str, sample_str_len)), sample_str_len)
            

This is our unit test for the string helper. So this is under tests/unit/test_string_helper.py and is testing our helper at helpers/string.py .

I’m not going to dig too far into what you should be trying to test for or all of the assertion method available to you (check out the list of assertion methods for unittest2 or the odoo/test/common.py file in Odoo core.)

Hopefully, you can get a general idea from the example above for how to write some sample test methods.

I wrote 3 methods in that example above:

  1. Does our string helper properly truncate a string when the string is longer than the size limit?
  2. Does our string helper do nothing when the string is exactly the size limit?
  3. Does our string helper do nothing when the string is less than the size limit?

To do this, I need to import our string helper from the module via a from odoo.addons.test_module.helpers import string call, create some sample strings to work with, call the string.limit(...) function that I’m trying to test, and then assert that the return values are equal to the expected results.

def test_string_should_do_nothing_when_same_size(self):
    sample_str = 'This is my sample string.'
    sample_str_len = len(sample_str)

    self.assertEqual(
        # Actual results from function call...
        len(string.limit(sample_str, sample_str_len)),
        # Expected results from the function call...
        sample_str_len
    )
            

Make sure to check out the other 3 test classes that I wrote as well for some more example tests:

Running Tests

Finally, we can try to run some tests. I’m going to show you how to run these via Docker and Docker Compose, but you can also run these directly from the odoo.py or odoo-bin executables.

Requirements

More requirements.

Similar to the requirements for writing a test. There are a few things that we need for running the test suite.

  • A database instance WITH demo data. So when you are creating a database from that database creation screen, make sure to mark to include demo data.
  • Run odoo with the --test-enable flag.
  • Run odoo with the -d {my_database flag.
  • Run odoo with the -i {modules_to_install} flag.
  • (optional) Sometimes it’s also nice to use --stop-after-init .

Run The Tests

With Docker

1. Clone down the sample project

$ git clone https://github.com/holdenrehg/sample_test_module.git
$ cd sample_test_module
            

2. Start up the instance and create a new database called test_1 with demo data. If you do not make your database first, the command in step #3 will automatically create the database but also run tests on core modules (this can take a while).

$ docker-compose up -d
            

3. Stop your instance and then re-run the container with the proper arguments for testing.

$ docker-compose stop
$ docker-compose run web \
    --test-enable \
    --stop-after-init \
    -d test_1 \
    -i test_module
            

Without Docker

If you are running your own local instance:

1. Clone down the sample project and move the addon into your addons directory.

$ git clone https://github.com/holdenrehg/sample_test_module.git
$ mv sample_test_module/test_module {my_project/my_addons/}
            

2. Make a database called test_1 with demo data. If you do not make your database first, the command in step #3 will automatically create the database but also run tests on core modules (this can take a while).

3. Run your odoo instance with the proper arguments for testing.

$ odoo.py \
      --test-enable \
      --stop-after-init \
      -d test_1 \
      -i test_module
            

Test Results

Good Results

If everything runs successfully then you will see the following in the log:

It gives you a lot of information to parse, but you will be looking for each section that says something similar toodoo.addons.test_module.tests.unit.test_string_helper running test because that will be an individual test class/test case. You can see where it runs each method in the test case.

Example Results For String Helper Tests

2019-02-02 20:11:36,199 1 INFO test_1 odoo.modules.module: odoo.addons.test_module.tests.unit.test_string_helper running tests.
2019-02-02 20:11:36,200 1 INFO test_1 odoo.addons.test_module.tests.unit.test_string_helper: test_string_should_do_nothing_when_less_than (odoo.addons.test_module.tests.unit.test_string_helper.StringHelperTest)
2019-02-02 20:11:36,278 1 INFO test_1 odoo.addons.test_module.tests.unit.test_string_helper: test_string_should_do_nothing_when_same_size (odoo.addons.test_module.tests.unit.test_string_helper.StringHelperTest)
2019-02-02 20:11:36,279 1 INFO test_1 odoo.addons.test_module.tests.unit.test_string_helper: test_string_should_truncate_when_greater (odoo.addons.test_module.tests.unit.test_string_helper.StringHelperTest)
2019-02-02 20:11:36,280 1 INFO test_1 odoo.addons.test_module.tests.unit.test_string_helper: Ran 3 tests in 0.080s
2019-02-02 20:11:36,280 1 INFO test_1 odoo.addons.test_module.tests.unit.test_string_helper: OK
            

Bad Results

All the tests don’t pass all the time, so let’s check out our log for a failing test.

You’ll see where again we get the logger for odoo.addons.test_module.tests.unit.test_string_helper running tests and then each method that runs below it.

In this scenario (where I forced a method to fail) we can see a stack trace with the problem:

FAIL: test_string_should_truncate_when_greater (odoo.addons.test_module.tests.unit.test_string_helper.StringHelperTest)
Traceback (most recent call last):
   File "/mnt/extra-addons/test_module/tests/unit/test_string_helper.py", line 8, in test_string_should_truncate_when_greater
     self.assertEqual(len(string.limit('A short string...', size=6)), 5)
 AssertionError: 6 != 5
            

Pretty clear that our string.limit method ran with size=6 but we were expecting the length of the final string to be equal to 5. This looks like a problem with our test instead of the functionality of the method, so we can fix that and re-run our test suite.

Conclusion

There’s a little bit of an additional learning curve testing in Odoo, especially if you are used to working on a vanilla python project with tools like pytest or unittest2.

But if you are an Odoo developer day-to-day it’s worth it.

Write more tests.

Then write even more tests.

They can save your ass in the long run especially as your testing suite builds up and provides more and more coverage of your project.

Thanks For Reading

I appreciate you taking the time to read any of my articles. I hope it has helped you out in some way. If you're looking for more ramblings, take a look at theentire catalog of articles I've written. Give me a follow on Twitter or Github to see what else I've got going on. Feel free to reach out if you want to talk!

software testing
web development
odoo
python
unit testing
Share:

Holden Rehg, Author

Posted February 2, 2019