Search code examples
react-nativedetox

Better way to e2e test Toast animations with detox


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 (https://github.com/wix/Detox/blob/master/docs/Troubleshooting.Synchronization.md) 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 waitFor(element(by.id("toastWTF"))).toBeVisible().withTimeout(1000)
      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 :)


Solution

  • Leo Natan was right. Something else was going on. Not sure what it was exactly, but after re-writing my Toast component not to use componentWillReceiveProps I was able to await it to appear without specifying a timeout.