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()

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!

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.

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()

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()