Orcraft 1.1.3 public beta released 08-Nov-2026. The Free Plan and the Team trial are open; card payments and invoices open shortly.

Orcraft 1.1.3 public beta released 08-Nov-2026. The Free Plan and the Team trial are open; card payments and invoices open shortly.

Orcraft

The platform for test development and orchestration
that lets you and your AI Agents run the entire testing operation

Watch the demo

Orcraft Setup, Teardown and Exit

Work that has to happen around the cases rather than inside them - launching an application, opening a connection, generating shared inputs - belongs in a setup or teardown method. A test can bracket three scopes, each with a setup and a teardown, and every one of them is optional.

Method Runs Typical use
_setup() Once, before the first case Launch an application, open a connection, generate shared inputs.
_teardown() Once, after the last case Release whatever _setup acquired.
_test_cycle_setup(index) At the start of every test cycle Rebuild per-cycle state so one cycle never inherits residue from the last.
_test_cycle_teardown(index) At the end of every test cycle Release per-cycle state.
_case_setup(case, index) Before each case's cycles Prepare per-case state, tailored using case.params.
_case_teardown(case, index) After each case's cycles Release per-case state.

Scope

The scope is a statement about how long the state is valid for. Something expensive that every case can share - an application process, a device connection - belongs in _setup. Something that must be pristine at the start of each pass through the matrix belongs in _test_cycle_setup. Something derived from the case parameters belongs in _case_setup, which is the only one that receives the case.

The framework knows which of these methods a test overrides and shows them in the run plan, so the extra lifecycle work is visible before the run rather than a surprise in the log.

Test scope

def _setup(self) -> None:
	workspace_folder = self._workspace_folder()

	Logger.log(
		f"Setup: preparing {self.config.input_count} input files in {workspace_folder}",
		color=Logger.Color.CYAN,
	)

	flow = MyPrepareInputsFlow(
		MyPrepareInputsFlowConfig(
			app_folder=self.config.app_folder,
			workspace_folder=workspace_folder,
			count=int(self.config.input_count),
		)
	)

	# Raising from _setup aborts the test: no cases run and _teardown is skipped.
	if self.handle_errors(flow.run(), self.result):
		raise RuntimeError(
			self.result.error_description or "Setup failed to prepare the input files."
		)

def _teardown(self) -> None:
	MyCleanupFlow(
		MyCleanupFlowConfig(workspace_folder=self._workspace_folder())
	).run()

Flows and steps called from a setup or teardown inherit the same endpoint and RPC security context as a case body, so setup work can reuse the very flows the cases use. That is worth leaning on: preparing inputs with the same flow that a case exercises keeps one implementation instead of two.

Case scope

_case_setup and _case_teardown receive the case, so they can tailor their work to that case's parameters:

def _case_setup(self, case: OrcraftTestCase, test_cycle_index: int) -> None:
	source = self._input_file_for(case.params["input_index"])
	shutil.copyfile(source, self._working_copy_path(case))

def _case_teardown(self, case: OrcraftTestCase, test_cycle_index: int) -> None:
	os.remove(self._working_copy_path(case))

Both run inside the case, nested beneath its banner in the log, and both wrap the case's cycle loop - so a case that repeats three times gets one setup, three executions and one teardown, not three of each.

Setup and teardown failures

Raising from a setup is how you say the work it was guarding must not proceed. Raising from a teardown says the scope could not be closed cleanly. Either way the blast radius follows the scope of the method, and the run goes on with the next scope of the same kind:

Raising from Effect
_setup The whole test is aborted: no cases run, _teardown is skipped, and the test is marked ERROR.
_test_cycle_setup That cycle errors: its cases do not run and its teardown is skipped. The next test cycle still runs.
_case_setup That case errors: its cycles do not run and its teardown is skipped. The next case still runs.
_teardown The test is marked ERROR with the teardown's message; the cases keep the results they produced.
_test_cycle_teardown That cycle is marked ERROR; its cases keep their results and the next test cycle still runs.
_case_teardown That case is marked ERROR, whatever its cycles reported, and the next case still runs.

When a setup raises, the matching teardown is skipped, because there is nothing to unwind - a setup that did not complete did not acquire what its teardown would release. The scope's error description names the method, as in "Case setup failed: ..." or "Test cycle teardown failed: ...", followed by the message.

A call to exit() inside a setup or teardown has the same effect as raising from it, with "exit() called by user" as the message; Calling exit() below goes through every place an exit can happen.

The mirror-image guarantee holds for the success path: once a setup completes, its teardown runs even if the work between them raised. Resources acquired in a setup are released whether the cases passed, failed or errored.

Calling exit()

Python's exit() and sys.exit() raise SystemExit, and a plain script ends the moment one is raised. In a suite that would be the wrong outcome: one case giving up must not take the rest of the run with it. So the framework treats an exit exactly like an uncaught exception. The component the call happened in ends with ERROR and the error description exit() called by user, no traceback is logged, and the run goes on with whatever comes next at that level. The exit's own code is discarded: a test run from the command line exits with the framework's error code, the same one an exception produces.

An exit in the case body ends that case. The case cycle it happened in is reported as ERROR, the case is counted as one executed, errored case, its remaining case cycles still run, its _case_teardown still runs, and the next case starts as usual.

An exit inside _run_step ends that step, whether the step runs locally or on an endpoint. On an endpoint the step's result comes back to the runner like any other result, and the endpoint application keeps serving. The flow that ran the step receives the errored step result and, if it hands it on with handle_errors, ends with the same description; the case that ran the flow does the same, and the run continues with the next case. An exit directly inside _run_flow ends the flow the same way.

def _run_flow(self) -> OrcraftTestFlowResult:
	step_result = self.run_step(StepLaunchApp, app_folder=self.config.app_folder)
	# An exit inside the step arrives here as an ERROR result, not as an exception.
	if self.handle_errors(step_result, self.result):
		return self.result
	...

An exit in a setup or teardown ends the scope it guards, exactly as raising from it does: the outcome per method is the Setup and teardown failures table above, with exit() called by user as the message.

The test's own status is the worst of what happened inside it, so a test with one errored case, cycle, setup or teardown reads ERROR even when everything else passed, and its error description is the first one recorded. The counters still tell the full story: the cases that ran and passed are counted as passed, the errored ones as errors, and none of them as done.

A group or a suite has no code of its own that runs; it holds tests, and each test runs in its own process, so an exit anywhere inside one test ends at most that test. The group or suite records it as ERROR and runs its other members as planned.

The one place an exit is meant to end the process is the script that runs the suite, after run() has returned. That is how a CI job or a script written by an AI agent turns the result into a process exit code:

if __name__ == "__main__":
	result = MyTest().run()
	sys.exit(0 if result.status is OrcraftStatus.PASS else 1)

Inside a component, reach for a checkpoint with is_error=True or assign error_description instead of calling exit(): the outcome is the same error, with a message that says why.

Checkpoints in setup and teardown

Inside a setup or teardown, a failing checkpoint is always treated as an ERROR, whether or not is_error was passed:

def _setup(self) -> None:
	self.checkpoint(
		os.path.isdir(self.config.app_folder),
		"App folder found.",
		f"App folder not found: {self.config.app_folder}",
	)

In a case body the same call would record a failure and carry on, because a case is expected to report every problem it finds. In a setup or teardown it aborts the scope instead - a precondition that does not hold is not a finding about the system under test, it is a reason not to test it yet. The failure is rendered as a clean error message rather than a traceback, since a lifecycle assertion failing is an expected outcome, not a crash.

Type to search the docs.

    ↑↓ navigate ↵ open Esc close