iOS/Swift: подробное руководство по модульным и UI-тестам. Часть 2

Первая часть статьи.

Модульный тест

Во-первых, что такое «модульный тест»? Это процесс проверки небольших фрагментов кода для обеспечения его целостности. Проверим пользовательские модели: struct, class, protocol и т. д.

Предпочитаю создавать отдельный class, делая из XCTestCase подкласс, соответствующий каждой тестируемой модели:

Тестирование моделей декодирования

Начнем с тестирования моделей декодирования Repository и Response. Упрощаем тестирование: в цель testsDemoTests добавляем SampleData. Это json-файлы с примерами ответов. Возьмите их на странице GitHub или создайте свои.

Тестируем Repository:

/
// RepositoryTests.swift
// testsDemoTests
//
// Создано Itsuki 17.10.2023.
//

import XCTest
// 1
@testable import testsDemo

final class RepositoryTests: XCTestCase {

// 2
var sut: Repository!

override func setUpWithError() throws {

try super.setUpWithError()
// 3: инициализируем экземпляр
// sut = YourTestInstance()
}

override func tearDownWithError() throws {
try super.tearDownWithError()
// 5: очистка
sut = nil
}

// 4
func testRepositoryDecoding() throws {

let path = Bundle(for: ResponseTest.self).path(forResource: "sampleRepository", ofType: "json")!
let data = NSData(contentsOfFile: path)! as Data

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
sut = try! decoder.decode(Repository.self, from: data)
XCTAssertEqual(sut.id, 44838949)
XCTAssertEqual(sut.fullName, "apple/swift")
XCTAssertEqual(sut.stargazersCount, 61951)
XCTAssertEqual(sut.language, "C++")

}

}

Присмотримся, что здесь происходит:

  1. Импортируем цель проекта как testable, получая доступ к определенным в этой цели моделям.
  2. Тестируемая система sut  —  объект модели, подлежащей тестированию.
  3. Любая инициализация происходит здесь. В данном случае тестируется декодирование, поэтому никакой инициализации не выполняется. Но, если создать экземпляр класса и протестировать его функциональность, этот экземпляр инициализируется функцией setUpWithError.
  4. Сам тест. При разделении теста на разные сценарии, то есть функции, учитывается то, что дано и что ожидается. Мы увидим это в сценарии посложнее, при тестировании GitHubService.
  5. Очищаем sub после тестирования.

Запускаем весь XCTestCase, нажав на ромбик слева от объявления названия класса, а один тест  —  на ромбик рядом с функцией.

Если тесты пройдены и все ожидания оправданы, ромбик станет зеленым, и с галочкой. Если не пройден, появится красный крестик.

То же и для RepositoryResponse:

//
// ResponseTest.swift
// testsDemoTests
//
// Создано Itsuki 17.10.2023.
//

import XCTest
@testable import testsDemo


final class ResponseTest: XCTestCase {

var sut: RepositoryResponse!

override func setUpWithError() throws {

try super.setUpWithError()

}

override func tearDownWithError() throws {
try super.tearDownWithError()
// 5
// Очистка объекта
sut = nil
}

func testResponseDecoding() throws {

let path = Bundle(for: ResponseTest.self).path(forResource: "sampleResponse", ofType: "json")!
let data = NSData(contentsOfFile: path)! as Data

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
sut = try! decoder.decode(RepositoryResponse.self, from: data)
XCTAssertEqual(sut.totalCount, 265387)
XCTAssertNotNil(sut.items)

}



}

Тестирование HTTP-запроса

Для тестирования HTTP-запросов понадобятся заглушки, с которыми приложения тестируются фиктивными сетевыми данными, настраиваемыми временем отклика, кодом ответа и заголовками.

Для упрощения тестирования воспользуемся библиотекой OHHTTPStubs. Добавим ее в проект, обязательно выбрав целью testsDemoTests:

Сначала настроим XCTestCase, по завершении удалим все заглушки:

//
// GitHubServiceTest.swift
// testsDemoTests
//
// Создано Itsuki 17.10.2023.
//

