Browse by section

Python 日本語

Created a Pairwise Test Generation GUI Tool That Runs on Mac

I built a GUI tool in Python that generates pairwise test cases and exports them to CSV or Excel. It runs on both macOS and Windows.

Command-line tools like PICT are powerful, but writing an input file and running it from a terminal is a real barrier for non-engineers. The goal here was to reduce that to filling in a form and pressing a button.

This article was published in 2025 and revised in September 2026. The original version failed to explain an important caveat about the “coverage rate” feature: lowering that number destroys pairwise coverage.

Sponsored

What I built

GUI Tkinter (Python standard library)
Pairwise generation allpairspy
Data handling pandas
Excel I/O openpyxl
Supported OS macOS / Windows

It does five things:

  • Add and remove parameters and values through a form
  • Generate pairwise test cases
  • Reduce the number of generated cases (with an important caveat, explained below)
  • Export results to CSV or Excel
  • Import an existing CSV or Excel file and restore the parameters

Screenshots

Enter each parameter’s values as a comma-separated list, press “Generate”, and the results appear in the table.

Sponsored

Source code

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pandas as pd
from allpairspy import AllPairs
import openpyxl

class MiniPictApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("MiniPict - Pairwise Test Generation Tool")
        self.geometry("700x650")

        self.param_entries = []
        self.create_widgets()

    def create_widgets(self):
        lbl = tk.Label(self, text="Enter parameters and values (comma-separated)")
        lbl.pack(pady=10)

        frame = tk.Frame(self)
        frame.pack(expand=True, fill='both', padx=10)

        canvas = tk.Canvas(frame)
        scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview)
        scrollable_frame = tk.Frame(canvas)

        scrollable_frame.bind(
            "<Configure>",
            lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
        )

        canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
        canvas.configure(yscrollcommand=scrollbar.set)

        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        self.scrollable_frame = scrollable_frame

        button_frame = tk.Frame(self)
        button_frame.pack(pady=10)

        add_btn = ttk.Button(button_frame, text="Add Parameter", command=self.add_parameter)
        add_btn.pack(side='left', padx=5)

        generate_btn = ttk.Button(button_frame, text="Generate Test Cases", command=self.generate_test_cases)
        generate_btn.pack(side='left', padx=5)

        save_btn = ttk.Button(button_frame, text="Save as CSV", command=self.save_to_csv)
        save_btn.pack(side='left', padx=5)

        save_excel_btn = ttk.Button(button_frame, text="Save as Excel", command=self.save_to_excel)
        save_excel_btn.pack(side='left', padx=5)

        import_btn = ttk.Button(button_frame, text="Import CSV/Excel", command=self.import_values)
        import_btn.pack(side='left', padx=5)

        self.coverage_label = tk.Label(self, text="Coverage (manual input):")
        self.coverage_label.pack(pady=10)

        self.coverage_entry = ttk.Entry(self, width=20)
        self.coverage_entry.pack(pady=5)

        self.result = None

    def add_parameter(self):
        frame = tk.Frame(self.scrollable_frame)
        frame.pack(pady=5, anchor='w')

        label_entry = ttk.Entry(frame, width=20)
        label_entry.insert(0, f"Parameter {len(self.param_entries)+1}")
        label_entry.pack(side="left", padx=5)

        value_entry = ttk.Entry(frame, width=50)
        value_entry.insert(0, "Value1, Value2, Value3")
        value_entry.pack(side="left", padx=5)

        delete_btn = ttk.Button(frame, text="Delete", command=lambda: self.delete_parameter(frame))
        delete_btn.pack(side="left", padx=5)

        self.param_entries.append((label_entry, value_entry, frame))

    def delete_parameter(self, frame):
        for entry in self.param_entries:
            if entry[2] == frame:
                self.param_entries.remove(entry)
                break
        frame.destroy()

    def generate_test_cases(self):
        try:
            parameters = []
            headers = []

            for label_entry, value_entry, _ in self.param_entries:
                name = label_entry.get()
                values = [v.strip() for v in value_entry.get().split(",") if v.strip()]
                if not values:
                    raise ValueError(f"Values for {name} are empty")
                headers.append(name)
                parameters.append(values)

            manual_coverage = self.coverage_entry.get().strip()
            if manual_coverage:
                try:
                    manual_coverage = float(manual_coverage)
                    if not (0 <= manual_coverage <= 100):
                        raise ValueError("Please enter a coverage between 0 and 100")
                except ValueError:
                    messagebox.showerror("Error", "Please enter a number between 0 and 100 for coverage")
                    return
            else:
                manual_coverage = 100

            all_pairs = list(AllPairs(parameters))
            total_combinations = len(all_pairs)

            if manual_coverage == 100:
                pairs = all_pairs
            else:
                required_cases = int(total_combinations * (manual_coverage / 100))
                pairs = all_pairs[:required_cases]

            df = pd.DataFrame(pairs, columns=headers)
            df = df.sort_values(by=headers[:len(parameters)], ascending=True).reset_index(drop=True)

            self.result = df

            preview = "\n".join(df.head(10).astype(str).agg(" | ".join, axis=1).tolist())
            messagebox.showinfo("Generated Test Cases (First 10)", preview)

        except Exception as e:
            messagebox.showerror("Error", str(e))

    def save_to_csv(self):
        if self.result is None:
            messagebox.showwarning("Warning", "Please generate the test cases first.")
            return

        file_path = filedialog.asksaveasfilename(defaultextension=".csv",
                                                 filetypes=[("CSV files", "*.csv")])
        if file_path:
            self.result.to_csv(file_path, index=False)
            messagebox.showinfo("Save Complete", f"Saved to {file_path}")

    def save_to_excel(self):
        if self.result is None:
            messagebox.showwarning("Warning", "Please generate the test cases first.")
            return

        file_path = filedialog.asksaveasfilename(defaultextension=".xlsx",
                                                 filetypes=[("Excel files", "*.xlsx")])
        if file_path:
            with pd.ExcelWriter(file_path, engine='openpyxl') as writer:
                self.result.to_excel(writer, index=False, sheet_name='Test Cases')
            messagebox.showinfo("Save Complete", f"Saved to {file_path}")

    def import_values(self):
        file_path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv"),
                                                          ("Excel files", "*.xlsx")])
        if not file_path:
            return

        try:
            if file_path.endswith(".csv"):
                data = pd.read_csv(file_path)
            else:
                data = pd.read_excel(file_path)

            for idx, col in enumerate(data.columns):
                if idx >= len(self.param_entries):
                    self.add_parameter()
                self.param_entries[idx][1].delete(0, tk.END)
                self.param_entries[idx][1].insert(0, ", ".join(data[col].dropna().astype(str).tolist()))

            messagebox.showinfo("Import Complete", "File import completed.")
        except Exception as e:
            messagebox.showerror("Error", f"Failed to import file: {str(e)}")

