Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to handle modal dialog in pytest-qt without mocking the dialog #256

Closed
Nagendraprasath-R opened this issue Mar 28, 2019 · 11 comments
Closed

Comments

@Nagendraprasath-R
Copy link

Nagendraprasath-R commented Mar 28, 2019

I am using pytest-qt to automate the testing of a PyQt GUI. The dialogs need to be handled as a part of the testing(dialogs should not be mocked).

For example, file dialog that comes after a button-click has to be handled. There are 2 problems

  1. After the button click command, the program control goes to the event handler and not to the next line where I can try to send mouseclick/keystrokes to the dialog.

  2. Since the QDialog is not added to the main widget, it is not being listed among the children of the main widget. So how to get the reference of the QDialog?

I tried multi-threading but that didn't work, later I found that QObjects are not thread-safe.

def test_filedialog(qtbot, window):
    qtbot.mouseClick(window.browseButton, QtCore.Qt.LeftButton, delay=1)    
    print("After mouse click")    
    #This is where I need to get the reference of QDialog and handle it
@nicoddemus
Copy link
Member

On way is to setup a timer which triggers a slot after a few miliseconds, giving it a chance for the dialog to show up:

def test_filedialog(qtbot, window):
    def handle_dialog():
        # get a reference to the dialog and handle it here
    QTimer.singleShot(500, handle_dialog)
    qtbot.mouseClick(window.browseButton, QtCore.Qt.LeftButton, delay=1)    

This method unfortunately is not very reliable because you might be unlucky if the dialog takes more than 500ms to show up (due to a spike in CPU usage by another process for example).

Is this dialog you trying to mock your own or Qt's?

@Nagendraprasath-R
Copy link
Author

Thanks, @nicoddemus. This works and it solves a part of my issue 👍

Is there any way to find the reference of a dialog when it is not added to any parent control? Else would you suggest to add the dialog as a child to any other control? I guess this might help in making the dialog handling more reliable as I could wait in handle_dialog function until I get the dialog's reference.

def test_filedialog(qtbot, window):
    def handle_dialog():
        # I am not able to find the reference here since the dialog is not added as a child to any parent control
    QTimer.singleShot(500, handle_dialog)
    qtbot.mouseClick(window.browseButton, QtCore.Qt.LeftButton, delay=1) 

Is this dialog you trying to mock your own or Qt's?

I am trying to handle QFileDialog and some custom QDialog pop-ups.

@nicoddemus
Copy link
Member

Is there any way to find the reference of a dialog when it is not added to any parent control? Else would you suggest to add the dialog as a child to any other control?

On way that I've accomplished this is to keep the reference to the dialog somewhere, for testing only. Then you can trigger your event in the next iteration of the event loop by passing 0 timeout to the timer:

class MyWindow(QWidget):


    def on_browse_button(self):
        self._test_dialog = QFileDialog(...)
        try:
            # do something with dialog
        finally:
            self._test_dialog = None
    
def test_filedialog(qtbot, window):
    def handle_dialog():
        while window._test_dialog is None:
            qApp.processEvents()
        # handle dialog now
    QTimer.singleShot(0, handle_dialog)
    qtbot.mouseClick(window.browseButton, QtCore.Qt.LeftButton, delay=1) 

I think this small hack is a small enough price for the reliability it brings to the table.

@Nagendraprasath-R
Copy link
Author

@nicoddemus, It works fine with a small change :)
A minimum timeout is needed before the timer triggers handle_dialog slot in order to execute the next line that performs mouseclick which launces the file dialog.

    QTimer.singleShot(100, handle_dialog)
    qtbot.mouseClick(window.browseButton, QtCore.Qt.LeftButton, delay=1) 

Also, in some cases, I thought it might be better to check the visibility of the dialog using isVisible() method rather than creating a new instance every time.

def test_filedialog(qtbot, window):
    def handle_dialog():
        while not window._test_dialog.isVisible():
            QtGui.QApplication.processEvents()
        # handle dialog now

It would be great if there is an example for dialog hanlding without mocking it.

Thanks a lot, @nicoddemus for this solution!! :)

@nicoddemus
Copy link
Member

Thanks! I would love a PR updating the docs with an example like this one.

Please close the issue if you don't have further questions. 👍

@Nagendraprasath-R
Copy link
Author

