Skip to content

Commit

Permalink
Add FXIOS-10481 [Unified Search] Add SearchEngineSelection middleware…
Browse files Browse the repository at this point in the history
… tests and a Redux MockStoreForMiddleware (#23065)

* Stub in preliminary middleware tests and mock Store for middleware. Finish writing SearchEngineSelectionMiddlewareTests.

* Extend the StoreTestUtilityHelper to accept a mock Store. Update usage across unit tests. Add StoreTestUtility conformance to SearchEngineSelectionMiddlewareTests.

* Round out the SearchEnginesManager mock, update usages, and use in the SearchEngineSelectionMiddlewareTests.
  • Loading branch information
ih-codes authored Nov 13, 2024
1 parent d53c6f4 commit 3068116
Show file tree
Hide file tree
Showing 12 changed files with 160 additions and 44 deletions.
4 changes: 2 additions & 2 deletions BrowserKit/Sources/Redux/DispatchStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ public protocol DispatchStore {
func dispatch(_ action: Action)
}

public protocol DefaultDispatchStore: DispatchStore {
associatedtype State: StateType
public protocol DefaultDispatchStore<State>: DispatchStore where State: StateType {
associatedtype State

var state: State { get }

Expand Down
4 changes: 4 additions & 0 deletions firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,7 @@
ED45893E2CC800D9006F2C0B /* SearchEngineSelectionViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED45893D2CC800D9006F2C0B /* SearchEngineSelectionViewControllerTests.swift */; };
ED4589402CC8220A006F2C0B /* MockSearchEngineSelectionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED45893F2CC8220A006F2C0B /* MockSearchEngineSelectionCoordinator.swift */; };
ED55DC8C2CC2D7DA00E3FE3A /* SearchEngineSelectionCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED55DC8B2CC2D7DA00E3FE3A /* SearchEngineSelectionCoordinatorTests.swift */; };
ED70CD812CE3BD2C0018761B /* MockStoreForMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED70CD802CE3BD2C0018761B /* MockStoreForMiddleware.swift */; };
EDC3C2562CCAC9CB005A047F /* SearchEnginesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC3C2552CCAC9CB005A047F /* SearchEnginesManager.swift */; };
EDC3D34F2CB5E70500C62DE3 /* SearchEngineTestAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EDC3D34E2CB5E70500C62DE3 /* SearchEngineTestAssets.xcassets */; };
EDC3D3552CB70A3F00C62DE3 /* OpenSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC3D3542CB70A3F00C62DE3 /* OpenSearchEngineTests.swift */; };
Expand Down Expand Up @@ -9295,6 +9296,7 @@
ED45893F2CC8220A006F2C0B /* MockSearchEngineSelectionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSearchEngineSelectionCoordinator.swift; sourceTree = "<group>"; };
ED5144A0BE3A8C7B15047C3F /* anp */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = anp; path = anp.lproj/3DTouchActions.strings; sourceTree = "<group>"; };
ED55DC8B2CC2D7DA00E3FE3A /* SearchEngineSelectionCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEngineSelectionCoordinatorTests.swift; sourceTree = "<group>"; };
ED70CD802CE3BD2C0018761B /* MockStoreForMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreForMiddleware.swift; sourceTree = "<group>"; };
ED84423D8666C751BBFC76AC /* lo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lo; path = lo.lproj/Storage.strings; sourceTree = "<group>"; };
EDA240A8BBFD0A19FB2C3D7E /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intro.strings; sourceTree = "<group>"; };
EDB94DDC89DE1F2C624C9841 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -10795,6 +10797,7 @@
C29B64822AD69C3E00F3244B /* MockQRCodeParentCoordinator.swift */,
21FA8FB12AE856EB0013B815 /* MockTabTrayCoordinatorDelegate.swift */,
E19443F72AF953B000964EA5 /* MockSidebarEnabledView.swift */,
ED70CD802CE3BD2C0018761B /* MockStoreForMiddleware.swift */,
);
path = Mocks;
sourceTree = "<group>";
Expand Down Expand Up @@ -16987,6 +16990,7 @@
C8EDDBF029DD83FC003A4C07 /* RouteTests.swift in Sources */,
0A7693612C7DD19600103A6D /* CertificatesViewModelTests.swift in Sources */,
8A454D372CB86B86009436D9 /* PocketStateTests.swift in Sources */,
ED70CD812CE3BD2C0018761B /* MockStoreForMiddleware.swift in Sources */,
8AED868328CA3B3400351A50 /* BookmarkPanelViewModelTests.swift in Sources */,
434CD57829F6FC4500A0D04B /* MockAppAuthenticator.swift in Sources */,
8A94418A2CE3E190007FF4E5 /* MockSearchEngineManager.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import ToolbarKit
final class SearchEngineSelectionMiddleware {
private let profile: Profile
private let logger: Logger
private let searchEnginesManager: SearchEnginesManager
private let searchEnginesManager: SearchEnginesManagerProvider

init(profile: Profile = AppContainer.shared.resolve(),
searchEnginesManager: SearchEnginesManager? = nil,
searchEnginesManager: SearchEnginesManagerProvider? = nil,
logger: Logger = DefaultLogger.shared) {
self.profile = profile
self.logger = logger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import Storage

protocol SearchEnginesManagerProvider {
var defaultEngine: OpenSearchEngine? { get }
var orderedEngines: [OpenSearchEngine]! { get }
func getOrderedEngines(completion: @escaping ([OpenSearchEngine]) -> Void)
}

protocol SearchEngineDelegate: AnyObject {
Expand Down
12 changes: 6 additions & 6 deletions firefox-ios/Client/Redux/GlobalState/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ let middlewares = [
// we change the store to be instantiated as a variable.
// For non testing builds, we leave the store as a constant.
#if TESTING
var store = Store(state: AppState(),
reducer: AppState.reducer,
middlewares: middlewares)
var store: any DefaultDispatchStore<AppState> = Store(state: AppState(),
reducer: AppState.reducer,
middlewares: middlewares)
#else
let store = Store(state: AppState(),
reducer: AppState.reducer,
middlewares: middlewares)
let store: any DefaultDispatchStore<AppState> = Store(state: AppState(),
reducer: AppState.reducer,
middlewares: middlewares)
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Redux

@testable import Client

/// A mock Store used to test redux middlewares.
///
/// If you need to highly customize this mock to meet your testing needs, you should subclass it and/or make your own mock
/// store implementation (e.g. storing a completion handler for asynchronous middleware actions so you can await expectations
/// in your tests).
class MockStoreForMiddleware<State: StateType>: DefaultDispatchStore {
var state: State

/// Records the number of times dispatch is called, and the actions with which it is called. Check this property to
/// ensure that your middleware correctly dispatches the right action(s) in response to a given action.
var dispatchCalled: (numberOfTimes: Int, withActions: [Redux.Action]) = (0, [])

init(state: State) {
self.state = state
}

func subscribe<S>(_ subscriber: S) where S: Redux.StoreSubscriber, State == S.SubscriberStateType {
// TODO if you need it
}

func subscribe<SubState, S>(
_ subscriber: S,
transform: (
(
Redux.Subscription<State>
) -> Redux.Subscription<SubState>
)?
) where SubState == S.SubscriberStateType, S: Redux.StoreSubscriber {
// TODO if you need it
}

func unsubscribe<S>(_ subscriber: S) where S: Redux.StoreSubscriber, State == S.SubscriberStateType {
// TODO if you need it
}

func unsubscribe(_ subscriber: any Redux.StoreSubscriber) {
// TODO if you need it
}

func dispatch(_ action: Redux.Action) {
var dispatchActions = dispatchCalled.withActions
dispatchActions.append(action)

dispatchCalled = (dispatchCalled.numberOfTimes + 1, dispatchActions)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,67 @@ import XCTest

@testable import Client

final class SearchEngineSelectionMiddlewareTests: XCTestCase {
final class SearchEngineSelectionMiddlewareTests: XCTestCase, StoreTestUtility {
var mockStore: MockStoreForMiddleware<AppState>!
var mockProfile: MockProfile!
var mockSearchEngines: [OpenSearchEngine]!
var mockSearchEnginesManager: SearchEnginesManagerProvider!
let mockSearchEngines: [OpenSearchEngine] = [
OpenSearchEngineTests.generateOpenSearchEngine(type: .wikipedia, withImage: UIImage()),
OpenSearchEngineTests.generateOpenSearchEngine(type: .youtube, withImage: UIImage()),
]
var mockSearchEngineModels: [SearchEngineModel] {
return mockSearchEngines.map({ $0.generateModel() })
}

override func setUp() {
super.setUp()

DependencyHelperMock().bootstrapDependencies()
mockProfile = MockProfile()
mockSearchEngines = [
OpenSearchEngineTests.generateOpenSearchEngine(type: .wikipedia, withImage: UIImage()),
OpenSearchEngineTests.generateOpenSearchEngine(type: .youtube, withImage: UIImage()),
]
mockSearchEnginesManager = MockSearchEnginesManager(searchEngines: mockSearchEngines)

// We must reset the global mock store prior to each test
setupTestingStore()
}

override func tearDown() {
DependencyHelperMock().reset()
resetTestingStore()
super.tearDown()
}

func testDismissMenuAction() throws {
let mockSearchEnginesManager = SearchEnginesManager(prefs: mockProfile.prefs, files: mockProfile.files)
mockSearchEnginesManager.orderedEngines = mockSearchEngines

func testViewDidLoad_dispatchesDidLoadSearchEngines() throws {
let subject = createSubject(mockSearchEnginesManager: mockSearchEnginesManager)
let action = getAction(for: .viewDidLoad)

let testStore = Store(
state: AppState(),
reducer: AppState.reducer,
middlewares: [subject.searchEngineSelectionProvider]
)
subject.searchEngineSelectionProvider(AppState(), action)

guard let actionCalled = mockStore.dispatchCalled.withActions.first as? SearchEngineSelectionAction,
case SearchEngineSelectionActionType.didLoadSearchEngines = actionCalled.actionType else {
XCTFail("Unexpected action type dispatched")
return
}
XCTAssertEqual(mockStore.dispatchCalled.numberOfTimes, 1)
XCTAssertEqual(actionCalled.searchEngines, mockSearchEngineModels)
}

func testDidTapSearchEngine_dispatchesDidStartEditingUrl() throws {
let subject = createSubject(mockSearchEnginesManager: mockSearchEnginesManager)
let action = getAction(for: .didTapSearchEngine)

testStore.dispatch(action)
subject.searchEngineSelectionProvider(AppState(), action)

// TODO FXIOS-10481 Flesh out middleware tests once we have decided on best practices
throw XCTSkip("Need Store architecture changes if we want to implement tests")
guard let actionCalled = mockStore.dispatchCalled.withActions.first as? ToolbarAction,
case ToolbarActionType.didStartEditingUrl = actionCalled.actionType else {
XCTFail("Unexpected action type dispatched")
return
}
XCTAssertEqual(mockStore.dispatchCalled.numberOfTimes, 1)
}

// MARK: - Helpers

private func createSubject(mockSearchEnginesManager: SearchEnginesManager) -> SearchEngineSelectionMiddleware {
private func createSubject(mockSearchEnginesManager: SearchEnginesManagerProvider) -> SearchEngineSelectionMiddleware {
return SearchEngineSelectionMiddleware(profile: mockProfile, searchEnginesManager: mockSearchEnginesManager)
}

Expand All @@ -57,4 +77,29 @@ final class SearchEngineSelectionMiddlewareTests: XCTestCase {
actionType: actionType
)
}

// MARK: StoreTestUtility

func setupAppState() -> AppState {
return AppState(
activeScreens: ActiveScreensState(
screens: [
.searchEngineSelection(
SearchEngineSelectionState(windowUUID: .XCTestDefaultUUID)
)
]
)
)
}

func setupTestingStore() {
mockStore = MockStoreForMiddleware(state: setupAppState())
StoreTestUtilityHelper.setupTestingStore(with: mockStore)
}

// In order to avoid flaky tests, we should reset the store
// similar to production
func resetTestingStore() {
StoreTestUtilityHelper.resetTestingStore()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@ import Foundation
@testable import Client

class MockSearchEnginesManager: SearchEnginesManagerProvider {
private let searchEngine: OpenSearchEngine?
private let searchEngines: [OpenSearchEngine]

var defaultEngine: OpenSearchEngine? {
return searchEngine
return searchEngines.first
}

var orderedEngines: [OpenSearchEngine]! {
return searchEngines
}
init(searchEngine: OpenSearchEngine? = nil) {
self.searchEngine = searchEngine

init(searchEngines: [OpenSearchEngine] = []) {
self.searchEngines = searchEngines
}

func getOrderedEngines(completion: @escaping ([OpenSearchEngine]) -> Void) {
completion(searchEngines)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import XCTest
@testable import Client

final class PocketMiddlewareTests: XCTestCase, StoreTestUtility {
let storeUtilityHelper = StoreTestUtilityHelper()
let pocketManager = MockPocketManager()
override func setUp() {
super.setUp()
Expand Down Expand Up @@ -68,7 +67,7 @@ final class PocketMiddlewareTests: XCTestCase, StoreTestUtility {
}

func setupTestingStore() {
storeUtilityHelper.setupTestingStore(
StoreTestUtilityHelper.setupTestingStore(
with: setupAppState(),
middlewares: [PocketMiddleware().pocketSectionProvider]
)
Expand All @@ -77,6 +76,6 @@ final class PocketMiddlewareTests: XCTestCase, StoreTestUtility {
// In order to avoid flaky tests, we should reset the store
// similar to production
func resetTestingStore() {
storeUtilityHelper.resetTestingStore()
StoreTestUtilityHelper.resetTestingStore()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ final class TopSitesManagerTests: XCTestCase {
contileProvider: MockContileProvider(
result: .success(MockContileProvider.defaultSuccessData)
),
searchEngineManager: MockSearchEnginesManager(searchEngine: searchEngine)
searchEngineManager: MockSearchEnginesManager(searchEngines: [searchEngine])
)

let topSites = await subject.getTopSites()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import XCTest
@testable import Client

final class MicrosurveyMiddlewareIntegrationTests: XCTestCase, StoreTestUtility {
let storeUtilityHelper = StoreTestUtilityHelper()
override func setUp() {
super.setUp()
Glean.shared.resetGlean(clearStores: true)
Expand Down Expand Up @@ -118,7 +117,7 @@ final class MicrosurveyMiddlewareIntegrationTests: XCTestCase, StoreTestUtility
}

func setupTestingStore() {
storeUtilityHelper.setupTestingStore(
StoreTestUtilityHelper.setupTestingStore(
with: setupAppState(),
middlewares: [MicrosurveyMiddleware().microsurveyProvider]
)
Expand All @@ -127,6 +126,6 @@ final class MicrosurveyMiddlewareIntegrationTests: XCTestCase, StoreTestUtility
// In order to avoid flaky tests, we should reset the store
// similar to production
func resetTestingStore() {
storeUtilityHelper.resetTestingStore()
StoreTestUtilityHelper.resetTestingStore()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,20 @@ protocol StoreTestUtility {

/// Utility class used when replacing the global store for testing purposes
class StoreTestUtilityHelper {
func setupTestingStore(with appState: AppState, middlewares: [Middleware<AppState>]) {
static func setupTestingStore(with appState: AppState, middlewares: [Middleware<AppState>]) {
store = Store(
state: appState,
reducer: AppState.reducer,
middlewares: middlewares
)
}

/// In order to avoid flaky tests, we should reset the store
/// similar to production
func resetTestingStore() {
static func setupTestingStore(with mockStore: any DefaultDispatchStore<AppState>) {
store = mockStore
}

/// In order to avoid flaky tests, we should reset the store similar to production
static func resetTestingStore() {
store = Store(
state: AppState(),
reducer: AppState.reducer,
Expand Down

0 comments on commit 3068116

Please sign in to comment.