使用 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 之后,我能够等待它出现而无需指定超时。
我正在尝试测试以下 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 之后,我能够等待它出现而无需指定超时。