Moya

Swiftのネットワーク抽象化ライブラリ。Alamofireのラッパーとして、タイプセーフなAPI定義とネットワーク層の抽象化を提供。プロトコル指向設計により、テスト可能で保守性の高いネットワークレイヤーを実現。RxSwift、Combine統合サポート。

HTTPクライアントSwiftiOS型安全ネットワーク抽象化Reactive

GitHub概要

Moya/Moya

Network abstraction layer written in Swift.

ホームページ:https://moya.github.io
スター15,298
ウォッチ245
フォーク2,009
作成日:2014年8月16日
言語:Swift
ライセンス:MIT License

トピックス

alamofirehacktoberfestnetworkingreactiveswiftrxswiftswift

スター履歴

Moya/Moya Star History
データ取得日時: 2025/7/18 01:38

ライブラリ

Moya

概要

Moyaは「Swift向けの型安全で抽象化されたネットワークライブラリ」として開発された、iOSアプリ開発で最も人気のあるHTTPクライアントライブラリの一つです。「Network abstraction layer for Swift」をコンセプトに、複雑なネットワーク処理をシンプルで直感的なAPIで提供。TargetTypeプロトコルによる型安全なAPI定義、プラグインシステムによる拡張性、RxSwift・Combineとの完全統合により、Swift開発者にとって理想的なネットワーキングソリューションとして地位を確立しています。

詳細

Moya 2025年版はSwiftネットワーキングの決定版として10年以上の開発実績を持つ成熟したライブラリです。TargetTypeプロトコルベースの設計により、APIエンドポイントを型安全で再利用可能な方法で定義可能。プラグインアーキテクチャにより認証、ログ、キャッシュ、テストモック等の横断的機能を効率的に実装。RxSwift、ReactiveSwift、Combineとのネイティブ統合により、リアクティブプログラミングパターンを活用した高度な非同期処理をサポートします。テストフレンドリーな設計により、ユニットテストとUIテストの両方で優れた開発体験を提供。

主な特徴

  • 型安全なAPI定義: TargetTypeプロトコルによるコンパイル時型チェック
  • プラグインアーキテクチャ: 認証、ログ、キャッシュ機能の効率的な実装
  • Reactiveエクステンション: RxSwift、Combine、ReactiveSwiftとの完全統合
  • テストサポート: モック機能とテストターゲット専用の豊富なツール
  • Alamofireベース: 業界標準ライブラリをベースとした信頼性の高い実装
  • 包括的なドキュメント: 充実した公式ドキュメントとサンプルコード

メリット・デメリット

メリット

  • TargetTypeプロトコルによる型安全性でランタイムエラーを大幅削減
  • プラグインシステムによる横断的機能の統一的な実装と管理
  • RxSwift/Combineとの統合により宣言的で読みやすい非同期コード記述
  • 豊富なテスト支援機能により高品質なユニットテストとUIテストを実現
  • Swiftコミュニティでの圧倒的な採用実績と豊富な学習リソース
  • 継続的なアップデートによる最新Swift機能とiOSバージョンへの対応

デメリット

  • 学習コストが比較的高く初心者には複雑に感じられる場合がある
  • 小規模プロジェクトでは機能過多になりオーバーエンジニアリングのリスク
  • プラグインやReactiveバインディングにより依存関係が複雑化する可能性
  • Alamofireに依存するためサードパーティライブラリへの間接的依存
  • 大規模なAPIでTargetType定義の管理が煩雑になる場合がある
  • iOS以外のプラットフォーム(macOS、watchOS等)での対応に制限

参考ページ

書き方の例

インストールと基本セットアップ

// Package.swift での依存関係追加
dependencies: [
    .package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.0"))
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [
            "Moya",
            // RxSwift使用の場合
            .product(name: "RxMoya", package: "Moya"),
            // Combine使用の場合
            .product(name: "CombineMoya", package: "Moya"),
        ]
    )
]

// CocoaPods での依存関係追加
// Podfile
pod 'Moya', '~> 15.0'
# RxSwift使用の場合
pod 'Moya/RxSwift', '~> 15.0'
# Combine使用の場合
pod 'Moya/Combine', '~> 15.0'

// Carthage での依存関係追加
// Cartfile
github "Moya/Moya" ~> 15.0

// Swift Package Manager (Xcode)
// File -> Add Package Dependencies...
// https://github.com/Moya/Moya.git

API定義とTargetTypeプロトコル実装

import Moya
import Foundation

