vaadin / testbench

Vaadin TestBench is a tool for automated user interface testing of Vaadin applications.
https://vaadin.com/testbench
Other
20 stars 22 forks source link

ComponentEvent.isFromClient() is false despite using TestWrappers.test() in a SpringUIUnitTest #1814

Open DennisSuffel opened 4 months ago

DennisSuffel commented 4 months ago

I expect ComponentEvent.isFromClient() to return true when using TestWrappers.test() in a SpringUIUnitTest to execute the action, that triggers the ComponentEvent. Instead it returns false.

Steps to reproduce

MainView.java

package org.vaadin.example;

import com.vaadin.flow.component.html.NativeLabel;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.Route;

@Route
public class MainView extends VerticalLayout {

  static final String TEXT_FIELD = "textField";
  static final String NATIVE_LABEL = "nativeLabel";

  public MainView() {
    TextField textField = new TextField();
    textField.setId(TEXT_FIELD);

    NativeLabel nativeLabel = new NativeLabel();
    nativeLabel.setId(NATIVE_LABEL);

    textField.addValueChangeListener(event -> {
      if (event.isFromClient()) {
        nativeLabel.setText(event.getValue());
      }
    });

    add(textField, nativeLabel);
  }
}

MainViewTest.java

package org.vaadin.example;

import static org.assertj.core.api.Assertions.*;
import static org.vaadin.example.MainView.NATIVE_LABEL;
import static org.vaadin.example.MainView.TEXT_FIELD;

import com.vaadin.flow.component.html.NativeLabel;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.testbench.unit.SpringUIUnitTest;
import org.junit.jupiter.api.Test;

class MainViewTest extends SpringUIUnitTest {

  @Test
  void whenSetTextThenSetLabel() {
    navigate(MainView.class);

    test($(TextField.class).id(TEXT_FIELD)).setValue("new value");

    String label = $(NativeLabel.class).id(NATIVE_LABEL).getText();
    assertThat(label).isEqualTo("new value");
  }
}

Expected Result

event.isFromClient() should return true, when TestWrappers.test() is used to set the value of a TextField. Then MainViewTest#whenSetTextThenSetLabel would complete successfuly.

Actual Result

MainViewTest#whenSetTextThenSetLabel fails with this AssertionError:

org.opentest4j.AssertionFailedError: expected: "new value" but was: ""

Versions

Vaadin 24.4.6 Java 17 macOS 14.5

TatuLund commented 4 months ago

True, the problem is here

https://github.com/vaadin/testbench/blob/main/vaadin-testbench-unit-shared/src/main/java/com/vaadin/flow/component/textfield/TextFieldTester.java#L57

The tester should call with introspection setModelValue(value, true) instead.

TatuLund commented 3 months ago

I checked this further ... It looks like it is problem with all the fields. The trouble is that not all of them are as easy to fix as the fields directly using AbstractField.setValue(..). Some other fields like DatePicker, ComboBox, Select... are overriding this method and for those fields using the setModelValue(value, true) is not enough as it is missing necessary things.

I first thought that I can simply write utility method that is used by all field Testers to set value, like below:

public class Utils {

    public static <V> void setValueAsUser(AbstractField<?, V> component, V value) {
        component.setValue(value);
        Class<?> clazz = component.getClass();
        while (!clazz.equals(AbstractField.class)) {
            clazz = clazz.getSuperclass();
        }
        try {
            Method setValueMethod = clazz.getDeclaredMethod("setModelValue",
                    Object.class, Boolean.TYPE);
            setValueMethod.setAccessible(true);
            setValueMethod.invoke(component, value, true);
        } catch (NoSuchMethodException | SecurityException
                | IllegalAccessException | IllegalArgumentException
                | InvocationTargetException e) {
            e.printStackTrace();
        }        
    }
}

Which would allow me to rewrite TextFieldTester as

    public void setValue(V value) {
        ensureComponentIsUsable();

        if (value == null && getComponent().getEmptyValue() != null) {
            throw new IllegalArgumentException(
                    "Field doesn't allow null values");
        }

        if (hasValidation() && value != null
                && getValidationSupport().isInvalid(value.toString())) {
            if (getComponent().isPreventInvalidInputBoolean()) {
                throw new IllegalArgumentException(
                        "Given value doesn't pass field value validation. Check validation settings for field.");
            }
            LoggerFactory.getLogger(TextFieldTester.class).warn(
                    "Gave invalid input, but value set as invalid input is not prevented.");
        }

        Utils.setValueAsUser(getComponent(), value);
    }

This works for TextFieldTester, NumberFieldTester and TextAreaTester ... But I think not for the others.

mvysny commented 3 months ago

I've used even wilder reflection which works for all AbstractFields:

public class FlowUtils {
    @NotNull
    private static <V> AbstractFieldSupport<?, V> getFieldSupport(@NotNull HasValue<?, V> component) {
        try {
            final Field javaField = AbstractField.class.getDeclaredField("fieldSupport");
            javaField.setAccessible(true);
            return (AbstractFieldSupport<?, V>) javaField.get(component);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Sets the value to given component. Supports pretending that the value came from the browser.
     * @param component the component to set the value to, not null.
     * @param value the new value, may be null.
     * @param isFromClient if true, we'll pretend that the value came from the browser. This causes the value change event to be fired
     *                     with `isFromClient` set to true.
     * @param <V> the value type
     */
    public static <V> void setValue(@NotNull HasValue<?, V> component, @Nullable V value, boolean isFromClient) {
        if (!isFromClient) {
            component.setValue(value);
            return;
        }
        if (component instanceof AbstractField) {
            final AbstractFieldSupport<?, V> fs = getFieldSupport(component);
            try {
                final Method m = AbstractFieldSupport.class.getDeclaredMethod("setValue", Object.class, boolean.class, boolean.class);
                m.setAccessible(true);
                m.invoke(fs, value, false, isFromClient);
            } catch (NoSuchMethodException | IllegalAccessException |
                     InvocationTargetException e) {
                throw new RuntimeException(e);
            }
            return;
        }
        throw new IllegalArgumentException("Parameter component: invalid value " + component + ": unsupported type of HasValue: " + component.getClass());
    }
}