1.2K Views
February 29, 24
スライド概要
「テストコードを導入できなくてつらい」「テストコードを書くのがつらい」といった悩みを抱えたことはありませんか?
ソフトウェア開発において、テスト容易設計は重要な課題です。しかし、実際にテスト容易設計を達成することは簡単ではありません。「外部から依存を注入することができない」「テスト対象に、テストが難しいモジュールが含まれる」などが原因となり、自動テストがしづらくなるという課題があります。
私たちは新卒で SWET グループに配属後、GitHub API を用いた簡易的な GitHub クライアントの実装を通して、上記の課題を解決するテスト容易設計について学びました。
本登壇では、テスト容易性の高い GitHub クライアントを実装する上でつまずいた点や、そこから学んだテスト容易設計のコツについて、テストに詳しくない方でも理解できる形で分かりやすくお話します。
DeNA が社会の技術向上に貢献するため、業務で得た知見を積極的に外部に発信する、DeNA 公式のアカウントです。DeNA エンジニアの登壇資料をお届けします。
SWET新卒研修で学んだテスト容易設計のコツ 品質本部品質管理部SWET第二グループ Yuya Okuse / Satoshi Iijima / Kento Wakamatsu © DeNA Co., Ltd.
⾃⼰紹介 飯島 慧 Satoshi Iijima 奥瀬 雄哉 Yuya Okuse 若松 健⽃ Kento Wakamatsu © DeNA Co., Ltd. 2023 年DeNA新卒⼊社。学⽣時代は、 ゲーム制作と競プロを趣味とし、要求⼯学(学部)と ⾃然⾔語処理(修⼠)に関する研究室に 所属していた。現在はSWET 2Gに所属。 2023年に DeNA に新卒⼊社。学⽣時代は、 主にReactNative + TypeScriptでアプリ開発を ⾏っていた。現在は、SWET 2Gに所属しており、 iOSアプリ開発を担当。 2023年にDeNA新卒⼊社。 学⽣時代に研究の⼀環でAndroid開発を始める。 現在は、SWET 2GのAndroidチームに所属している。 2
SWETとは? SoftWare Engineer in Test ● ソフトウェアに対するテストの専⾨家集団 ● 以下をソフトウェアテストの観点から攻略 ○ DeNAサービス全般の品質向上 ○ DeNAエンジニアの開発⽣産性向上 © DeNA Co., Ltd. 3
SWET新卒研修について ● テストに関する知⾒をSWETで成果が出せる ⽔準まで引き上げるために実施 ● GitHubクライアントアプリ実装を通して、 テスト容易設計を学習 © DeNA Co., Ltd. 4
GitHubクライアントアプリの仕様 アプリの主な機能 ● Personal Access Tokenによるログイン ● GitHub上のリポジトリをキーワード検索 ● リポジトリ毎のスターの付与/解除 制約 ● テストカバレッジをなるべく増やす ● Swift(iOS), Kotlin(Android), C#(Unity)でそれぞれ作成 © DeNA Co., Ltd. 5
© DeNA Co., Ltd. 6
テスト容易設計のコツ 1. プロセス外依存を注⼊する 2. フレームワークに依存しない オブジェクトにロジックを移動する © DeNA Co., Ltd. 7
テスト容易設計のコツ 1. プロセス外依存を注⼊する 2. フレームワークに依存しない オブジェクトにロジックを移動する © DeNA Co., Ltd. 8
テスト容易設計のコツ 1. プロセス外依存を注⼊する 2. フレームワークに依存しない DBや外部APIなど、アプリケーションを実⾏する オブジェクトにロジックを移動する プロセスの外で稼働する依存 © DeNA Co., Ltd. 9
// 検索処理を行うクラス class ReposModel { // 依存 var githubAPI: GitHubAPI = GitHubAPI() 実際にAPIを叩く クラス // クエリをもとにリポジトリ検索を行う func fetch(query: String) async throws /> [Repository] { do { let result = try await githubAPI.fetchRepos() return result } catch { githubAPI を通して、 // エラー時の処理 リポジトリを取得 } } } © DeNA Co., Ltd. 10
func test_検索処理が成功した場合のテスト() async { let repos = ReposModel() do { let result = try await repos.fetch(query: "Swift") // 以下、成功した場合に検証したいこと // Assert } catch {} } func test_検索処理が失敗した場合のテスト() async { let repos = ReposModel() do { let _ = try await repos.fetch(query: "Swift") } catch { // 以下、失敗した場合に検証したいこと // Assert } © DeNA Co., Ltd. } 11
class ReposModel {
var githubAPI: GitHubAPI = GitHubAPI()
func fetch(query: String) async throws /> [Repository] {
do {
let result = try await githubAPI.fetchRepos()
return result
} catch {
// エラー時の処理
}
}
}
func test_検索処理が失敗した場合のテスト () async {
let repos = ReposModel()
do {
let _ = try await repos.fetch(query: "Swift")
} catch {
// 以下、失敗した場合に検証したいこと
}
} Ltd.
© DeNA Co.,
12
class ReposModel {
var githubAPI: GitHubAPI = GitHubAPI()
func fetch(query: String) async throws /> [Repository] {
do {
let result = try await githubAPI.fetchRepos()
return result
} catch {
// エラー時の処理
}
}
}
func test_検索処理が失敗した場合のテスト () async {
let repos = ReposModel()
do {
let _ = try await repos.fetch(query: "Swift")
} catch {
// 以下、失敗した場合に検証したいこと
}
} Ltd.
© DeNA Co.,
テスト時に実際のAPIを
叩いてしまう
13
実際のAPIを叩く場合のデメリット ● テスト時間の増加 ● ネットワークへの依存 ● テスト内容に応じて、APIからの レスポンスを変更できない © DeNA Co., Ltd. 14
エラー時の処理をテストしたいが… class ReposModel { var githubAPI: GitHubAPI = GitHubAPI() func fetch(query: String) async throws /> [Repository] { do { let result = try await githubAPI.fetchRepos() return result } catch { // エラー時の処理 取得に失敗した状態を意図的に 作り出せない } } } © DeNA Co., Ltd. 15
エラー時の処理をテストしたいが… class ReposModel { var githubAPI: GitHubAPI = GitHubAPI() func fetch(query: String) async throws /> [Repository] { do { let result = try await githubAPI.fetchRepos() return result } catch { // エラー時の処理 githubAPI.fetchRepos()で 意図的にエラーを発⽣させたい } } } © DeNA Co., Ltd. 16
スタブについて テスト対象が内部的に実行している関数の戻り値を、 テストコード作成者が意図的に設定するためのオブジェクト © DeNA Co., Ltd. 17
class ReposModel {
var githubAPI: GitHubAPI = GitHubAPI()
func fetch(query: String) async throws /> [Repository] {
do {
let result = try await githubAPI.fetchRepos()
return result
} catch {
テスト対象が内部的に実行
// エラー時の処理
している関数
}
}
}
func test_検索処理が失敗した場合のテスト () async {
let repos = ReposModel()
do {
let _ = try await repos.fetch(query: "Swift")
} catch {
// 以下、失敗した場合の検証
}
}
© DeNA Co., Ltd.
18
class ReposModel {
var githubAPI: GitHubAPI = GitHubAPI()
func fetch(query: String) async throws /> [Repository] {
do {
let result = try await githubAPI.fetchRepos()
return result
} catch {
意図的にエラーを発⽣
// エラー時の処理
させたい
}
}
}
func test_検索処理が失敗した場合のテスト () async {
let repos = ReposModel()
do {
let _ = try await repos.fetch(query: "Swift")
} catch {
// 以下、失敗した場合の検証
}
}
© DeNA Co., Ltd.
19
スタブについて(再掲) テスト対象が内部的に実行している関数の戻り値を、 テストコード作成者が意図的に設定するためのオブジェクト © DeNA Co., Ltd. 20
class ReposModel {
var githubAPI: GitHubAPI = GitHubAPI()
func fetch(query: String) async throws /> [Repository] {
do {
let result = try await githubAPI.fetchRepos()
return result
} catch {
ここをスタブで置き換えて、
// エラー時の処理
意図的にエラーを発生させる
}
}
}
func test_検索処理が失敗した場合のテスト () async {
let repos = ReposModel()
do {
let _ = try await repos.fetch(query: "Swift")
} catch {
// 以下、失敗した場合の検証
}
}
© DeNA Co., Ltd.
21
class GitHubAPIStub: GitHubAPIProtocol {
private var result: [Repository]?
private var error: GitHubError?
init(result: [Repository]?) {
self.result = result
self.error = nil
}
init(error: GitHubError?) {
self.result = nil
self.error = error
}
func fetchRepos(query: String) async throws /> [Repository] {
if let error = error {
throw error
} else {
return result!
}
}
}
© DeNA Co., Ltd.
22
class GitHubAPIStub: GitHubAPIProtocol {
private var result: [Repository]?
private var error: GitHubError?
2つのイニシャライザを実装
init(result: [Repository]?) {
self.result = result
init(result:)
self.error = nil
成功時に期待する値
}
init(error: GitHubError?) {
init(error:)
self.result = nil
失敗時に期待する値
self.error = error
}
func fetchRepos(query: String) async throws /> [Repository] {
if let error = error {
throw error
} else {
return result!
}
}
}
© DeNA Co., Ltd.
23
class GitHubAPIStub: GitHubAPIProtocol {
private var result: [Repository]?
private var error: GitHubError?
init(result: [Repository]?) {
self.result = result
fetchRepos()関数の実装
self.error = nil
}
初期化時に受け取った値に
init(error: GitHubError?) {
応じた処理を⾏う
self.result = nil
self.error = error
}
func fetchRepos(query: String) async throws /> [Repository] {
if let error = error {
throw error
} else {
return result!
}
}
}
© DeNA Co., Ltd.
24
失敗結果 let error = GitHubError() let githubAPIStub = GitHubAPIStub(error: error) let result = try await githubAPIStub.fetchRepos(query: "Swift") 成功結果 let githubAPIStub = GitHubAPIStub(result: [repo1, repo2]) let result = try await githubAPIStub.fetchRepos(query: "Swift") © DeNA Co., Ltd. 25
失敗結果 let error = GitHubError() let githubAPIStub = GitHubAPIStub(error: error) let _ = try await githubAPIStub.fetchRepos(query: "Swift") 成功結果 エラーを throw する let githubAPIStub = GitHubAPIStub(result: [repo1, repo2]) let result = try await githubAPIStub.fetchRepos(query: "Swift") © DeNA Co., Ltd. 26
失敗結果 let error = GitHubError() let githubAPIStub = GitHubAPIStub(error: error) let _ = try await githubAPIStub.fetchRepos(query: "Swift") 指定した値が返る 成功結果 let githubAPIStub = GitHubAPIStub(result: [repo1, repo2]) let result = try await githubAPIStub.fetchRepos(query: "Swift") © DeNA Co., Ltd. 27
class ReposModel {
func fetch(query: String) async throws /> [Repository] {
do {
let result = try await githubAPI.fetchRepos(query: "Swift")
return result
} catch {
// エラー時の処理
スタブに置き換えたい
}
}
}
© DeNA Co., Ltd.
28
class ReposModel {
func fetch(query: String) async throws /> [Repository] {
do {
let error = Error()
let stub = GithubAPIStub(error: error)
let _ = try await githubAPIStub.fetchRepos(query: "Swift")
} catch {
//エラー時の処理
スタブに置き換えられると…
}
}
}
エラー時の処理が実⾏される
© DeNA Co., Ltd.
29
スタブに置き換えられるようにするために 1. interfaceを⽤意(Swiftの場合はprotocol) 2. 本番実装とスタブの両⽅をinterfaceに準拠 3. スタブを注⼊する © DeNA Co., Ltd. 30
interface(protocol)の実装 protocol GitHubAPIProtocol { func fetchRepos(query: String) async throws /> [Repository] } © DeNA Co., Ltd. 31
class GitHubAPIStub: GitHubAPIProtocol {
private var result: [Repository]?
private var error: GitHubError?
init(result: [Repository]?) {
interface(protocol)に準拠
self.result = result
self.error = nil
}
init(error: GitHubError) {
self.result = nil
self.error = error
}
func fetchRepos(query: String) async throws /> [Repository] {
if let error = error {
throw error
} else {
return result!
}
}
}
© DeNA Co., Ltd.
32
修正後のReposModel
class ReposModel {
var githubAPI: any GitHubAPIProtocol
init(githubAPI: any GitHubAPIProtocol)
self.githubAPI = githubAPI
}
{
func fetch(query: String) async throws /> [Repository] {
do {
let _ = try await githubAPI.fetchRepos(query: "Swift")
} catch {
// エラー時の処理
}
}
}
© DeNA Co., Ltd.
33
修正後のReposModel
class ReposModel {
var githubAPI: any GitHubAPIProtocol
init(githubAPI: any GitHubAPIProtocol)
self.githubAPI = githubAPI
}
githubAPI が
GitHubAPIProtocolに依存する
形に変更
{
スタブの注⼊が可能
func fetch(query: String) async throws /> [Repository] {
do {
let _ = try await githubAPI.fetchRepos(query: "Swift")
} catch {
// エラー時の処理
}
}
}
© DeNA Co., Ltd.
34
修正後のテストコード func test_検索処理が失敗した場合() async { let error = GitHubError() let stub = GitHubAPIStub(error: error) let repos = ReposModel(githubAPI: stub) do { let _ = try await repos.fetch(query: "Swift") XCTFail() } catch { // 想定されるエラーがスローされることのテスト XCTAssertTrue(error is GitHubError) } } © DeNA Co., Ltd. 35
テスト容易設計のコツ 1. プロセス外依存を注⼊する 2. フレームワークに依存しない オブジェクトにロジックを移動する © DeNA Co., Ltd. 36
テスト容易設計のコツ 1. プロセス外依存を注⼊する 2. フレームワークに依存しない オブジェクトにロジックを移動する © DeNA Co., Ltd. 37
テスト対象がフレームワークに依存した際の問題点と解決策 問題点 ● テストの準備に⼿間がかかる ● テストの実⾏に時間がかかる 解決策 フレームワークに依存しないオブジェクトにロジックを移し、 そこでテストを作成する © DeNA Co., Ltd. 38
// スター表示UI専用ViewController class StarViewController: UIViewController { private let label = UILabel() private let client: IStarClient フレームワークに依存 @IBAction func buttonTapped() { Task { let data = await client.fetch() if (data.isStar) { label.text = "★" } else { label.text = "☆" } } } } © DeNA Co., Ltd. している 39
class StarViewController: UIViewController { private let label = UILabel() private let client: IStarClient @IBAction func buttonTapped() { Task { let data = await client.fetch() if (data.isStar) { label.text = "★" } else { label.text = "☆" } } } } © DeNA Co., Ltd. スターがタップされた 時に呼ばれる 40
class StarViewController: UIViewController { private let label = UILabel() private let client: IStarClient @IBAction func buttonTapped() { Task { let data = await client.fetch() if (data.isStar) { label.text = "★" 今回テストしたいロジック } else { 例)data.isStarがtrue/falseの時に、 label.text = "☆" ★/☆が代⼊される } } } } © DeNA Co., Ltd. 41
class StarTest: XCTestCase { func スターをつけた場合_★が表示されることを確認する { // テスト準備 let storyboard = UIStoryboard(name: "Main", bundle: nil) let clientStub = ClientStub(value = true) let viewController = storyboard.instantiateViewController(identifier: "StarViewController") { coder in return StarViewController(coder, value: clientStub) } let label = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "starLabel" }) .compactMap({ $0 as? UILabel }) .first let button = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "button" }) .compactMap({ $0 as? UIButton }) .first // テスト対象を実行 button/.sendActions(for: .touchUpInside) } } © DeNA Co., Ltd. // 検証 XCTAssertEqual(label/.text, "★") 少量のロジックの検証 に⾮常に⼿間がかかる 42
class StarTest: XCTestCase { func スターをつけた場合_★が表示されることを確認する { // テスト準備 let storyboard = UIStoryboard(name: "Main", bundle: nil) let clientStub = ClientStub(value = true) let viewController = storyboard.instantiateViewController(identifier: "StarViewController") { coder in return StarViewController(coder, value: clientStub) } let label = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "starLabel" }) .compactMap({ $0 as? UILabel }) .first let button = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "button" }) .compactMap({ $0 as? UIButton }) .first 設計を⾒直す必要がある // テスト対象を実行 button/.sendActions(for: .touchUpInside) } } © DeNA Co., Ltd. // 検証 XCTAssertEqual(label/.text, "★") 少量のロジックの検証 に⾮常に⼿間がかかる 43
解決策:ロジックをフレームワークに依存しないオブジェクトに移す class StarViewController: UIViewController { private let label = UILabel() private let client: IStarClient class StarModel { private let client: IStarClient private(set) var starText: String init(client: IStarClient) { @IBAction func buttonTapped() { self.client = client Task { } let data = await client.fetch() if (data.isStar) { テストしたい func fetch() async { label.text = "★" var data = await client.fetch() ロジックを } else { label.text = "☆" 別クラスに抽出 if (data.isStar) { starText = "★" } } else { } starText = "☆" } } } } } © DeNA Co., Ltd. 44
解決策:ロジックをフレームワークに依存しないオブジェクトに移す class StarViewController: UIViewController { private let label = UILabel() private let client: IStarClient class StarModel { private let client: IStarClient private(set) var starText: String init(client: IStarClient) { @IBAction func buttonTapped() { self.client = client Task { } let data = await client.fetch() if (data.isStar) { テストしたい func fetch() async { label.text = "★" var data = await client.fetch() ロジックを } else { label.text = "☆" 別クラスに抽出 if (data.isStar) { starText = "★" } } else { } starText = "☆" } } } } } フレームワークに依存しない状態でテストが可能 © DeNA Co., Ltd. 45
改善後のコードとそのテストコード class StarModel { private let client: IStarClient private(set) var starText: String init(client: IStarClient) { self.client = client } } func fetch() async { var data = await client.fetch() if (data.isStar) { starText = "★" } else { starText = "☆" } } © DeNA Co., Ltd. class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let clientStub = ClientStub(true) let starModel = StarModel(clientStub) // テスト対象を実行 starModel.fetch() // 検証 XCTAssertEqual(starModel.text, "★") } } 46
設計⾒直し前と後のテストコードの⽐較 class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let storyboard = UIStoryboard(name: "Main", bundle: nil) let clientStub = ClientStub(value = true) let viewController = storyboard.instantiateViewController( identifier: "StarViewController") { coder in return StarViewController(coder, value: clientStub) } class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let clientStub = ClientStub(true) let starModel = StarModel(clientStub) let label = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "starLabel" }) .compactMap({ $0 as? UILabel }) .first let button = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "button" }) .compactMap({ $0 as? UIButton }) .first // テスト対象を実行 starModel.fetch() // 検証 フレームワークに依存 XCTAssertEqual(starModel.text, "★") // テスト対象を実行 button/.sendActions(for: .touchUpInside) } } // 検証 XCTAssertEqual(label/.text, "★") } } していないためテスト が容易になった 圧倒的にテストしやすくなった © DeNA Co., Ltd. 47
設計⾒直し前と後のテストコードの⽐較 class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let storyboard = UIStoryboard(name: "Main", bundle: nil) let clientStub = ClientStub(value = true) let viewController = storyboard.instantiateViewController( identifier: "StarViewController") { coder in return StarViewController(coder, value: clientStub) } class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let clientStub = ClientStub(true) let starModel = StarModel(clientStub) let label = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "starLabel" }) .compactMap({ $0 as? UILabel }) .first let button = viewController.view.subviews .filter({ $0.accessibilityIdentifier /= "button" }) .compactMap({ $0 as? UIButton }) .first // テスト対象を実行 starModel.fetch() これでも⼗分テストしやすいが、 // 検証 フレームワークに依存 更に改善できる XCTAssertEqual(starModel.text, "★") // テスト対象を実行 button/.sendActions(for: .touchUpInside) } } // 検証 XCTAssertEqual(label/.text, "★") } } していないためテスト が容易になった 圧倒的にテストしやすくなった © DeNA Co., Ltd. 48
現状のテストコードで気になるポイント class StarTest: XCTestCase { func スターをつけた場合_★の表示を確認 { // テスト準備 let clientStub = ClientStub(true) let starModel = StarModel(clientMock) スタブを作成するのが めんどくさい.... // テスト対象を実行 starModel.fetch() } } © DeNA Co., Ltd. // 検証 XCTAssertEqual(starModel.text, "★") 49
解決策:更にロジックを別クラスに移す class StarModel { private let client: IStarClient private(set) var starText: String init(client: IStarClient) { self.client = client } } func fetch() async { var data = await client.fetch() if (data.isStar) { starText = "★" } else { 別クラスに抽出 starText = "☆" } } © DeNA Co., Ltd. class Star { var starText: String } fun update(isStar: Bool) { if (isStar) { starText = "★" } else { starText = "☆" } } 50
ロジック移⾏後のStarModel class StarModel { private let client: IStarClient private(set) var starText: String init(client: IStarClient) { self.client = client } func fetch() async { var data = await client.fetch() if (data.isStar) { starText = "★" } else { starText = "☆" } } } class StarModel { private let client: IStarClient private(set) var star: Star init(client: IStarClient) { self.client = client self.star = Star() } func fetch() async { var data = await client.fetch() テストする必要がないく star.update(data.isStar) らいに簡単になった } } StarModelを通してこのロジックをテストする必要がなくなった © DeNA Co., Ltd. 51
修正前と修正後のテストコードの⽐較 class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let clientStub = ClientStub(true) let starModel = StarModel(clientStub) class StarTest: XCTestCase { func スターをつけた場合 _★の表示を確認 { // テスト準備 let star = Star() // テスト対象を実行 star.update(true) // テスト対象を実行 starModel.fetch() // 検証 XCTAssertEqual(starModel.text, "★") } // 検証 XCTAssertEqual(star.starText, "★") } } } スタブが不要になり、テストが容易に © DeNA Co., Ltd. 52
まとめ テスト容易設計のコツ ● プロセス外依存を注⼊する ● フレームワークに依存しない オブジェクトにロジックを移動する © DeNA Co., Ltd. 53