// APIエンドポイントの定義
enum UserAPI {
    case getUsers(page: Int, limit: Int)
    case getUser(id: Int)
    case createUser(name: String, email: String)
    case updateUser(id: Int, name: String, email: String)
    case deleteUser(id: Int)
    case uploadAvatar(userId: Int, imageData: Data)
}

// TargetTypeプロトコルの実装
extension UserAPI: TargetType {
    var baseURL: URL {
        return URL(string: "https://api.example.com/v1")!
    }
    
    var path: String {
        switch self {
        case .getUsers:
            return "/users"
        case .getUser(let id), .updateUser(let id, _, _), .deleteUser(let id):
            return "/users/\(id)"
        case .createUser:
            return "/users"
        case .uploadAvatar(let userId, _):
            return "/users/\(userId)/avatar"
        }
    }
    
    var method: Moya.Method {
        switch self {
        case .getUsers, .getUser:
            return .get
        case .createUser:
            return .post
        case .updateUser:
            return .put
        case .deleteUser:
            return .delete
        case .uploadAvatar:
            return .post
        }
    }
    
    var sampleData: Data {
        switch self {
        case .getUsers:
            return """
            {
                "users": [
                    {"id": 1, "name": "田中太郎", "email": "[email protected]"},
                    {"id": 2, "name": "佐藤花子", "email": "[email protected]"}
                ],
                "pagination": {"page": 1, "total": 2}
            }
            """.data(using: .utf8)!
        case .getUser:
            return """
            {"id": 1, "name": "田中太郎", "email": "[email protected]", "createdAt": "2025-01-01T00:00:00Z"}
            """.data(using: .utf8)!
        case .createUser, .updateUser:
            return """
            {"id": 1, "name": "田中太郎", "email": "[email protected]", "createdAt": "2025-01-01T00:00:00Z"}
            """.data(using: .utf8)!
        case .deleteUser:
            return Data()
        case .uploadAvatar:
            return """
            {"avatarUrl": "https://api.example.com/avatars/user123.jpg"}
            """.data(using: .utf8)!
        }
    }
    
    var task: Task {
        switch self {
        case .getUsers(let page, let limit):
            return .requestParameters(
                parameters: ["page": page, "limit": limit],
                encoding: URLEncoding.queryString
            )
        case .getUser, .deleteUser:
            return .requestPlain
        case .createUser(let name, let email):
            let parameters = ["name": name, "email": email]
            return .requestParameters(parameters: parameters, encoding: JSONEncoding.default)
        case .updateUser(_, let name, let email):
            let parameters = ["name": name, "email": email]
            return .requestParameters(parameters: parameters, encoding: JSONEncoding.default)
        case .uploadAvatar(_, let imageData):
            let formData = MultipartFormData(
                provider: .data(imageData),
                name: "avatar",
                fileName: "avatar.jpg",
                mimeType: "image/jpeg"
            )
            return .uploadMultipart([formData])
        }
    }
    
    var headers: [String: String]? {
        var headers = ["Content-Type": "application/json"]
        
        // 認証トークンがある場合
        if let token = AuthManager.shared.accessToken {
            headers["Authorization"] = "Bearer \(token)"
        }
        
        return headers
    }
    
    var validationType: ValidationType {
        return .successCodes
    }
}

// データモデル定義
struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let createdAt: Date?
    
    enum CodingKeys: String, CodingKey {
        case id, name, email
        case createdAt = "created_at"
    }
}

struct UsersResponse: Codable {
    let users: [User]
    let pagination: Pagination
}

struct Pagination: Codable {
    let page: Int
    let total: Int
    let hasMore: Bool
    
    enum CodingKeys: String, CodingKey {
        case page, total
        case hasMore = "has_more"
    }
}

プロバイダーとリクエスト実行

import Moya

class UserService {
    // プロバイダーの初期化
    private let provider = MoyaProvider<UserAPI>(
        endpointClosure: { target in
            let defaultEndpoint = MoyaProvider.defaultEndpointMapping(for: target)
            return defaultEndpoint.adding(newHTTPHeaderFields: ["User-Agent": "MyApp/1.0"])
        },
        requestClosure: { (endpoint, done) in
            do {
                var request = try endpoint.urlRequest()
                request.timeoutInterval = 30.0
                done(.success(request))
            } catch {
                done(.failure(MoyaError.underlying(error, nil)))
            }
        },
        plugins: [
            NetworkLoggerPlugin(configuration: .init(logOptions: .verbose))
        ]
    )
    
