On creating complete Sao tests

We’ve started creating a test for sao. We need it to cover some of the customizations we have but also to test patches that sometimes we backport.

While working on that I thought that in fact most of the test should be part of core.

The idea is to create a test that covers all the workflows: login, menu navigation, creating, reading, updating and deleting records, using wizards, relate buttons, etc, etc.

It will be a very extensive test but it should prevent regressions that appear with relative frequency.

My proposal is to use Playwright for the automation. There are other tools but testing several of those over the years Playwright has proved to be the most stable one.

Never had an issue installing it or the different browsers it supports (the experience with other tools was quite different) and we use it flawlessly as part of some functionalities we have in production servers inside docker containers after several years.

Playwright also allows to store screenshots, videos as well as traces. It has a tool to reproduce those traces and a tool to simplify the creation of tests (although it may or may not be appropriate for the sao tests).

Thoughts?

Here’s a snippet of a basic proof of concept of using plain unittest with playwright. We’re already using it + some more tests in our CI system, as one more test in “tests” directory of an internal module we have.

import os
import re
import threading
import unittest
from functools import wraps

from playwright.sync_api import Page, expect, sync_playwright, Locator
from trytond import wsgi
from trytond.pool import Pool
from trytond.tests.test_tryton import drop_create, drop_db
from trytond.transaction import Transaction
from werkzeug.serving import make_server
from trytond.config import config

expect.set_options(timeout=2500)

# CAUTION: If you want to execute this test locally or on a new server, please define the 'root' variable with the value 'sao', on TRYTOND.conf
# HINT: You can temporarily define it with the command 'export TRYTOND_WEB__ROOT=sao'

class ServerThread(threading.Thread):
    def __init__(self, app):
        threading.Thread.__init__(self)

        # When the system gets a '0' on the socket, it automatically finds a not-used port.
        # SEE: https://stackoverflow.com/questions/1365265/on-localhost-how-do-i-pick-a-free-port-number
        self.port = 0
        self.host = 'localhost'

        # Threaded is put to TRUE to ensure that the server is stopped.
        # Werkzeug says: shutdown() must be called while serve_forever() is running in another thread, or it will deadlock.
        self.server = make_server(self.host, self.port, app, threaded=True)

        # Retrieve the port selected by the system
        self.port = self.server.socket.getsockname()[1]

    def run(self):/
        self.server.serve_forever()

    def stop(self):
        if self.is_alive():
            print('Stopping server...')
            self.server.shutdown()

def browser():
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with sync_playwright() as playwright:
                try:
                    browser = playwright.firefox.launch(headless='DISPLAY' not in os.environ)
                    context = browser.new_context(locale='en-US')
                    context.set_default_timeout(2500)
                    page = context.new_page()

                    return func(*args, page=page, **kwargs)
                except:
                    args[0].fail('An exception ocurred!')
                    args[0].server.stop()
                finally:
                    context.clear_cookies()
                    context.clear_permissions()
                    context.close()
                    browser.close()
        return wrapper
    return decorator

user, password = None, None

