support call activities in process model test runner w/ burnettk

This commit is contained in:
jasquat 2023-05-17 16:35:04 -04:00
parent f2439848c0
commit acaf3a3c24
8 changed files with 280 additions and 37 deletions

View File

@ -39,16 +39,26 @@ class MissingBpmnFileForTestCaseError(Exception):
@dataclass
class TestCaseResult:
passed: bool
bpmn_file: str
test_case_name: str
error: Optional[str] = None
DEFAULT_NSMAP = {
'bpmn': 'http://www.omg.org/spec/BPMN/20100524/MODEL',
'bpmndi': 'http://www.omg.org/spec/BPMN/20100524/DI',
'dc': 'http://www.omg.org/spec/DD/20100524/DC',
}
# input:
# json_file:
# {
# [TEST_CASE_NAME]: {
# "tasks": {
# [BPMN_TASK_IDENTIIFER]: [DATA]
# [BPMN_TASK_IDENTIIFER]: {
# "data": [DATA]
# }
# },
# "expected_output_json": [DATA]
# }
@ -62,17 +72,23 @@ class ProcessModelTestRunner:
def __init__(
self,
process_model_directory_path: str,
instantiate_executer_callback: Callable[[str], Any],
execute_task_callback: Callable[[Any, Optional[dict]], Any],
get_next_task_callback: Callable[[Any], Any],
process_model_directory_for_test_discovery: Optional[str] = None,
instantiate_executer_callback: Optional[Callable[[str], Any]] = None,
execute_task_callback: Optional[Callable[[Any, Optional[dict]], Any]] = None,
get_next_task_callback: Optional[Callable[[Any], Any]] = None,
) -> None:
self.process_model_directory_path = process_model_directory_path
self.test_mappings = self._discover_process_model_directories()
self.process_model_directory_for_test_discovery = process_model_directory_for_test_discovery or process_model_directory_path
self.instantiate_executer_callback = instantiate_executer_callback
self.execute_task_callback = execute_task_callback
self.get_next_task_callback = get_next_task_callback
self.test_case_results: list[TestCaseResult] = []
self.bpmn_processes_to_file_mappings: dict[str, str] = {}
self.bpmn_files_to_called_element_mappings: dict[str, list[str]] = {}
self.test_mappings = self._discover_process_model_test_cases()
self._discover_process_model_processes()
def all_test_cases_passed(self) -> bool:
failed_tests = [t for t in self.test_case_results if t.passed is False]
@ -87,59 +103,161 @@ class ProcessModelTestRunner:
try:
self.run_test_case(bpmn_file, test_case_name, test_case_contents)
except Exception as ex:
self.test_case_results.append(
TestCaseResult(
passed=False,
test_case_name=test_case_name,
error=f"Syntax error: {str(ex)}",
)
)
self._add_test_result(False, bpmn_file, test_case_name, f"Syntax error: {str(ex)}")
def run_test_case(self, bpmn_file: str, test_case_name: str, test_case_contents: dict) -> None:
bpmn_process_instance = self.instantiate_executer_callback(bpmn_file)
next_task = self.get_next_task_callback(bpmn_process_instance)
bpmn_process_instance = self._instantiate_executer(bpmn_file)
next_task = self._get_next_task(bpmn_process_instance)
while next_task is not None:
test_case_json = None
test_case_task_properties = None
if "tasks" in test_case_contents:
if next_task.task_spec.bpmn_id in test_case_contents["tasks"]:
test_case_json = test_case_contents["tasks"][next_task.task_spec.bpmn_id]
test_case_task_properties = test_case_contents["tasks"][next_task.task_spec.bpmn_id]
task_type = next_task.task_spec.__class__.__name__
if task_type in ["ServiceTask", "UserTask"] and test_case_json is None:
if task_type in ["ServiceTask", "UserTask", "CallActivity"] and test_case_task_properties is None:
raise UnrunnableTestCaseError(
f"Cannot run test case '{test_case_name}'. It requires task data for"
f" {next_task.task_spec.bpmn_id} because it is of type '{task_type}'"
)
self.execute_task_callback(next_task, test_case_json)
next_task = self.get_next_task_callback(bpmn_process_instance)
self._execute_task(next_task, test_case_task_properties)
next_task = self._get_next_task(bpmn_process_instance)
test_passed = test_case_contents["expected_output_json"] == bpmn_process_instance.data
self.test_case_results.append(
TestCaseResult(
passed=test_passed,
test_case_name=test_case_name,
)
error_message = None
if test_passed is False:
error_message = (
f"Expected output did not match actual output:"
f"\nexpected: {test_case_contents['expected_output_json']}"
f"\nactual: {bpmn_process_instance.data}"
)
self._add_test_result(test_passed, bpmn_file, test_case_name, error_message)
def _discover_process_model_directories(
def _discover_process_model_test_cases(
self,
) -> dict[str, str]:
test_mappings = {}
json_test_file_glob = os.path.join(self.process_model_directory_path, "**", "test_*.json")
json_test_file_glob = os.path.join(self.process_model_directory_for_test_discovery, "**", "test_*.json")
for file in glob.glob(json_test_file_glob, recursive=True):
file_dir = os.path.dirname(file)
json_file_name = os.path.basename(file)
file_norm = os.path.normpath(file)
file_dir = os.path.dirname(file_norm)
json_file_name = os.path.basename(file_norm)
bpmn_file_name = re.sub(r"^test_(.*)\.json", r"\1.bpmn", json_file_name)
bpmn_file_path = os.path.join(file_dir, bpmn_file_name)
if os.path.isfile(bpmn_file_path):
test_mappings[file] = bpmn_file_path
test_mappings[file_norm] = bpmn_file_path
else:
raise MissingBpmnFileForTestCaseError(
f"Cannot find a matching bpmn file for test case json file: '{file}'"
f"Cannot find a matching bpmn file for test case json file: '{file_norm}'"
)
return test_mappings
def _discover_process_model_processes(
self,
) -> None:
process_model_bpmn_file_glob = os.path.join(self.process_model_directory_path, "**", "*.bpmn")
for file in glob.glob(process_model_bpmn_file_glob, recursive=True):
file_norm = os.path.normpath(file)
if file_norm not in self.bpmn_files_to_called_element_mappings:
self.bpmn_files_to_called_element_mappings[file_norm] = []
with open(file_norm, 'rb') as f:
file_contents = f.read()
etree_xml_parser = etree.XMLParser(resolve_entities=False)
root = etree.fromstring(file_contents, parser=etree_xml_parser)
call_activities = root.findall('.//bpmn:callActivity', namespaces=DEFAULT_NSMAP)
for call_activity in call_activities:
called_element = call_activity.attrib['calledElement']
self.bpmn_files_to_called_element_mappings[file_norm].append(called_element)
bpmn_process_element = root.find('.//bpmn:process[@isExecutable="true"]', namespaces=DEFAULT_NSMAP)
bpmn_process_identifier = bpmn_process_element.attrib['id']
self.bpmn_processes_to_file_mappings[bpmn_process_identifier] = file_norm
def _execute_task(self, spiff_task: SpiffTask, test_case_task_properties: Optional[dict]) -> None:
if self.execute_task_callback:
self.execute_task_callback(spiff_task, test_case_task_properties)
self._default_execute_task(spiff_task, test_case_task_properties)
def _get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]:
if self.get_next_task_callback:
return self.get_next_task_callback(bpmn_process_instance)
return self._default_get_next_task(bpmn_process_instance)
def _instantiate_executer(self, bpmn_file: str) -> BpmnWorkflow:
if self.instantiate_executer_callback:
return self.instantiate_executer_callback(bpmn_file)
return self._default_instantiate_executer(bpmn_file)
def _get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual])
if len(tasks) > 0:
tasks = [tasks[0]]
return tasks
def _default_get_next_task(self, bpmn_process_instance: BpmnWorkflow) -> Optional[SpiffTask]:
engine_steps = self._get_ready_engine_steps(bpmn_process_instance)
if len(engine_steps) > 0:
return engine_steps[0]
return None
def _default_execute_task(self, spiff_task: SpiffTask, test_case_task_properties: Optional[dict]) -> None:
if spiff_task.task_spec.manual:
if test_case_task_properties and 'data' in test_case_task_properties:
spiff_task.update_data(test_case_task_properties['data'])
spiff_task.complete()
else:
spiff_task.run()
def _find_related_bpmn_files(self, bpmn_file: str) -> list[str]:
related_bpmn_files = []
if bpmn_file in self.bpmn_files_to_called_element_mappings:
for bpmn_process_identifier in self.bpmn_files_to_called_element_mappings[bpmn_file]:
if bpmn_process_identifier in self.bpmn_processes_to_file_mappings:
new_file = self.bpmn_processes_to_file_mappings[bpmn_process_identifier]
related_bpmn_files.append(new_file)
related_bpmn_files.extend(self._find_related_bpmn_files(new_file))
return related_bpmn_files
def _get_etree_from_bpmn_file(self, bpmn_file: str) -> etree.Element:
data = None
with open(bpmn_file, "rb") as f_handle:
data = f_handle.read()
etree_xml_parser = etree.XMLParser(resolve_entities=False)
return etree.fromstring(data, parser=etree_xml_parser)
def _default_instantiate_executer(self, bpmn_file: str) -> BpmnWorkflow:
parser = MyCustomParser()
bpmn_file_etree = self._get_etree_from_bpmn_file(bpmn_file)
parser.add_bpmn_xml(bpmn_file_etree, filename=os.path.basename(bpmn_file))
all_related = self._find_related_bpmn_files(bpmn_file)
for related_file in all_related:
related_file_etree = self._get_etree_from_bpmn_file(related_file)
parser.add_bpmn_xml(related_file_etree, filename=os.path.basename(related_file))
sub_parsers = list(parser.process_parsers.values())
executable_process = None
for sub_parser in sub_parsers:
if sub_parser.process_executable:
executable_process = sub_parser.bpmn_id
if executable_process is None:
raise BpmnFileMissingExecutableProcessError(
f"Executable process cannot be found in {bpmn_file}. Test cannot run."
)
bpmn_process_spec = parser.get_spec(executable_process)
bpmn_process_instance = BpmnWorkflow(bpmn_process_spec)
return bpmn_process_instance
def _add_test_result(self, passed: bool, bpmn_file: str, test_case_name: str, error: Optional[str] = None) -> None:
bpmn_file_relative = os.path.relpath(bpmn_file, start=self.process_model_directory_path)
test_result = TestCaseResult(
passed=passed,
bpmn_file=bpmn_file_relative,
test_case_name=test_case_name,
error=error,
)
self.test_case_results.append(test_result)
class BpmnFileMissingExecutableProcessError(Exception):
pass
@ -149,9 +267,9 @@ class ProcessModelTestRunnerService:
def __init__(self, process_model_directory_path: str) -> None:
self.process_model_test_runner = ProcessModelTestRunner(
process_model_directory_path,
instantiate_executer_callback=self._instantiate_executer_callback,
execute_task_callback=self._execute_task_callback,
get_next_task_callback=self._get_next_task_callback,
# instantiate_executer_callback=self._instantiate_executer_callback,
# execute_task_callback=self._execute_task_callback,
# get_next_task_callback=self._get_next_task_callback,
)
def run(self) -> None:
@ -168,7 +286,6 @@ class ProcessModelTestRunnerService:
def _get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
tasks = list([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual])
if len(tasks) > 0:
tasks = [tasks[0]]
@ -179,7 +296,8 @@ class ProcessModelTestRunnerService:
data = None
with open(bpmn_file, "rb") as f_handle:
data = f_handle.read()
bpmn: etree.Element = SpecFileService.get_etree_from_xml_bytes(data)
etree_xml_parser = etree.XMLParser(resolve_entities=False)
bpmn = etree.fromstring(data, parser=etree_xml_parser)
parser.add_bpmn_xml(bpmn, filename=os.path.basename(bpmn_file))
sub_parsers = list(parser.process_parsers.values())
executable_process = None

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="BasicCallActivityProcess" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0ext5lt</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0ext5lt" sourceRef="StartEvent_1" targetRef="Activity_0irfg4l" />
<bpmn:endEvent id="Event_0bz40ol">
<bpmn:incoming>Flow_1hzwssi</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1hzwssi" sourceRef="Activity_0irfg4l" targetRef="Event_0bz40ol" />
<bpmn:callActivity id="Activity_0irfg4l" name="Call Activity" calledElement="BasicManualTaskProcess">
<!-- <bpmn:callActivity id="Activity_0irfg4l" name="Call Activity" calledElement="BasicManualTaskProcess2"> -->
<bpmn:incoming>Flow_0ext5lt</bpmn:incoming>
<bpmn:outgoing>Flow_1hzwssi</bpmn:outgoing>
</bpmn:callActivity>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_test_a42_A_4_2_bd2e724">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0bz40ol_di" bpmnElement="Event_0bz40ol">
<dc:Bounds x="422" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0148g78_di" bpmnElement="Activity_0irfg4l">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0ext5lt_di" bpmnElement="Flow_0ext5lt">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1hzwssi_di" bpmnElement="Flow_1hzwssi">
<di:waypoint x="370" y="177" />
<di:waypoint x="422" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,9 @@
{
"description": "",
"display_name": "Basic Call Activity",
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"primary_file_name": "basic_call_activity.bpmn",
"primary_process_id": "BasicCallActivityProcess"
}

