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

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

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.
Avatar for aoi aoi
September 18, 2021

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

Avatar for aoi

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