likes
comments
collection

手把手教你创建第一个React Native自动化测试工具Detox

作者站长头像
站长
· 阅读数 46

| Detox([ˈdiˌtɑks])

Detox 是一个用于移动端 APP 灰盒测试(介于白盒测试和黑盒测试之间,既关注内部逻辑实现也关注软件最终效果,通常在集成测试阶段进行)的自动化测试框架。

Detox提供了清晰的api来获取引用和触发元素上的操作 示例代码:

describe('Login flow', () => {
  it('should login successfully', async () => {
    await device.launchApp();
    // 通过ID获取元素的引用,并显示出来
    await expect(element(by.id('email'))).toBeVisible();

    // 获取引用并键入指
    await element(by.id('email')).typeText('john@example.com');
    await element(by.id('password')).typeText('123456');

    // 获取引用并执行点击操作
    await element(by.text('Login')).tap();

    await expect(element(by.text('Welcome'))).toBeVisible();
    await expect(element(by.id('email'))).toNotExist();
  });
});

工作原理  手把手教你创建第一个React Native自动化测试工具Detox

功能

  • 跨平台
  • async-await异步断点调试
  • 自动化同步
  • 专为CI打造
  • 支持在设备上运行

优点

  1. Detox支持Android和iOS。与React Native 在iOS和Android的代码几乎相同、可复用
  2. 支持各种Test runner, 比如Mocha(轻量级),Jest(推荐使用)等
  3. 代码侵入性小
  4. 搭建简单、运行的时候只需要detox build命令来编测试app和detox test来执行脚本即可
  5. 社区活跃
  6. 使用async-await同步执行异步任务
await element(by.id('ButtonA')).tap();  
await element(by.id('ButtonB')).tap();  
  1. api清晰、学习成本低、减少心智负担

缺点

  1. 在进程中执行了额外的代码来监听 App 的行为
  2. 无限重复的动画会让脚本一直处于等待状态,需要额外的代码让自动化测试的build去掉无限循环的动画

使用

默认您已经安装node以及对应的Android或IOS等相关环境 这里只介绍对应的Detox安装使用

  1. 安装对应工具
// Command Line Tools 
npm install detox-cli --global

// 添加到当前RN项目中
yarn add detox -D

将你的android文件放在Android studio中构建

  1. 更改android/build.gradle 文件

在allprojects.repositories中添加以下

maven { 
    // 所有 Detox 的模块都通过 npm 模块
    url "$rootDir/../node_modules/detox/Detox-android" 
}

buildscript的ext 中添加kotlinVersion字段

kotlinVersion = '1.6.21' // (check what the latest version is!)

在dependencies中添加

classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
  1. 更改android/ app/build.gradle 文件

在中android.defaultConfig添加以下2行

testBuildType System.getProperty('testBuildType', 'debug')  
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

在android.buildTypes.release中添加以下3行

minifyEnabled enableProguardInReleaseBuilds
// Typical pro-guard definitions
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Detox-specific additions to pro-guard
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"

在dependencies中添加以下两行

 // detox config
androidTestImplementation 'com.wix:detox:+'
androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1'
  1. android文件夹中创建对应的目录及文件

添加文件和目录到 android/app/src/androidTest/java/com/你的包名,全小写/DetoxTest.java 不要忘记将包名称更改为您的项目 将代码内容复制到DetoxText文件中

注意:复制后的内容需要处理一下  手把手教你创建第一个React Native自动化测试工具Detox

  1. detoxrc与e2e文件配置

输入detox init -r jest 生成e2e文件夹和.detoxrc.json文件 配置.detoxrc.json文件

{
  "testRunner": "jest",
  "runnerConfig": "e2e/config.json",
  "skipLegacyWorkersInjection": true,
  "devices": {
    "emulator": {
      "type": "android.emulator",
      "device": {
        "avdName": "Nexus_S_API_28" // 设备名称 执行adb -s emulator-5554 emu avd name 获取
      }
    }
  },
  "apps": {
    "android.debug": {
      "type": "android.apk",
      "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
      "build": "cd android && gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd .."
    },
    "android.release": {
      "type": "android.apk",
      "binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
      "build": "cd android && gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd .."
    }
  },
  "configurations": {
    "android.emu.debug": {
      "device": "emulator",
      "app": "android.debug"
    },
    "android.emu.release": {
      "device": "emulator",
      "app": "android.release"
    }
  }
}

配置e2e/firstTest.e2e.js文件

// eslint-disable-next-line no-undef
describe('Login flow test', () => {
  beforeEach(async () => {
    await device.launchApp();
    // await device.reloadReactNative();
  });

  it('should have login screen', async () => {
    await expect(element(by.id('loginView'))).toBeVisible();
  });

  it('should fill login form', async () => {
    await element(by.id('usernameInput')).typeText('zzzzz');
    await element(by.id('passwordInput')).typeText('test123\n');
    await element(by.id('loginButton')).tap();
  });

  it('should show dashboard screen', async () => {
    await expect(element(by.id('dashboardView'))).toBeVisible();
    await expect(element(by.id('loginView'))).not.toExist();
  });
});

在e2e中创建 随便命名xxx.spec.js随便命名xxx文件夹

const parseSpecJson = specJson => {
  describe(specJson.describe, () => {
    for (let i = 0; i < specJson.flow.length; i++) {
      const flow = specJson.flow[i];
      it(flow.it, async () => {
        for (let j = 0; j < flow.steps.length; j++) {
          const step = flow.steps[j];
          const targetElement = element(
            by[step.element.by](step.element.value),
          );

          if (step.type === 'assertion') {
            await expect(targetElement)[step.effect.key](step.effect.value);
          } else {
            await targetElement[step.effect.key](step.effect.value);
          }
        }
      });
    }
  });
};