class SaoTest(unittest.TestCase):

    @classmethod
    def setUpClass(self):
        global user, password
        self.database = 'sao_database'

        # CAUTION: When creating a database in SQLITE, by default it will create a 'in-memory' database,
        # making tryton unable to find a database to connect, because it only search databases in a directory, by default, /home/your_user/db.
        drop_create(name=self.database)

        with Transaction().start(self.database, 0, close=True, autocommit=True, readonly=False):
            pool = Pool()
            Module = pool.get('ir.module')
            User = pool.get('res.user')
            ActivateUpgrade = pool.get('ir.module.activate_upgrade', type='wizard')

            users = User.search([('login','=','admin')])

            assert users
            user, = users
            user.reset_password() # Create a reset password
            self.password = user.password_reset
            self.user = user.login
            password = self.password
            user = self.user

            records = Module.search([
                ('name', '=', 'res'),
                ('state', '!=', 'activated')
            ])
            assert len(records) == 1

            Module.activate(records) # Code from 'activate_module' of test_tryton.py, line 111
            instance_id, _, _ = ActivateUpgrade.create()
            ActivateUpgrade(instance_id).transition_upgrade()
            ActivateUpgrade.delete(instance_id)

    def setUp(self):
        global user, password
        self.database = 'sao_database'
        self.user = user
        self.password = password

        self.server = ServerThread(wsgi.app)
        self.server.start()

    @classmethod
    def tearDownClass(self):
        drop_db(name=self.database)

    def tearDown(self):
        self.server.stop()

    def login(self, page: Page):
        page.goto(f'http://{self.server.host}:{self.server.port}')
        page.wait_for_load_state('load')

        login_popup = page.locator('div.login-dialog')
        login_popup.wait_for(state='visible')

        database = page.locator('select[name="database"]')
        database_unique = page.locator('input[name="database"]')

        if database.is_hidden():
            if (database_unique.is_editable()):
                database_unique.fill(self.database)
        else:
            database.select_option(value=self.database)

        # Putting the username
        username = page.locator('input[name="login"]')
        username.wait_for(state='visible')
        username.fill(self.user)

        # Accept the username and go to the password popup
        access = page.get_by_text(re.compile('login$', re.IGNORECASE), exact=True)
        access.click()

        # Putting the password
        password = page.locator('input[type="password"]')
        password.wait_for(state='visible')
        password.fill(self.password)

        # Accept and try login
        accept = page.get_by_text(re.compile('^ok$', re.IGNORECASE))
        accept.click()

    def login_test(self, page: Page):
        login_popup = page.locator('div.login-dialog')
        database_list = page.locator('select[name="database"]')
        database_input = page.locator('input[name="database"]')

        # Putting the username
        username = page.locator('input[name="login"]')
        expect(username).to_have_count(1)
        expect(username).to_be_visible()
        expect(username).to_be_editable()
        expect(username).not_to_be_disabled()

        username.fill(self.user)
        expect(username).to_have_value(self.user)

        # Accept the username and go to the password popup
        access = page.get_by_text(re.compile('login$', re.IGNORECASE), exact=True)
        expect(access).to_have_count(1)
        expect(access).to_be_visible()
        access.click()

        # Check the ask popup
        ask_popup = page.locator("div.ask-dialog")
        expect(ask_popup).to_have_count(1)
        expect(ask_popup).to_be_visible()

        # Some verifications
        expect(database_input).to_be_disabled()
        expect(database_list).to_be_disabled()
        expect(access).to_be_disabled()

        # Putting the password
        password = page.locator('input[type="password"]')
        password.wait_for(state='visible')
        expect(password).to_have_count(1)
        expect(password).to_be_editable()
        password.fill(self.password)

        # Accept and try login
        accept = page.get_by_text(re.compile('^ok$', re.IGNORECASE))
        expect(accept).to_have_count(1)
        expect(accept).to_be_visible()
        accept.click()

        # Wait for the popups to be hidden
        expect(ask_popup).to_be_hidden()
        expect(login_popup).to_be_hidden()

    def button_fail(self, button: Locator):
        with self.assertRaises(Exception):
            button.click(trial=True)

    @browser()
    def test_01(self, page: Page):
        """
        Checking the login without database list
        """
        config.set('database', 'list', 'False')
        page.goto(f'http://{self.server.host}:{self.server.port}')
        page.wait_for_load_state('load')

        login_popup = page.locator('div.login-dialog')
        expect(login_popup).to_be_visible()

        database_list = page.locator('select[name="database"]')
        database_input = page.locator('input[name="database"]')

        expect(database_list).to_have_count(1)
        expect(database_list).to_be_hidden()
        expect(database_input).to_have_count(1)
        expect(database_input).to_be_visible()

        if (database_input.is_editable()):
            expect(database_input).to_be_editable()
            database_input.fill(self.database)
        else:
            expect(database_input).not_to_be_editable()
        expect(database_input).to_have_value(self.database)

        self.login_test(page)
1 Like

For me the benefit does not worth the cost of writing and maintaining such tests. And it will neither remove the need of actual testing.