Источник: Nuances of Programming
Первая часть статьи.
Модульный тест
Во-первых, что такое «модульный тест»? Это процесс проверки небольших фрагментов кода для обеспечения его целостности. Проверим пользовательские модели: 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++")
}
}
Присмотримся, что здесь происходит:
- Импортируем цель проекта как testable, получая доступ к определенным в этой цели моделям.
- Тестируемая система sut — объект модели, подлежащей тестированию.
- Любая инициализация происходит здесь. В данном случае тестируется декодирование, поэтому никакой инициализации не выполняется. Но, если создать экземпляр класса и протестировать его функциональность, этот экземпляр инициализируется функцией setUpWithError.
- Сам тест. При разделении теста на разные сценарии, то есть функции, учитывается то, что дано и что ожидается. Мы увидим это в сценарии посложнее, при тестировании GitHubService.
- Очищаем 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")
}
Этот тест посложнее предыдущего, рассмотрим его подробнее:
- Чтобы дождаться загрузки данных, сначала создали ожидание expectation существования первой tableViewCell, это индикатор завершения выборки данных. С помощью waitForExpectations() задали время ожидания 10 секунд: если первая ячейка cell за это время появилась, ожидание ожидание выполняется и тестирование tableView продолжается.
- Последнюю ячейку tableViewCell получаем, преобразуя XCUIElementQuery в массив Array с помощью allElementsBoundByIndex и индексируя его.
- Прокручивание вниз: добрались ли до конца, проверяем по isHittable, то есть нажимается ли последняя tableViewCell.
- Прокручивание обратно вверх, пока не нажмется первая 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 и получите текстовую строку этой метки.
Читайте также:
Перевод статьи Itsuki: iOS/Swift: (Super Detailed) Guide to Unit Tests and UITests