Search code examples
react-nativee2e-testingdetox

Detox: detect that element was displayed


We have a toast component in our app that is adding considerable flakiness to our tests. The toast component displays an animated View for 4s and then disappears. In a lot of tests I need to check what the message content is in order to continue with the test.

The toast component is implemented with the following code:

// @flow
import * as React from "react"
import { StyleSheet, View, Animated, Dimensions, Text } from "react-native"

import type {
  TextStyle,
  ViewStyle,
} from "react-native/Libraries/StyleSheet/StyleSheet"

import type AnimatedValue from "react-native/Libraries/Animated/src/nodes/AnimatedValue"
import type { CompositeAnimation } from "react-native/Libraries/Animated/src/AnimatedImplementation"
import { AnimationConstants } from "constants/animations"

const styles = StyleSheet.create({
  container: {
    position: "absolute",
    left: 0,
    right: 0,
    elevation: 999,
    alignItems: "center",
    zIndex: 10000,
  },
  content: {
    backgroundColor: "black",
    borderRadius: 5,
    padding: 10,
  },
  text: {
    color: "white",
  },
})

type Props = {
  style: ViewStyle,
  position: "top" | "center" | "bottom",
  textStyle: TextStyle,
  positionValue: number,
  fadeInDuration: number,
  fadeOutDuration: number,
  opacity: number,
}

type State = {
  isShown: boolean,
  text: string | React.Node,
  opacityValue: AnimatedValue,
}

export const DURATION = AnimationConstants.durationShort

const { height } = Dimensions.get("window")

export default class Toast extends React.PureComponent<Props, State> {
  static defaultProps = {
    position: "bottom",
    textStyle: styles.text,
    positionValue: 120,
    fadeInDuration: AnimationConstants.fadeInDuration,
    fadeOutDuration: AnimationConstants.fadeOutDuration,
    opacity: 1,
  }

  isShown: boolean
  duration: number
  callback: Function
  animation: CompositeAnimation
  timer: TimeoutID

  constructor(props: Props) {
    super(props)
    this.state = {
      isShown: false,
      text: "",
      opacityValue: new Animated.Value(this.props.opacity),
    }
  }

  show(text: string | React.Node, duration: number, callback: Function) {
    this.duration = typeof duration === "number" ? duration : DURATION
    this.callback = callback
    this.setState({
      isShown: true,
      text: text,
    })

    this.animation = Animated.timing(this.state.opacityValue, {
      toValue: this.props.opacity,
      duration: this.props.fadeInDuration,
      useNativeDriver: true,
    })
    this.animation.start(() => {
      this.isShown = true
      this.close()
    })
  }

  close(duration?: number) {
    const delay = typeof duration === "undefined" ? this.duration : duration

    if (!this.isShown && !this.state.isShown) return
    this.timer && clearTimeout(this.timer)

    this.timer = setTimeout(() => {
      this.animation = Animated.timing(this.state.opacityValue, {
        toValue: 0.0,
        duration: this.props.fadeOutDuration,
        useNativeDriver: true,
      })
      this.animation.start(() => {
        this.setState({
          isShown: false,
        })
        this.isShown = false
        if (typeof this.callback === "function") {
          this.callback()
        }
      })
    }, delay)
  }

  componentWillUnmount() {
    this.animation && this.animation.stop()
    this.timer && clearTimeout(this.timer)
  }

  render() {
    const { isShown, text, opacityValue } = this.state
    const { position, positionValue } = this.props
    const pos = {
      top: positionValue,
      center: height / 2,
      bottom: height - positionValue,
    }[position]

    if (isShown) {
      return (
        <View style={[styles.container, { top: pos }]}>
          <Animated.View
            style={[
              styles.content,
              { opacity: opacityValue },
              this.props.style,
            ]}
          >
            {React.isValidElement(text) ? (
              text
            ) : (
              <Text style={this.props.textStyle}>{text}</Text>
            )}
          </Animated.View>
        </View>
      )
    }

    return null
  }
}

Normally we display the toast message for 4s, but I decided to display it in e2e tests for 1.5s in order to make some what faster.

I'm testing for the presence of the toast like this:

await expect(element(by.text(text))).toBeVisible()
await waitFor(element(by.text(text))).toBeNotVisible().withTimeout(2000)

However it happens often that detox fails at "toBeVisible". I can see the message on the screen, but for some reason detox is missing it.

What is the minimum time I should keep the message on the screen for detox to detect it?

On .circleCI we record videos of failing tests. When a test fails with "cannot find element" and I watch the video I clearly see the toast appearing on the screen, but detox fails to find it.


Solution

  • I'm still not sure if there is a better way, but I found a way that currently works for us.

    Instead of automatically hiding the toast in e2e test, we mock the modal component to display and stay visible until tapped on.

    Once detox sees the element we tap on it, close it and continue with our test.