import XCTest
import OHHTTPStubs
import OHHTTPStubsSwift
@testable import testsDemo


final class GitHubServiceTest: XCTestCase {

override func setUpWithError() throws {
super.setUp()

}

override func tearDownWithError() throws {
HTTPStubs.removeAllStubs()
super.tearDown()
}


}

Теперь протестируем успешный ответ. Для каждого типа ServiceError также создадим отдельный тестовый сценарий.

Имитируем успешный ответ заглушкой:

func testGitHubRepositoryPublisherSuccess() async {     
var repositoryList: [Repository] = []
var error: Error?

stub(condition: isHost("api.github.com") && isPath("/search/repositories") ) { _ in
return HTTPStubsResponse(
fileAtPath: OHPathForFile("sampleResponse.json", type(of: self))!,
statusCode: 200,
headers: ["Content-Type":"application/json"]
)
}
do {
repositoryList = try await GitHubService.fetchRepositories(query: "swift")
} catch(let e) {
error = e
}

XCTAssertNil(error)
XCTAssertEqual(repositoryList.count, 30)
XCTAssertEqual(repositoryList.first?.id, 44838949)
}

В этом примере мы создали имитированный ответ с помощью stub() , указывая host и path так, чтобы имитировались только сетевые запросы к хосту "api.github.com" с путем “/search/repositories”, и возвращая имитированный ответ с данными из файла sampleResponse.json.

Так как это тест на успешный запрос, ошибка error будет nil. Данные ответа имеются заранее, поэтому также проверяем, соответствует ли каждый получаемый репозиторий ожидаемому.

Теперь перейдем к тестированию ServiceError, где получим ошибку .network. Она возникает, например, в случае сбоя bad network. timeoutInterval для запроса установлен на 180: все, что больше, приводит к ошибке.

Чтобы сымитировать сбой bad network, указываем время запроса и отклика и подтверждаем, что ошибка error не nil, а GitHubService.ServiceError.network:

func testGitHubRepositoryPublisherTimeOut() async {

var error: Error?

stub(condition: isHost("api.github.com") && isPath("/search/repositories") ) { _ in
return HTTPStubsResponse(
fileAtPath: OHPathForFile("sampleResponse.json", type(of: self))!,
statusCode: 200,
headers: ["Content-Type":"application/json"]
).requestTime(360, responseTime: 360)
}
do {
let repositoryList = try await GitHubService.fetchRepositories(query: "")
} catch(let e) {
error = e
}

XCTAssertNotNil(error)
XCTAssertEqual(error as! GitHubService.ServiceError, GitHubService.ServiceError.network)

}

То же самое делаем для другого ServiceError, проверьте на GitHub.

Вот еще примеры использования библиотеки для тестирования HTTP-запросов.

Тест пользовательского интерфейса

UITest  —  это место, где тестируются все компоненты интерфейса, проверяется удобство приложения и наличие в нем желаемого функционала.

Настройка идентификатора доступности

Прежде чем переходить к фактическому тестированию пользовательского интерфейса, идентифицируем каждый элемент представления View, задав для всех тестируемых компонентов accessibilityIdentifier:

Вот список использованных мной accessibilityIdentifier:

  • searchBar для UISearchBar;
  • tableView для TableView;
  • staticLabel для статичной UILabel;
  • nameLabel для UILabel полного названия;
  • navBar для панели навигации.

Новый «TestCase»

Чтобы добавить новый TestCase пользовательского интерфейса, при создании нового файла выбираем UI test Case Class:

Настройка тестового сценария

UITest настраивается аналогично модульному, только вместо заглушки будет приложение, запускаемое launch при настройке setUp и останавливаемое terminate при завершении tearDown:

//
// ViewControllerUITest.swift
// testsDemoUITests
//
// Создано Itsuki 20.10.2023.
//

import XCTest
@testable import testsDemo


final class ViewControllerUITest: XCTestCase {
var app: XCUIApplication!


override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}

override func tearDownWithError() throws {
app.terminate()
app = nil
try super.tearDownWithError()

}
}

Инициализация тестового представления

