Application Unit Testing

We have recently moved to a formal DEV/QA/PROD instance for application development due to the fact we are in a regulated industry (Med Device).

Migrating applications between instances still has its rough edges, such as app completions with app/step changes. This made me think about how I might detect specific issues in applications now that I have access to the underlying JSON file via an Export.

With the help of Grok 3 I developed a couple of python scripts, one that performs the tests I want using the python unittest module, and then a simple GUI wrapper that I intend to package up into a standalone executable for other to use.

Here are the conditions I wanted to test for:

  • Save All Data and Complete App... in the same trigger clause creating redundant completion data. (ERROR)
  • Comple App with Step Change... which do not survive migration and must be fixed up after import. (WARNING)
  • Untitled Steps (ERROR)
  • Untitled Triggers (ERROR)
  • Disabled Triggers (WARNING)

I just realized I’m missing a test, a Clear Variables or Clear Record Placeholders in the same clause as a Save All Data or Comple App... since that would be rather useless.

Here’s a screenshot of the app in action:

I may copy to to my personal github at some point but for now here’s the files. Feedback welcomed, especially @Geoff.Winkley.

apptest.py

# apptest.py
import sys
import zipfile
import json
import unittest

class TestAppJson(unittest.TestCase):
    json_data = None  # Can be set by GUI or loaded in setUpClass
    test_outcomes = {}  # Class-level dict to store test outcome types

    @classmethod
    def setUpClass(cls):
        """
        Load JSON data from zip file if provided as a command-line argument,
        unless json_data is already set (e.g., by GUI).
        """
        if cls.json_data is None:  # Only load if not already set
            if len(sys.argv) != 2:
                raise ValueError("Usage: python apptest.py <zipfile>")
            zip_path = sys.argv[1]
            with zipfile.ZipFile(zip_path, 'r') as zip_ref:
                with zip_ref.open('app.json') as json_file:
                    cls.json_data = json.load(json_file)

    @classmethod
    def register_test_outcome(cls, test_name, outcome_type):
        """Register the outcome type (error or warning) for a test."""
        cls.test_outcomes[test_name] = outcome_type

    def test_no_untitled_trigger(self):
        """Test that there are no untitled triggers."""
        self.register_test_outcome('test_no_untitled_trigger', 'error')
        triggers = self.json_data.get("triggers", [])
        for trigger in triggers:
            description = trigger.get("description", [])
            if description == "":
                self.fail("Found untitled trigger")

    def test_disabled_trigger(self):
        """Test for disabled triggers."""
        self.register_test_outcome('test_disabled_trigger', 'warning')
        triggers = self.json_data.get("triggers", [])
        for trigger in triggers:
            disabled = trigger.get("disabled", False)  # Default to False, not []
            if disabled:
                self.fail("Found disabled trigger")

    def test_no_untitled_step(self):
        """Test that there are no untitled steps."""
        self.register_test_outcome('test_no_untitled_step', 'error')
        steps = self.json_data.get("steps", [])
        for step in steps:
            name = step.get("name", [])
            if name == "":
                self.fail("Found Untitled Step")

    def test_no_conflicting_actions(self):
        """Test that no clause has both 'save_process_data' and 'complete_process' actions."""
        self.register_test_outcome('test_no_conflicting_actions', 'error')
        triggers = self.json_data.get("triggers", [])
        for trigger in triggers:
            clauses = trigger.get("clauses", [])
            for clause in clauses:
                actions = clause.get("actions", [])
                action_types = {action.get("type") for action in actions if isinstance(action, dict)}
                if "save_process_data" in action_types and "complete_process" in action_types:
                    self.fail("Found a clause with both 'save_process_data' and 'complete_process' actions")

    def test_for_complete_with_step_change(self):
        """Test for app completions with step changes."""
        self.register_test_outcome('test_for_complete_with_step_change', 'warning')
        triggers = self.json_data.get("triggers", [])
        for trigger in triggers:
            clauses = trigger.get("clauses", [])
            for clause in clauses:
                actions = clause.get("actions", [])
                action_types = {action.get("type") for action in actions if isinstance(action, dict)}
                if "complete_then_change_process_and_step" in action_types:
                    self.fail("Found a clause with App Completion with Step Change.")


