[Swift]WebDriver clientライブラリを作ってみる

Selenium/Appium Advent Calendar 2018、7日目の記事です。

Selenium/Appiumを使っている皆さんはWebDriverの W3C仕様 を見聞きしたことがあるかと思います。このW3C仕様に沿ったクライアントライブラリを使用することで、この仕様をサポートするサーバに対して同じ操作を発行、実行させることができます。そのため、必要であれば自分たちの開発言語を用い、必要な仕様だけ満足するカスタムクライアントを実装する、といったことも容易になります。

モバイルには限らないのですが、テスト自動化などの文脈でテストツールを選ぶ際、この どの言語のWebDriverクライアントを使うか はよく議題に上がると思います。実際に開発に用いている言語を使うとメンテできる人も増えるし、選べるなら選びたいところですね。

ただ、公式にメンテされている言語、されていない言語と様々ありますね。

モバイル・Appiumの文脈において

Appiumを使って自動化を行う際、JavaScript/Java/Python/Ruby/.NETのクライアントがちょくちょく使われるようです。

JavaScriptはAppium自体がNodeJSを基盤として開発であることや、ReactNativeを使ったことのある人にとって馴染みやすいようです。エンタープライズ向けの利用も含めてJavaも多いですね。ちらほらとPython、Rubyも見ます。iOSだと、FastlaneなどのツールもRuby実装なのでRubyをプロジェクトに入れることに対して比較的抵抗は低いみたいです。Pythonは機械学習を絡めたりする場合は特に、とても使い勝手が良いみたいです。.NET を使ってる人は、Xamarinとかなのかな、と推測します。

JavaとSwift

Androidの主な開発言語は(Android)JavaとKotlinです。Appium projectがJavaクライアントをメンテしていることもあり、Androidに対してJavaクライアントを選ぶことは普通な選択肢の1つのようです。Javaは周辺のテストツールの既存資産も多いので、テスト集計やSeleniumを使ったWeb側との連携もある程度既存の道を走ることができる点も良いですね。

iOSはSwiftですが、これ向けの主なメンテされているライブラリはありません。一応 selenium-swift はありますが、これは4年間継続してメンテされてはいないようです。元のObjective-C版のSwiftラッパーという位置付けです。このライブラリコードをのぞいてみましたが、大体は class で定義(状態をそもそも持たないのでstructで書いたりできる箇所も含めて)していたりと、書き方など含めてよくできるかなーという感じのものでした。

そのため、W3C仕様の把握も含めて実験的にSwiftをベースにクライアントを書いてみました。まだ構造として変えたいところもあるので、本当に実験的なものです。その時の、どういう感じでWebDriverクライアントを作ることができるか、という感覚を今回は共有しようと思います。

以下は AppiumSwiftClient#8182d8f4時点のものをベースに話をしています。セッションを確立し、その要素に対してタップするなどの操作を実現するためのコードは以下な感じです。(AppiumFuncTests)

let caps = AppiumCapabilities(...)
let driver = try AppiumDriver(caps)

let el = try driver.findElement(by: .accessibilityId, with: "Buttons")
_ = el.click()

WebDriverにおけるクライアント/サーバ間のやり取りはJSON形式です。そのため、SwiftではCodableを中心に使っています。これは色々と説明しているWebサイトも多いので、Codableを使ってJSONのマッピングに関しては特に言及しません。エラー処理も良くしては行きたいですね。

実装

以下ではSwiftのコードを書いていきます。どんな感じでクライアントライブラリを書いていったのかを載せていこうと思います。

外部ライブラリに対しては特別な依存関係は持たせていません。テストコードで利用しているものはありますが、ライブラリの中ではありません。似た簡単な処理が続くので、簡易的な処理だけであれば多分使う必要は無いかな、という感覚ではいます。

https://github.com/KazuCocoa/AppiumSwiftClient

Create Session

まずは入り口

Spec

command

  • HTTP method
    • POST
  • URI
    • /session

Request body

クライアントがサーバに対して送るJSONは以下なフォーマットです。実際はalwaysMatchesもあるのですが、それは空でも問題は無い(現状のSeleniumクライアントライブラリのいくつかはそもそも送ってない)のでここでも省略します。

{
  "capabilities": {
    "firstMatch": [
      {
        "appium:app": "path/to/appium",
        "deviceName": "iPhone Simulator",
        "platformVersion": "11.4",
        "automationName": "XCUITest",
        "platformName": "iOS"
      }
    ]
  }
}

Response body

それに対して、例えば以下のような結果が得られます。

{
  "value": {
    "sessionId":"9C9D08C2-6024-4132-8E2C-D2292672C0E2",
    "capabilities": {
      "device":"iphone",
      "browserName":"UICatalog",
      "sdkVersion":"11.4",
      "CFBundleIdentifier":"com.example.apple-samplecode.UICatalog"
    }
  }
}

この応答はW3Cのスペックに沿ったものです。

Implementation

// Request bodyの生成
let json = generateBodyData(with: caps)
// FoundationのHttpClientを利用
let (statusCode, returnValue) =
  HttpClient().sendSyncRequest(method: "POST",
                               commandPath: commandUrl(),
                               json: json)