Начнем с простейшего. Протестируем корректность настройки представления, то есть наличие ожидаемых в нем элементов:

func testViewControllerInitialization() {
let searchBarElement = app.otherElements["searchBar"]
XCTAssertTrue(searchBarElement.exists)

let tableView = app.tables["tableView"]
XCTAssertTrue(tableView.exists)

}

Так по accessibilityIdentifier элемент получается, и подтверждается его существование. Запустив тест, видим зеленый ромбик, значит, все элементы в наличии.

Не уверены, как выбрать конкретный элемент? Воспользуйтесь функцией записи record в Xcode, нажав красный кружок для справки:

Тестирование ввода в «UISearchBar»

Подтвердив наличие SearchBar, переходим к проверке корректного взаимодействовия: нажатием на ней отобразим клавиатуру, введем ключевое слово:

func testUISearchBarTyping() {

let searchBarElement = app.otherElements["searchBar"]
XCTAssertTrue(searchBarElement.exists)

searchBarElement.tap()
XCTAssertTrue(app.keyboards.firstMatch.exists)

searchBarElement.typeText("test")

}

Чтобы установить фокус ввода на SearchBar, с помощью tap() активируем стандартное нажатие. Имеются и другие способы взаимодействия с элементом.

Нажатия

  • tap(): стандартное нажатие.
  • doubleTap(): два нажатия подряд.
  • twoFingerTap(): нажатие одновременно двумя пальцами.
  • tap(withNumberOfTaps:numberOfTouches:): нажатие с определенным количеством касаний, где numberOfTaps  —  это число касаний, а numberOfTouches  —  число точек касания.
  • press(forDuration:): длительные нажатия конкретной продолжительности.

Жесты

  • swipeLeft(), swipeRight(), swipeUp() и swipeDown(): одиночные смахивания в конкретном направлении.
  • pinch(withScale:velocity:): сведение/разведение двух пальцев и жест изменения масштаба. Между 0 и 1 масштаб уменьшается, больше 1  —  увеличивается. Velocity: коэффициент масштабирования в секунду. velocity=0 для точного масштаба.
  • rotate(_: withVelocity:): поворот элемента. Первый параметр  —  это угол в радианах, второй  —  скорость в радианах в секунду.

Текст вводится в TextField/SearchBar/TextView двумя способами: первый  —  с помощью typeText(), как показано выше, второй  —  побуквенным вводом через клавиатуру. Например, test вводится так:

app.keyboards.keys["T"].tap()
app.keyboards.keys["E"].tap()
app.keyboards.keys["S"].tap()
app.keyboards.keys["T"].tap()

Внимание: в индексе используются прописные буквы, даже если нужны строчные. Это обусловлено тем, как определяются идентификаторы для ключей. Если нужны не строчные буквы, а прописные, перед вводом нажимаем caps lock.

Тестирование прокрутки «TableView»

Сначала загружаем данные в tableView, вводя ключевое слово в searchBar и выполняя HTTP-запрос, затем прокручиваем tableView вниз и обратно вверх, подтверждая соответствующее поведение:

func testTableViewScroll() {

let searchBarElement = app.otherElements["searchBar"]
let tableView = app.tables["tableView"]
let exists = NSPredicate(format: "exists == 1")


searchBarElement.tap()
searchBarElement.typeText("google")
app.keyboards.buttons["search"].tap()


// 1: дожидаемся загрузки данных
let firstTableCell = tableView.cells.firstMatch
let expectation = expectation(for: exists, evaluatedWith: firstTableCell)
waitForExpectations(timeout: 10, handler: nil)

XCTAssertTrue(firstTableCell.exists, "cell 0 is not on the table")
expectation.fulfill()

// 2: получаем элемент последней ячейки
let cellCount = tableView.cells.count
let lastTableCell = tableView.cells.allElementsBoundByIndex[cellCount-1]

// 3: прокручиваем вниз
while !lastTableCell.isHittable {
tableView.swipeUp()
}
XCTAssertTrue(lastTableCell.isHittable, "Not able to scroll to the end of the Table")

// 4: прокручиваем обратно вверх
while !firstTableCell.isHittable {
tableView.swipeDown()
}
XCTAssertTrue(firstTableCell.isHittable, "Not able to scroll to the beginning of the Table")

}