if __name__ == '__main__':
    unittest.main(argv=[sys.argv[0]])

gui.py

# gui.py
import tkinter as tk
from tkinter import filedialog, scrolledtext, ttk
import sys
from io import StringIO
import unittest
import zipfile
import json
import apptest  # Import the test module

class RedirectText:
    """Redirect stdout to a text widget."""
    def __init__(self, text_widget):
        self.text_widget = text_widget

    def write(self, string):
        self.text_widget.insert(tk.END, string)
        self.text_widget.see(tk.END)

    def flush(self):
        pass

class GUIApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Tulip App Unit Tester")
        self.root.geometry("800x600")

        # Top frame for file chooser and run button
        top_frame = ttk.Frame(self.root)
        top_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)

        # File chooser button and entry
        self.file_path_var = tk.StringVar()
        ttk.Button(top_frame, text="Choose Zip File", command=self.choose_file).pack(side=tk.LEFT, padx=5)
        self.file_entry = ttk.Entry(top_frame, textvariable=self.file_path_var, width=100)
        self.file_entry.pack(side=tk.LEFT, padx=5)

        # Run button
        ttk.Button(top_frame, text="Run", command=self.run_tests).pack(side=tk.RIGHT, padx=5)

        # Console output text box
        self.output_text = scrolledtext.ScrolledText(self.root, height=25, width=90)
        self.output_text.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)

        # Bottom frame for test results
        bottom_frame = ttk.Frame(self.root)
        bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=5)

        # Test result labels
        self.passed_var = tk.StringVar(value="Passed: 0")
        self.error_var = tk.StringVar(value="Error Fails: 0")
        self.warning_var = tk.StringVar(value="Warning Fails: 0")
        ttk.Label(bottom_frame, textvariable=self.passed_var).pack(side=tk.LEFT, padx=5)
        ttk.Label(bottom_frame, textvariable=self.error_var).pack(side=tk.LEFT, padx=5)
        ttk.Label(bottom_frame, textvariable=self.warning_var).pack(side=tk.LEFT, padx=5)

    def choose_file(self):
        """Open file dialog to choose a zip file."""
        file_path = filedialog.askopenfilename(filetypes=[("Zip files", "*.zip")])
        if file_path:
            self.file_path_var.set(file_path)

    def load_json_from_zip(self, zip_path):
        """Load app.json from the given zip file."""
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            with zip_ref.open('app.json') as json_file:
                return json.load(json_file)

    def run_tests(self):
        """Run the unit tests from apptest.py and display results."""
        self.output_text.delete(1.0, tk.END)  # Clear previous output
        file_path = self.file_path_var.get()
        if not file_path:
            self.output_text.insert(tk.END, "Please select a zip file first.\n")
            return

        # Load JSON from zip file and set it on the test class
        try:
            apptest.TestAppJson.json_data = self.load_json_from_zip(file_path)
        except Exception as e:
            self.output_text.insert(tk.END, f"Error loading JSON: {str(e)}\n")
            return

        # Redirect stdout to the text box
        old_stdout = sys.stdout
        sys.stdout = RedirectText(self.output_text)

        # Run the tests from apptest
        suite = unittest.TestLoader().loadTestsFromTestCase(apptest.TestAppJson)
        runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=2)
        result = runner.run(suite)

        # Restore stdout
        sys.stdout = old_stdout

        # Update test result indicators
        total_tests = result.testsRun
        passed = total_tests - len(result.failures) - len(result.errors)

        # Use test_outcomes to categorize failures
        error_fails = len(result.errors)  # Unittest exceptions
        warning_fails = 0
        for test, _ in result.failures:
            test_name = test.id().split('.')[-1]  # Extract method name
            outcome = apptest.TestAppJson.test_outcomes.get(test_name, 'error')  # Default to error if not specified
            if outcome == 'error':
                error_fails += 1
            elif outcome == 'warning':
                warning_fails += 1

        self.passed_var.set(f"Passed: {passed}")
        self.error_var.set(f"Error Fails: {error_fails}")
        self.warning_var.set(f"Warning Fails: {warning_fails}")