// W3Cでは、HTTP status codeと、得られるJSONの `value` の中に基本的には情報が載っています。なので、もし"value"がなければおそらくサーバ側がW3Cに沿っていないということです。
// 一応、チェック。
guard let value = returnValue["value"] as? [String: Any] else {
  return ""
}

// 200であれば期待通りに処理が終わりということです。
// CreateSessionの返り値として特に大事なのはsession idです。他で使います。
if statusCode == 200 {
  let sessionId = value["sessionId"] as! String
  // この sessionId は他で使いまわすので、インスタンスにでも保存しておくと良いです
} else {
  // error
}

code:CreateSession.swift

エラー処理は除いています。ここで session id を得られたら、通常はサーバとのセッションが確立しています。いったんの目的は達成。

Find Element

1つの要素の取得。

Spec

command

  • HTTP method
    • POST
  • URI
    • /session/{session id}/element

Request body

{
  "using": "accessibility id",
  "value": "some value"
}

Response body

以下のelement-6066-11e4-a52e-4f735466cecf 固定値です。

{
  "value": {
    "element-6066-11e4-a52e-4f735466cecf": "element id"
  }
}

Implementation

let json = helper.generateBodyData(by: locator, with: value)
// /session/{session id}/element の `{session id}` を `sessionId` に置き換える
let (statusCode, returnValue) =
  HttpClient().sendSyncRequest(method: "POST",
                               commandPath: commandUrl(with: sessionId),
                               json: json)

if statusCode == 200 {
  return Element(
    id: helper.elementIdFrom(param:
      returnValue["value"] as! W3CFindElementHelper.ElementValue
    ),
    sessionId: sessionId
  )
} else {
  // ...
}

ここでは Element というクラスを返すようにしています。操作対象となる要素(Element)に対して、例えばvisibilityのチェックやsend textする場合など、element idとsession idのペアを元にコマンドを発行する必要がある場面があります。そのため、このElementはSwiftではclassとしています。

code:FindElement.swift, code:Element.swift

コラム

find element系で影響を受けるlocator strategiesはW3C specでは以下のみ定義されています。

CSS selector “css selector”
Link text selector“link text”
Partial link text selector“partial link text”
Tag name“tag name”
XPath selector“xpath”

一方、古いWebDriverでは id といったlocatorが使われていました。Seleniumのリポジトリで管理されているクライアントでは、利用ユーザのために後方互換を考慮して id のlocator strategyを利用した場合はクライアント側で css selecotor に変換するといった対応が入っています。

一方、これはAppiumでは期待した動作をしなくなります。Appiumにおけるid というlocatorは、例えばAndroidではresource idを参照します。そのため、Appiumのクライアントではこの css selector への変換ロジックを削除しています。

Selenium ClientをAppiumに対して使っていたり、古いバージョンのAppiumクライアントを利用している場合はこの変換ロジックが機能し、find element系の命令が壊れる場合があります。もしこういう形に遭遇したらライブラリを更新しましょう。

Click

クリック(タップ)操作

Spec

command

  • HTTP method
    • POST
  • URI
    • /session/{session id}/element/{element id}/click

Request body

{}

Response body

{ "value": "" }

Implementation

let json = generateBodyData()
// `/session/{session id}/element/{element id}/click` の `{session id}` を `sessionId` に、
// `{element id}` を `elementId` に置き換える
let (statusCode, returnValue) =
  HttpClient().sendSyncRequest(method: "POST",
                               commandPath: commandUrl(with: sessionId, and: elementId),
                               json: json)

if statusCode == 200 {
    return returnValue["value"] as! String
} else {
  //...
}

code:Click.swift

Clickは特に何か値が帰ってくることを求めはしません。なので、HTTP statusが 200 であれば基本は大丈夫です。

締め

ここまでいくつか実装することで、先に載せた以下の操作が可能になります。AppiumCapabilitiesに関してはJSONのマッピングです。

let caps = AppiumCapabilities(...)
let driver = try AppiumDriver(caps)

let el = try driver.findElement(by: .accessibilityId, with: "Buttons")
_ = el.click()

まとめ

いくつか例を出しながらSwiftでWebDriver Clientを実装してみました。

基本的にはWebDriverはREST APIのようなコマンド体系ですし、HTTP Status code + JSONの中身のペアの話だけをしています。想定されるエラーも handling-errors や、どのAPIに対してどのエラーが発生する可能性があるのかも仕様に載っています。

サーバ側は色々とiOS/Androidに対して考慮する必要のある面も多々ですが、クライアント側はW3Cに沿っていれば比較的変化の少ないものです。AppiumもW3C対応は終えたのですが、例えばモバイル向けChromeやSafariはW3C対応できていないところも散見されます。そのため、Appiumの裏側ではiOS/Andorid <=> Appium間で考慮が必要な範囲は多々ありますが…

ともあれ、このようにSelenium/AppiumともにW3Cに沿う感じになってきているので、ブラウザの対応次第ではありますが必要なだけの機能を持ったWebDriverクライアントを作ってみるのも良いですね。

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.