How to call TestFairy SDK with Appium

Since joining Sauce Labs earlier this year, we’ve been busy making TestFairy better not only for mobile developers but also for SDETs that are looking for innovative ways to make their mobile test automation awesome. So, the challenge is howe to make Appium scripts talk with TestFairy SDK. All example code presented in this post is available as open source, code is open source and free for you to copy and adapt to your needs.

Environment

We’ll be using WebdriverIO with Javascript but all examples can be ported to other clients and languages with little to no change. The only requirement to have in our drivers is the ability to invoke "mobile: startService" via execute() which is already available on the latest UIAutomator2 drivers. Our test bed app is also included in the repo.

How it works

Our solution is based on the principle that Appium can start Android services by constructing intents over adb. These intents can hold extra arguments for services to interpret. We’ve created a simple service to parse incoming intent data to decide which TestFairy SDK method to invoke. Every time Appium starts our service, the service will read the intent, invoke TestFairy SDK and stop itself once the work is done. The service we launch will not interrupt any of the running activities, giving us ability to use it without change across various kind of apps such as video games, social networking and camera apps. Here is what you need to do.

Steps

Copy this file into your Android app project.

Add this line to your app manifest.

<service android:exported="true" android:name=".TestFairyService" />
<service android:exported="true" android:name=".TestFairyService" />
<service android:exported="true" android:name=".TestFairyService" />

Add these helpers to your Appium script. As example, we provide begin(), stop() and addEvent() but you can easily copy one of these to add invocation for other methods as you need. Make sure you update TestFairyService to parse your newly added invocations. Update these functions with your package name by replacing "com.example.appiumcallstestfairy/.TestFairyService" with "com.your.package.name/.TestFairyService".