if __name__ == "__main__":
    root = tk.Tk()
    app = GUIApp(root)
    root.mainloop()
10 Likes

Thank you for sharing Richard! Great use of AI to do some heavy lifting on the coding for you here.

Tagging @life_sciences folks to check this out - anyone else do something similar?

Impressive @Richard-SNN! Really creative way to automate this “App scrub” as oppose to the manual process it is today. This is ideally something we productize, similar to the in-product warning that currently exist when you go to publish an App with disabled triggers. Thank you for sharing your script!

1 Like

Thanks Geoff! I have some updates coming so stay tuned. Yesterday I added logic so that it will at least tell you what step the problem is on.

Today I plan to make it a bit more user friendly. Right now it’s on-shot, you have to close and reopen the gui to run another test. I’m going to have it clear the text box and re-attach to stdout when a new zip file is loaded.

I’m also going to see if I can clean up some of the assertion debug output as it doesn’t add any value.

1 Like

It turns out supressing the traceback output is more difficult than it should be but here’s the updated scripts showing the step the error is found on.

apptest.py:

# apptest.py
import sys
import zipfile
import json
import unittest

class TestAppJson(unittest.TestCase):
    json_data = None  # Raw JSON data
    processed_data = {}  # Processed top-level elements
    test_outcomes = {}  # Class-level dict to store test outcome types

    @classmethod
    def setUpClass(cls):
        """
        Load and process JSON data from zip file if provided,
        unless json_data is already set (e.g., by GUI).
        """
        if cls.json_data is None:
            if len(sys.argv) != 2:
                raise ValueError("Usage: python apptest.py <zipfile>")
            zip_path = sys.argv[1]
            cls.load_and_process_json(zip_path)

    @classmethod
    def load_and_process_json(cls, zip_path):
        """Load JSON from zip and process top-level elements once."""
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            with zip_ref.open('app.json') as json_file:
                cls.json_data = json.load(json_file)
        
        # Process top-level elements once
        cls.processed_data = {
            'triggers': cls.json_data.get("triggers", []),
            'widgets': cls.json_data.get("widgets", []),
            'steps': cls.json_data.get("steps", [])
        }

    @classmethod
    def register_test_outcome(cls, test_name, outcome_type):
        """Register the outcome type (error or warning) for a test."""
        cls.test_outcomes[test_name] = outcome_type

    def test_no_untitled_trigger(self):
        """Test that there are no untitled triggers."""
        self.register_test_outcome('test_no_untitled_trigger', 'error')
        for trigger in self.processed_data['triggers']:
            description = trigger.get("description", "")
            if description == "":
                parent_step = trigger.get("parent_step", "")
                step_name = "Unknown"  # Default if no matching step is found
                for step in self.processed_data['steps']:                    
                    if parent_step == step.get("_id", ""):
                        step_name = step.get("name", "Unnamed Step")
                        if step_name == "":
                            step_name = "Untitled Step"
                        break
                self.fail(f"Found untitled trigger on step: {step_name}")

    def test_disabled_trigger(self):
        """Test for disabled triggers."""
        self.register_test_outcome('test_disabled_trigger', 'warning')
        for trigger in self.processed_data['triggers']:
            disabled = trigger.get("disabled", False)
            if disabled:
                parent_step = trigger.get("parent_step", "")
                step_name = "Unknown"  # Default if no matching step is found
                for step in self.processed_data['steps']:                    
                    if parent_step == step.get("_id", ""):
                        step_name = step.get("name", "Unnamed Step")
                        if step_name == "":
                            step_name = "Untitled Step"
                        break                
                self.fail(f"Found disabled trigger on step: {step_name}")

    def test_no_untitled_step(self):
        """Test that there are no untitled steps."""
        self.register_test_outcome('test_no_untitled_step', 'error')
        for step in self.processed_data['steps']:
            name = step.get("name", "")
            if name == "":
                self.fail("Found Untitled Step")

    def test_no_conflicting_actions(self):
        """Test that no clause has both 'save_process_data' and 'complete_process' actions."""
        self.register_test_outcome('test_no_conflicting_actions', 'error')
        for trigger in self.processed_data['triggers']:
            clauses = trigger.get("clauses", [])
            for clause in clauses:
                actions = clause.get("actions", [])
                action_types = {action.get("type") for action in actions if isinstance(action, dict)}
                if "save_process_data" in action_types and "complete_process" in action_types:
                    parent_step = trigger.get("parent_step", "")
                    step_name = "Unknown"  # Default if no matching step is found
                    for step in self.processed_data['steps']:                    
                        if parent_step == step.get("_id", ""):
                            step_name = step.get("name", "Unnamed Step")
                            if step_name == "":
                                step_name = "Untitled Step"
                            break
                    self.fail(f"Found a trigger clause with both 'Save All Data' and 'Complete' actions on step: {step_name}")

    def test_for_complete_with_step_change(self):
        """Test for app completions with step changes."""
        self.register_test_outcome('test_for_complete_with_step_change', 'warning')
        for trigger in self.processed_data['triggers']:
            clauses = trigger.get("clauses", [])
            for clause in clauses:
                actions = clause.get("actions", [])
                action_types = {action.get("type") for action in actions if isinstance(action, dict)}
                if "complete_then_change_process_and_step" in action_types:
                    parent_step = trigger.get("parent_step", "")
                    step_name = "Unknown"  # Default if no matching step is found
                    for step in self.processed_data['steps']:                    
                        if parent_step == step.get("_id", ""):
                            step_name = step.get("name", "Unnamed Step")
                            if step_name == "":
                                step_name = "Untitled Step"
                            break
                    self.fail(f"Found a clause with App Completion with Step Change on step: {step_name}.")