Этот тест посложнее предыдущего, рассмотрим его подробнее:

  1. Чтобы дождаться загрузки данных, сначала создали ожидание expectation существования первой tableViewCell, это индикатор завершения выборки данных. С помощью waitForExpectations() задали время ожидания 10 секунд: если первая ячейка cell за это время появилась, ожидание ожидание выполняется и тестирование tableView продолжается.
  2. Последнюю ячейку tableViewCell получаем, преобразуя XCUIElementQuery в массив Array с помощью allElementsBoundByIndex и индексируя его.
  3. Прокручивание вниз: добрались ли до конца, проверяем по isHittable, то есть нажимается ли последняя tableViewCell.
  4. Прокручивание обратно вверх, пока не нажмется первая tableViewCell.

Тестирование навигации

Навигация между основным viewController и DetailViewController тестируется нажатием tableViewCell, проверкой наличия кнопки back (назад) на панели навигации и ее нажатием:

func testNavigation() {

let searchBarElement = app.otherElements["searchBar"]
let tableView = app.tables["tableView"]
let exists = NSPredicate(format: "exists == 1")


searchBarElement.tap()
searchBarElement.typeText("google")
app.keyboards.buttons["search"].tap()

// дожидаемся загрузки данных
let firstTableCell = tableView.cells.firstMatch
let expectation = expectation(for: exists, evaluatedWith: firstTableCell)
waitForExpectations(timeout: 10, handler: nil)

XCTAssertTrue(firstTableCell.exists, "cell 0 is not on the table")
expectation.fulfill()

firstTableCell.tap()

let backButton = app.navigationBars["navBar"].buttons["Back"]
XCTAssertTrue(backButton.exists)
backButton.tap()

}

Тестирование статичных и динамических меток

Последнее, но не менее важное: проверим наличие в метках DetailViewController значения, которое должно там быть. Для staticLabel это текст, заданный в storyboard, для динамического nameLabel  —  полное название выбранного репозитория:

func testDetailViewControllerInitialization() {

let searchBarElement = app.otherElements["searchBar"]
let tableView = app.tables["tableView"]
let exists = NSPredicate(format: "exists == 1")


searchBarElement.tap()
searchBarElement.typeText("google")
app.keyboards.buttons["search"].tap()

// дожидаемся загрузки данных
let firstTableCell = tableView.cells.firstMatch
let expectation = expectation(for: exists, evaluatedWith: firstTableCell)
waitForExpectations(timeout: 10, handler: nil)

XCTAssertTrue(firstTableCell.exists, "cell 0 is not on the table")
expectation.fulfill()

let cellName = firstTableCell.staticTexts.firstMatch.label
firstTableCell.tap()


let staticLabel = app.staticTexts["staticLabel"]
XCTAssertTrue(staticLabel.exists)
XCTAssertEqual(staticLabel.label, "Static Label")

let nameLabel = app.staticTexts["nameLabel"]
XCTAssertTrue(nameLabel.exists)
XCTAssertEqual(nameLabel.label, cellName)

}

Первая часть фактически та же, что была выше: ввод в searchBar, ожидание загрузки tableView, нажатие cell и переход к DetailViewController.

Ключевое здесь то, как получается доступ к staticText, то есть к UIlabels в UI-тестах Swift. С помощью firstTableCell.staticTexts.firstMatch.label получаем полное название репозитория, с помощью app.staticTexts[“labelIdentifier”].label  —  текст метки UILabel.

Но как проверить конкретную метку настраиваемой tableViewCell? Задайте ей accessibilityIdentifier, как мы это сделали для всех остальных элементов, вызовите firstTableCell.staticTexts[“yourIdentifier”].label и получите текстовую строку этой метки.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Itsuki: iOS/Swift: (Super Detailed) Guide to Unit Tests and UITests

Предыдущая статьяМиграции баз данных с Golang
Следующая статьяПолное руководство по CASE WHEN в SQL