使用 detox 对 Toast 动画进行 e2e 测试的更好方法

Posted

技术标签:

【中文标题】使用 detox 对 Toast 动画进行 e2e 测试的更好方法【英文标题】:Better way to e2e test Toast animations with detox 【发布时间】:2020-03-06 23:06:38 【问题描述】:

我正在尝试测试以下 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”。为了显示 toast,我们调度了一个 redux 操作,它在术语上触发了 Toast。

我有以下测试失败:

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

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

对此有不同的解决方法。第一个是在点击按钮之前禁用同步,然后在显示吐司后启用它。这可行,但测试需要 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 测试的动画。我对此进行了测试并且它正在工作,但我想知道是否有更好的方法?

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

有什么方法可以让整个事情对编写测试的人更加用户友好:)

【问题讨论】:

排毒不会等待超过 1.5 秒的计时器。 所以这里:dismissTimeout = setTimeout(() => this.props.hideToast() , 4000) 将在 1.5 秒后被忽略??? 从一开始就会被忽略。任何在 1.5 秒后触发的计时器都会被忽略。 好的,那么这没有意义。如果排毒忽略计时器,为什么我的测试要等待 4 秒才能完成?它不应该立即完成测试吗? github.com/wix/Detox/blob/… 其他东西让 Detox 等待。 【参考方案1】:

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

【讨论】:

以上是关于使用 detox 对 Toast 动画进行 e2e 测试的更好方法的主要内容,如果未能解决你的问题,请参考以下文章

在 e2e 测试中的某个点开始使用 Detox 在 react native

使用 detox 测试 e2e 无法启动 iPhone 模拟器,而是启动 Apple TV

Detox E2E 测试不会在 react-native 项目上运行

React Native E2E w/Detox 错误:“无法在设备上运行应用程序”

有没有一种方法可以使用 Detox E2E 测试在 FlatList 中找到一个元素

用 RN Detox 模拟