if __name__ == '__main__':
    unittest.main(argv=[sys.argv[0]])

gui.py:

# gui.py
import tkinter as tk
from tkinter import filedialog, scrolledtext, ttk
import sys
from io import StringIO
import unittest
import apptest  # Import the test module

class RedirectText:
    """Redirect stdout to a text widget."""
    def __init__(self, text_widget):
        self.text_widget = text_widget

    def write(self, string):
        self.text_widget.insert(tk.END, string)
        self.text_widget.see(tk.END)

    def flush(self):
        pass

class GUIApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Tulip App Unit Tester")
        self.root.geometry("800x600")

        # Top frame for file chooser and run button
        top_frame = ttk.Frame(self.root)
        top_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)

        # File chooser button and entry
        self.file_path_var = tk.StringVar()
        ttk.Button(top_frame, text="Choose Zip File", command=self.choose_file).pack(side=tk.LEFT, padx=5)
        self.file_entry = ttk.Entry(top_frame, textvariable=self.file_path_var, width=100)
        self.file_entry.pack(side=tk.LEFT, padx=5)

        # Run button
        ttk.Button(top_frame, text="Run", command=self.run_tests).pack(side=tk.RIGHT, padx=5)

        # Console output text box
        self.output_text = scrolledtext.ScrolledText(self.root, height=25, width=90)
        self.output_text.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)

        # Bottom frame for test results
        bottom_frame = ttk.Frame(self.root)
        bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=5)

        # Test result labels
        self.passed_var = tk.StringVar(value="Passed: 0")
        self.error_var = tk.StringVar(value="Errors: 0")
        self.warning_var = tk.StringVar(value="Warnings: 0")
        ttk.Label(bottom_frame, textvariable=self.passed_var).pack(side=tk.LEFT, padx=5)
        ttk.Label(bottom_frame, textvariable=self.error_var).pack(side=tk.LEFT, padx=5)
        ttk.Label(bottom_frame, textvariable=self.warning_var).pack(side=tk.LEFT, padx=5)

    def choose_file(self):
        """Open file dialog to choose a zip file."""
        file_path = filedialog.askopenfilename(filetypes=[("Zip files", "*.zip")])
        if file_path:
            self.file_path_var.set(file_path)

    def run_tests(self):
        """Run the unit tests from apptest.py and display results."""
        self.output_text.delete(1.0, tk.END)  # Clear previous output
        file_path = self.file_path_var.get()
        if not file_path:
            self.output_text.insert(tk.END, "Please select a zip file first.\n")
            return

        # Use apptest to load and process JSON
        try:
            apptest.TestAppJson.load_and_process_json(file_path)
        except Exception as e:
            self.output_text.insert(tk.END, f"Error loading JSON: {str(e)}\n")
            return

        # Redirect stdout to the text box
        old_stdout = sys.stdout
        sys.stdout = RedirectText(self.output_text)

        # Run the tests from apptest
        suite = unittest.TestLoader().loadTestsFromTestCase(apptest.TestAppJson)
        runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=1)
        result = runner.run(suite)

        # Restore stdout
        sys.stdout = old_stdout

        # Update test result indicators
        total_tests = result.testsRun
        passed = total_tests - len(result.failures) - len(result.errors)

        # Use test_outcomes to categorize failures
        error_fails = len(result.errors)  # Unittest exceptions
        warning_fails = 0
        for test, _ in result.failures:
            test_name = test.id().split('.')[-1]  # Extract method name
            outcome = apptest.TestAppJson.test_outcomes.get(test_name, 'error')
            if outcome == 'error':
                error_fails += 1
            elif outcome == 'warning':
                warning_fails += 1

        self.passed_var.set(f"Passed: {passed}")
        self.error_var.set(f"Errors: {error_fails}")
        self.warning_var.set(f"Warnings: {warning_fails}")

