--- name: odoo-testing-framework description: > Use this skill whenever the user is writing, reviewing, debugging, or planning tests for an Odoo Community module. Trigger phrases: "write a test", "add a unit test", "pytest-odoo", "TransactionCase", "BDD scenario", "Gherkin", "fixture", "conftest", "test is failing", "coverage", "factory", "assertQueryCount", "pre-commit", "CI pipeline", "template database", "Runboat", "Playwright", "E2E test", "CLAUDE.md", "GitLab CI", "ArgoCD", "how do I test", "vibe code Odoo". Also trigger when the user opens or edits any file matching tests/test_*.py, features/*.feature, conftest.py, or .gitlab-ci.yml in an Odoo repo. --- # Odoo Testing Framework Skill This skill encodes the complete ITSulu testing framework for Odoo Community modules. Read it top to bottom the first time you consult it in a session; return to specific sections as needed during development. --- ## 0. The Golden Rules (read these first every time) 1. **Test first.** Write a failing test before any implementation code. Never write implementation and tests at the same time. 2. **One behaviour per test.** Each test method verifies exactly one assertion. The method name is the specification: `test_gold_tier_gets_ten_percent_discount`. 3. **Isolate with TransactionCase.** Unit tests roll back automatically. Never clean up the database manually. 4. **Template databases, always.** Every CI job that runs tests must use a primed template DB. Never install Odoo from scratch per test class. 5. **10–30 E2E scenarios, maximum.** Playwright is for critical user journeys only. 6. **BDD only when stakeholders read Gherkin.** If they don't, use TDD with descriptive names. Do not generate Gherkin no-one will read. 7. **Mask all CI secrets.** Never commit tokens, passwords, or keys to YAML. 8. **Every addition is pain-driven.** Add a tool only when a concrete problem demands it, not because the roadmap has a box for it. --- ## 1. Repository Layout ``` / ├── CLAUDE.md # Conventions file — Claude Code reads this ├── .pre-commit-config.yaml ├── .gitlab-ci.yml ├── addons/ │ └── / │ ├── __manifest__.py │ ├── models/ │ ├── tests/ │ │ ├── __init__.py │ │ ├── test_.py # TransactionCase unit tests │ │ └── factories.py # OdooFactory for test data │ └── features/ # Only if using BDD │ └── .feature ├── e2e/ # Playwright scenarios │ ├── conftest.py │ └── test_.py └── conftest.py # Template DB fixture lives here ``` --- ## 2. TDD Cycle ``` RED → write test describing one behaviour (must fail) GREEN → write minimum code to make it pass REFACTOR → clean up; test stays green REPEAT → next behaviour ``` Cycle time target: **5–15 minutes per behaviour**. Never skip RED. Never write code before the test exists. --- ## 3. Writing pytest-odoo Tests ### 3.1 TransactionCase (default — use for almost everything) ```python from odoo.tests import TransactionCase, tagged @tagged('post_install', '-at_install', 'sale') class TestSaleOrderDiscount(TransactionCase): """Loyalty discount rules on sale order lines.""" @classmethod def setUpClass(cls): super().setUpClass() # Expensive shared setup — runs once per class cls.product = cls.env['product.product'].create({ 'name': 'Widget', 'list_price': 100.0, 'type': 'consu' }) def _make_order(self, tier): """Helper — always use helpers for repeated data creation.""" partner = self.env['res.partner'].create({ 'name': f'Customer-{tier}', 'loyalty_tier': tier }) return self.env['sale.order'].create({ 'partner_id': partner.id, 'order_line': [(0, 0, { 'product_id': self.product.id, 'product_uom_qty': 1.0, })] }) def test_gold_tier_gets_ten_percent_discount(self): order = self._make_order('gold') self.assertEqual(order.order_line.discount, 10.0) def test_silver_tier_gets_five_percent_discount(self): order = self._make_order('silver') self.assertEqual(order.order_line.discount, 5.0) def test_no_tier_gets_no_discount(self): order = self._make_order(False) self.assertEqual(order.order_line.discount, 0.0) ``` **Rules:** - Class name: `TestXxxYyy` — describes the unit under test. - Method name: complete sentence — `test_what_when_condition`. - `setUpClass`: shared, expensive objects (products, categories, pricelists). - `setUp` / inline: test-specific data (partners, orders, invoices). - Do **not** create shared mutable state; each test must be independent. ### 3.2 HttpCase (use rarely — only for controllers or UI tours) ```python from odoo.tests import HttpCase class TestSaleOrderUI(HttpCase): def test_create_order_tour(self): self.start_tour('/odoo/sales', 'sale_order_tour', login='admin') ``` Use HttpCase only when the test genuinely requires a running controller or JavaScript tour. It is ~10× slower than TransactionCase. ### 3.3 Test tags ```python @tagged('post_install', '-at_install') # Standard for module tests @tagged('post_install', '-at_install', 'sale') # + module-specific tag @tagged('post_install', '-at_install', 'performance') # Performance tests ``` Run subsets: ```bash pytest addons/ -m sale # Only sale-tagged tests pytest addons/ -m "not slow" # Exclude slow tests ``` ### 3.4 Common assertions ```python self.assertEqual(record.field, expected) self.assertIn(item, collection) self.assertTrue(condition) self.assertFalse(condition) self.assertRaises(ValidationError, lambda: record.write({'field': bad_value})) # Performance assertion — always add to hot paths with self.assertQueryCount(50): records.action_confirm() ``` --- ## 4. Test Data Factories Use factories, not demo data. Demo data changes; factories are explicit contracts. ```python # addons//tests/factories.py class OdooFactory: def __init__(self, env): self.env = env self._seq = 0 def _next(self): self._seq += 1 return self._seq def partner(self, **kw): vals = {'name': f'Partner-{self._next()}', 'email': f'p{self._next()}@test.com'} vals.update(kw) return self.env['res.partner'].create(vals) def product(self, **kw): vals = {'name': f'Product-{self._next()}', 'list_price': 100.0, 'type': 'consu'} vals.update(kw) return self.env['product.product'].create(vals) def sale_order(self, partner=None, lines=None, **kw): partner = partner or self.partner() lines = lines or [{'product_id': self.product().id, 'product_uom_qty': 1}] vals = { 'partner_id': partner.id, 'order_line': [(0, 0, l) for l in lines], } vals.update(kw) return self.env['sale.order'].create(vals) ``` Use in tests: ```python from .factories import OdooFactory class TestMyFeature(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.factory = OdooFactory(cls.env) def test_something(self): order = self.factory.sale_order(lines=[ {'product_id': self.factory.product(list_price=200).id, 'product_uom_qty': 2} ]) self.assertEqual(order.amount_untaxed, 400.0) ``` --- ## 5. Template Database (conftest.py) This is the single biggest speed optimisation. Always use it. ```python # conftest.py (repo root) import os import pytest import psycopg2 TEMPLATE_DB = os.environ.get('TEMPLATE_DATABASE', 'odoo_primed') PG = dict( host=os.environ.get('POSTGRES_HOST', 'localhost'), user=os.environ.get('POSTGRES_USER', 'odoo'), password=os.environ.get('POSTGRES_PASSWORD', 'odoo'), dbname='postgres', ) @pytest.fixture(scope='class') def clone_db(request): """Clone the primed template database for this test class. Drop on teardown.""" db_name = f"test_{request.cls.__name__.lower()}_{os.getpid()}" conn = psycopg2.connect(**PG) conn.autocommit = True conn.execute(f"CREATE DATABASE {db_name} TEMPLATE {TEMPLATE_DB}") conn.close() yield db_name conn = psycopg2.connect(**PG) conn.autocommit = True conn.execute(f"DROP DATABASE IF EXISTS {db_name}") conn.close() ``` CI before_script: ```yaml before_script: - createdb -h postgres -U odoo odoo_primed - odoo -d odoo_primed -i sale,your_module --without-demo=all --stop-after-init ``` Expected speedup: **8–20× faster** test setup (typically ~12×). --- ## 6. BDD with pytest-bdd Only add BDD when stakeholders read and contribute to feature files. ### 6.1 Feature file ```gherkin # addons/sale_loyalty/features/loyalty_discount.feature Feature: Loyalty discount on sale orders As a sales manager I want loyalty tier discounts applied automatically So that high-value customers feel valued Scenario Outline: Discount by tier Given a customer with loyalty tier "" When I create a sale order with 1 Widget Then the order line discount should be % Examples: | tier | discount | | gold | 10 | | silver | 5 | ``` ### 6.2 Step definitions ```python # tests/test_loyalty_bdd.py import pytest from pytest_bdd import scenarios, given, when, then, parsers scenarios('../features/loyalty_discount.feature') @pytest.fixture def ctx(): return {} @given(parsers.parse('a customer with loyalty tier "{tier}"'), target_fixture='customer') def make_customer(env, tier): return env['res.partner'].create({'name': f'BDD-{tier}', 'loyalty_tier': tier}) @when(parsers.parse('I create a sale order with {qty:d} Widget')) def create_order(env, customer, qty, ctx): product = env['product.product'].search([('name', '=', 'Widget')], limit=1) ctx['order'] = env['sale.order'].create({ 'partner_id': customer.id, 'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': qty})], }) @then(parsers.parse('the order line discount should be {pct:d}%')) def assert_discount(ctx, pct): assert ctx['order'].order_line[0].discount == pct ``` --- ## 7. Playwright E2E ### 7.1 Core rules - Maximum **10–30 scenarios**. Do not grow past this. - Run against **Runboat live URLs**, never bootstrap Odoo inside the CI job. - Selectors: `data-test-id` on custom views, `get_by_role`, `get_by_label`. - Never use auto-generated Odoo CSS classes (`.o_form_view`, `.o_field_widget`, etc.). - Add `data-test-id` to your XML views: ``. - Set default timeout ≥ 30 seconds (Odoo JS is slow to load). - Poll `BASE_URL + /web/login` until 200 before the suite starts (Runboat cold-start). ### 7.2 Session fixture ```python # e2e/conftest.py import pytest import requests import time BASE_URL = os.environ['ODOO_BASE_URL'] # Set by CI from Runboat def wait_for_odoo(url, timeout=180): deadline = time.time() + timeout while time.time() < deadline: try: r = requests.get(f"{url}/web/login", timeout=5) if r.status_code == 200: return except Exception: pass time.sleep(5) raise TimeoutError(f"Odoo at {url} not ready after {timeout}s") @pytest.fixture(scope='session') def browser_context_args(browser_context_args): wait_for_odoo(BASE_URL) return {**browser_context_args, 'base_url': BASE_URL} @pytest.fixture(scope='session') def auth_state(browser, browser_context_args): ctx = browser.new_context(**browser_context_args) page = ctx.new_page() page.goto('/web/login') page.get_by_label('Email').fill('admin') page.get_by_label('Password').fill('admin') page.get_by_role('button', name='Log in').click() page.wait_for_url('**/odoo/**') state = ctx.storage_state() ctx.close() return state @pytest.fixture def page(browser, browser_context_args, auth_state): ctx = browser.new_context(**browser_context_args, storage_state=auth_state) pg = ctx.new_page() pg.set_default_timeout(30_000) yield pg ctx.close() ``` ### 7.3 Example scenario ```python # e2e/test_sale_order.py from playwright.sync_api import expect def test_gold_customer_discount_shows_in_ui(page): page.goto('/odoo/sales/new') page.get_by_label('Customer').fill('Acme Corp') page.get_by_role('option', name='Acme Corp').click() page.get_by_role('button', name='Add a product').click() page.get_by_label('Product').last.fill('Widget') page.get_by_role('option', name='Widget').click() expect(page.locator('[data-test-id="line-discount"]').first).to_have_text('10.00') ``` --- ## 8. GitLab CI Pipeline ```yaml # .gitlab-ci.yml (skeleton) stages: [lint, test, build, preview, e2e] variables: ODOO_IMAGE: registry.example.com/oca/oca-ci/py3.11-odoo17.0:latest POSTGRES_DB: odoo_test POSTGRES_USER: odoo # POSTGRES_PASSWORD → set as masked CI/CD variable, never inline lint: stage: lint image: $ODOO_IMAGE script: - pre-commit run --all-files - pylint-odoo --rcfile=.pylintrc-odoo addons/ unit_tests: stage: test image: $ODOO_IMAGE services: - name: postgres:15 alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "shared_buffers=512MB"] before_script: - createdb -h postgres -U odoo odoo_primed - odoo -d odoo_primed -i sale,your_module --without-demo=all --stop-after-init script: - pytest addons/ --odoo-database=odoo_primed --template-database=odoo_primed --cov=addons --cov-report=xml --junitxml=report.xml artifacts: when: always reports: junit: report.xml coverage_report: {coverage_format: cobertura, path: coverage.xml} build_image: stage: build image: gcr.io/kaniko-project/executor:debug script: - /kaniko/executor --context=. --dockerfile=Dockerfile --destination=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" runboat_build: stage: preview image: curlimages/curl:latest script: - | RESP=$(curl -fsSL -X POST $RUNBOAT_URL/builds \ -H "Authorization: Bearer $RUNBOAT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"repo":"'"$CI_PROJECT_PATH"'","sha":"'"$CI_COMMIT_SHA"'"}') BUILD_URL=$(echo $RESP | jq -r .url) echo "BUILD_URL=$BUILD_URL" >> build.env - | for i in $(seq 1 36); do curl -sf "$BUILD_URL/web/login" && break || sleep 5 done - 'curl -sf -X POST "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" -H "PRIVATE-TOKEN: $GITLAB_BOT_TOKEN" --data "body=Preview: $BUILD_URL"' artifacts: reports: {dotenv: build.env} rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" playwright_e2e: stage: e2e image: mcr.microsoft.com/playwright/python:latest needs: [runboat_build] script: - pip install -r e2e/requirements.txt - pytest e2e/ --base-url=$BUILD_URL --tracing=retain-on-failure artifacts: when: on_failure paths: [e2e/traces/] expire_in: 1 week rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" ``` ### CI secrets — always mask | Variable | Where to set | Notes | |---|---|---| | `POSTGRES_PASSWORD` | GitLab CI/CD variables | Masked | | `RUNBOAT_TOKEN` | GitLab CI/CD variables | Masked + Protected | | `GITLAB_BOT_TOKEN` | GitLab CI/CD variables | Masked + Protected | | `CI_REGISTRY_PASSWORD` | Auto (GitLab) | Built-in | Never put secrets in `.gitlab-ci.yml`. Never `echo $SECRET` in scripts. --- ## 9. Pre-commit Configuration ```yaml # .pre-commit-config.yaml repos: - repo: https://github.com/OCA/pylint-odoo rev: v9.1.0 hooks: - id: pylint-odoo additional_dependencies: [pylint-odoo] - repo: https://github.com/psf/black rev: 24.3.0 hooks: - id: black language_version: python3 - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 additional_dependencies: [flake8-bugbear] - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort ``` Run locally: `pre-commit run --all-files` Run on commit: automatic after `pre-commit install` --- ## 10. CLAUDE.md Template Copy this to the repo root and fill in the blanks. Claude Code reads this automatically. ```markdown # CLAUDE.md ## Project Odoo 17.0 Community — ITSulu Framework: pytest-odoo (unit), pytest-bdd (BDD if stakeholders engage), Playwright (E2E) Deploy: GitLab CI → Kaniko → ArgoCD → ITSulu Kubernetes ## Layout addons// Odoo addon code addons//tests/ Unit tests (TransactionCase) addons//features/ Gherkin features (BDD, when used) e2e/ Playwright E2E scenarios .gitlab-ci.yml Six-stage CI pipeline ## Development workflow — STRICT TDD 1. Write a failing test. Show it to me for approval. 2. I confirm the test matches intent before you write code. 3. Write minimum code to make it pass. 4. Run the full test class. Confirm green. 5. Refactor if needed. Test stays green. 6. Run pre-commit before considering the task done. 7. Never proceed to step 3 before step 2 is complete. ## Test naming Class: TestXxxYyy (describes unit under test) Method: test_what_when_condition (complete sentence, underscore-separated) Good: test_gold_tier_customer_gets_ten_percent_discount Bad: test_discount, test1, test_it_works ## When to use BDD Only when a business stakeholder reads the feature file. Ask me first. Default: TDD only with descriptive test names. ## Commands pytest addons/ Run all tests pytest addons/ -k TestSaleOrderDiscount Run one class pytest addons/ -m "not slow" Exclude tagged tests pytest e2e/ --base-url=$BUILD_URL E2E against Runboat pre-commit run --all-files Lint + format ## Patterns to follow - Use OdooFactory from tests/factories.py for record creation - Use assertQueryCount on hot paths (confirm budget with me first) - Add data-test-id to custom view fields used by Playwright - One assertion per test method (exceptions need justification) ## Patterns to avoid - Never create records using demo data or pre-seeded fixtures - Never bypass pre-commit hooks - Never silence test failures with try/except or xfail without issue ref - Never write "assert True" or trivially-passing tests - Never combine setup + assertion in one line ## When in doubt Stop and ask me before: creating a new addon, modifying __manifest__, changing a model that another module inherits, any sudo() or ir.rule change. Show options with trade-offs. I will choose. ``` --- ## 11. Failure Recovery Quick Reference | Symptom | Most likely cause | Fix | |---|---|---| | All tests fail with `relation does not exist` | Template DB corrupted / stale | Rebuild: `dropdb odoo_primed && createdb odoo_primed && odoo -d odoo_primed -i ...` | | E2E first scenario fails, rest pass | Runboat not ready | Add readiness poll: `curl -sf $URL/web/login \|\| sleep 5` in a loop | | E2E selector breaks after Odoo update | Auto-generated class changed | Replace with `data-test-id` attribute or `get_by_role` | | Pipeline stuck "initializing" | Runboat cluster resource exhaustion | Increase namespace ResourceQuota; close stale MRs | | Flaky test fails > twice/week | Non-deterministic test | Quarantine with `@pytest.mark.skip(reason="flaky #123")`; fix within 1 week | | Secret appears in CI logs | Variable not masked | Rotate immediately; add to masked variables in GitLab | | ArgoCD stuck OutOfSync | Manual cluster change | `argocd app diff`; revert manual change; re-sync | --- ## 12. SLO Targets | Metric | Target | Why | |---|---|---| | Unit test suite duration | < 3 min | Keeps TDD cycles tight | | Full pipeline P50 | < 15 min | Fast enough to wait for | | Full pipeline P95 | < 25 min | Outliers do not ruin the day | | Flaky test rate | < 2%/week | Maintains trust in suite | | Coverage on new code | ≥ 80% | Enforces test-first habit | | Runboat cold-start P95 | < 120 s | E2E does not time out | | Production deploy MTTR | < 15 min | Git revert + ArgoCD sync | --- ## 13. Phased Adoption Triggers **Add Phase 2 (BDD, factories, coverage gates) when ≥ 2:** - Stakeholders ask "how do we know X works?" - Tests break on demo-data changes, not code changes - Production N+1 regression caught post-deploy - Coverage < 60% on touched files **Add Phase 3 (Runboat) when ≥ 2:** - Stakeholder asks "can I see it before merge?" - Staging environment contention costs developer time - Bugs escape CI that a human click-through would catch **Add Phase 4 (Playwright, migration testing) when ≥ 2:** - Production incident that automated testing would have caught - Odoo upgrade requires emergency rollback - Team says "I don't trust the test suite"