Testing a mod
Status: guide (2026-10-22).
Almost everything a mod does can be driven without a window or a GPU. The
application’s own tests do exactly that, by executing InitGui.py and calling
the same functions the shell calls.
Run the suite with:
PYTHONPATH=python python3 -m unittest discover -s tests -v
What needs no GPU
| You can test | How |
|---|---|
| import-time registration | load InitGui.py by path, then inspect FreeCADGui.listCommands() |
| command resources and enablement | FreeCADGui._command_list(), FreeCADGui.listCommands() |
| running a command | ferrocad_shell.run_command(id) or FreeCADGui.runCommand(id) |
| dialogs, accept and cancel | ferrocad_shell.dialog_accept / dialog_cancel |
| provider selection and switching | registerProvider, activeProvider, setActiveProvider, refreshProviders |
selection-dependent IsActive | ferrocad_shell.set_selection(...) |
view-dependent IsChecked | ferrocad_shell.set_view_state(...) |
| display meshes | FerroCAD.display_mesh(obj) or ferrocad_shell.stage_meshes() |
| interaction commits | ferrocad_shell.interaction(...) |
| overlay registration | needs a pivy.coin facade; the Coin tests install a stub |
| shader compilation, drawing, the window | needs a GPU and a viewport |
Load the mod by path
Do not depend on sys.path order. Load the file directly:
import importlib.util
import pathlib
import FreeCADGui
MOD = pathlib.Path("mods/MinimalBox/InitGui.py").resolve()
def load_mod():
FreeCADGui.removeCommand("Minimal_AddBox") # reload-safe
spec = importlib.util.spec_from_file_location(
"MinimalBox_InitGui", MOD
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
This is the pattern in tests/test_grid_overlay_fork.py:
import sys
before = set(sys.modules)
FreeCADGui.removeCommand(COMMAND)
spec = importlib.util.spec_from_file_location("GridOverlay_InitGui", MOD_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
It also records the modules the mod imported, so a test can assert that loading the mod did not pull in Qt or Coin.
If the mod is already registered in this interpreter, removeCommand is what
avoids the registry’s duplicate-id ValueError. A provider re-registration is
safe; a display/interaction provider is not removable, so load that module once
per process.
A test class
import importlib.util
import json
import pathlib
import unittest
import FreeCAD
import FreeCADGui
import ferrocad_shell
MOD = pathlib.Path("mods/MinimalBox/InitGui.py").resolve()
COMMAND = "Minimal_AddBox"
class MinimalModTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
FreeCADGui.removeCommand(COMMAND)
spec = importlib.util.spec_from_file_location("MinimalBox_InitGui", MOD)
cls.mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.mod)
@classmethod
def tearDownClass(cls):
FreeCADGui.removeCommand(COMMAND)
def setUp(self):
for name in list(FreeCAD.listDocuments()):
FreeCAD.closeDocument(name)
self.doc = FreeCAD.newDocument("Test")
def tearDown(self):
FreeCADGui._cancel_all()
FreeCAD.closeDocument(self.doc.Name)
def test_the_command_is_registered(self):
self.assertIn(COMMAND, FreeCADGui.listCommands())
def test_the_command_creates_a_displayed_box(self):
result = json.loads(ferrocad_shell.run_command(COMMAND))
self.assertTrue(result["ok"], result)
meshes = json.loads(ferrocad_shell.stage_meshes())
self.assertEqual(meshes[0]["object"], "Box")
self.assertEqual(len(meshes[0]["triangles"]) // 3, 12)
The class loads the module once, so the display provider is registered once.
tearDownClass removes the command; the provider stays for the process, which
is the best the facade allows.
Push the shell’s state
The shell owns selection and presentation state. In a test, push them yourself.
import json
import FreeCADGui
import FerroCADGui
import ferrocad_shell
import ferrocad_toy
ferrocad_toy.bootstrap("State")
ferrocad_shell.set_selection(
json.dumps([{"object": "Slab", "elements": ["Face1"]}])
)
assert FreeCADGui.Selection.getSelection()[0].Name == "Slab"
assert FreeCADGui.Selection.getSelectionEx()[0][1] == ["Face1"]
ferrocad_shell.set_view_state(
json.dumps({"grid": True, "projection": "ortho"})
)
assert FerroCADGui.ViewState.grid is True
assert FerroCADGui.ViewState.projection == "ortho"
set_selectionpayload:[{"object": name, "elements": [...]}, ...]. Objects that are no longer in the document are pruned when the snapshot is resolved.set_view_statepayload: any ofgrid,axis_cross,projection,grid_minor,grid_major,grid_radius. Missing keys use defaults (grid=False,axis_cross=True,projection="persp",grid_minor=10.0,grid_major=100.0,grid_radius=None).
Clear the selection at the end with ferrocad_shell.set_selection("[]") so the
next test does not inherit it.
Run commands and dialogs
| Call | Returns |
|---|---|
FreeCADGui.runCommand(id) | the raw result of Activated; raises KeyError for an unknown id |
FreeCADGui._run(id) | the shell payload {ok, status?, value?, view?, tool?, dialog?, token?}; never raises |
ferrocad_shell.run_command(id) | the shell payload as a JSON string |
import json
import ferrocad_shell
run = json.loads(ferrocad_shell.run_command("View_GridSettings"))
assert "token" in run
accepted = json.loads(ferrocad_shell.dialog_accept(json.dumps({
"token": run["token"],
"values": {"enabled": True, "color": "#00ff00"},
})))
assert accepted["ok"], accepted
View_GridSettings is used here because it is already registered; a mod command
with an on_accept behaves identically.
Cancel instead:
import json
import ferrocad_shell
run = json.loads(ferrocad_shell.run_command("View_GridSettings"))
assert json.loads(ferrocad_shell.dialog_cancel(run["token"])) == {"ok": True}
tests/test_grid_overlay_fork.py uses FreeCADGui._run and FreeCADGui._accept
directly; those are the private half of the same protocol.
Providers
tests/test_mod_providers.py is the
reference. Its pattern:
- load each mod once, then call the module’s
register_provider()insetUpbecause the module object is cached and the registry may have been cleared; - clear the applied-backend map and the persisted choices between cases;
- switch with
setActiveProviderand assert the callbacks ran exactly once; unregisterProviderevery backend intearDown.
import FreeCAD
import FreeCADGui
FreeCADGui._PROVIDER_APPLIED.clear()
FreeCAD.ParamGet(
"User parameter:BaseApp/Preferences/Mods"
).GetGroup("Providers").Clear()
_PROVIDER_APPLIED is private; it is acceptable in tests that need to forget
which backend was applied while keeping the registrations.
import FerroCADGui
FerroCADGui.registerProvider("demo", "a", "A", priority=10)
FerroCADGui.registerProvider("demo", "b", "B", priority=0)
assert FerroCADGui.activeProvider("demo") == "a"
result = FerroCADGui.setActiveProvider("demo", "b")
assert result["previous"] == "a"
assert result["ok"]
for entry in FerroCADGui.providers("demo"):
FerroCADGui.unregisterProvider("demo", entry["backend"])
tests/test_mod_providers.py also asserts that callback errors are reported
under errors and do not break the registry.
Display and interaction
import json
import FreeCAD
import FerroCAD
import ferrocad_shell
import ferrocad_toy
ferrocad_toy.bootstrap("Display")
ferrocad_toy.makeSlab(name="Box")
mesh = FerroCAD.display_mesh(FreeCAD.ActiveDocument.getObject("Box"))
assert len(mesh["triangles"]) // 3 == 12
response = json.loads(
ferrocad_shell.interaction(
"Slab", "toy_height", "set", "", json.dumps([42.0])
)
)
assert response["ok"]
stage_meshes()needs an active document; it returns[]otherwise.- A mesh reaches the viewport only if a provider claims the object and
positionsis not empty. interaction()returns{ok: false, error: "no module handles ..."}when no provider claims the request.
Cleanup checklist
A test that leaves global state behind makes the next test lie. At the end of a case (or class):
FreeCADGui.removeCommand(id)for every command the test registered.FerroCADGui.unregisterProvider(feature, backend)for every provider.FreeCADGui._cancel_all()to drop open dialog tokens.FreeCADGui._PROVIDER_APPLIED.clear()and clear theProviderspreference group if the test changed the provider selection.FreeCAD.closeDocument(name)for every document.FerroCAD.Grid.reset()if the test changed grid settings.ferrocad_shell.set_selection("[]")if the test pushed a selection.FerroCADGui.removeOverlay(node)if the test registered an overlay.
You cannot unregister a display or interaction provider. Keep those modules loaded once, or accept that each re-execution adds another provider.
What still needs a window
- Overlay extraction needs
pivy.coinwithaddOverlay/removeOverlay.tests/test_grid_overlay_coin.pyinstalls a fakepivy.coinand asserts the bridge calls; the real draw is manual. - Shader compilation, the viewport, and anything that measures pixels need a
GPU and
cargo run -p ferrocad_gpui. - The headless facade has
FreeCAD.GuiUp == 0;FerroCADGui.ViewStateis still writable byset_view_state, andFerroCAD.Grid.snapshot()reads it only whenGuiUpis truthy.