tests/common.py)Extracted 2026-07-29 after the DTON rollout’s own DTON pass over the test suite itself found
the same fixture/mock boilerplate hand-written identically across dozens of files —
tests/ had grown to more lines than models/ (17,576 vs. 14,773), and several of these
exact patterns had already independently drifted or been subtly wrong in more than one file
before being unified here. Not a test framework — just four plain functions imported where
needed (from .common import ...), no base class required.
create_level_study(cls, prefix, **overrides) / create_level_study_group(cls, prefix, **overrides)The single most duplicated fixture in the suite: a level+study(+group) triple with unique
codes, needed by nearly every test touching curriculum-adjacent data. create_level_study
returns (level, study); create_level_study_group composes on top of it and additionally
returns group.
cls.level, cls.study = create_level_study(cls, 'TAI', study={'name': 'Test Study (Attendance Issue)'})
cls.level, cls.study, cls.group = create_level_study_group(cls, 'TCF', level={'name': '...'}, study={'code': 'TCF001', ...})
prefix becomes the level’s and study’s acronym by default (f'{prefix}-01' for the
study’s code); pass level=/study=/group= sub-dicts to override any field that doesn’t
match a given file’s actual fixture data — most callers need at least a name override, and
several need code/acronym overrides too since not every file used the same value for both.
Why two functions, not one: the majority of real call sites create a subject (or some
other record) between study and group — create_level_study_group alone didn’t fit
roughly half the files this was extracted from, so create_level_study (level+study only) is
the primary building block; call it directly and create group by hand afterward whenever
something needs to happen in between.
Not a fit for: tests of ems.level/ems.study/ems.group themselves that exercise
validation edge cases (missing required fields, duplicate acronyms, etc.) — those need
deliberately incomplete/colliding data, the opposite of what a “give me a valid one” factory
provides. tests/test_level.py was left unmigrated for exactly this reason.
mock_outgoing_email(cls)The mechanism CLAUDE.md’s “Email safety in tests” section already mandates
(patch('...ir_mail_server.IrMailServer.send_email', return_value='test-message-id'),
started in setUpClass, stopped via addClassCleanup), as a one-line call instead of a
copy-pasted five-line block — reduces the chance a future test forgets it, since forgetting it
means a real SMTP send to a real address (see CLAUDE.md for why this environment’s mail
servers are real and credentialed).
mock_outgoing_email(cls)
# or, if a test needs to assert on the mock itself (call count, reset_mock()):
cls.mail_transport = mock_outgoing_email(cls)
make_synchronous_run_in_thread(record)Every LimeSurvey test that exercises run_action()/action methods needs run_in_thread
mocked to run setup/compute/store/callback synchronously against a real record instead
of spawning a real thread (see multithreading.md for why real threading
can’t be exercised in TransactionCase at all). This factory returns exactly that
side_effect function, closing over record:
with patch.object(type(header), 'run_in_thread', side_effect=make_synchronous_run_in_thread(header), autospec=True):
...
Only fits when the record already exists at patch-setup time. One test in
tests/test_limesurvey_recipient.py (test_create_manual_on_uploaded_header_triggers_upload_without_real_api)
creates the recipient inside the mocked block itself (create()’s manual-state flow
triggers action_upload() synchronously) — that one keeps its own inline closure using the
actual self_recipient argument autospec passes at call time, since there’s no pre-existing
record to close over.
DocsScreenshotMixin (user-manual screenshots)The screenshots in docs/assets/<role>/ are regenerated by HttpCase classes that mix in
DocsScreenshotMixin: one file per role, tests/test_docs_screenshots_<role>.py (plus the
original test_docs_screenshots.py and test_docs_screenshots_academic_history.py). Each builds
fictitious data in its own rolled-back transaction, logs in as a fixture user of that role with
lang='ca_ES', and calls _capture() once per image. The PNGs land in /tmp/ems_doc_screenshots
(EMS_SCREENSHOT_DIR overrides it) and are copied into docs/assets/<role>/ by hand, since the
tests run as the odoo user. Read every image back before publishing it: nothing personal may
show (see CLAUDE.md).
Every class is tagged -standard so no normal run picks it up, which also means the bare
./test.sh ClassName shorthand finds 0 tests. Run one by hand:
sudo -u odoo bash -c "odoo -d ems -u ems --test-enable --test-tags='*/ems:TestDocsScreenshotsTutors' --stop-after-init -c /etc/odoo/odoo.conf"
A new file must be imported in tests/__init__.py, or it silently runs 0 tests.
_capture(url_path, selector, filename, login=None, ...)Loads url_path (1400px-wide viewport) as login (the password is the login), waits for
wait_for (default: selector), optionally performs click or run (a CSS selector / a raw JS
expression, or a list of them, each paired with its own wait_after), then saves a PNG clipped to
selector and trims the uniform background around it. max_height cuts a tall element short.
login=None renders a page before signing in (it forces Accept-Language: ca, which is what the
login page follows). tour runs a registered tour instead of the plain wait.
marks=[(selector, label[, anchor]), ...] draws the numbered callouts a manual’s text refers
to (“click (1), then (2)”) next to each element. anchor: left (default), right, top,
center, or text-right (right after the element’s own text rather than its box - for a table
cell or group header that spans the whole row). A selector that matches nothing fails the test
naming it.click='mouse:<selector>' clicks with a real (trusted) mouse event through CDP instead of
.click(), for a control that ignores synthetic clicks (the apps menu dropdown).beyond_viewport=False for a shot of an open navbar section dropdown (Educational Community >
Students): the default capture makes Chrome resize the page and Odoo closes that dropdown (the apps
menu survives it). Open it once the view’s data has loaded, since the navbar redraws then, and keep
the clip inside the viewport (max_height)._union_clip_js([sel1, sel2, ...]) (use it in run, then clip to #ems-clip) lays an
invisible box over several blocks that share no container, so one shot can span, say, a payment
plan and the bank-account notice below it.ir.actions.act_window of your
own with domain=[('id', 'in', fixture.ids)] and open /odoo/action-<id>[/<res_id>]. The same
applies to anything a screen computes centre-wide: the course transition preview names up to 10
students with no group whatever studies are picked, so its test patches _orphan_students to
return nothing. Shipped configuration (levels, space types, statuses) can be shown as is, limited
to the records EMS owns (ir.model.data, module ems) when a centre may have added its own.with_user(<login user>): a
transient record is only readable by its creator. Wizards driven by active_ids open through an
own action with target='new' and context={'active_ids': [...]}.wait_for/click selectors run through document.querySelector: no :contains() (that is
tour syntax); target button[name='...'], .nav-link[name='<page name>'], data-* attributes.
To require two rows use .o_data_row + .o_data_row, not :nth-of-type(2) (a group header row
counts as a sibling)..o_list_table (sized to its rows) rather than
.o_list_renderer/.o_content, which stretch to the viewport and defeat the trim - except after
a click that expands rows, where .o_content is safer because the table’s box is read before it
relays out. A form’s stat buttons (button_box) render in the control panel, outside
.o_form_sheet: clip .o_action_manager with max_height. A dropdown overflows its own element
(clip an ancestor such as #wrapwrap with max_height). A dialog: .modal-content.<setting id="x"> renders #x; clip a block with
.o_settings_container:has(#x), reach the EMS tab with click='a.tab[data-key="ems"]', and log
in with a user holding ems.group_settings_admin (academic admin alone cannot open Settings).run
(set input.value, dispatch input with bubbles: true), wait for
.o-autocomplete--dropdown-item a and click it; typing a value keeps its dropdown short enough
to stay inside a dialog.click the button[data-bs-target='#modal-x'], wait for
#modal-x.show, clip #modal-x .modal-content. Native file and date inputs render in headless
Chrome’s own language (English): browser chrome, not EMS text.search_default_group_by_*, or two levels
deep) is simpler to capture through an own action with no group_by than by clicking its
headers open.create(): set the resulting field directly
on the fixture instead (e.g. line.attendance_justification_id).start_time=0.0, end_time=23.0).domain (and context for a default filter) to the fixture ids,
e.g. self.env.ref('ems.action_ems_applicants').domain = str([('id', 'in', ids)]); for a server
action, overwrite its code. The navbar, breadcrumbs and filters then are the real ones. Likewise
replace any real people a screen lists by configuration (task assignees, the company e-mail a
development box rewrote) with fixtures before capturing.[data-menu-xmlid='<menu xmlid>']) and let the manual’s text give the
full path. Dropdown entries carry no xmlid: to mark one, give it an id first with a small run
script that finds it by its label.partner.signup_prepare(signup_type='signup')
and signup_force_type_in_url='signup', or it falls back to the plain login page. An e-mail can
be captured by rendering its template (_render_field('body_html', ...)) and writing it into a
blank page with run.flush_all() right after creating the fixture in a Catalan environment, or the pending
compute runs later in another context._fields[f].selection read, a
string without _(), a .po block missing a view’s or field’s #: reference), and fixing it
before the image is published.No dedicated test file for tests/common.py itself — these are test utilities, exercised
implicitly by every test file that imports and uses them (all of tests/, effectively).