Share
Embed
Swift Concurrency時代のiOSアプリの作り方
昨年、Swift Concurrencyが導入されました。当初はiOS 15のみでサポートされていましたが、Concurrencyのback deploymentが実現されたため、iOS 13以降であれば今すぐにでもConcurrencyを取り入れることができます。
しかし、実際にConcurencyを取り入れようとすると、参考となる情報はまだまだ少ないのではないでしょうか。Conccurency自体の情報は豊富でも、iOSアプリ開発での活用、特にactorや単体テストなどについてはほとんど語られていないように思います。
本トークでは、iOSアプリ開発におけるConcurrency活用の一つのベースラインとなることを目指して、async/awaitやTask、actor、MainActorなどを、アプリやテストのコードにどのように取り入れるか、具体例を用いて紹介します。
Yuta Koshizawa
September 12, 2022
Transcript
167 slides
-
͜ͷͷ iOS ΞϓϦ։ൃͷมԽ • Swi%UI (2019) • Combine (2019) •
Swi$ Concurrency (2021) -
LoginViewController final class LoginViewController: UIViewController { @IBOutlet private var idField:
UITextField! @IBOutlet private var passwordField: UITextField! @IBOutlet private var loginButton: UIButton! ... -
LoginViewController final class LoginViewController: UIViewController { @IBOutlet private var idField:
UITextField! @IBOutlet private var passwordField: UITextField! @IBOutlet private var loginButton: UIButton! @IBAction private func loginButtonPressed( sender: UIButton) { ... } ... -
AuthAPI.logIn enum AuthAPI { static func logIn( id: String, password:
String ) async throws -> String { // ID token ... } } -
AuthAPI.logIn enum AuthAPI { static func logIn( for id: String,
with password: String ) async throws -> String { // ID token ... } } -
AuthAPI.logIn enum AuthAPI { static func logIn( for id: User.ID,
with password: String ) async throws -> IDToken { ... } } -
User.ID struct User: Identifiable { let id: String var nickname:
String var birthday: Date } -
User.ID struct User: Identifiable { let id: ID var nickname:
String var birthday: Date } -
User.ID struct User: Identifiable { let id: ID var nickname:
String var birthday: Date struct ID: Hashable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } } -
User.ID struct User: Identifiable, Sendable { let id: ID var
nickname: String var birthday: Date struct ID: Hashable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } } -
User.ID struct User: Identifiable, Sendable { let id: ID var
nickname: String var birthday: Date struct ID: Hashable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } } -
User.ID struct User: Identifiable, Sendable { let id: ID var
nickname: String var birthday: Date struct ID: Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } } -
User.ID struct User: Identifiable, Sendable { let id: ID var
nickname: String var birthday: Date struct ID: Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } } -
AuthAPI.logIn enum AuthAPI { static func logIn( for id: User.ID,
with password: String ) async throws -> IDToken { ... } } -
AuthAPI.logIn static func logIn(...) async throws -> IDToken { let
url: URL = ... let request: URLRequest = ... // Concurrency Ҏલ let dataTask = URLSession.shared .dataTask(with: request) { // ίʔϧόοΫͰ݁ՌΛड͚औΔ } ... } -
AuthAPI.logIn static func logIn(...) async throws -> IDToken { let
url: URL = ... let request: URLRequest = ... // Concurrency Ҏ߱ let (data, response) = try await URLSession.shared .data(for: request) // ΓͰ݁ՌΛड͚औΔ ... } -
AuthAPI.logIn static func logIn(...) async throws -> IDToken { let
(data, response) = ... let idToken: IDToken = ... return idToken } -
͢ͰʹίʔϧόοΫ൛͕͋Δ߹ enum AuthAPI { static func logIn( for id: User.ID,
with password: String, completion: @escaping (Result<IDToken, Error>) -> Void ) { ... } } -
͢ͰʹίʔϧόοΫ൛͕͋Δ߹ extension AuthAPI { static func logIn( for id: User.ID,
with password: String ) async throws -> IDToken { try await withCheckedThrowingContinuation { continuation in logIn(for: id, with: password) { result in continuation.resume(with: result) } } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { let idToken
= try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
// async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task.init {
// async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
// async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
// async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
// async throws do { let idToken = try await AuthAPI.logIn(...) ... } catch { // ΤϥʔϋϯυϦϯά } } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // ??? loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
loginButtonPressed @IBAction private func loginButtonPressed(...) { // main εϨου Task
{ // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } do { let idToken = try await AuthAPI.logIn(...) // main εϨου -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
Swi$ ͷόά @MainActor ͳϝιουͰɺ Task.init ʹ͢Ϋϩʔδϟͷத Ͱ defer ͢Δͱɺ MainActor
Ͱอޢ͞Ε͍ͯͳ͍ͱஅ͞Εͯ ͠·͏ɻ 2021/08/01 ʹϑΥʔϥϜͰใࠂ Swi* Forums #50796 ͞ΕΒ͘ղܾ͞ Ε͍ͯͳ͔͕ͬͨɺ 2022/08/22 ʹ PR apple/swi* #60688 ͕࡞ΒΕɺ 8/31 ʹϚʔδɻ apple/swi) #60688 h0ps:/ /github.com/apple/swi)/pull/60688 Swi$ Forums #50796 h3ps:/ /forums.swi$.org/t/a-bug-cant-defer-actor-isolated-variable-access/50796 -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
MainActor Ͱ࣮ߦ͢Δํ๏ • await MainActor.run { } • Task {
@MainActor in ... } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // main εϨου loginButton.isEnabled = false defer { Task { await MainActor.run { loginButton.isEnabled = true } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main
εϨου Task { // main εϨου loginButton.isEnabled = false defer { Task { @MainActor in loginButton.isEnabled = true } } -
MainActor.run ͱ Task { @MainActor in } func foo() async
{ await MainActor.run { // ϝΠϯεϨουͰ࣮ߦ͍ͨ͠ॲཧ } } func bar() { Task { @MainActor in // ϝΠϯεϨουͰ࣮ߦ͍ͨ͠ॲཧ } } -
DispatchQueue.main.async ʁ @Sendable ͕͍͍ͯͳ͍ͷͰආ͚ͨํ͕ྑ͍ɻ func async( group: DispatchGroup? = nil,
qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = [], execute work: @escaping () -> Void ) -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
loginButton.isEnabled = false defer { Task { @MainActor in loginButton.isEnabled = true } } ... } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
loginButton.isEnabled = false ... loginButton.isEnabled = true } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) ... } catch { // ΤϥʔϋϯυϦϯά } } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) let data: Data = idToken.rawValue .data(using: .utf8)! let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) ... -
Task.detached Task.detached { let data: Data = idToken.rawValue .data(using: .utf8)!
let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) Task.detached { ... try data.write(to: url, options: .atomic) } ... -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) _ = try await Task.detached { ... try data.write(to: url, options: .atomic) }.value ... -
actor actor IDTokenStore { func update(_ value: IDToken) throws {
let data: Data = value.rawValue .data(using: .utf8)! let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) } } -
actor actor IDTokenStore { var value: IDToken { get throws
{ let url: URL = .libraryDirectory .appendingPathComponent("IDToken") let data: Data = try .init(contentsOf: url) let rawValue: String = .init(data: data, encoding: .utf8)! return IDToken(rawValue: rawValue) } } ... -
actor actor IDTokenStore { static let shared: IDTokenStore = .init()
private init() {} ... } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) try await IDTokenStore.shared .update(idToken) ... -
actor ͷϝιου֎͔ΒݟΔͱ async ʹͳΔ actor IDTokenStore { func update(_ value:
IDToken) throws { let data: Data = value.rawValue .data(using: .utf8)! let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) } } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) try await IDTokenStore.shared .update(idToken) ... -
AuthService actor AuthService { func logIn( for id: User.ID, with
password: String ) async throws { ... } } -
AuthService actor AuthService { func logIn( for id: User.ID, with
password: String ) async throws { let idToken = try await AuthAPI .logIn(for: id, with: password) try await IDTokenStore.shared .update(idToken) } } -
AuthService actor AuthService { func logIn( for id: User.ID, with
password: String ) async throws { let idToken = try await AuthAPI .logIn(for: id, with: password) try await IDTokenStore.shared .update(idToken) } } -
AuthService actor AuthService { private var isLoggingIn: Bool = false
func logIn( for id: User.ID, with password: String ) async throws { let idToken = try await AuthAPI .logIn(for: id, with: password) try await IDTokenStore.shared .update(idToken) } -
AuthService actor AuthService { private var isLoggingIn: Bool = false
func logIn( for id: User.ID, with password: String ) async throws { if isLoggingIn { ... } isLoggingIn = true defer { isLoggingIn = false} ... } -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { let idToken = try await AuthAPI.logIn(...) try await IDTokenStore.shared .update(idToken) ... -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { try await AuthService.shared.logIn(...) ... -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { try await AuthService.shared.logIn(...) parent?.dismiss(animated: true) } catch { ... -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { ... } catch { logger.warning("\(error)") ... -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { ... } catch { ... let title: String let message: String if error is LoginError { title = "ϩάΠϯΤϥʔ" message = "ID·ͨύεϫʔυ͕ਖ਼͋͘͠Γ·ͤΜɻ" } else ... -
loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {
... do { ... } catch { ... let alertController: UIAlertController = .init(title: title, message: message, preferredStyle: .alert) alertController .addAction(.init(title: "ด͡Δ", style: .default, handler: nil)) present(alertController, animated: true) } -
LoginViewModel import Combine final class LoginViewModel { @Published var id:
String = "" @Published var password: String = "" @Published private(set) var isLoginButtonEnabled: Bool = true } -
LoginViewModel import Combine final class LoginViewModel: ObservableObject { @Published var
id: String = "" @Published var password: String = "" @Published private(set) var isLoginButtonEnabled: Bool = true } -
LoginViewModel import Combine @MainActor final class LoginViewModel: ObservableObject { @Published
var id: String = "" @Published var password: String = "" @Published private(set) var isLoginButtonEnabled: Bool = true } -
LoginViewModel import Combine @MainActor final class LoginViewModel: ObservableObject { ...
func loginButtonPressed() async { ... } } -
LoginViewController.loginButtonPressed // LoginViewController @IBAction private func loginButtonPressed(...) { Task {
loginButton.isEnabled = false defer { loginButton.isEnabled = true } do { ... } catch { ... } } } -
LoginViewController.loginButtonPressed // LoginViewController @IBAction private func loginButtonPressed(...) { Task {
await viewModel.loginButtonPressed() } } -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { isLoginButtonEnabled = false
defer { isLoginButtonEnabled = true } do { ... } catch { ... } } -
! Task.init Λ ViewModel Ͱͳ͘ View ଆʹॻ͘ ※ ViewModel ͷςετ͕ॻ͖͘͢ͳΔ
※ ͨͩ͠ɺ ViewModel ͰΩϟϯηϧΛѻ͍ͮΒ͘ͳΔ -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { isLoginButtonEnabled = false
defer { isLoginButtonEnabled = true } ... } -
isLoginButtonEnabled ͷ View ͷө // LoginViewController override func viewDidLoad() {
super.viewDidLoad() ... viewModel.$isLoginButtonEnabled .sink { [weak self] isEnabled in guard let self else { return } self.loginButton.isEnabled = isEnabled } .store(in: &cancellables) -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { isLoginButtonEnabled = false
defer { isLoginButtonEnabled = true } ... } -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { isLoginButtonEnabled = false
defer { isLoginButtonEnabled = true } do { try await AuthService.shared.logIn(...) dismiss.send() } catch { ... } } -
LoginViewModel.dismiss @MainActor final class LoginViewModel: ObservableObject { ... let dismiss:
PassthroughSubject<Void, Never> = .init() ... } -
dismiss ͷ View Ͱͷ࣮ߦ // LoginViewController override func viewDidLoad() {
super.viewDidLoad() ... viewModel.dismiss .sink { [weak self] _ in self?.parent?.dismiss(animated: true) } .store(in: &cancellables) -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { ... do {
... } catch { logger.warning("\(error)") if error is LoginError { ... } else ... } } -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { ... do {
... } catch { logger.warning("\(error)") if error is LoginError { presentLoginErrorAlert.send(...) } else ... } } -
LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { ... do {
... } catch { logger.warning("\(error)") if error is LoginError { loginErrorMessage = .login } else ... } } -
LoginViewModel.loginErrorMessage @MainActor final class LoginViewModel: ObservableObject { ... @Published var
loginErrorMessage: LoginErrorMessage? ... } -
LoginErrorMessage enum LoginErrorMessage: Hashable { case login case network case
server case system } -
Swi$UI struct LoginView: View { ... var body: some View
{ VStack(alignment: .center) { Spacer() TextField("ID", text: $viewModel.id) .textInputAutocapitalization(.never) .textFieldStyle(.roundedBorder) .frame(width: 200) ... -
Swi$UI struct LoginView: View { ... var body: some View
{ VStack(alignment: .center) { ... Button("Log in") { Task { await viewModel .loginButtonPressed() } } ... -
Swi$UI struct LoginView: View { ... var body: some View
{ VStack(alignment: .center) { ... } .onReceive(viewModel.dismiss) { _ in dismiss() } ... } } -
Swi$UI struct LoginView: View { ... @Environment(\.dismiss) private var dismiss
var body: some View { VStack(alignment: .center) { ... } .onReceive(viewModel.dismiss) { _ in dismiss() } ... } } -
LoginViewModel ͷςετ͕ॻ͖ͮΒ͍ func loginButtonPressed() async { isLoginButtonEnabled = false defer
{ isLoginButtonEnabled = true } do { // αʔόʔʹΞΫηεɺϑΝΠϧ I/O try await AuthService.shared.logIn(...) dismiss.send() } catch { ... } } -
AuthServiceProtocol protocol AuthServiceProtocol { static var shared: Self { get
} func logIn( for id: User.ID, with password: String ) async throws ... } -
AuthService ͷར༻ՕॴͷίʔυมΘΒͳ͍ func loginButtonPressed() async { isLoginButtonEnabled = false defer
{ isLoginButtonEnabled = true } do { try await AuthService.shared.logIn(...) dismiss.send() } catch { ... } } -
loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =
.init() await viewModel.loginButtonPressed() // LoginError ... } -
loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =
.init() await viewModel.loginButtonPressed() // LoginError XCTAssertEqual(viewModel.loginErrorMessage, .login) } -
loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =
.init() await viewModel.loginButtonPressed() // LoginError XCTAssertEqual(viewModel.loginErrorMessage, .login) } -
ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { static var
shared: AuthService = .init() private init() {} ... } -
ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... func
logIn(for id: User.ID, with password: String) async throws { throw LoginError() } } -
ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... var
logInResult: Result<Void, Error>? func logIn(for id: User.ID, with password: String) async throws { try logInResult!.get() } } -
loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =
.init() await viewModel.loginButtonPressed() XCTAssertEqual(viewModel.loginErrorMessage, .login) } -
loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =
.init() AuthService.shared.logInResult = .failure(LoginError()) await viewModel.loginButtonPressed() XCTAssertEqual(viewModel.loginErrorMessage, .login) } -
loginErrorMessage ͷςετ func testLoginErrorMessageByServerError() async { let viewModel: LoginViewModel<AuthService> =
.init() AuthService.shared.logInResult = .failure(ServerError.response(...)) await viewModel.loginButtonPressed() XCTAssertEqual(viewModel.loginErrorMessage, .server) } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) ... } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) viewModel.loginButtonPressed() ... } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) ... } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) ... XCTAssertTrue(viewModel.isLoginButtonEnabled) } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) await viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) ... XCTAssertTrue(viewModel.isLoginButtonEnabled) } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) async let logIn: Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) await logIn XCTAssertTrue(viewModel.isLoginButtonEnabled) } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =
.init() XCTAssertTrue(viewModel.isLoginButtonEnabled) async let logIn: Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.??? await logIn XCTAssertTrue(viewModel.isLoginButtonEnabled) -
ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... var
logInResult: Result<Void, Error>? func logIn(for id: User.ID, with password: String) async throws { try logInResult!.get() } } -
ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... var
logInContinuation: CheckedContinuation<Void, Error>? func logIn(for id: User.ID, with password: String) async throws { try await withCheckedThrowingContinuation { continuation in logInContinuation = continuation } } } -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:
Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.??? await logIn -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:
Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) await logIn -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:
Void = viewModel.loginButtonPressed() await Task.yield() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) await logIn -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:
Void = viewModel.loginButtonPressed() while AuthService.shared.logInContinuation == nil { await Task.yield() } XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) await logIn -
isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:
Void = viewModel.loginButtonPressed() while AuthService.shared.logInContinuation == nil { await Task.yield() } XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) AuthService.shared.logInContinuation = nil await logIn