if __name__ == "__main__":
    root = tk.Tk()
    app = GUIApp(root)
    root.mainloop()
1 Like

I realized that using the standard unittest framework was stopping at the first assertion and not processing all triggers which is fixed in this version:

apptest.py:

# apptest.py
import sys
import zipfile
import json
import unittest

class TestAppJson(unittest.TestCase):
    json_data = None  # Raw JSON data
    processed_data = {}  # Processed top-level elements
    test_outcomes = {}  # Class-level dict to store test outcome types

    @classmethod
    def setUpClass(cls):
        """
        Load and process JSON data from zip file if provided,
        unless json_data is already set (e.g., by GUI).
        """
        if cls.json_data is None:
            if len(sys.argv) != 2:
                raise ValueError("Usage: python apptest.py <zipfile>")
            zip_path = sys.argv[1]
            cls.load_and_process_json(zip_path)

    @classmethod
    def load_and_process_json(cls, zip_path):
        """Load JSON from zip and process top-level elements once."""
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            with zip_ref.open('app.json') as json_file:
                cls.json_data = json.load(json_file)
        
        # Process top-level elements once
        cls.processed_data = {
            'triggers': cls.json_data.get("triggers", []),
            'widgets': cls.json_data.get("widgets", []),
            'steps': cls.json_data.get("steps", [])
        }

    @classmethod
    def register_test_outcome(cls, test_name, outcome_type):
        """Register the outcome type (error or warning) for a test."""
        cls.test_outcomes[test_name] = outcome_type

    def collect_failures(self, failures, message):
        """Collect failure messages instead of failing immediately."""
        failures.append(message)

    def test_no_untitled_trigger(self):
        """Test that there are no untitled triggers."""
        self.register_test_outcome('test_no_untitled_trigger', 'error')
        failures = []
        for trigger in self.processed_data['triggers']:
            description = trigger.get("description", "")
            if description == "":
                parent_step = trigger.get("parent_step", "")
                step_name = "Unknown"
                for step in self.processed_data['steps']:                    
                    if parent_step == step.get("_id", ""):
                        step_name = step.get("name", "Unnamed Step")
                        if step_name == "":
                            step_name = "Untitled Step"
                        break
                self.collect_failures(failures, f"Found untitled trigger on step: {step_name}")
        if failures:
            self.fail("\n".join(failures))

    def test_disabled_trigger(self):
        """Test for disabled triggers."""
        self.register_test_outcome('test_disabled_trigger', 'warning')
        failures = []
        for trigger in self.processed_data['triggers']:
            disabled = trigger.get("disabled", False)
            if disabled:
                parent_step = trigger.get("parent_step", "")
                step_name = "Unknown"
                for step in self.processed_data['steps']:                    
                    if parent_step == step.get("_id", ""):
                        step_name = step.get("name", "Unnamed Step")
                        if step_name == "":
                            step_name = "Untitled Step"
                        break                
                self.collect_failures(failures, f"Found disabled trigger on step: {step_name}")
        if failures:
            self.fail("\n".join(failures))

    def test_no_untitled_step(self):
        """Test that there are no untitled steps."""
        self.register_test_outcome('test_no_untitled_step', 'error')
        failures = []
        for step in self.processed_data['steps']:
            name = step.get("name", "")
            if name == "":
                self.collect_failures(failures, "Found Untitled Step")
        if failures:
            self.fail("\n".join(failures))

    def test_no_conflicting_actions(self):
        """Test that no clause has both 'save_process_data' and 'complete_process' actions."""
        self.register_test_outcome('test_no_conflicting_actions', 'error')
        failures = []
        for trigger in self.processed_data['triggers']:
            clauses = trigger.get("clauses", [])
            for clause in clauses:
                actions = clause.get("actions", [])
                action_types = {action.get("type") for action in actions if isinstance(action, dict)}
                if "save_process_data" in action_types and "complete_process" in action_types:
                    parent_step = trigger.get("parent_step", "")
                    step_name = "Unknown"
                    for step in self.processed_data['steps']:                    
                        if parent_step == step.get("_id", ""):
                            step_name = step.get("name", "Unnamed Step")
                            if step_name == "":
                                step_name = "Untitled Step"
                            break
                    self.collect_failures(failures, f"Found a trigger clause with both 'Save All Data' and 'Complete' actions on step: {step_name}")
        if failures:
            self.fail("\n".join(failures))

    def test_for_complete_with_step_change(self):
        """Test for app completions with step changes."""
        self.register_test_outcome('test_for_complete_with_step_change', 'warning')
        failures = []
        for trigger in self.processed_data['triggers']:
            clauses = trigger.get("clauses", [])
            for clause in clauses:
                actions = clause.get("actions", [])
                action_types = {action.get("type") for action in actions if isinstance(action, dict)}
                if "complete_then_change_process_and_step" in action_types:
                    parent_step = trigger.get("parent_step", "")
                    step_name = "Unknown"
                    for step in self.processed_data['steps']:                    
                        if parent_step == step.get("_id", ""):
                            step_name = step.get("name", "Unnamed Step")
                            if step_name == "":
                                step_name = "Untitled Step"
                            break
                    self.collect_failures(failures, f"Found a clause with App Completion with Step Change on step: {step_name}")
        if failures:
            self.fail("\n".join(failures))