View File

@ -0,0 +1,5 @@
{
"test_case_one": {
"expected_output_json": {}
}
}

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="BasicManualTaskProcess" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0gz6i84</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0gz6i84" sourceRef="StartEvent_1" targetRef="manual_task_one" />
<bpmn:endEvent id="Event_0ynlmo7">
<bpmn:incoming>Flow_0ikklg6</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0ikklg6" sourceRef="manual_task_one" targetRef="Event_0ynlmo7" />
<bpmn:manualTask id="manual_task_one" name="Manual">
<bpmn:incoming>Flow_0gz6i84</bpmn:incoming>
<bpmn:outgoing>Flow_0ikklg6</bpmn:outgoing>
</bpmn:manualTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="BasicManualTaskProcess">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0ynlmo7_di" bpmnElement="Event_0ynlmo7">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0sji2rz_di" bpmnElement="manualt_task_one">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0gz6i84_di" bpmnElement="Flow_0gz6i84">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ikklg6_di" bpmnElement="Flow_0ikklg6">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,9 @@
{
"description": "Baisc Manual Task",
"display_name": "Baisc Manual Task",
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"files": [],
"primary_file_name": "baisc_manual_task.bpmn",
"primary_process_id": "BasicManualTaskProcess"
}

View File

@ -0,0 +1,10 @@
{
"test_case_one": {
"tasks": {
"manual_task_one": {
"data": {}
}
},
"expected_output_json": {}
}
}

