Upgrade to Pro — share decks privately, control downloads, hide ads and more …

機能ごとに動作するミニアプリでプレビューサイクルを爆速にした話

aoi
September 18, 2021

 機能ごとに動作するミニアプリでプレビューサイクルを爆速にした話

aoi

September 18, 2021
Tweet

More Decks by aoi

Other Decks in Technology

Transcript

  1. ΫοΫύουΞϓϦ • 50 Releases / year • 15 developers /

    release • 40 ~ 50 PR / release • 328,000 lines
  2. Environment import Foundation import UIKit public protocol Environment { var

    client: ServiceClient { get } var serviceAccountProvider: ServiceAccountProvider { get } var activityLogger: ActivityLogger { get } var urlSchemeOpener: URLSchemeOpener { get } /// … func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Outpu t }
  3. class RecipeDetailsDataStoreTest: XCTestCase { let dataStore = RecipeDetailsDataStore( ) private

    let recipeID: Int64 = 100 func testSuccessFetchRecipeDetailsRecipes() { let expectation = self.expectation(description: "Could get recipe details recipe" ) dataStore.request { (data, error ) if let error = error { XCTFail( ) } else { expectation.fulfill( ) let recipe = try! JSONEncoder().decode(Recipe.self, from: data ) XCTAssertEqual(recipe.name, "Ͱ͔͍ΠʔϒΠ" ) XCTAssertEqual(recipe.title, "ϚϦτοπΥ" ) } } waitUntilDefaultTimeout(for: expectation ) } }
  4. class RecipeDetailsDataStoreTest: XCTestCase { let testingEnvironment = StubbableEnvironment( ) lazy

    var dataStore = RecipeDetailsDataStore(environment: testingEnvironment ) private let disposeBag = DisposeBag( ) private let recipeID: Int64 = 100 func testSuccessFetchRecipeDetailsRecipes() { let responseData = loadJSONData(named: "recipe_details_recipe" ) testingEnvironment.registerClientResponse ( responseData , for: "/recipes/\(recipeID)" , method: .get ) let expectation = self.expectation(description: "Could get recipe details recipe" ) dataStore.fetchRecipe(recipeID: recipeID ) .subscribe(onSuccess: { response in expectation.fulfill( ) XCTAssertEqual(response.author?.name, "Ͱ͔͍ΠʔϒΠ" ) XCTAssertEqual(response.title, "ϚϦτοπΥ" ) } ) .disposed(by: disposeBag ) waitUntilDefaultTimeout(for: expectation ) } }
  5. public protocol GarageRequest { associatedtype Respons e var baseURL: URL?

    { get } var method: HTTPMethod { get } var path: String { get } var parameters: [String: Any] { get } var headerFields: [String: String] { get } func makeResponse(from data: Data, urlResponse: HTTPURLResponse) throws -> Respons e } public protocol ServiceClient { @discardableResul t func sendRequest<Request: GarageRequest> ( _ request: Request , handler: @escaping (Result<Request.Response, ClientTaskError>) -> Void) -> Cancellabl e } αʔόʔ΁ͷRequest ʹඞཁͳ΋ͷΛڞ௨Խ Request ϝιου
  6. struct ServiceClientStub { typealias Response = Swift.Result<Data?, APIRequestError > var

    path: Strin g var method: HTTPMetho d var parameters: [String: Any ] var fields: [GarageFieldDescriptor] ? enum ParametersMatchingPattern { case exactMatc h case includin g } var parametersMatchingPattern: ParametersMatchingPatter n var result: Respons e var urlResponse: HTTPURLRespons e var parameterSet: Set<Parameter> { return makeParameterSet(from: parameters ) } } RequestʹରԠ͢Δμϛʔ ͷResponseΛηοτͰ࣋ͭ Response Request
  7. // StubbableServiceClient private var responseStubs: [ServiceClientStub] = [ ] func

    registerClientResponse<Response: Data?> ( _ response: Response , for path: String , method: HTTPMethod , statusCode: Int , parameters: [String: Any] = [:] , fields: [GarageFieldDescriptor]? = nil , parametersMatchingPattern: ServiceClientStub.ParametersMatchingPattern , httpHeaders: [String: String]? = nil ) { let stub = ServiceClientStub ( path: path , method: method , parameters: parameters , fields: fields , parametersMatchingPattern: parametersMatchingPattern , result: .success(response.jsonData) , urlResponse: makeURLResponse(path: path, statusCode: statusCode, httpHeaders: httpHeaders ) ) responseStubs.append(stub ) } StubbableServiceClient ͷதͰStubΛอ࣋͢Δ ϝιουɾPath͝ͱʹҰҙʹ ͳΔΑ͏ʹϞοΫσʔλΛొ࿥
  8. let matchingStubs = responseStubs.filter { stub in if stub.path !=

    request.path || stub.method != request.method { return false } if let stubFields = stub.fields, stubFields != request.fields { return false } switch stub.parametersMatchingPattern { case .exactMatch : return stub.parameterSet == request.parameterSe t case .including : return stub.parameterSet.isSubset(of: request.parameterSet ) } } Pathɺύϥϝʔλɺ fi eld͕ొ࿥ࡁΈ ͷStubͱҰக͢Δ͔νΣοΫ
  9. public enum StubbingError: Error { case noMatchingStubs(AnyGarageRequest ) case multipleMatchingStubs(AnyGarageRequest,

    [ServiceClientStub] ) public var localizedDescription: String { switch self { case let .noMatchingStubs(request) : return "No matching stubs found for \(request)" case let .multipleMatchingStubs(request, stubs) : return "Multiple stubs matching \(request): \(stubs)" } } } // StubbableServiceClient let matchingStub: ServiceClientStu b switch matchingStubs.count { case 0 : return stubbingErrorHandler(.noMatchingStubs(AnyGarageRequest(request)) ) case 1 : matchingStub = matchingStubs[0 ] default : return stubbingErrorHandler(.multipleMatchingStubs(AnyGarageRequest(request), matchingStubs) ) } ద੾ͳελϒ͕ͳ͍ɺ·ͨ͸ॏෳͯ͠ ଘࡏ͢Δ৔߹͸ErrorΛੜ੒͢Δ
  10. switch matchingStub.result { case let .success(data) : do { let

    response = try request.makeResponse(from: data ?? Data(), urlResponse: matchingStub.urlResponse ) handler(.success(response) ) } catch { switch error { case let clientTaskError as ClientTaskError : handler(.failure(clientTaskError) ) case let responseError as ResponseError : handler(.failure(.rawNetworkingTaskError(.responseError(responseError))) ) case let decodingError as DecodingError : handler(.failure(.rawNetworkingTaskError(.responseError(.serializationError(decodingError)))) ) default : handler(.failure(.rawNetworkingTaskError(.responseError(.unknownError(error)))) ) } } case let .failure(error) : handler(.failure(error) ) }
  11. let environment = StubbableEnvironment( ) let categoriesJSONData = fixtureLoader.loadJSONData(named: "categories"

    ) environment.registerClientResponse ( categoriesJSONData , for: "/v1/top_categories" , method: .get ) let viewController = RecipeCategoryListViewBuilder.build(environment: environment )
  12. public struct SandboxScene { public init(sceneName: String, initializer: @escaping (SandboxInitializer)

    -> UIViewController) { self.sceneName = sceneNam e self.initializer = initialize r } var sceneName: Strin g var initializer: (SandboxInitializer) -> UIViewControlle r } // AppDelegate.swift let rootViewController = SandboxSceneSelectTableViewController ( scenes: [ .recipeCategoryList , .subCategories , ] ) Sandboxͷը໘ Λߏ੒͢Δ΋ͷ SandboxScene ͷ഑ྻΛڞ༗
  13. CookpadCore CookpadComponent SandboxCore Feature B Sandbox Feature A Sandbox Feature

    C Sandbox Sandbox༻ͷڞ௨࣮૷ ຊମͱͷڞ௨࣮૷
  14. options : - name: sceneName question: Sandbox scene name? description:

    new Sandbox scene name to generate. (e.g. recipeDetails). type: string required: true - name: moduleName question: Destination target? description: module name to generate new sandbox scene for. (e.g. RecipeDetails) type: string required: true files : - template: Sandbox/SandboxScene.stencil path: "Sandbox/{{ moduleName }}Sandbox/{{ sceneName }}SandboxScene.swift" - template: Sandbox/AppDelegate.swift.stencil path: "Sandbox/{{ moduleName }}Sandbox/{{ moduleName }}AppDelegate.swift"
  15. @testable import {{ moduleName } } import UIKit @UIApplicationMain class

    AppDelegate: UIResponder, UIApplicationDelegate { private let environment = StubbableEnvironment( ) var window: UIWindow ? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds ) // Inject Scenes to RootTableViewController let rootViewController = {{ sceneName }}ViewBuilder.build(environment: environment ) window?.rootViewController = rootViewControlle r window?.makeKeyAndVisible( ) return true } } https://github.com/stencilproject/Stencil
  16. @testable import RecipeDetails import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate

    { private let environment = StubbableEnvironment( ) var window: UIWindow ? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds ) // Inject Scenes to RootTableViewController let rootViewController = RecipeDetailsViewBuilder.build(environment: environment ) window?.rootViewController = rootViewControlle r window?.makeKeyAndVisible( ) return true } }
  17. ωοτϫʔΫͷ஗Ԇͷઃఆ • ελϒͨ͠ϨεϙϯεΛฦ͢෦෼ͰਓҝతʹϦΫΤετॲཧΛ ஗Ԇͤ͞ΔΑ͏ʹ // StubbableServiceClient.swift open func sendRequest<Request> (

    _ request: Request , handler: @escaping (Result<Request.Response, ClientTaskError>) -> Void) -> Cancellable where Request: GarageRequest { let workItem = DispatchWorkItem { self.handleSendRequest(request, handler: handler ) } DispatchQueue.main.asyncAfter(deadline: .now() + requestDelayDuration, execute: workItem ) return workIte m }
  18. schemes : CookpadMartECSandbox : build : config: Debug targets :

    CookpadMartEC: all run : config: Debug environmentVariables : RUNNING_SANDBOX: 1 profile : config: Debug
  19. func application ( _ application: UIApplication , didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey:

    Any] ? ) -> Bool { if isRunningSandbox { window = UIWindow(frame: UIScreen.main.bounds ) let rootViewController = SandboxSceneSelectTableViewController { Scene ( name: "Coupon" , CouponListViewController.create(dependency: .init(launchMode: .setting, shouldCloseButtonHidden: false) ) ) { Mock(path: "/v2/coupons", statusCode: 200, method: .get) { JSONData(fromBundle: "coupons" ) } } Scene ( name: "Coupon (Error)" , CouponListViewController.create(dependency: .init(launchMode: .setting, shouldCloseButtonHidden: false) ) ) { Mock(path: "/v2/coupons", statusCode: 403, method: .get) { JSONData("\"error\": \"message\"" ) } } } let navigationController = UINavigationController(rootViewController: rootViewController ) window?.rootViewController = navigationControlle r window?.makeKeyAndVisible( ) return true } /// .... return true }
  20. ϦΫΤετ͸ελϒ͢Δ let stubbableServiceClient = StubbableServiceClient( ) if let jsonData =

    mock.json.data { stubbableServiceClient.registerClientResponse ( jsonData , for: mock.path , method: mock.method , statusCode: mock.statusCod e ) } ServiceClientProvider.injectionContainer.serviceClient = stubbableServiceClien t