手把手教你創建第一個React Native自動化測試工具Detox

語言: CN / TW / HK

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

Detox 是一個用於移動端 APP 灰盒測試(介於白盒測試和黑盒測試之間,既關注內部邏輯實現也關注軟件最終效果,通常在集成測試階段進行)的自動化測試框架。

Detox提供了清晰的api來獲取引用和觸發元素上的操作 示例代碼: ```javascript 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('[email protected]');
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();

}); }); ```

工作原理 image.png

功能

  • 跨平台
  • 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同步執行異步任務 javascript await element(by.id('ButtonA')).tap(); await element(by.id('ButtonB')).tap();

  7. api清晰、學習成本低、減少心智負擔

缺點

  1. 在進程中執行了額外的代碼來監聽 App 的行為
  2. 無限重複的動畫會讓腳本一直處於等待狀態,需要額外的代碼讓自動化測試的build去掉無限循環的動畫

使用

默認您已經安裝node以及對應的Android或IOS等相關環境
這裏只介紹對應的Detox安裝使用

  1. 安裝對應工具 ```javascript // Command Line Tools npm install detox-cli --global

// 添加到當前RN項目中 yarn add detox -D ``` 將你的android文件放在Android studio中構建

  1. 更改android/build.gradle 文件

在allprojects.repositories中添加以下 javascript maven { // 所有 Detox 的模塊都通過 npm 模塊 url "$rootDir/../node_modules/detox/Detox-android" } buildscript的ext 中添加kotlinVersion字段 javascript kotlinVersion = '1.6.21' // (check what the latest version is!) 在dependencies中添加 javascript classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"

  1. 更改android/ app/build.gradle 文件

在中android.defaultConfig添加以下2行 javascript testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' 在android.buildTypes.release中添加以下3行 javascript 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中添加以下兩行 javascript // 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文件中

注意:複製後的內容需要處理一下 image.png

  1. detoxrc與e2e文件配置

輸入detox init -r jest生成e2e文件夾和.detoxrc.json文件 配置.detoxrc.json文件 javascript { "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文件 ```javascript // 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`文件夾javascript 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( bystep.element.by, );

      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`javascript { "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 ```javascript /**
  2. Sample React Native App
  3. http://github.com/facebook/react-native *
  4. @format
  5. @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 ( <> {loginStatus === LOGIN_STATUS.LOGGED_IN ? ( Hello {loginData.username} Edit your profile ) : ( Please Login Username Password