if __name__ == "__main__":
    app = MiniPictApp()
    app.mainloop()

How the code works

The GUI (Tkinter)

Since the number of parameters has to be unbounded, I combined a Canvas with a Scrollbar to make a scrollable area.

  • Frame / Canvas / Scrollbar build the scrollable region
  • Each parameter row holds two Entry widgets (name and values)
  • Each row has a delete button so rows can be removed later

Tkinter ships with Python, but Homebrew’s Python needs python-tk installed separately. If it will not start, see how to fix the “_tkinter module not found” error.

Pairwise generation (allpairspy)

The generation itself is a single line.

from allpairspy import AllPairs
...
all_pairs = list(AllPairs(parameters))

parameters is a list of value lists, like this:

parameters = [
    ["Windows", "macOS", "iOS"],
    ["Chrome", "Safari"],
    ["Free", "Paid"],
]

for case in AllPairs(parameters):
    print(case)

Controlling the order

Results are sorted back into the order the parameters were entered.

df = df.sort_values(by=headers[:len(parameters)], ascending=True).reset_index(drop=True)

Without this, the output order is hard to read and hard to review.

CSV and Excel I/O

pandas handles all of it.

  • Save as CSV: to_csv()
  • Save as Excel: to_excel() (requires openpyxl)
  • Load: read_csv() / read_excel()

I added Excel support because test designs often have to be shared with people who are not engineers.

Sponsored

Lowering the “coverage rate” destroys coverage

This is the most important caveat, and the original version of this article did not explain it properly.

The tool has a feature that reduces the number of generated cases, which I originally called “coverage rate”. But setting it below 100 breaks the very premise of pairwise testing: that every two-factor combination appears at least once.

Setting What actually happens
100 Correct pairwise output. All pairs are covered
Below 100 Keeps only part of the generated set. Some pairs are never covered

“50% coverage” does not mean “half of the pairs are covered”. It simply takes the first portion of the generated list, and which pairs get dropped is not controlled at all.

If you use it, treat it this way:

  • Always use 100 for real test design
  • Below 100 is a stopgap for “we are out of time, run part of it”. It is not evidence of coverage
  • If you must cut cases, keep the high-risk combinations rather than mechanically taking the first N

The reasoning behind pairwise testing is covered in the basics and practice of pairwise testing. The lower bound on case count is the product of the two factors with the most values, so once you drop below that, you can be certain coverage is gone.

Limits you hit in real work

Here is what the tool cannot do, which I only found out by building it.

You cannot declare constraints

This is the biggest practical gap. There is no way to exclude impossible combinations such as “iOS x Internet Explorer”.

allpairspy itself supports this via filter_func, so it can be added with a code change.

def is_valid(row):
    # Partial rows are passed in during generation, so check the length first
    if len(row) >= 2:
        os_name, browser = row[0], row[1]
        if os_name in ("iOS", "Android") and browser == "Edge":
            return False
    return True

for case in AllPairs(parameters, filter_func=is_valid):
    print(case)

filter_func receives incomplete rows while generation is still in progress. Indexing without checking the length first will raise IndexError.

You cannot pin combinations that must be included

There is no equivalent of PICT’s seeding. A major real-world usage pattern may simply not appear in the output, so the results need a human review pass.

No three-factor coverage

There is no equivalent of PICT’s /o:3. If a critical feature needs three-factor combinations, use PICT.

Library maintenance

The latest allpairspy release is 2.5.1 (July 2023), so it is not an actively developed library. It works fine, but if this is going to sit at the center of your workflow long term, a proven tool like PICT is the safer choice.

How to run it

Required libraries

pip install allpairspy pandas openpyxl

Run

python3 test.py

If it fails to start, check how to fix the “_tkinter module not found” error. Homebrew’s Python needs python-tk installed on top.

Summary

  • Tkinter + allpairspy + pandas is enough to build a GUI pairwise generator
  • CSV and Excel support lets you share test designs with non-engineers
  • Setting “coverage rate” below 100 destroys pairwise coverage. Always use 100 for real design work
  • Constraints can be added with filter_func, but remember it receives partial rows
  • Pinned combinations and three-factor coverage are not supported
  • Use PICT for serious work. This tool is for trying pairwise testing quickly

The real payoff of building it yourself is seeing exactly what the tool does and does not do. You stop taking the generated set on faith and start checking the coverage yourself.