Давайте воспользуемся XCTest Framework в iOS для тестирования кодовой базы Combine.

Combine — это среда реактивного программирования, выпущенная Apple в iOS 13, которая позволяет нам работать с асинхронными потоками данных.

По словам Apple, Combine позволяет нам:

Настройте обработку асинхронных событий, комбинируя операторы обработки событий.

В этой статье мы рассмотрим, как выполнять модульное тестирование кода на основе Combine с использованием платформы XCTest Apple.

Для этого мы напишем модульные тесты для примера приложения, которое будет отображать список заданий. Основные классы, которые мы будем использовать:

Job: представляет объект задания. Наша модель.

JobsViewModel: Отвечает за всю логику презентации. Мы будем работать с двумя состояниями просмотра: Populated и Empty.

struct Job {
let id: String
let title: String
let description: String
}
class JobsViewModel {
private let client: JobClientProtocol
init(client: JobClientProtocol) {
self.client = client
}
enum JobsViewState: Equatable {
case populated
case empty
case error(_ error: Error)
static func == (lhs: JobsViewState, rhs: JobsViewState) -> Bool {
switch (lhs, rhs) {
case (.populated, .populated): return true
case (.empty, .empty): return true
case (.error, .error): return true
default: return false
}
}
}
@Published var viewState: JobsViewState = .empty
private func loadJobs() {
client.fetchJobs()
.map { jobs -> JobsViewState in
return jobs.isEmpty ? .empty : .populated
}.catch { error -> Just<JobsViewState> in
return Just(.error(error))
}.assign(to: &$viewState)
}
}
view raw JobsCore.swift hosted with ❤ by GitHub

ПротоколJobClientProtocol будет служить фасадом для нашей сетевой логики.

В вашем приложении должен быть соответствующий ему тип JobClient, но мы не будем его реализовывать в этой статье (тем более, что он нам не нужен):

protocol JobClientProtocol {
func fetchJobs() -> AnyPublisher<[Job], Error>
}

Примечание. Для простоты JobsViewModel будет иметь JobClientProtocol в качестве зависимости (в идеале мы хотели бы иметь дополнительный уровень, чтобы наша модель представления не взаимодействовала напрямую с сетевым кодом).

Что будет протестировано

Нашей основной целью было бы проверить, правильно ли настроены все состояния представления в модели представления в соответствии с выбранными заданиями.

Два состояния, которые мы будем тестировать, и их условия:

  1. Populated: Если у нас есть одно или несколько загруженных заданий.
  2. Empty: Если нет выбранных заданий для отображения.

Создание макетов

Мы начнем с издевательства над нашим JobClientProtocol. Это не должно быть сложной задачей, потому что наша модель представления зависит от абстракций (в данном случае от протокола), а не от конкретных типов. Таким образом, MockJobClient будет выглядеть так:

class MockJobClient: JobClientProtocol {
var fetchJobsResult: AnyPublisher<[Job], Error>!
func fetchJobs() -> AnyPublisher<[Job], Error> {
return fetchJobsResult
}
}

Это позволит нам контролировать поведение сетевого клиента так, как мы хотим, давая нам большую гибкость для написания наших модульных тестов.

Примечание. Мы работаем с AnyPublisher, потому что эта форма стирания типов сохраняет абстракцию и, следовательно, скрывает базовую реализацию нашего сетевого клиента.

Определение наших сохраненных свойств

Сначала нам нужно будет объявить два хранимых свойства:

  1. viewModelToTest: Это будет экземпляр модели представления, который будет протестирован. Также известен как SUT (тестируемая система).
  2. mockJobClient: Экземпляр нашего фиктивного JobClientProtocol.
class JobsViewModelTests: XCTestCase {
private var mockJobClient: MockJobClient!
private var viewModelToTest: JobsViewModel!
override func setUpWithError() throws {
try super.setUpWithError()
mockJobClient = MockJobClient()
viewModelToTest = JobsViewModel(client: mockJobClient)
}
override func tearDownWithError() throws {
mockJobClient = nil
viewModelToTest = nil
try super.tearDownWithError()
}
}