View File

@ -9,7 +9,7 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunnerService
from spiffworkflow_backend.services.process_model_test_runner_service import ProcessModelTestRunner, ProcessModelTestRunnerService
class TestProcessModelTestRunnerService(BaseTest):
@ -23,7 +23,7 @@ class TestProcessModelTestRunnerService(BaseTest):
os.path.join(FileSystemService.root_path(), "basic_script_task")
)
test_runner_service.run()
assert test_runner_service.process_model_test_runner.all_test_cases_passed()
assert test_runner_service.process_model_test_runner.all_test_cases_passed(), test_runner_service.process_model_test_runner.test_case_results
def test_can_test_multiple_process_models(
self,
@ -35,6 +35,19 @@ class TestProcessModelTestRunnerService(BaseTest):
test_runner_service.run()
assert test_runner_service.process_model_test_runner.all_test_cases_passed() is False
def test_can_test_process_model_call_activity(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
with_mocked_root_path: Any,
) -> None:
test_runner_service = ProcessModelTestRunner(
process_model_directory_path=FileSystemService.root_path(),
process_model_directory_for_test_discovery=os.path.join(FileSystemService.root_path(), "basic_call_activity")
)
test_runner_service.run()
assert test_runner_service.all_test_cases_passed() is True, test_runner_service.test_case_results
@pytest.fixture()
def with_mocked_root_path(self, mocker: MockerFixture) -> None:
path = os.path.join(