使用 detox 端到端测试 Toast 动画的更好方法

Better way to e2e test Toast animations with detox

我正在尝试测试以下 Toast 组件:

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

运行 在 iOS 上输出带有文本消息的视图。我的视图将 testID 设置为 "toast"。为了显示祝酒词,我们发送了一个 redux 动作,这在术语中触发了祝酒词。

我有以下测试失败:

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

我了解到测试失败是因为排毒自动同步(https://github.com/wix/Detox/blob/master/docs/Troubleshooting.Synchronization.md)。当我们按下按钮时,我们会发送一个 redux 动作。 toast 显示并设置 setTimeout 为 4 秒。现在 detox 在测试 "toast" 元素是否可见之前等待 4 秒。当 4s 结束时,元素从视图中被破坏并且排毒无法找到它。

对此有不同的解决方法。第一个是在点击按钮之前禁用同步,然后在显示吐司后启用它。这可行,但测试需要 4s+ 才能完成。出于某种原因,即使同步被禁用,我们仍然等待 setTimeout 完成,但这次我们看到了元素。

    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();
    });

文档中的另一个选项是禁用 e2e 测试的动画。我测试了这个并且它正在工作,但我想知道是否有更好的方法?

在这种特殊情况下,实际动画需要几百毫秒,然后我们显示视图并等待它消失。排毒无需等待。使用该应用程序的真实用户也不必等待。

有什么方法可以使整个事情对编写测试的人来说更友好一些:)

Leo Natan 是对的。其他事情正在发生。不确定它到底是什么,但是在重写我的 Toast 组件而不使用 componentWillReceiveProps 之后,我能够等待它出现而无需指定超时。