    // ユーザー一覧取得
    func getUsers(page: Int = 1, limit: Int = 20, completion: @escaping (Result<UsersResponse, Error>) -> Void) {
        provider.request(.getUsers(page: page, limit: limit)) { result in
            switch result {
            case .success(let response):
                do {
                    let decoder = JSONDecoder()
                    decoder.dateDecodingStrategy = .iso8601
                    let usersResponse = try decoder.decode(UsersResponse.self, from: response.data)
                    completion(.success(usersResponse))
                } catch {
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
    
    // 特定ユーザー取得
    func getUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
        provider.request(.getUser(id: id)) { result in
            switch result {
            case .success(let response):
                do {
                    // レスポンスステータスチェック
                    if response.statusCode == 200 {
                        let decoder = JSONDecoder()
                        decoder.dateDecodingStrategy = .iso8601
                        let user = try decoder.decode(User.self, from: response.data)
                        completion(.success(user))
                    } else {
                        let error = NSError(domain: "UserServiceError", code: response.statusCode, userInfo: [
                            NSLocalizedDescriptionKey: "ユーザーの取得に失敗しました"
                        ])
                        completion(.failure(error))
                    }
                } catch {
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
    
    // ユーザー作成
    func createUser(name: String, email: String, completion: @escaping (Result<User, Error>) -> Void) {
        provider.request(.createUser(name: name, email: email)) { result in
            switch result {
            case .success(let response):
                do {
                    if response.statusCode == 201 {
                        let decoder = JSONDecoder()
                        decoder.dateDecodingStrategy = .iso8601
                        let user = try decoder.decode(User.self, from: response.data)
                        completion(.success(user))
                    } else {
                        // エラーレスポンスの詳細処理
                        let errorResponse = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any]
                        let message = errorResponse?["message"] as? String ?? "ユーザーの作成に失敗しました"
                        let error = NSError(domain: "UserServiceError", code: response.statusCode, userInfo: [
                            NSLocalizedDescriptionKey: message
                        ])
                        completion(.failure(error))
                    }
                } catch {
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
    
    // ユーザー更新
    func updateUser(id: Int, name: String, email: String, completion: @escaping (Result<User, Error>) -> Void) {
        provider.request(.updateUser(id: id, name: name, email: email)) { result in
            switch result {
            case .success(let response):
                do {
                    let decoder = JSONDecoder()
                    decoder.dateDecodingStrategy = .iso8601
                    let user = try decoder.decode(User.self, from: response.data)
                    completion(.success(user))
                } catch {
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
    
    // ユーザー削除
    func deleteUser(id: Int, completion: @escaping (Result<Void, Error>) -> Void) {
        provider.request(.deleteUser(id: id)) { result in
            switch result {
            case .success(let response):
                if response.statusCode == 204 {
                    completion(.success(()))
                } else {
                    let error = NSError(domain: "UserServiceError", code: response.statusCode, userInfo: [
                        NSLocalizedDescriptionKey: "ユーザーの削除に失敗しました"
                    ])
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

// 使用例
class UserViewController: UIViewController {
    private let userService = UserService()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        loadUsers()
    }
    
    private func loadUsers() {
        userService.getUsers(page: 1, limit: 20) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let response):
                    print("ユーザー一覧取得成功: \(response.users.count)件")
                    // UIの更新処理
                case .failure(let error):
                    print("エラー: \(error.localizedDescription)")
                    // エラーハンドリング
                }
            }
        }
    }
}

プラグインシステムによる拡張機能

import Moya

// 認証プラグイン
struct AuthPlugin: PluginType {
    func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
        var request = request
        
        // トークンの自動追加
        if let token = AuthManager.shared.accessToken {
            request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        
        // API キーの追加
        request.addValue("your-api-key", forHTTPHeaderField: "X-API-Key")
        
        return request
    }
    
    func willSend(_ request: RequestType, target: TargetType) {
        print("🚀 リクエスト送信開始: \(target)")
    }
    
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        switch result {
        case .success(let response):
            print("✅ レスポンス受信成功: \(target) - Status: \(response.statusCode)")
        case .failure(let error):
            print("❌ リクエスト失敗: \(target) - Error: \(error)")
        }
    }
}

// キャッシュプラグイン
struct CachePlugin: PluginType {
    private let cache = URLCache.shared
    
    func willSend(_ request: RequestType, target: TargetType) {
        // GETリクエストのみキャッシュ対象
        guard let urlRequest = request.request,
              urlRequest.httpMethod == "GET" else {
            return
        }
        
        // キャッシュからレスポンスをチェック
        if let cachedResponse = cache.cachedResponse(for: urlRequest) {
            let age = Date().timeIntervalSince(cachedResponse.timestamp)
            if age < 300 { // 5分以内のキャッシュは有効
                print("📦 キャッシュからレスポンス取得: \(target)")
            }
        }
    }
    
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        switch result {
        case .success(let response):
            // 成功レスポンスをキャッシュに保存
            if let urlRequest = response.request,
               urlRequest.httpMethod == "GET",
               response.statusCode == 200 {
                let cachedResponse = CachedURLResponse(response: response.response!, data: response.data)
                cache.storeCachedResponse(cachedResponse, for: urlRequest)
                print("💾 レスポンスをキャッシュに保存: \(target)")
            }
        case .failure:
            break
        }
    }
}

// ログプラグイン(カスタム)
struct CustomLoggerPlugin: PluginType {
    private let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
        return formatter
    }()
    
    func willSend(_ request: RequestType, target: TargetType) {
        let timestamp = dateFormatter.string(from: Date())
        print("📤 [\(timestamp)] REQUEST: \(request.request?.httpMethod ?? "UNKNOWN") \(target)")
        
        if let headers = request.request?.allHTTPHeaderFields {
            print("📋 Headers: \(headers)")
        }
        
        if let body = request.request?.httpBody {
            print("📝 Body: \(String(data: body, encoding: .utf8) ?? "Binary data")")
        }
    }
    
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        let timestamp = dateFormatter.string(from: Date())
        
        switch result {
        case .success(let response):
            print("📥 [\(timestamp)] RESPONSE: \(response.statusCode) \(target)")
            print("📋 Headers: \(response.response?.allHeaderFields ?? [:])")
            
            if let responseString = String(data: response.data, encoding: .utf8) {
                print("📄 Body: \(responseString)")
            }
        case .failure(let error):
            print("🚨 [\(timestamp)] ERROR: \(error) \(target)")
        }
    }
}

// リトライプラグイン
struct RetryPlugin: PluginType {
    private let maxRetries: Int
    private let retryDelay: TimeInterval
    
    init(maxRetries: Int = 3, retryDelay: TimeInterval = 1.0) {
        self.maxRetries = maxRetries
        self.retryDelay = retryDelay
    }
    
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        switch result {
        case .success(let response):
            // 500番台エラーの場合はリトライ対象
            if response.statusCode >= 500 {
                print("🔄 サーバーエラー検出、リトライ対象: \(response.statusCode)")
            }
        case .failure(let error):
            // ネットワークエラーの場合はリトライ対象
            if case .underlying(let nsError, _) = error,
               (nsError as NSError).domain == NSURLErrorDomain {
                print("🔄 ネットワークエラー検出、リトライ対象: \(nsError)")
            }
        }
    }
}

// プラグイン統合プロバイダー
class EnhancedUserService {
    private let provider = MoyaProvider<UserAPI>(
        plugins: [
            AuthPlugin(),
            CachePlugin(),
            CustomLoggerPlugin(),
            RetryPlugin(),
            NetworkLoggerPlugin(configuration: .init(
                formatter: .init(responseData: { data in
                    return String(data: data, encoding: .utf8) ?? "Binary data"
                }),
                logOptions: [.requestBody, .responseBody]
            ))
        ]
    )
    
    func getUsersWithEnhancedFeatures(completion: @escaping (Result<UsersResponse, Error>) -> Void) {
        provider.request(.getUsers(page: 1, limit: 20)) { result in
            switch result {
            case .success(let response):
                do {
                    let decoder = JSONDecoder()
                    decoder.dateDecodingStrategy = .iso8601
                    let usersResponse = try decoder.decode(UsersResponse.self, from: response.data)
                    completion(.success(usersResponse))
                } catch {
                    completion(.failure(error))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

RxSwiftとCombineによるReactive拡張

import Moya
import RxSwift
import RxCocoa
import Combine

// RxSwift版サービス
class RxUserService {
    private let provider = MoyaProvider<UserAPI>()
    private let disposeBag = DisposeBag()
    
    // Observable を返すメソッド
    func getUsers(page: Int = 1, limit: Int = 20) -> Observable<UsersResponse> {
        return provider.rx
            .request(.getUsers(page: page, limit: limit))
            .filterSuccessfulStatusCodes()
            .map(UsersResponse.self, using: JSONDecoder.iso8601)
            .asObservable()
    }
    
    func getUser(id: Int) -> Observable<User> {
        return provider.rx
            .request(.getUser(id: id))
            .filterSuccessfulStatusCodes()
            .map(User.self, using: JSONDecoder.iso8601)
            .asObservable()
    }
    
    func createUser(name: String, email: String) -> Observable<User> {
        return provider.rx
            .request(.createUser(name: name, email: email))
            .filterSuccessfulStatusCodes()
            .map(User.self, using: JSONDecoder.iso8601)
            .asObservable()
    }
    
    // 複合操作例:ユーザー作成 → 詳細取得
    func createAndFetchUser(name: String, email: String) -> Observable<User> {
        return createUser(name: name, email: email)
            .flatMapLatest { user in
                self.getUser(id: user.id)
            }
    }
    
    // エラーハンドリング付き
    func getUsersWithErrorHandling(page: Int = 1) -> Observable<UsersResponse> {
        return getUsers(page: page)
            .retry(3)
            .catchAndReturn(UsersResponse(users: [], pagination: Pagination(page: 1, total: 0, hasMore: false)))
    }
}

// Combine版サービス
@available(iOS 13.0, *)
class CombineUserService {
    private let provider = MoyaProvider<UserAPI>()
    private var cancellables = Set<AnyCancellable>()
    
    // Publisher を返すメソッド
    func getUsers(page: Int = 1, limit: Int = 20) -> AnyPublisher<UsersResponse, Error> {
        return provider.requestPublisher(.getUsers(page: page, limit: limit))
            .filterSuccessfulStatusCodes()
            .map(UsersResponse.self, using: JSONDecoder.iso8601)
            .eraseToAnyPublisher()
    }
    
    func getUser(id: Int) -> AnyPublisher<User, Error> {
        return provider.requestPublisher(.getUser(id: id))
            .filterSuccessfulStatusCodes()
            .map(User.self, using: JSONDecoder.iso8601)
            .eraseToAnyPublisher()
    }
    
    func createUser(name: String, email: String) -> AnyPublisher<User, Error> {
        return provider.requestPublisher(.createUser(name: name, email: email))
            .filterSuccessfulStatusCodes()
            .map(User.self, using: JSONDecoder.iso8601)
            .eraseToAnyPublisher()
    }
    
    // 複合操作例:複数ユーザーの並列取得
    func getMultipleUsers(ids: [Int]) -> AnyPublisher<[User], Error> {
        let publishers = ids.map { getUser(id: $0) }
        
        return Publishers.MergeMany(publishers)
            .collect()
            .eraseToAnyPublisher()
    }
    
    // リアルタイムデータ更新例
    func startUserListUpdates() -> AnyPublisher<UsersResponse, Error> {
        return Timer.publish(every: 30.0, on: .main, in: .common)
            .autoconnect()
            .flatMap { _ in
                self.getUsers(page: 1, limit: 20)
            }
            .eraseToAnyPublisher()
    }
}

// ViewModelでの使用例(RxSwift)
class RxUserListViewModel {
    private let userService = RxUserService()
    private let disposeBag = DisposeBag()
    
    // 入力
    let loadTrigger = PublishSubject<Void>()
    let refreshTrigger = PublishSubject<Void>()
    
    // 出力
    let users = BehaviorRelay<[User]>(value: [])
    let isLoading = BehaviorRelay<Bool>(value: false)
    let error = PublishSubject<Error>()
    
    init() {
        let loadUsers = Observable.merge(loadTrigger, refreshTrigger)
            .do(onNext: { [weak self] in
                self?.isLoading.accept(true)
            })
            .flatMapLatest { [weak self] _ -> Observable<UsersResponse> in
                guard let self = self else { return .empty() }
                return self.userService.getUsers()
                    .catchError { error in
                        self.error.onNext(error)
                        return .empty()
                    }
            }
            .do(onNext: { [weak self] _ in
                self?.isLoading.accept(false)
            })
        
        loadUsers
            .map { $0.users }
            .bind(to: users)
            .disposed(by: disposeBag)
    }
}

// ViewModelでの使用例(Combine)
@available(iOS 13.0, *)
class CombineUserListViewModel: ObservableObject {
    private let userService = CombineUserService()
    private var cancellables = Set<AnyCancellable>()
    
    @Published var users: [User] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    func loadUsers() {
        isLoading = true
        errorMessage = nil
        
        userService.getUsers()
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] response in
                    self?.users = response.users
                }
            )
            .store(in: &cancellables)
    }
}

// JSONDecoder拡張
extension JSONDecoder {
    static let iso8601: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }()
}

テスト支援機能とモック実装

import Moya
import XCTest

// テスト用プロバイダー
class UserServiceTests: XCTestCase {
    var sut: UserService!
    var mockProvider: MoyaProvider<UserAPI>!
    
    override func setUp() {
        super.setUp()
        
        // モックプロバイダーの設定
        mockProvider = MoyaProvider<UserAPI>(
            endpointClosure: { target in
                return Endpoint(
                    url: URL(target: target).absoluteString,
                    sampleResponseClosure: { .networkResponse(200, target.sampleData) },
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers
                )
            },
            stubClosure: MoyaProvider.immediatelyStub
        )
        
        sut = UserService(provider: mockProvider)
    }
    
    override func tearDown() {
        sut = nil
        mockProvider = nil
        super.tearDown()
    }
    
    // 成功ケースのテスト
    func testGetUsersSuccess() {
        let expectation = self.expectation(description: "Get users success")
        
        sut.getUsers { result in
            switch result {
            case .success(let response):
                XCTAssertEqual(response.users.count, 2)
                XCTAssertEqual(response.users.first?.name, "田中太郎")
                expectation.fulfill()
            case .failure(let error):
                XCTFail("Expected success, got error: \(error)")
            }
        }
        
        waitForExpectations(timeout: 1.0)
    }
    
    // エラーケースのテスト
    func testGetUsersNetworkError() {
        // ネットワークエラーを模擬するプロバイダー
        let errorProvider = MoyaProvider<UserAPI>(
            endpointClosure: { target in
                return Endpoint(
                    url: URL(target: target).absoluteString,
                    sampleResponseClosure: { .networkError(NSError(domain: "TestError", code: -1009, userInfo: nil)) },
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers
                )
            },
            stubClosure: MoyaProvider.immediatelyStub
        )
        
        let errorService = UserService(provider: errorProvider)
        let expectation = self.expectation(description: "Get users network error")
        
        errorService.getUsers { result in
            switch result {
            case .success:
                XCTFail("Expected error, got success")
            case .failure(let error):
                XCTAssertNotNil(error)
                expectation.fulfill()
            }
        }
        
        waitForExpectations(timeout: 1.0)
    }
    
    // カスタムレスポンステスト
    func testCreateUserWithCustomResponse() {
        let customProvider = MoyaProvider<UserAPI>(
            endpointClosure: { target in
                let customData: Data
                if case .createUser(let name, let email) = target {
                    let responseDict = [
                        "id": 999,
                        "name": name,
                        "email": email,
                        "created_at": "2025-01-01T12:00:00Z"
                    ] as [String : Any]
                    customData = try! JSONSerialization.data(withJSONObject: responseDict)
                } else {
                    customData = target.sampleData
                }
                
                return Endpoint(
                    url: URL(target: target).absoluteString,
                    sampleResponseClosure: { .networkResponse(201, customData) },
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers
                )
            },
            stubClosure: MoyaProvider.immediatelyStub
        )
        
        let customService = UserService(provider: customProvider)
        let expectation = self.expectation(description: "Create user with custom response")
        
        customService.createUser(name: "テストユーザー", email: "[email protected]") { result in
            switch result {
            case .success(let user):
                XCTAssertEqual(user.id, 999)
                XCTAssertEqual(user.name, "テストユーザー")
                XCTAssertEqual(user.email, "[email protected]")
                expectation.fulfill()
            case .failure(let error):
                XCTFail("Expected success, got error: \(error)")
            }
        }
        
        waitForExpectations(timeout: 1.0)
    }
}

// パフォーマンステスト
extension UserServiceTests {
    func testGetUsersPerformance() {
        self.measure {
            let expectation = self.expectation(description: "Performance test")
            
            sut.getUsers { _ in
                expectation.fulfill()
            }
            
            waitForExpectations(timeout: 1.0)
        }
    }
}

// UserServiceの改良版(テスト対応)
class UserService {
    private let provider: MoyaProvider<UserAPI>
    
    init(provider: MoyaProvider<UserAPI>? = nil) {
        self.provider = provider ?? MoyaProvider<UserAPI>()
    }
    
    // 実装は前のサンプルと同じ...
}