Added SKILL_odoo_testing_framework.md which is the Odoo Testing Framework Skill
This commit is contained in:
parent
e9d3e30925
commit
1c8cfa21cb
1 changed files with 651 additions and 0 deletions
651
CLAUDE.md
Normal file
651
CLAUDE.md
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
```
|
||||||
|
<repo-root>/
|
||||||
|
├── CLAUDE.md # Conventions file — Claude Code reads this
|
||||||
|
├── .pre-commit-config.yaml
|
||||||
|
├── .gitlab-ci.yml
|
||||||
|
├── addons/
|
||||||
|
│ └── <module>/
|
||||||
|
│ ├── __manifest__.py
|
||||||
|
│ ├── models/
|
||||||
|
│ ├── tests/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── test_<feature>.py # TransactionCase unit tests
|
||||||
|
│ │ └── factories.py # OdooFactory for test data
|
||||||
|
│ └── features/ # Only if using BDD
|
||||||
|
│ └── <feature>.feature
|
||||||
|
├── e2e/ # Playwright scenarios
|
||||||
|
│ ├── conftest.py
|
||||||
|
│ └── test_<journey>.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/<module>/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 "<tier>"
|
||||||
|
When I create a sale order with 1 Widget
|
||||||
|
Then the order line discount should be <discount>%
|
||||||
|
|
||||||
|
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: `<field name="discount" data-test-id="line-discount"/>`.
|
||||||
|
- 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/<module>/ Odoo addon code
|
||||||
|
addons/<module>/tests/ Unit tests (TransactionCase)
|
||||||
|
addons/<module>/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"
|
||||||
Loading…
Reference in a new issue