Unityを利用したスマホゲーム開発のアプリ内課金システムに単体テストを導入した話
はじめに
はじめまして!G2 StudiosのクライアントエンジニアのS.Nです。
本記事では、Unityを用いたスマホゲームの課金システムのユースケーステストについて、具体的なコードを載せて解説していきます。
Google Play ストアでは2021年11月以降のアプリ更新で「Google Billing Library Version 3」への対応が必須となりました。ここ最近で課金システムのアップデートをされたプロジェクトも多いのではないでしょうか。私たちのプロジェクトも例に漏れずアップデートを行いましたが、アプリのリリース時点から課金システムに単体テストを導入していたため、安全にバージョンアップできました。
Unityには「Unity Test Runner」という単体テストを行うための枠組みが用意されており、技術ブログなどでも使用方法や簡単なサンプルが取り上げられています。実際にプロジェクトでの導入事例もありますが、具体的なコードは公開されていないため実装するイメージを持ちづらく、導入に踏み切れていない方も多いかと思います。
単体テストのサンプルは理解できたものの先へ進めない方や、私と同じように、課金システムを実装する方の助けになれば幸いです。
課金システムに単体テストを導入した経緯
課金システムは、実装コストが高くなります。スマホアプリの課金はAndroid/iOS端末でしか行えないため、実機確認が必要になりますが、アプリのビルドには長い時間(私達のプロジェクトでは30~40分程度)が必要になります。また、未消費のレシートが端末に残っている場合の動作やストアとの通信遮断時の動作など、素直に実装しただけでは確認が難しいケースも多くあります。
一般的に、不具合を検知するタイミングが遅くなるほど、修正コストが増大することが知られています。プロジェクトの規模や不具合の種類によって程度の差はあると思いますが、課金システムの不具合への対応は、ユーザーサポート、ユーザーが所持する購入レシートとDBの照合、下位互換性を保った修正といった複雑な状況を生むため、修正コストは大きなものとなるでしょう。可能な限り早く不具合を検出することが重要ですが、前述の通り、短いサイクルでコード修正/動作確認を行うことが困難です。
また、Google Billing Libraryのバージョンアップ対応のように運用中もコードは変化し続けます。最初の実装とバージョンアップは、別の人が担当する場合もあるでしょう。その場合は、次の担当者が実装時に想定したユースケースを把握することになりますが、その作業には時間がかかります。
これらの問題は、単体テストを導入することで改善することができます。Unity上でユースケースのテストを高速に繰り返すことが可能になるため、多くの不具合をテスト実装時に見つけることができ、結果的にリリース後の不具合を低減させることができます。また、テストコードでは既存の実装の意図や目的が表現されているため、これらを低コストで次の実装者へと受け渡すことも可能となるのです。
課金処理の流れ
具体的な実装を見ていく前に、スマホゲームにおけるアプリ内アイテムの購入フローを確認しておきましょう。
サーバーサイドでのレシート検証を伴う場合、これが最も基本的なフローになります。実際にはこれをベースにして商品の購入制限、購入上限額の制御、通信エラー時の処理などプロダクトの要件に合わせて条件が加わります。
アーキテクチャ
クライアントサイドの実装を解説していきます。
上の図は、UserCaseの単体テストに関する部分だけ抽出したアーキテクチャです。実際のプロジェクトでは、図に記載されているよりも多くのオブジェクトに依存していますが、説明が複雑になるため簡略化しています。また、レイヤー(≒ アセンブリ)毎に色を分けていますが、単体テストの話に限ると各レイヤーの役割はさほど重要ではないため、レイヤーの説明は省略します。
依存を減らすためのインターフェース
アーキテクチャの図の中にインターフェースが多く登場していますが、これらの存在がテストの記述を助けます。例えば、ユースケースの中で購入確認ダイアログを表示することを考えた場合、素直に実装するとUseCaseがDialogFactoryに依存することになります。DialogFactoryが更に他のオブジェクトに依存して…と依存が連鎖していると、テストを記述することが大変難しくなります。また、単体テストでは「テスト対象が依存するオブジェクトへ与えた影響」も確認したいところですが、Loaderがその役に適していないかもしれません。内部の状態を公開しているとは限らないですからね。
そこで、UseCasesレイヤー側で必要となるインターフェースIDialogFactoryを定義しておき、これをDialogFactoryが実装します。IDialogFactoryを実装したものがあればUseCaseを動作させられるので、差し替えることが可能になるわけです。
Presentationsレイヤーと比較すると、UseCasesレイヤーの方がより安定しています。UseCasesレイヤーの変更は必ずPresentationsレイヤーの変更を引き起こしますが、画像の変更やテキストの調整はユースケースに影響を与えないことも多いですよね。より安定したレイヤーに依存させるように実装した場合、素直な実装と比べると依存の向きが逆になります。これは「依存性逆転の原則」と呼ばれます。
クラスの役割
それでは各クラスやインターフェースの役割を解説していきます。
UseCase
このクラスがテスト対象となります。IStoreなどのインターフェースを利用して、ユースケースを組み立てます。具体的には以下のようなユースケースになります。
IRepository、Repository
永続的なデータを扱います。ユーザーが入力した生年月日の端末への保存、マスターデータの参照やAPIサーバーとの通信を担います。基本的にはこのクラスはFacadeの位置付けで、ネットワークやファイルI/Oなどの具体的な処理は、他のオブジェクトに移譲します。
IStore、Store
AndroidのGoogle Billing Libraryや、iOSのStoreKitが担当する範囲を扱います。アプリ内アイテムの情報取得、商品の決済、レシートの管理、レシートの消費などを担当します。
IDialogFactory、DialogFactory
生年月日の入力や未成年への親の同意確認、購入確認、購入完了、保留中の取引表示、キャンセル表示など、あらゆるダイアログの表示を担当します。
ILoader、Loader
購入処理を行っている間のローダーの表示を担当します。
TestRepository、TestStoreなど
単体テスト専用に用意したクラスです。単体テストでは、実際に購入確認ダイアログやストアの決済画面を表示することは難しいため、これらのクラスが代わりに処理したように振る舞います。テスト対象へのデータの入力と、テスト対象が外部のクラスに与える影響を観測するためにも利用されます。テスト用に用意されたこれらのコンポーネントは「テストダブル」と呼ばれます。
コード解説
それでは具体的なコードを見ていきましょう。アーキテクチャと同様に簡略化した内容となっている点にご留意下さい。
IStore、TestStore
public interface IStore
{
// アプリ内アイテムを購入する
UniTask<StorePurchaseResult> Purchase(string productId);
// レシートを消費する
void ConsumeReceipt(string productId);
// 未消費のレシートを持っているかどうか
bool HasUnconsumedReceipt();
// 未消費のレシートを取得する
Receipt GetUnconsumedReceipt();
}
public class TestStore : IStore
{
public class Setting
{
public StorePurchaseResult PurchaseResult { get; set; }
public Receipt UnconsumedReceipt { get; set; }
}
public bool HasPurchased { get; private set; }
public bool HasConsumed { get; private set; }
readonly Setting setting;
public TestStore(Setting setting)
{
this.setting = setting;
}
public async UniTask<StorePurchaseResult> Purchase(string productId)
{
await UniTask.Delay(1);
HasPurchased = true;
return setting.PurchaseResult;
}
public void ConsumeReceipt(string productId)
{
HasConsumed = true;
}
public bool HasUnconsumedReceipt()
{
return setting.UnconsumedReceipt != null;
}
public Receipt GetUnconsumedReceipt()
{
return setting.UnconsumedReceipt;
}
}
TestStoreでは、コンストラクタで受け取ったパラメーターを各メソッドでそのまま返却しています。テストケース毎に「購入に成功した時」、「未消費のレシートがある時」、「購入をキャンセルした時」のように異なる振る舞いを要求されるため、手軽に動作を変更できるようにしておくのがポイントです。
HasPurchasedやHasConsumedといったプロパティは、UseCaseが期待通りにStoreのメソッドを呼び出したかを検証するために使用します。
その他のインターフェースとテストダブル
TestStore以外のテストダブルも同様に、コンストラクタで振る舞いを受け取り、メソッドが呼ばれたという情報はプロパティに書き込んでいます。
public interface IRepository
{
// レシートの検証を行う
UniTask<ValidationResult> ValidateReceipt(string productId, string receipt);
}
public class TestRepository : IRepository
{
public class Setting
{
public ValidationResult ValidationResult { get; set; }
}
public bool HasValidated { get; private set; }
readonly Setting setting;
public TestRepository(Setting setting)
{
this.setting = setting;
}
public async UniTask<ValidationResult> ValidateReceipt(string productId, string receipt)
{
await UniTask.Delay(1);
HasValidated = true;
return setting.ValidationResult;
}
}
public interface IDialogFactory
{
// レシートの検証に成功成功した時のダイアログを表示する
UniTask DisplayReceiptValidationSucceededDialog();
// レシートの検証で失敗した時のダイアログを表示する
UniTask DisplayReceiptValidationFailedDialog();
// ストアで購入をキャンセルした時のダイアログを表示する
UniTask DisplayStoreCancelDialog();
// 保留中の取引である旨を伝えるダイアログを表示する
UniTask DisplayPendingTransactionDialog();
// 未消費のレシートが残っていることを伝えるダイアログを表示する
UniTask DisplayUnconsumedReceiptExistenceDialog();
}
public class TestDialogFactory : IDialogFactory
{
public bool HasReceiptValidationSucceededDialogDisplayed { get; private set; }
public bool HasReceiptValidationFailedDialogDisplayed { get; private set; }
public bool HasStoreCancelDialogDisplayed { get; private set; }
public bool HasPendingTransactionDialogDisplayed { get; private set; }
public bool HasUnconsumedReceiptExistenceDialogDisplayed { get; private set; }
public async UniTask DisplayReceiptValidationSucceededDialog()
{
await UniTask.Delay(1);
HasReceiptValidationSucceededDialogDisplayed = true;
}
public async UniTask DisplayReceiptValidationFailedDialog()
{
await UniTask.Delay(1);
HasReceiptValidationFailedDialogDisplayed = true;
}
public async UniTask DisplayStoreCancelDialog()
{
await UniTask.Delay(1);
HasStoreCancelDialogDisplayed = true;
}
public async UniTask DisplayPendingTransactionDialog()
{
await UniTask.Delay(1);
HasPendingTransactionDialogDisplayed = true;
}
public async UniTask DisplayUnconsumedReceiptExistenceDialog()
{
await UniTask.Delay(1);
HasUnconsumedReceiptExistenceDialogDisplayed = true;
}
}
public interface ILoader
{
// ローダーを表示する
void Show();
// ローダーを非表示にする
void Hide();
}
public class TestLoader : ILoader
{
public bool IsVisible => count > 0;
int count;
public void Show()
{
++count;
}
public void Hide()
{
--count;
}
}
UseCase
public class UseCase
{
readonly ILoader loader;
readonly IDialogFactory dialogFactory;
readonly IRepository repository;
readonly IStore store;
public UseCase(ILoader loader, IDialogFactory dialogFactory, IRepository repository, IStore store)
{
this.loader = loader;
this.dialogFactory = dialogFactory;
this.repository = repository;
this.store = store;
}
public async UniTask<bool> Purchase(string productId)
{
// 未消費レシートがあれば優先的に処理する
if (store.HasUnconsumedReceipt())
{
var unconsumedReceipt = store.GetUnconsumedReceipt();
if (unconsumedReceipt.IsPending)
{
await dialogFactory.DisplayPendingTransactionDialog();
return false;
}
await dialogFactory.DisplayUnconsumedReceiptExistenceDialog();
return await ValidateAndConsume(unconsumedReceipt);
}
// ストアでの決済処理
var storePurchaseResult = await store.Purchase(productId);
if (storePurchaseResult.ResultType == StoreResultType.Cancel)
{
await dialogFactory.DisplayStoreCancelDialog();
return false;
}
// 購入が保留となった場合はユーザーに購入を促す
if (storePurchaseResult.Receipt.IsPending)
{
await dialogFactory.DisplayPendingTransactionDialog();
return false;
}
// 購入後はレシートの検証と消費を行う
return await ValidateAndConsume(storePurchaseResult.Receipt);
}
private async UniTask<bool> ValidateAndConsume(Receipt receipt)
{
try
{
loader.Show();
var validationResult = await repository.ValidateReceipt(receipt.ProductId, receipt.Text);
if (validationResult == ValidationResult.Success || validationResult == ValidationResult.Used)
{
store.ConsumeReceipt(receipt.ProductId);
await dialogFactory.DisplayReceiptValidationSucceededDialog();
return true;
}
else
{
await dialogFactory.DisplayReceiptValidationFailedDialog();
return false;
}
}
finally
{
loader.Hide();
}
}
}
ユーザーが商品をタップした時に、Purchaseメソッドが呼ばれることを想定しています。Purchaseメソッドでは、未消費のレシートがあれば優先的に処理を行い、なければストアで商品を購入後、レシートの検証/消費へと処理を進めます。
アーキテクチャの項目でも説明した通り、コンストラクタではインターフェースのみを受け取っています。
UseCaseTest
public class UseCaseTest
{
static readonly string DefaultProductId = "test_product_id";
TestStore.Setting storeSetting;
TestStore store;
TestRepository.Setting repositorySetting;
TestRepository repository;
TestDialogFactory dialogFactory;
TestLoader loader;
[SetUp]
public void SetUp()
{
// 一番よく利用する値を設定しておくと変更箇所が少なくて楽
storeSetting = new TestStore.Setting
{
PurchaseResult = new StorePurchaseResult
{
Receipt = new Receipt
{
Text = "test_receipt",
IsPending = false,
ProductId = DefaultProductId,
},
},
UnconsumedReceipt = null,
};
repositorySetting = new TestRepository.Setting
{
ValidationResult = ValidationResult.Success,
};
}
[UnityTest]
public IEnumerator 購入できる()
{
yield return UniTask.ToCoroutine(async () =>
{
var useCase = Prepare();
var result = await useCase.Purchase(DefaultProductId);
// このメソッドでだけ大量のAssertが並んでいるが、"○○なダイアログが表示されないこと"といったケースは基本的に検証しなくても良い
// 念の為どれか1つのテストケースでチェックしておいても良いかな、くらいの温度感
Assert.That(result, Is.True);
Assert.That(store.HasConsumed, Is.True);
Assert.That(store.HasPurchased, Is.True);
Assert.That(dialogFactory.HasReceiptValidationSucceededDialogDisplayed, Is.True);
Assert.That(dialogFactory.HasReceiptValidationFailedDialogDisplayed, Is.False);
Assert.That(dialogFactory.HasPendingTransactionDialogDisplayed, Is.False);
Assert.That(dialogFactory.HasStoreCancelDialogDisplayed, Is.False);
Assert.That(dialogFactory.HasUnconsumedReceiptExistenceDialogDisplayed, Is.False);
Assert.That(repository.HasValidated, Is.True);
Assert.That(loader.IsVisible, Is.False);
});
}
[UnityTest]
public IEnumerator 購入を保留にした場合は保留中のダイアログが表示される()
{
yield return UniTask.ToCoroutine(async () =>
{
storeSetting.PurchaseResult.Receipt.IsPending = true;
var useCase = Prepare();
var result = await useCase.Purchase(DefaultProductId);
Assert.That(result, Is.False);
Assert.That(store.HasPurchased, Is.True);
Assert.That(store.HasConsumed, Is.False);
Assert.That(repository.HasValidated, Is.False);
Assert.That(dialogFactory.HasPendingTransactionDialogDisplayed, Is.True);
});
}
[UnityTest]
public IEnumerator 保留中のレシートを持っている場合には保留中のダイアログが表示される()
{
yield return UniTask.ToCoroutine(async () =>
{
storeSetting.UnconsumedReceipt = new Receipt
{
ProductId = "unconsumed_product_id",
Text = "unconsumed_receipt",
IsPending = true,
};
var useCase = Prepare();
var result = await useCase.Purchase(DefaultProductId);
Assert.That(result, Is.False);
Assert.That(store.HasPurchased, Is.False);
Assert.That(store.HasConsumed, Is.False);
Assert.That(dialogFactory.HasPendingTransactionDialogDisplayed, Is.True);
});
}
[UnityTest]
public IEnumerator 購入済みの未消費レシートを持っている場合には反映を促すダイアログが表示されてレシートが消費される()
{
yield return UniTask.ToCoroutine(async () =>
{
storeSetting.UnconsumedReceipt = new Receipt
{
ProductId = "unconsumed_product_id",
Text = "unconsumed_receipt",
IsPending = false,
};
var useCase = Prepare();
var result = await useCase.Purchase(DefaultProductId);
Assert.That(result, Is.True);
Assert.That(store.HasPurchased, Is.False);
Assert.That(store.HasConsumed, Is.True);
Assert.That(dialogFactory.HasUnconsumedReceiptExistenceDialogDisplayed, Is.True);
});
}
[UnityTest]
public IEnumerator アプリサーバーでアイテム付与済みのレシートをもう一度リクエストしてもレシートを消費できる()
{
yield return UniTask.ToCoroutine(async () =>
{
repositorySetting.ValidationResult = ValidationResult.Used;
storeSetting.UnconsumedReceipt = new Receipt
{
ProductId = "unconsumed_product_id",
Text = "unconsumed_receipt",
IsPending = false,
};
var useCase = Prepare();
var result = await useCase.Purchase(DefaultProductId);
Assert.That(result, Is.True);
Assert.That(store.HasPurchased, Is.False);
Assert.That(store.HasConsumed, Is.True);
Assert.That(dialogFactory.HasUnconsumedReceiptExistenceDialogDisplayed, Is.True);
});
}
private UseCase Prepare()
{
store = new TestStore(storeSetting);
repository = new TestRepository(repositorySetting);
loader = new TestLoader();
dialogFactory = new TestDialogFactory();
return new UseCase(loader, dialogFactory, repository, store);
}
UseCaseTestには、アプリで想定するユースケースのテストが記載されています。1つのメソッドで、1つのユースケースを表現しています。
UseCaseは複数のテストダブルに依存しているため処理が複雑に見えますが、どのテストも以下を順番に実行しているだけです。
例えば「購入を保留にした場合は保留中のダイアログが表示される」というユースケースでは、以下のような処理が記載されています。
処理1
storeSetting.UnconsumedReceipt = new Receipt
{
ProductId = "unconsumed_product_id",
Text = "unconsumed_receipt",
IsPending = true,
};
処理2
var useCase = Prepare();
var result = await useCase.Purchase(DefaultProductId);
処理3
Assert.That(result, Is.False);
Assert.That(store.HasPurchased, Is.False);
Assert.That(store.HasConsumed, Is.False);
Assert.That(dialogFactory.HasPendingTransactionDialogDisplayed, Is.True);
手動テストでは確認しづらい以下のようなユースケースについても、テストを記述できています!
その他のテストコードに登場するクラス
// ストアの決済のレスポンス
public class StorePurchaseResult
{
public StoreResultType ResultType { get; set; }
public Receipt Receipt { get; set; }
}
// ストアが発行するレシート情報
public class Receipt
{
public string ProductId { get; set; }
public string Text { get; set; }
public bool IsPending { get; set; }
}
// ストアの決済種別
public enum StoreResultType
{
Purchase,
Cancel,
}
// レシート検証結果
public enum ValidationResult
{
Success,
Used,
Invalid,
}
コードの説明は以上になります。
おわりに
今回はスマホゲームの課金システムのユースケースの単体テストについて、具体的なコードを添えて紹介しました。複数のテストダブルに依存する、やや複雑なテストとなりましたが、導入の効果は高かったと感じています。実際のプロジェクトでは40を超えるテスト(パラメタライズされたものも含めると60ほど)を記述しており、ユースケースの不具合は、ほとんど単体テスト中に出し切れたと思います。
また、実際のプロジェクトでは「誕生日入力のバリデーション」や「年齢による課金上限額の算出」などのテストも記述しています。日付が絡む処理は閏年を考慮する必要があり、テストパターンも多くなるので、単体テストの導入効果が高くなります。計算だけを担当するシンプルなクラスであれば、テストダブルも不要で手軽にテストを実装できるため、まずはこの辺りからテストを導入しても良いかもしれません。
G2 Studiosでは一緒に働く仲間を募集しています▼
フリーランスのエンジニアも多数活躍中です▼