Нам нужно хранимое свойство для каждого из них, потому что мы будем напрямую изменять mockJobClient для имитации различных сценариев, которые нам нужны (MockJobClient— этокласс,поэтому он будет передан как ссылка в инициализаторе модели представления, и любое обновление или изменение, выполненное в экземпляре MockJobClient, повлияет на поведение модели представления).

Наконец, у нас будет экземпляр AnyCancellableколлекции, потому что мы присоединим подписчика к издателям, возвращенным mockJobClient, и этого подписчика необходимо сохранить; в противном случае он будет немедленно освобожден:

private var cancellables: Set<AnyCancellable> = []

Написание наших модульных тестов

Чтобы выполнять наши модульные тесты, нам нужно подписаться на издателя viewState в JobsViewModel. Для этого мы будем использовать метод sink, который будет прикреплять подписчика к viewState, используя поведение на основе замыкания:

viewModelToTest.$viewState.dropFirst().sink { state in
    // Evaluate state here
}.store(in: &cancellables)

Следующим шагом является имитация ответов сетевых клиентов. Мы можем легко сделать это, используя наш MockJobClient. Чтобы смоделировать пустой ответ, мы должны сделать:

mockJobClient.fetchJobsResult = Result.success([]).publisher.eraseToAnyPublisher()

И для имитации заполненного ответа:

let jobsToTest = [Job(id: "1", title: "title", description: "desc")]
mockJobClient.fetchJobsResult = Result.success(jobsToTest).publisher.eraseToAnyPublisher()

Последним шагом будет вызов метода loadJobs, который запустит процесс извлечения с помощью MockJobClient:

viewModelToTest.loadJobs()

Примечание. Важно вызвать метод loadJobs после предыдущих шагов (присоединение подписчика и имитация ответа сети); в противном случае наш viewState издатель не будет выдавать никакого значения, потому что к этому моменту еще не был подключен ни один подписчик (издатели Combine не выдают никакого значения, если к нему не подключены подписчики).

Чтобы убедиться, что мы получаем правильное состояние, мы будем использовать XCTestExpectation — класс, который Apple предоставляет нам для создания асинхронных тестов.

Окончательный тестовый класс будет выглядеть так:

class JobsViewModelTests: XCTestCase {
private var mockJobClient: MockJobClient!
private var viewModelToTest: JobsViewModel!
private var cancellables: Set<AnyCancellable> = []
override func setUpWithError() throws {
try super.setUpWithError()
mockJobClient = MockJobClient()
viewModelToTest = JobsViewModel(client: mockJobClient)
}
override func tearDownWithError() throws {
mockJobClient = nil
viewModelToTest = nil
try super.tearDownWithError()
}
func testGetJobsPopulated() {
let jobsToTest = [Job(id: "1", title: "title", description: "desc")]
let expectation = XCTestExpectation(description: "State is set to populated")
viewModelToTest.$viewState.dropFirst().sink { state in
XCTAssertEqual(state, .populated)
expectation.fulfill()
}.store(in: &cancellables)
mockJobClient.fetchJobsResult = Result.success(jobsToTest).publisher.eraseToAnyPublisher()
viewModelToTest.loadJobs()
wait(for: [expectation], timeout: 1)
}
func testGetJobsEmpty() {
let jobsToTest: [Job] = []
let expectation = XCTestExpectation(description: "State is set to empty")
viewModelToTest.$viewState.dropFirst().sink { state in
XCTAssertEqual(state, .empty)
expectation.fulfill()
}.store(in: &cancellables)
mockJobClient.fetchJobsResult = Result.success(jobsToTest).publisher.eraseToAnyPublisher()
viewModelToTest.loadJobs()
wait(for: [expectation], timeout: 1)
}
}

Вот и все. Вы можете найти полный пример кода в этом репозитории: