はじめに
ABテストを導入しているアプリは多いと思います。
ユニットテストを書くときにABテスト部分をうまく制御できないかなと思って、制御できるようなコードを書いてみました。
ABテストでどちらのバケット(パターン)に当たっているかを取得するようなメソッドは色々な箇所で使われると思います。
class ABTesting {
class func getBucket() -> String {
return "バケットを示す値" // 内部で判定したりAPIから取得したりするかもしれない
}
}
コードで使う際はこんな感じでしょうか。
func targetMethod() -> Bool {
switch ABTesting.getBucket() {
case "patternA":
return true // patternAのときの処理
case "patternB":
return false // patternBのときの処理
...
}
}
実装
普段からユニットテストを書いている方なら、このsomething()メソッドが動くときにABテストのバケットに応じて期待する処理が行われていることを確かめたいはずですね😊
では、XCTest実行中には返すバケットの値を外から制御できるようにしてみましょう。
class ABTesting {
class func getBucketId() -> String {
if isTesting {
return DataStoreForTesting.bucket
} else {
return "patternA" // 内部で判定したりAPIから取得したりするかもしれない
}
}
}
struct DataStoreForTesting {
static var bucket: String = ""
}
// XCTest実行中かどうかの判定
// 注意!:プログラムから何度も実行されるとパフォーマンスに影響を与える場合があったので、都度アクセスするのでなく起動時に一度読んでメモリに載せとくのが良さそうでした。
var isTesting: Bool {
return ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}
テスト側ではこんな風にbucketを渡してあげることで、patternAのときのテスト、patternBのときのテストが書けるようになりました。
注意としては他のテストケース実行時にも、ここで設定したbucketが使われるので、setUp/tearDownでクリーンしておきましょう。テストの基本ですね。
class ViewControllerTests: XCTestCase {
override func setUp() {
super.setUp()
DataStoreForTesting.bucket = ""
}
override func tearDown() {
DataStoreForTesting.bucket = ""
super.tearDown()
}
func test_targetMethod_patternA() {
DataStoreForTesting.bucket = "patternA"
XCTAssertTrue(targetMethod())
}
func test_targetMethod_patternB() {
DataStoreForTesting.bucket = "patternB"
XCTAssertFalse(targetMethod())
}
}
sample code: https://github.com/shindyu/ABTestingSample/tree/ProductionCodeIncludeTestFrag
実務で運用する上ではABTestingの中身がもう少し複雑ですが、ABTestに限らずテスト実行時に特定の値を使いたい場合など結構応用できると思います。
ABテストで実装するものって半分以上は消えてしまうことがわかっているコードなのでテストを書くモチベ低いと思います。
ただ、テスト書かずに実装しちゃうと、100%反映するってなったときには、採用されるコード以外消すだけなので、そこに後からテストコード書くって多分やらないと思うんですよね。
なのでABテスト作成段階からある程度テスト書いていく方針が良いかなーと個人的には思います。
おまけ
method swizzlingで書いてみる方法もあるかなーと思って書いてみました。
ABTestingのgetBucket()と差し替えるためのメソッドとしてgetBucketForTesting()を用意します。
getBucketForTestingではStructに保存した値を返すようにしておきます。
そしたらmethodSwizzlingでgetBucketとgetBucketForTestingを差し替えてしまいます。
あ、もとのABTestingのgetBucket()に@objc dynamicをつけるのも忘れずに。
extension ABTesting {
@objc dynamic class func getBucketForTesting() -> String {
return DataStoreForTesting.bucket
}
// methodSwizzlingは一度しかよばれないようにします。
// 間違って何度も実行するとそのたびにメソッドの処理が入れ替わってしまうので。
// ここでは簡単にフラグで制御してます。
class func methodSwizzeling() {
if !DataStoreForTesting.wasMethodSwizzling {
let originalMethod = class_getClassMethod(ABTesting.self, #selector(ABTesting.getBucket()))
let swizzledMethod = class_getClassMethod(ABTesting.self, #selector(ABTesting.getBucketForTesting()))
method_exchangeImplementations(originalMethod!, swizzledMethod!)
DataStoreForTesting.wasMethodSwizzling = true
}
}
}
struct DataStoreForTesting {
static var bucket = ""
static var wasMethodSwizzling = false
}
class SampleTests: XCTestCase {
override func setUp() {
super.setUp()
ABTesting.methodSwizzeling()
DataStoreForTesting.bucket = ""
}
override func tearDown() {
DataStoreForTesting.bucket = ""
super.tearDown()
}
func test_something_patternA() {
DataStoreForTesting.bucket = "patternA"
XCTAssertTrue(targetMethod())
}
func test_something_patternB() {
DataStoreForTesting.bucket = "patternB"
XCTAssertFalse(targetMethod())
}
}
sample code: https://github.com/shindyu/ABTestingSample/tree/UsingMethodSwizzling
まぁどう考えても最初に書いたやり方のほうが簡単だし影響もわかりやすいので、swizzleする必要はないですねw