wix / Detox

Gray box end-to-end testing and automation framework for mobile apps
https://wix.github.io/Detox/
MIT License
11.22k stars 1.92k forks source link

How to test toast Messages #1750

Closed compojoom closed 4 years ago

compojoom commented 4 years ago

I'm trying to test the following Toast component:

import React, { Component } from "react"
import PropTypes from "prop-types"
import {
  Animated,
  Platform,
  Text,
  ToastAndroid,
  TouchableOpacity,
  View,
} from "react-native"
import { RkStyleSheet, RkText } from "react-native-ui-kitten"
import IconFe from "react-native-vector-icons/Feather"
import { UIConstants } from "constants/appConstants"

class Toast extends Component {
  constructor(props) {
    super(props)
    this.state = {
      fadeAnimation: new Animated.Value(0),
      shadowOpacity: new Animated.Value(0),
      timeLeftAnimation: new Animated.Value(0),
      present: false,
      message: "",
      dismissTimeout: null,
      height: 0,
      width: 0,
    }
  }

  /* eslint-disable-next-line  */
  UNSAFE_componentWillReceiveProps(
    { message, error, duration, warning },
    ...rest
  ) {
    if (message) {
      let dismissTimeout = null
      if (duration > 0) {
        dismissTimeout = setTimeout(() => {
          this.props.hideToast()
        }, duration)
      }

      clearTimeout(this.state.dismissTimeout)
      this.show(message, { error, warning, dismissTimeout, duration })
    } else {
      this.state.dismissTimeout && clearTimeout(this.state.dismissTimeout)
      this.hide()
    }
  }

  show(message, { error, warning, dismissTimeout, duration }) {
    if (Platform.OS === "android") {
      const androidDuration =
        duration < 3000 ? ToastAndroid.SHORT : ToastAndroid.LONG
      ToastAndroid.showWithGravityAndOffset(
        message,
        androidDuration,
        ToastAndroid.TOP,
        0,
        UIConstants.HeaderHeight
      )
    } else {
      this.setState(
        {
          present: true,
          fadeAnimation: new Animated.Value(0),
          shadowOpacity: new Animated.Value(0),
          timeLeftAnimation: new Animated.Value(0),
          message,
          error,
          warning,
          dismissTimeout,
        },
        () => {
          Animated.spring(this.state.fadeAnimation, {
            toValue: 1,
            friction: 4,
            tension: 40,
          }).start()
          Animated.timing(this.state.shadowOpacity, { toValue: 0.5 }).start()
          Animated.timing(this.state.timeLeftAnimation, {
            duration,
            toValue: 1,
          }).start()
        }
      )
    }
  }

  hide() {
    if (Platform.OS === "ios") {
      Animated.timing(this.state.shadowOpacity, { toValue: 0 }).start()
      Animated.spring(this.state.fadeAnimation, { toValue: 0 }).start(() => {
        this.setState({
          present: false,
          message: null,
          error: false,
          warning: false,
          dismissTimeout: null,
        })
      })
    }
  }

  dispatchHide() {
    this.props.hideToast()
  }

  _renderIOS() {
    if (!this.state.present) {
      return null
    }

    const messageStyles = [styles.messageContainer, this.props.containerStyle]
    if (this.state.error) {
      messageStyles.push(styles.error, this.props.errorStyle)
    } else if (this.state.warning) {
      messageStyles.push(styles.warning, this.props.warningStyle)
    }

    return (
      <Animated.View
        style={[
          styles.container,
          {
            opacity: this.state.fadeAnimation,
            transform: [
              {
                translateY: this.state.fadeAnimation.interpolate({
                  inputRange: [0, 1],
                  outputRange: [0, this.state.height], // 0 : 150, 0.5 : 75, 1 : 0
                }),
              },
            ],
          },
        ]}
        onLayout={evt => this.setState({})}
      >
        <TouchableOpacity
          onPress={this.dispatchHide.bind(this)}
          activeOpacity={1}
        >
          <View style={styles.messageWrapper}>
            <View
              testID={"toast"}
              style={messageStyles}
              onLayout={evt => {
                this.setState({
                  width: evt.nativeEvent.layout.width,
                  height: evt.nativeEvent.layout.height,
                })
              }}
            >
              {this.state.dismissTimeout === null ? (
                <TouchableOpacity
                  style={{ alignItems: "flex-end" }}
                  onPress={this.dispatchHide.bind(this)}
                >
                  <IconFe name={"x"} color={"white"} size={16} />
                </TouchableOpacity>
              ) : null}
              {this.props.getMessageComponent(this.state.message, {
                error: this.state.error,
                warning: this.state.warning,
              })}
            </View>
          </View>
        </TouchableOpacity>
      </Animated.View>
    )
  }