if __name__ == '__main__':
    unittest.main(argv=[sys.argv[0]])

gui.py:

# gui.py
import tkinter as tk
from tkinter import filedialog, scrolledtext, ttk
import sys
from io import StringIO
import unittest
import apptest  # Import the test module

class RedirectText:
    """Redirect stdout to a text widget."""
    def __init__(self, text_widget):
        self.text_widget = text_widget

    def write(self, string):
        self.text_widget.insert(tk.END, string)
        self.text_widget.see(tk.END)

    def flush(self):
        pass

class GUIApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Tulip App Unit Tester")
        self.root.geometry("800x600")

        # Top frame for file chooser and run button
        top_frame = ttk.Frame(self.root)
        top_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)

        # File chooser button and entry
        self.file_path_var = tk.StringVar()
        ttk.Button(top_frame, text="Choose Zip File", command=self.choose_file).pack(side=tk.LEFT, padx=5)
        self.file_entry = ttk.Entry(top_frame, textvariable=self.file_path_var, width=100)
        self.file_entry.pack(side=tk.LEFT, padx=5)

        # Run button
        ttk.Button(top_frame, text="Run", command=self.run_tests).pack(side=tk.RIGHT, padx=5)

        # Console output text box
        self.output_text = scrolledtext.ScrolledText(self.root, height=25, width=90)
        self.output_text.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)

        # Bottom frame for test results
        bottom_frame = ttk.Frame(self.root)
        bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=5)

        # Test result labels
        self.passed_var = tk.StringVar(value="Passed: 0")
        self.error_var = tk.StringVar(value="Errors: 0")
        self.warning_var = tk.StringVar(value="Warnings: 0")
        ttk.Label(bottom_frame, textvariable=self.passed_var).pack(side=tk.LEFT, padx=5)
        ttk.Label(bottom_frame, textvariable=self.error_var).pack(side=tk.LEFT, padx=5)
        ttk.Label(bottom_frame, textvariable=self.warning_var).pack(side=tk.LEFT, padx=5)

    def choose_file(self):
        """Open file dialog to choose a zip file."""
        file_path = filedialog.askopenfilename(filetypes=[("Zip files", "*.zip")])
        if file_path:
            self.file_path_var.set(file_path)

    def run_tests(self):
        """Run the unit tests from apptest.py and display results."""
        self.output_text.delete(1.0, tk.END)  # Clear previous output
        file_path = self.file_path_var.get()
        if not file_path:
            self.output_text.insert(tk.END, "Please select a zip file first.\n")
            return

        # Use apptest to load and process JSON
        try:
            apptest.TestAppJson.load_and_process_json(file_path)
        except Exception as e:
            self.output_text.insert(tk.END, f"Error loading JSON: {str(e)}\n")
            return

        # Redirect stdout to the text box
        old_stdout = sys.stdout
        sys.stdout = RedirectText(self.output_text)

        # Run the tests from apptest with verbose output
        suite = unittest.TestLoader().loadTestsFromTestCase(apptest.TestAppJson)
        runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=2)
        result = runner.run(suite)

        # Restore stdout
        sys.stdout = old_stdout

        # Update test result indicators
        total_tests = result.testsRun
        passed = total_tests - len(result.failures) - len(result.errors)

        # Use test_outcomes to categorize failures
        error_fails = len(result.errors)  # Unittest exceptions
        warning_fails = 0
        for test, _ in result.failures:
            test_name = test.id().split('.')[-1]  # Extract method name
            outcome = apptest.TestAppJson.test_outcomes.get(test_name, 'error')
            if outcome == 'error':
                error_fails += 1
            elif outcome == 'warning':
                warning_fails += 1

        self.passed_var.set(f"Passed: {passed}")
        self.error_var.set(f"Errors: {error_fails}")
        self.warning_var.set(f"Warnings: {warning_fails}")

if __name__ == "__main__":
    root = tk.Tk()
    app = GUIApp(root)
    root.mainloop()
1 Like