parseSpecJson(require('./随便命名xxx/login.json'));

e2e/随便命名xxx文件夹中创建login.json

{
  "describe": "Login flow test",
  "flow": [
    {
      "it": "should have login screen",
      "steps": [
        {
          "type": "assertion",
          "element": {
            "by": "id",
            "value": "loginView"
          },
          "effect": {
            "key": "toBeVisible",
            "value": ""
          }
        }
      ]
    },
    {
      "it": "should fill login form",
      "steps": [
        {
          "type": "action",
          "element": {
            "by": "id",
            "value": "usernameInput"
          },
          "effect": {
            "key": "typeText",
            "value": "varunk"
          }
        },
        {
          "type": "action",
          "element": {
            "by": "id",
            "value": "passwordInput"
          },
          "effect": {
            "key": "typeText",
            "value": "test123\n"
          }
        },
        {
          "type": "action",
          "element": {
            "by": "id",
            "value": "loginButton"
          },
          "effect": {
            "key": "tap",
            "value": ""
          }
        }
      ]
    },
    {
      "it": "should show dashboard screen",
      "steps": [
        {
          "type": "assertion",
          "element": {
            "by": "id",
            "value": "dashboardView"
          },
          "effect": {
            "key": "toBeVisible",
            "value": ""
          }
        },
        {
          "type": "assertion",
          "element": {
            "by": "id",
            "value": "loginView"
          },
          "effect": {
            "key": "toNotExist",
            "value": ""
          }
        }
      ]
    }
  ]
}
  1. 给你的组件加上testID
/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 *
 * @format
 * @flow
 */

import React, {useState} from 'react';
import {
  SafeAreaView,
  StyleSheet,
  ScrollView,
  View,
  Text,
  StatusBar,
  TextInput,
  Button,
  ActivityIndicator,
} from 'react-native';

import {Colors} from 'react-native/Libraries/NewAppScreen';

const LOGIN_STATUS = {
  NOT_LOGGED_IN: -1,
  LOGGING_IN: 0,
  LOGGED_IN: 1,
};

const App: () => React$Node = () => {
  const [loginData, setLoginData] = useState({
    username: '',
    password: '',
  });

  const [loginStatus, setLoginStatus] = useState(LOGIN_STATUS.NOT_LOGGED_IN);

  const onLoginDataChange = key => {
    return value => {
      const newLoginData = Object.assign({}, loginData);
      newLoginData[key] = value;
      setLoginData(newLoginData);
    };
  };

  const onLoginPress = () => {
    setLoginStatus(LOGIN_STATUS.LOGGING_IN);
    setTimeout(() => {
      setLoginStatus(LOGIN_STATUS.LOGGED_IN);
    }, 1500);
  };

  return (
    <>
      <StatusBar barStyle="dark-content" />
      <SafeAreaView style={styles.container}>
        <ScrollView
          contentInsetAdjustmentBehavior="automatic"
          style={styles.scrollView}>
          {loginStatus === LOGIN_STATUS.LOGGED_IN ? (
            <View testID="dashboardView">
              <Text style={styles.heading} testID="dashboardHeadingText">
                Hello {loginData.username}
              </Text>
              <Text style={[styles.link, styles.mt12]}>Edit your profile</Text>
            </View>
          ) : (
            <View testID="loginView">
              <Text style={styles.heading}>Please Login</Text>
              <Text style={styles.mt12}>Username</Text>
              <TextInput
                style={[styles.textInput, styles.mt12]}
                placeholder={'Enter your username'}
                onChangeText={onLoginDataChange('username')}
                value={loginData.username}
                testID="usernameInput"
              />
              <Text style={styles.mt12}>Password</Text>
              <TextInput
                secureTextEntry
                style={[styles.textInput, styles.mt12, styles.mb12]}
                placeholder={'Enter your password'}
                onChangeText={onLoginDataChange('password')}
                value={loginData.password}
                testID="passwordInput"
              />
              <Button
                title="Login"
                onPress={onLoginPress}
                testID="loginButton"
              />
              {loginStatus === LOGIN_STATUS.LOGGING_IN ? (
                <ActivityIndicator style={styles.mt12} />
              ) : null}
            </View>
          )}
        </ScrollView>
      </SafeAreaView>
    </>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  scrollView: {
    backgroundColor: Colors.white,
    padding: 16,
  },
  heading: {
    textAlign: 'center',
    fontSize: 18,
  },
  textInput: {
    borderColor: Colors.lighter,
    borderWidth: 1,
    borderRadius: 4,
    paddingLeft: 10,
    paddingTop: 4,
    paddingRight: 4,
    paddingBottom: 4,
  },
  mt12: {
    marginTop: 12,
  },
  mb12: {
    marginBottom: 12,
  },
  link: {
    color: '#3543bf',
  },
});

export default App;

  1. 测试开始
  • 打包app到模拟器上
  • 执行detox build --configuration android.emu.debug
  • 执行yarn start
  • 执行adb reverse tcp:8081 tcp:8081
  • 执行detox test -c android.emu.debug

执行detox test -c android.emu.debug之后,项目开始运行测试如下  手把手教你创建第一个React Native自动化测试工具Detox 至此运行通过,测试结束

| 参考资料

# 如何自动化测试 React Native 项目 (上篇) - 核心思想与E2E自动化 # React Native end-to-end testing with Detox # e2e testing of react native app (Android) using detox — a step by step guide