Hi @nicoddemus, I have another small query. In the above method, the reference of the dialog is stored in a data member of some class, which is used to interact with the dialog., Is there any way to find the reference of the dialog when it is not stored?
I have tried QtGui.QApplication.topLevelWidgets(), QtGui.QApplication.activeModalWidget() but I could not find it among them.

@nicoddemus
Copy link
Member

I don't know, it should be.

But I find that just storing the reference for tests is reliable and a small wart that is worth in the end.

@Nagendraprasath-R
Copy link
Author

Sure, @nicoddemus. Thank you.

k-dominik added a commit to k-dominik/ilastik that referenced this issue Aug 12, 2019
unfortunately it is not possible to use qtbot with appropriate mouse actions
to test drag-n-drop:
pytest-dev/pytest-qt#256

I went with the suggestion to invoke event handlers directly. This makes the
test not very useful - but still, better than nothing.
@christinab12
Copy link

christinab12 commented Dec 20, 2023

Hi,

Thanks for the useful tips here @nicoddemus and @Nagendraprasath-R . I'm also writing some tests for my PyQt application and have come across this issue while trying to deal with QFileDialog.

I tried your solution, but the File Dialog still hangs open when I run the test until the user interacts. I wonder, what did you mean to do in the try statement of your on_browse_button (at the # do something with dialog comment)?

My code looks like this:

def on_button_clicked(self):

      self.fd = QFileDialog()
      try:
          self.fd.setFileMode(QFileDialog.Directory)
          if self.fd.exec_():
              self.app.data_path = self.fd.selectedFiles()[0]
          self.textbox.setText(self.app.eval_data_path)
      finally:
          self.fd = None

So, I guess self.fd is never set to None. Any ideas how I could deal with this?

Many thanks :)

@nicoddemus
Copy link
Member

@christinab12 one option is to mock QFileDialog.exec_ to call your own function, which selects the files you want in the test.

@Josdelsan
Copy link

Handle dialog function proposed by @nicoddemus works great. I want to share my experience after writing multiple dialog tests since the application I am working on has tons of dialogs.

Using a handle_dialog function inside the test function causes two main problems:

  • Compromises the readability of the test. This is critical when dealing with nested dialogs or context menus (example: trigger a context menu that has an action that triggers a dialog).
  • Assertions cannot be performed inside nested functions in test function. This is not always neccesary but might be convenient.

To solve the problems mentioned above I came up with the following solution:

def get_dialog(dialog_trigger: Callable, time_out: int = 5) -> QDialog:
    """
    Returns the current dialog (active modal widget). If there is no
    dialog, it waits until one is created for a maximum of 5 seconds (by
    default).

    :param dialog_trigger: Callable that triggers the dialog creation.
    :param time_out: Maximum time (seconds) to wait for the dialog creation.
    """

    dialog: QDialog = None
    start_time = time.time()

    # Helper function to catch the dialog instance and hide it
    def dialog_creation():
        # Wait for the dialog to be created or timeout
        nonlocal dialog
        while dialog is None and time.time() - start_time < time_out:
            dialog = QApplication.activeModalWidget()

        # Avoid errors when dialog is not created
        if dialog is not None:
            # Hide dialog to avoid interrupting the tests execution
            # It has the same effect as close()
            dialog.hide()

    # Create a thread to get the dialog instance and call dialog_creation trigger
    QTimer.singleShot(1, dialog_creation)  
    dialog_trigger()

    # Wait for the dialog to be created or timeout
    while dialog is None and time.time() - start_time < time_out:
        continue

    assert isinstance(
        dialog, QDialog
    ), f"No dialog was created after {time_out} seconds. Dialog type: {type(dialog)}"

    return dialog

This function receives a dialog trigger callable (example: button.click or button.clicked.emit) and returns the dialog instance. It hides (close also works) the dialog so it doesnt interrupt the test execution thread but the instance can be manipulated.

This function have some drawbacks:

  • As mentioned before in this issue, using activeModalWidget() is not as reliable as storing its reference. This could be fix adding the reference as a function argument and handling that scenario. In the app were I am working on most of the dialogs are not stored and can be triggered from many places so changing the code base is not an option.
  • Creating a dialog using methods like getExistingDirectory from QFileDialog will not work. It would be neccesary to set the values and accept before hide() is called.

I hope this helps someone. I might find this useful due to my application current code context and do not apply to everyone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants