diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..07a0304 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,651 @@ +--- +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" \ No newline at end of file