  render() {
    if (Platform.OS === "ios") {
      return this._renderIOS()
    } else {
      return null
    }
  }
}

const styles = RkStyleSheet.create(theme => {
  return {
    container: {
      zIndex: 10000,
      position: "absolute",
      left: 0,
      right: 0,
      top: 10,
    },
    messageWrapper: {
      justifyContent: "center",
      alignItems: "center",
    },
    messageContainer: {
      paddingHorizontal: 15,
      paddingVertical: 15,
      borderRadius: 15,
      backgroundColor: "rgba(238,238,238,0.9)",
    },
    messageStyle: {
      color: theme.colors.black,
      fontSize: theme.fonts.sizes.small,
    },
    timeLeft: {
      height: 2,
      backgroundColor: theme.colors.primary,
      top: 2,
      zIndex: 10,
    },
    error: {
      backgroundColor: "red",
    },
    warning: {
      backgroundColor: "yellow",
    },
  }
})

Toast.defaultProps = {
  getMessageComponent(message) {
    return <RkText style={styles.messageStyle}>{message}</RkText>
  },
  duration: 5000,
}

Toast.propTypes = {
  // containerStyle: View.propTypes.style,
  message: PropTypes.string,
  messageStyle: Text.propTypes.style, // eslint-disable-line react/no-unused-prop-types
  error: PropTypes.bool,
  // errorStyle: View.propTypes.style,
  warning: PropTypes.bool,
  // warningStyle: View.propTypes.style,
  duration: PropTypes.number,
  getMessageComponent: PropTypes.func,
}

export default Toast

Running this on iOS outputs a View with a Text message. My view has a testID set to "toast". To show the toast we dispatch a redux action, which in term triggers the Toast.

I have the following test that fails:

    it("submit without username should display invalid username", async () => {
      await element(by.id("letsGo")).tap()
      await expect(element(by.id("toast"))).toBeVisible()
    });

I understand that the test fails because of the automatic synchronization of detox. When we press the button we dispatch a redux action. The toast displays and a setTimeout of 4s is set. Now detox waits 4s before it tests whether the "toast"element is visible or not. When the 4s are over the element is destroyed from the view and detox cannot find it.

There are different workarounds for this. The first one would be to disableSynchronization before taping on the button and then to enable it after the toast has been displayed. this works, but the test needs 4s+ to complete. For some reason even though the sync is disabled we still wait for the setTimeout to complete, but this time we see the element.

    it("submit without username should display invalid username", async () => {
      await device.disableSynchronization();
      await element(by.id("letsGo")).tap()
      await expect(element(by.id("toastWTF"))).toBeVisible()
      await device.enableSynchronization();
    });

Another option as per the docs is to disable the animation for e2e tests. I tested this and it's working, but I'm wondering if there is a better way?

In this particular case the actual animation takes few hundred ms and after that we display the view and wait for it to disapper. There is no need for detox to wait. The real users using the app also don't have to wait either.

Is there any way to make this whole thing a little more user friendly for people writing the tests :)

support[bot] commented 4 years ago

We use the issue tracker exclusively for bug reports and feature requests. This issue appears to be a general usage or support question. Instead, please ask a question on Stack Overflow with the detox tag.

Feel free to post your Stack Overflow question here for more visility. We'll take a look at it.

For issues with Expo apps, it is most likely not an issue with Detox itself, but with the Expo runtime or with incorrect Detox setup. For support on how to use Detox with Expo, you should contact the Expo team or the Expo community.

For more information on bots in this reporsitory, read this discussion.

compojoom commented 4 years ago

Posted on stackoverflow: https://stackoverflow.com/questions/58798351/better-way-to-e2e-test-toast-animations-with-detox

@LeoNatan - maybe something to think about? No idea if a better solution exist, but somehow the whole thing feels awkward . The actuall message is visible on the screen for 4s. Displaying the message takes less than few hundred miliseconds and after that the message is there. Detox does nothing because it sees a setTimeout. The app however is fully responsive in the meantime... We don't have an endless loop animation...