/**
* Starts recording a TestFaiy session.
*
* @param client wdio client
* @param appToken TestFairy app token can be found at https://app.testfairy.com/settings
*/
async function begin(client, appToken) {
client.startActivity()
const args = Buffer.from(JSON.stringify(['begin', appToken])).toString('base64');
await client.execute('mobile: startService', {
intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService',
});
}
/**
* Sends a string event to currently recorded session. It will show up in the session timeline.
* Multiple sessions that has the same event can be searched at https://app.testfairy.com/sessions
*
* @param client wdio client
* @param event Some string value to represent a significant event happening during tests
*/
async function addEvent(client, event) {
const args = Buffer.from(JSON.stringify(['addEvent', event])).toString('base64');
await client.execute('mobile: startService', {
intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService',
});
}
/**
* Stops recording a TestFairy session.
*
* @param client wdio client
*/
async function stop(client) {
const args = Buffer.from(JSON.stringify(['stop'])).toString('base64');
await client.execute('mobile: startService', {
intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService',
});
}
/** * Starts recording a TestFaiy session. * * @param client wdio client * @param appToken TestFairy app token can be found at https://app.testfairy.com/settings */ async function begin(client, appToken) { client.startActivity() const args = Buffer.from(JSON.stringify(['begin', appToken])).toString('base64'); await client.execute('mobile: startService', { intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService', }); } /** * Sends a string event to currently recorded session. It will show up in the session timeline. * Multiple sessions that has the same event can be searched at https://app.testfairy.com/sessions * * @param client wdio client * @param event Some string value to represent a significant event happening during tests */ async function addEvent(client, event) { const args = Buffer.from(JSON.stringify(['addEvent', event])).toString('base64'); await client.execute('mobile: startService', { intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService', }); } /** * Stops recording a TestFairy session. * * @param client wdio client */ async function stop(client) { const args = Buffer.from(JSON.stringify(['stop'])).toString('base64'); await client.execute('mobile: startService', { intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService', }); }
/**
 * Starts recording a TestFaiy session.
 * 
 * @param client wdio client
 * @param appToken TestFairy app token can be found at https://app.testfairy.com/settings
 */
async function begin(client, appToken) {
  client.startActivity()
  const args = Buffer.from(JSON.stringify(['begin', appToken])).toString('base64');
  await client.execute('mobile: startService', {
    intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService',
  });
}

/**
 * Sends a string event to currently recorded session. It will show up in the session timeline.
 * Multiple sessions that has the same event can be searched at https://app.testfairy.com/sessions
 * 
 * @param client wdio client
 * @param event Some string value to represent a significant event happening during tests
 */
async function addEvent(client, event) {
  const args = Buffer.from(JSON.stringify(['addEvent', event])).toString('base64');
  await client.execute('mobile: startService', {
    intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService',
  });
}

/**
 * Stops recording a TestFairy session.
 * 
 * @param client wdio client
 */
async function stop(client) {
  const args = Buffer.from(JSON.stringify(['stop'])).toString('base64');
  await client.execute('mobile: startService', {
    intent: '--es "args" "' + args + '" com.example.appiumcallstestfairy/.TestFairyService',
  });
}

Invoke TestFairy methods wherever you need them.

// Test suite
describe('Create TestFairy session', function () {
let client;
before(async function () {
client = await webdriverio.remote(androidOptions);
});
it('should create and destroy a session', async function () {
const res = await client.status();
assert.isObject(res.build);
const current_package = await client.getCurrentPackage();
assert.equal(current_package, 'com.example.appiumcallstestfairy');
// Start a session
console.log("Starting a TestFairy session");
await begin(client, "SDK-XXXXXXX"); // TestFairy app token can be found at https://app.testfairy.com/settings
// Make your assertions
// Mark significant points in time during the session to be able to use them later in your TestFairy dashboard for search or preview
await addEvent(client, "Initial assertions passed, this will show up in session timeline");
// Make your assertions
// Stop session
await stop(client);
console.log("Ending TestFairy session");
const delete_session = await client.deleteSession();
assert.isNull(delete_session);
});
});
// Test suite describe('Create TestFairy session', function () { let client; before(async function () { client = await webdriverio.remote(androidOptions); }); it('should create and destroy a session', async function () { const res = await client.status(); assert.isObject(res.build); const current_package = await client.getCurrentPackage(); assert.equal(current_package, 'com.example.appiumcallstestfairy'); // Start a session console.log("Starting a TestFairy session"); await begin(client, "SDK-XXXXXXX"); // TestFairy app token can be found at https://app.testfairy.com/settings // Make your assertions // Mark significant points in time during the session to be able to use them later in your TestFairy dashboard for search or preview await addEvent(client, "Initial assertions passed, this will show up in session timeline"); // Make your assertions // Stop session await stop(client); console.log("Ending TestFairy session"); const delete_session = await client.deleteSession(); assert.isNull(delete_session); }); });
// Test suite
describe('Create TestFairy session', function () {
  let client;

  before(async function () {
    client = await webdriverio.remote(androidOptions);
  });

  it('should create and destroy a session', async function () {
    const res = await client.status();
    assert.isObject(res.build);

    const current_package = await client.getCurrentPackage();
    assert.equal(current_package, 'com.example.appiumcallstestfairy');

    // Start a session
    console.log("Starting a TestFairy session");
    await begin(client, "SDK-XXXXXXX"); // TestFairy app token can be found at https://app.testfairy.com/settings

    // Make your assertions

    // Mark significant points in time during the session to be able to use them later in your TestFairy dashboard for search or preview
    await addEvent(client, "Initial assertions passed, this will show up in session timeline");

    // Make your assertions

    // Stop session
    await stop(client);
    console.log("Ending TestFairy session");

    const delete_session = await client.deleteSession();
    assert.isNull(delete_session);
  });
});

What is inside TestFairyService?

It is a small service which parses the bundled Intent extra. For simplicity, we pass all arguments as a single string value, json encoded in base64.

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Bundle extras = intent.getExtras();
if (extras != null && extras.containsKey("args")) {
try {
final String args = extras.getString("args");
final JSONArray argsArray = new JSONArray(new String(Base64.decode(args, Base64.DEFAULT), StandardCharsets.UTF_8));
switch (argsArray.getString(0)) {
case "begin":
TestFairy.begin(getApplicationContext(), argsArray.getString(1));
break;
case "addEvent":
TestFairy.addEvent(argsArray.getString(1));
break;
case "stop":
TestFairy.stop();
break;
default:
break;
}
} catch (JSONException t) {
Log.w("TestFairyService", "Can't invoke TestFairy", t);
}
}
stopSelf();
return super.onStartCommand(intent, flags, startId);
}
@Override public int onStartCommand(Intent intent, int flags, int startId) { Bundle extras = intent.getExtras(); if (extras != null && extras.containsKey("args")) { try { final String args = extras.getString("args"); final JSONArray argsArray = new JSONArray(new String(Base64.decode(args, Base64.DEFAULT), StandardCharsets.UTF_8)); switch (argsArray.getString(0)) { case "begin": TestFairy.begin(getApplicationContext(), argsArray.getString(1)); break; case "addEvent": TestFairy.addEvent(argsArray.getString(1)); break; case "stop": TestFairy.stop(); break; default: break; } } catch (JSONException t) { Log.w("TestFairyService", "Can't invoke TestFairy", t); } } stopSelf(); return super.onStartCommand(intent, flags, startId); }
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
  Bundle extras = intent.getExtras();
  if (extras != null && extras.containsKey("args")) {
    try {
      final String args = extras.getString("args");
      final JSONArray argsArray = new JSONArray(new String(Base64.decode(args, Base64.DEFAULT), StandardCharsets.UTF_8));

      switch (argsArray.getString(0)) {
        case "begin":
          TestFairy.begin(getApplicationContext(), argsArray.getString(1));
          break;
        case "addEvent":
          TestFairy.addEvent(argsArray.getString(1));
          break;
        case "stop":
          TestFairy.stop();
          break;
        default:
          break;
      }
    } catch (JSONException t) {
      Log.w("TestFairyService", "Can't invoke TestFairy", t);
    }
  }

  stopSelf();

  return super.onStartCommand(intent, flags, startId);
}

Once the call is done, we stop the service in order not to consume more resources than necessary.

Win

You can find the complete example including the test app here. Let us know if we can improve it further. Until then, stay safe and enjoy hacking!