Давайте воспользуемся 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) | |
} | |
} |
ПротоколJobClientProtocol
будет служить фасадом для нашей сетевой логики.
В вашем приложении должен быть соответствующий ему тип JobClient
, но мы не будем его реализовывать в этой статье (тем более, что он нам не нужен):
protocol JobClientProtocol { | |
func fetchJobs() -> AnyPublisher<[Job], Error> | |
} |
Примечание. Для простоты JobsViewModel
будет иметь JobClientProtocol
в качестве зависимости (в идеале мы хотели бы иметь дополнительный уровень, чтобы наша модель представления не взаимодействовала напрямую с сетевым кодом).
Что будет протестировано
Нашей основной целью было бы проверить, правильно ли настроены все состояния представления в модели представления в соответствии с выбранными заданиями.
Два состояния, которые мы будем тестировать, и их условия:
Populated
: Если у нас есть одно или несколько загруженных заданий.Empty
: Если нет выбранных заданий для отображения.
Создание макетов
Мы начнем с издевательства над нашим JobClientProtocol
. Это не должно быть сложной задачей, потому что наша модель представления зависит от абстракций (в данном случае от протокола), а не от конкретных типов. Таким образом, MockJobClient
будет выглядеть так:
class MockJobClient: JobClientProtocol { | |
var fetchJobsResult: AnyPublisher<[Job], Error>! | |
func fetchJobs() -> AnyPublisher<[Job], Error> { | |
return fetchJobsResult | |
} | |
} |
Это позволит нам контролировать поведение сетевого клиента так, как мы хотим, давая нам большую гибкость для написания наших модульных тестов.
Примечание. Мы работаем с AnyPublisher
, потому что эта форма стирания типов сохраняет абстракцию и, следовательно, скрывает базовую реализацию нашего сетевого клиента.
Определение наших сохраненных свойств
Сначала нам нужно будет объявить два хранимых свойства:
viewModelToTest
: Это будет экземпляр модели представления, который будет протестирован. Также известен как SUT (тестируемая система).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) | |
} | |
} |
Вот и все. Вы можете найти полный пример кода в этом репозитории: