Модульный тест
Во-первых, что такое «модульный тест»? Это процесс проверки небольших фрагментов кода для обеспечения его целостности. Проверим пользовательские модели: struct
, class
, protocol
и т. д.
Предпочитаю создавать отдельный class
, делая из XCTestCase
подкласс, соответствующий каждой тестируемой модели:
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-dERVRdNQZyV_d1i8.png)
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-LohgQLoOfXQB9Nhd.png)
Тестирование моделей декодирования
Начнем с тестирования моделей декодирования 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
, нажав на ромбик слева от объявления названия класса, а один тест — на ромбик рядом с функцией.
Если тесты пройдены и все ожидания оправданы, ромбик станет зеленым, и с галочкой. Если не пройден, появится красный крестик.
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-Bk5oynhSSDkpxaEQ.png)
То же и для 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
:
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-K4p9WpN2-0aLvPL3.png)
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-rqQxdGDBU7N85qAo.png)
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-BiLYRntbCaMRZ6er.png)
Сначала настроим 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
:
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-xXMcTNHqVlUxUAhq.png)
Вот список использованных мной accessibilityIdentifier
:
searchBar
для UISearchBar;tableView
для TableView;staticLabel
для статичной UILabel;nameLabel
для UILabel полного названия;navBar
для панели навигации.
Новый «TestCase»
Чтобы добавить новый TestCase пользовательского интерфейса, при создании нового файла выбираем UI test Case Class:
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-4tCWWE0F9T_dN-J6.png)
Настройка тестового сценария
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, нажав красный кружок для справки:
![](https://nuancesprog.ru/wp-content/uploads/2024/02/0-xwZzF88Lgn_bWdu1.png)
Тестирование ввода в «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
и получите текстовую строку этой метки.
Читайте также:
- Swift: ссылочные типы и циклы сохранения, weak и unowned
- Вопросы для собеседования iOS — Swift. Часть 2
- Использование SwiftUI в UIKit
Читайте нас в Telegram, VK и Дзен
Перевод статьи Itsuki: iOS/Swift: (Super Detailed) Guide to Unit Tests and UITests